mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: implement backlog plan management and UI enhancements
- Added functionality to save, clear, and load backlog plans within the application. - Introduced a new API endpoint for clearing saved backlog plans. - Enhanced the backlog plan dialog to allow users to review and apply changes to their features. - Integrated dependency management features in the UI, allowing users to select parent and child dependencies for features. - Improved the graph view with options to manage plans and visualize dependencies effectively. - Updated the sidebar and settings to include provider visibility toggles for better user control over model selection. These changes aim to enhance the user experience by providing robust backlog management capabilities and improving the overall UI for feature planning.
This commit is contained in:
@@ -3,12 +3,31 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('BacklogPlan');
|
||||
|
||||
// State for tracking running generation
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
let runningDetails: {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
startedAt: string;
|
||||
} | null = null;
|
||||
|
||||
const BACKLOG_PLAN_FILENAME = 'backlog-plan.json';
|
||||
|
||||
export interface StoredBacklogPlan {
|
||||
savedAt: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
result: BacklogPlanResult;
|
||||
}
|
||||
|
||||
export function getBacklogPlanStatus(): { isRunning: boolean } {
|
||||
return { isRunning };
|
||||
@@ -16,11 +35,67 @@ export function getBacklogPlanStatus(): { isRunning: boolean } {
|
||||
|
||||
export function setRunningState(running: boolean, abortController?: AbortController | null): void {
|
||||
isRunning = running;
|
||||
if (!running) {
|
||||
runningDetails = null;
|
||||
}
|
||||
if (abortController !== undefined) {
|
||||
currentAbortController = abortController;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRunningDetails(
|
||||
details: {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
startedAt: string;
|
||||
} | null
|
||||
): void {
|
||||
runningDetails = details;
|
||||
}
|
||||
|
||||
export function getRunningDetails(): {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
startedAt: string;
|
||||
} | null {
|
||||
return runningDetails;
|
||||
}
|
||||
|
||||
function getBacklogPlanPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME);
|
||||
}
|
||||
|
||||
export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise<void> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const filePath = getBacklogPlanPath(projectPath);
|
||||
await secureFs.writeFile(filePath, JSON.stringify(plan, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function loadBacklogPlan(projectPath: string): Promise<StoredBacklogPlan | null> {
|
||||
try {
|
||||
const filePath = getBacklogPlanPath(projectPath);
|
||||
const raw = await secureFs.readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as StoredBacklogPlan;
|
||||
if (!parsed?.result?.changes) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearBacklogPlan(projectPath: string): Promise<void> {
|
||||
try {
|
||||
const filePath = getBacklogPlanPath(projectPath);
|
||||
await secureFs.unlink(filePath);
|
||||
} catch {
|
||||
// ignore missing file
|
||||
}
|
||||
}
|
||||
|
||||
export function getAbortController(): AbortController | null {
|
||||
return currentAbortController;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { logger, setRunningState, getErrorMessage } from './common.js';
|
||||
import { logger, setRunningState, getErrorMessage, saveBacklogPlan } from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
|
||||
@@ -200,6 +200,13 @@ ${userPrompt}`;
|
||||
// Parse the response
|
||||
const result = parsePlanResponse(responseText);
|
||||
|
||||
await saveBacklogPlan(projectPath, {
|
||||
savedAt: new Date().toISOString(),
|
||||
prompt,
|
||||
model: effectiveModel,
|
||||
result,
|
||||
});
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_complete',
|
||||
result,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createApplyHandler } from './routes/apply.js';
|
||||
import { createClearHandler } from './routes/clear.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createBacklogPlanRoutes(
|
||||
@@ -23,8 +24,9 @@ export function createBacklogPlanRoutes(
|
||||
createGenerateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
|
||||
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
|
||||
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
@@ -151,6 +151,8 @@ export function createApplyHandler() {
|
||||
success: true,
|
||||
appliedChanges,
|
||||
});
|
||||
|
||||
await clearBacklogPlan(projectPath);
|
||||
} catch (error) {
|
||||
logError(error, 'Apply backlog plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
25
apps/server/src/routes/backlog-plan/routes/clear.ts
Normal file
25
apps/server/src/routes/backlog-plan/routes/clear.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* POST /clear endpoint - Clear saved backlog plan
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { clearBacklogPlan, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createClearHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await clearBacklogPlan(projectPath);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Clear backlog plan failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,13 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import {
|
||||
getBacklogPlanStatus,
|
||||
setRunningState,
|
||||
setRunningDetails,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
import { generateBacklogPlan } from '../generate-plan.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
@@ -37,6 +43,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
setRunningDetails({
|
||||
projectPath,
|
||||
prompt,
|
||||
model,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js';
|
||||
import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const status = getBacklogPlanStatus();
|
||||
res.json({ success: true, ...status });
|
||||
const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : '';
|
||||
const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null;
|
||||
res.json({ success: true, ...status, savedPlan });
|
||||
} catch (error) {
|
||||
logError(error, 'Get backlog plan status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -4,12 +4,27 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIndexHandler(autoModeService: AutoModeService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const runningAgents = await autoModeService.getRunningAgents();
|
||||
const runningAgents = [...(await autoModeService.getRunningAgents())];
|
||||
const backlogPlanStatus = getBacklogPlanStatus();
|
||||
const backlogPlanDetails = getRunningDetails();
|
||||
|
||||
if (backlogPlanStatus.isRunning && backlogPlanDetails) {
|
||||
runningAgents.push({
|
||||
featureId: `backlog-plan:${backlogPlanDetails.projectPath}`,
|
||||
projectPath: backlogPlanDetails.projectPath,
|
||||
projectName: path.basename(backlogPlanDetails.projectPath),
|
||||
isAutoMode: false,
|
||||
title: 'Backlog plan',
|
||||
description: backlogPlanDetails.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Plus, Bug, FolderOpen } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
@@ -10,6 +10,7 @@ import { EditProjectDialog } from './components/edit-project-dialog';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
|
||||
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
|
||||
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||
@@ -31,6 +32,9 @@ function getOSAbbreviation(os: string): string {
|
||||
|
||||
export function ProjectSwitcher() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { hideWiki } = SIDEBAR_FEATURE_FLAGS;
|
||||
const isWikiActive = location.pathname === '/wiki';
|
||||
const {
|
||||
projects,
|
||||
currentProject,
|
||||
@@ -124,6 +128,10 @@ export function ProjectSwitcher() {
|
||||
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
|
||||
}, []);
|
||||
|
||||
const handleWikiClick = useCallback(() => {
|
||||
navigate({ to: '/wiki' });
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Opens the system folder selection dialog and initializes the selected project.
|
||||
*/
|
||||
@@ -405,8 +413,37 @@ export function ProjectSwitcher() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bug Report Button at the very bottom */}
|
||||
<div className="p-2 border-t border-border/40">
|
||||
{/* Wiki and Bug Report Buttons at the very bottom */}
|
||||
<div className="p-2 border-t border-border/40 space-y-2">
|
||||
{/* Wiki Button */}
|
||||
{!hideWiki && (
|
||||
<button
|
||||
onClick={handleWikiClick}
|
||||
className={cn(
|
||||
'w-full aspect-square rounded-xl flex items-center justify-center',
|
||||
'transition-all duration-200 ease-out',
|
||||
isWikiActive
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground',
|
||||
'border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm hover:scale-105 active:scale-95',
|
||||
]
|
||||
)}
|
||||
title="Wiki"
|
||||
data-testid="wiki-button"
|
||||
>
|
||||
<BookOpen
|
||||
className={cn('w-5 h-5', isWikiActive && 'text-brand-500 drop-shadow-sm')}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{/* Bug Report Button */}
|
||||
<button
|
||||
onClick={handleBugReportClick}
|
||||
className={cn(
|
||||
|
||||
@@ -57,8 +57,7 @@ export function Sidebar() {
|
||||
} = useAppStore();
|
||||
|
||||
// Environment variable flags for hiding sidebar items
|
||||
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } =
|
||||
SIDEBAR_FEATURE_FLAGS;
|
||||
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
|
||||
|
||||
// Get customizable keyboard shortcuts
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
@@ -297,7 +296,6 @@ export function Sidebar() {
|
||||
sidebarOpen={sidebarOpen}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
hideWiki={hideWiki}
|
||||
hideRunningAgents={hideRunningAgents}
|
||||
runningAgentsCount={runningAgentsCount}
|
||||
shortcuts={{ settings: shortcuts.settings }}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { BookOpen, Activity, Settings } from 'lucide-react';
|
||||
import { Activity, Settings } from 'lucide-react';
|
||||
|
||||
interface SidebarFooterProps {
|
||||
sidebarOpen: boolean;
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
hideWiki: boolean;
|
||||
hideRunningAgents: boolean;
|
||||
runningAgentsCount: number;
|
||||
shortcuts: {
|
||||
@@ -19,7 +18,6 @@ export function SidebarFooter({
|
||||
sidebarOpen,
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
hideWiki,
|
||||
hideRunningAgents,
|
||||
runningAgentsCount,
|
||||
shortcuts,
|
||||
@@ -34,66 +32,6 @@ export function SidebarFooter({
|
||||
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Wiki Link */}
|
||||
{!hideWiki && (
|
||||
<div className="p-2 pb-0">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/wiki' })}
|
||||
className={cn(
|
||||
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
|
||||
'transition-all duration-200 ease-out',
|
||||
isActiveRoute('wiki')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground font-medium',
|
||||
'border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
'hover:shadow-sm',
|
||||
],
|
||||
sidebarOpen ? 'justify-start' : 'justify-center',
|
||||
'hover:scale-[1.02] active:scale-[0.97]'
|
||||
)}
|
||||
title={!sidebarOpen ? 'Wiki' : undefined}
|
||||
data-testid="wiki-link"
|
||||
>
|
||||
<BookOpen
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
|
||||
isActiveRoute('wiki')
|
||||
? 'text-brand-500 drop-shadow-sm'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-3 font-medium text-sm flex-1 text-left',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
Wiki
|
||||
</span>
|
||||
{!sidebarOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
|
||||
'bg-popover text-popover-foreground text-xs font-medium',
|
||||
'border border-border shadow-lg',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
'transition-all duration-200 whitespace-nowrap z-50',
|
||||
'translate-x-1 group-hover:translate-x-0'
|
||||
)}
|
||||
>
|
||||
Wiki
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Running Agents Link */}
|
||||
{!hideRunningAgents && (
|
||||
<div className="p-2 pb-0">
|
||||
|
||||
@@ -12,10 +12,20 @@ export function useRunningAgents() {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.runningAgents) {
|
||||
logger.debug('Fetching running agents count');
|
||||
const result = await api.runningAgents.getAll();
|
||||
if (result.success && result.runningAgents) {
|
||||
logger.debug('Running agents count fetched', {
|
||||
count: result.runningAgents.length,
|
||||
});
|
||||
setRunningAgentsCount(result.runningAgents.length);
|
||||
} else {
|
||||
logger.debug('Running agents count fetch returned empty/failed', {
|
||||
success: result.success,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.debug('Running agents API not available');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching running agents count:', error);
|
||||
@@ -26,6 +36,7 @@ export function useRunningAgents() {
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.autoMode) {
|
||||
logger.debug('Auto mode API not available for running agents hook');
|
||||
// If autoMode is not available, still fetch initial count
|
||||
fetchRunningAgentsCount();
|
||||
return;
|
||||
@@ -35,6 +46,9 @@ export function useRunningAgents() {
|
||||
fetchRunningAgentsCount();
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
logger.debug('Auto mode event for running agents hook', {
|
||||
type: event.type,
|
||||
});
|
||||
// When a feature starts, completes, or errors, refresh the count
|
||||
if (
|
||||
event.type === 'auto_mode_feature_complete' ||
|
||||
@@ -50,6 +64,22 @@ export function useRunningAgents() {
|
||||
};
|
||||
}, [fetchRunningAgentsCount]);
|
||||
|
||||
// Subscribe to backlog plan events to update running agents count
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.backlogPlan) return;
|
||||
|
||||
fetchRunningAgentsCount();
|
||||
|
||||
const unsubscribe = api.backlogPlan.onEvent(() => {
|
||||
fetchRunningAgentsCount();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [fetchRunningAgentsCount]);
|
||||
|
||||
return {
|
||||
runningAgentsCount,
|
||||
};
|
||||
|
||||
245
apps/ui/src/components/ui/dependency-selector.tsx
Normal file
245
apps/ui/src/components/ui/dependency-selector.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import * as React from 'react';
|
||||
import { ChevronsUpDown, X, GitBranch, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { wouldCreateCircularDependency } from '@automaker/dependency-resolver';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
interface DependencySelectorProps {
|
||||
/** The current feature being edited (null for add mode) */
|
||||
currentFeatureId?: string;
|
||||
/** Selected feature IDs */
|
||||
value: string[];
|
||||
/** Callback when selection changes */
|
||||
onChange: (ids: string[]) => void;
|
||||
/** All available features to select from */
|
||||
features: Feature[];
|
||||
/** Type of dependency - 'parent' means features this depends on, 'child' means features that depend on this */
|
||||
type: 'parent' | 'child';
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Test ID for testing */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export function DependencySelector({
|
||||
currentFeatureId,
|
||||
value,
|
||||
onChange,
|
||||
features,
|
||||
type,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
'data-testid': testId,
|
||||
}: DependencySelectorProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Update trigger width when component mounts or value changes
|
||||
React.useEffect(() => {
|
||||
if (triggerRef.current) {
|
||||
const updateWidth = () => {
|
||||
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(triggerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Get display label for a feature
|
||||
const getFeatureLabel = (feature: Feature): string => {
|
||||
if (feature.title && feature.title.trim()) {
|
||||
return feature.title;
|
||||
}
|
||||
// Truncate description to 50 chars
|
||||
const desc = feature.description || '';
|
||||
return desc.length > 50 ? desc.slice(0, 47) + '...' : desc;
|
||||
};
|
||||
|
||||
// Filter out current feature and already selected features from options
|
||||
const availableFeatures = React.useMemo(() => {
|
||||
return features.filter((f) => {
|
||||
// Don't show current feature
|
||||
if (currentFeatureId && f.id === currentFeatureId) return false;
|
||||
// Don't show already selected features
|
||||
if (value.includes(f.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [features, currentFeatureId, value]);
|
||||
|
||||
// Filter by search input
|
||||
const filteredFeatures = React.useMemo(() => {
|
||||
if (!inputValue) return availableFeatures;
|
||||
const lower = inputValue.toLowerCase();
|
||||
return availableFeatures.filter((f) => {
|
||||
const label = getFeatureLabel(f).toLowerCase();
|
||||
return label.includes(lower) || f.id.toLowerCase().includes(lower);
|
||||
});
|
||||
}, [availableFeatures, inputValue]);
|
||||
|
||||
// Check if selecting a feature would create a circular dependency
|
||||
const wouldCreateCycle = React.useCallback(
|
||||
(featureId: string): boolean => {
|
||||
if (!currentFeatureId) return false;
|
||||
|
||||
// For parent dependencies: we're adding featureId to currentFeature.dependencies
|
||||
// This would create a cycle if featureId already depends on currentFeatureId
|
||||
if (type === 'parent') {
|
||||
return wouldCreateCircularDependency(features, featureId, currentFeatureId);
|
||||
}
|
||||
|
||||
// For child dependencies: we're adding currentFeatureId to featureId.dependencies
|
||||
// This would create a cycle if currentFeatureId already depends on featureId
|
||||
return wouldCreateCircularDependency(features, currentFeatureId, featureId);
|
||||
},
|
||||
[features, currentFeatureId, type]
|
||||
);
|
||||
|
||||
// Get selected features for display
|
||||
const selectedFeatures = React.useMemo(() => {
|
||||
return value
|
||||
.map((id) => features.find((f) => f.id === id))
|
||||
.filter((f): f is Feature => f !== undefined);
|
||||
}, [value, features]);
|
||||
|
||||
const handleSelect = (featureId: string) => {
|
||||
if (!value.includes(featureId)) {
|
||||
onChange([...value, featureId]);
|
||||
}
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleRemove = (featureId: string) => {
|
||||
onChange(value.filter((id) => id !== featureId));
|
||||
};
|
||||
|
||||
const defaultPlaceholder =
|
||||
type === 'parent' ? 'Select parent dependencies...' : 'Select child dependencies...';
|
||||
|
||||
const Icon = type === 'parent' ? ArrowUp : ArrowDown;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn('w-full justify-between min-h-[40px]')}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate text-muted-foreground">
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{placeholder || defaultPlaceholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{
|
||||
width: Math.max(triggerWidth, 300),
|
||||
}}
|
||||
data-testid={testId ? `${testId}-list` : undefined}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search features..."
|
||||
className="h-9"
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No features found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredFeatures.map((feature) => {
|
||||
const willCreateCycle = wouldCreateCycle(feature.id);
|
||||
const label = getFeatureLabel(feature);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={feature.id}
|
||||
value={feature.id}
|
||||
onSelect={() => {
|
||||
if (!willCreateCycle) {
|
||||
handleSelect(feature.id);
|
||||
}
|
||||
}}
|
||||
disabled={willCreateCycle}
|
||||
className={cn(willCreateCycle && 'opacity-50 cursor-not-allowed')}
|
||||
data-testid={`${testId}-option-${feature.id}`}
|
||||
>
|
||||
<GitBranch className="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{willCreateCycle && (
|
||||
<span className="ml-2 text-xs text-destructive">(circular)</span>
|
||||
)}
|
||||
{feature.status && (
|
||||
<Badge variant="outline" className="ml-2 text-xs">
|
||||
{feature.status}
|
||||
</Badge>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected items as badges */}
|
||||
{selectedFeatures.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedFeatures.map((feature) => (
|
||||
<Badge
|
||||
key={feature.id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 pr-1 text-xs"
|
||||
>
|
||||
<span className="truncate max-w-[150px]">{getFeatureLabel(feature)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRemove(feature.id);
|
||||
}}
|
||||
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -951,6 +951,32 @@ export function BoardView() {
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Load any saved plan from disk when opening the board
|
||||
useEffect(() => {
|
||||
if (!currentProject || pendingBacklogPlan) return;
|
||||
|
||||
let isActive = true;
|
||||
const loadSavedPlan = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const result = await api.backlogPlan.status(currentProject.path);
|
||||
if (
|
||||
isActive &&
|
||||
result.success &&
|
||||
result.savedPlan?.result &&
|
||||
result.savedPlan.result.changes?.length > 0
|
||||
) {
|
||||
setPendingBacklogPlan(result.savedPlan.result);
|
||||
}
|
||||
};
|
||||
|
||||
loadSavedPlan();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [currentProject, pendingBacklogPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
'[AutoMode] Effect triggered - isRunning:',
|
||||
@@ -1384,6 +1410,8 @@ export function BoardView() {
|
||||
}
|
||||
}}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
hasPendingPlan={Boolean(pendingBacklogPlan)}
|
||||
onOpenPendingPlan={() => setShowPlanDialog(true)}
|
||||
isMounted={isMounted}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch } from 'lucide-react';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
@@ -25,6 +25,8 @@ interface BoardHeaderProps {
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onOpenPlanDialog: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
onOpenPendingPlan?: () => void;
|
||||
isMounted: boolean;
|
||||
// Search bar props
|
||||
searchQuery: string;
|
||||
@@ -50,6 +52,8 @@ export function BoardHeader({
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
onOpenPendingPlan,
|
||||
isMounted,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
@@ -192,6 +196,15 @@ export function BoardHeader({
|
||||
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||
{isMounted && !isMobile && (
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
onClick={onOpenPendingPlan || onOpenPlanDialog}
|
||||
className="flex items-center gap-1.5 text-emerald-500 hover:text-emerald-400 transition-colors"
|
||||
data-testid="plan-review-button"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import { DependencySelector } from '@/components/ui/dependency-selector';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -99,6 +100,7 @@ type FeatureData = {
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
workMode: WorkMode;
|
||||
};
|
||||
|
||||
@@ -188,6 +190,10 @@ export function AddFeatureDialog({
|
||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Dependency selection state (not in spawn mode)
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
||||
useAppStore();
|
||||
@@ -224,6 +230,10 @@ export function AddFeatureDialog({
|
||||
setAncestors([]);
|
||||
setSelectedAncestorIds(new Set());
|
||||
}
|
||||
|
||||
// Reset dependency selections
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
@@ -291,6 +301,16 @@ export function AddFeatureDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final dependencies
|
||||
// In spawn mode, use parent feature as dependency
|
||||
// Otherwise, use manually selected parent dependencies
|
||||
const finalDependencies =
|
||||
isSpawnMode && parentFeature
|
||||
? [parentFeature.id]
|
||||
: parentDependencies.length > 0
|
||||
? parentDependencies
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
category: finalCategory,
|
||||
@@ -306,7 +326,8 @@ export function AddFeatureDialog({
|
||||
priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
||||
dependencies: finalDependencies,
|
||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||
workMode,
|
||||
};
|
||||
};
|
||||
@@ -331,6 +352,8 @@ export function AddFeatureDialog({
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
setDescriptionHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -641,6 +664,38 @@ export function AddFeatureDialog({
|
||||
testIdPrefix="feature-work-mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies - only show when not in spawn mode */}
|
||||
{!isSpawnMode && allFeatures.length > 0 && (
|
||||
<div className="pt-2 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Parent Dependencies (this feature depends on)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
value={parentDependencies}
|
||||
onChange={setParentDependencies}
|
||||
features={allFeatures}
|
||||
type="parent"
|
||||
placeholder="Select features this depends on..."
|
||||
data-testid="add-feature-parent-deps"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Child Dependencies (features that depend on this)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
value={childDependencies}
|
||||
onChange={setChildDependencies}
|
||||
features={allFeatures}
|
||||
type="child"
|
||||
placeholder="Select features that will depend on this..."
|
||||
data-testid="add-feature-child-deps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export function AgentOutputModal({
|
||||
onNumberKeyPress,
|
||||
projectPath: projectPathProp,
|
||||
}: AgentOutputModalProps) {
|
||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||
@@ -83,6 +84,11 @@ export function AgentOutputModal({
|
||||
projectPathRef.current = resolvedProjectPath;
|
||||
setProjectPath(resolvedProjectPath);
|
||||
|
||||
if (isBacklogPlan) {
|
||||
setOutput('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use features API to get agent output
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
|
||||
@@ -104,14 +110,14 @@ export function AgentOutputModal({
|
||||
};
|
||||
|
||||
loadOutput();
|
||||
}, [open, featureId, projectPathProp]);
|
||||
}, [open, featureId, projectPathProp, isBacklogPlan]);
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
if (!api?.autoMode || isBacklogPlan) return;
|
||||
|
||||
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
|
||||
|
||||
@@ -272,7 +278,43 @@ export function AgentOutputModal({
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, featureId]);
|
||||
}, [open, featureId, isBacklogPlan]);
|
||||
|
||||
// Listen to backlog plan events and update output
|
||||
useEffect(() => {
|
||||
if (!open || !isBacklogPlan) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const unsubscribe = api.backlogPlan.onEvent((event: any) => {
|
||||
if (!event?.type) return;
|
||||
|
||||
let newContent = '';
|
||||
switch (event.type) {
|
||||
case 'backlog_plan_progress':
|
||||
newContent = `\n🧭 ${event.content || 'Backlog plan progress update'}\n`;
|
||||
break;
|
||||
case 'backlog_plan_error':
|
||||
newContent = `\n❌ Backlog plan error: ${event.error || 'Unknown error'}\n`;
|
||||
break;
|
||||
case 'backlog_plan_complete':
|
||||
newContent = `\n✅ Backlog plan completed\n`;
|
||||
break;
|
||||
default:
|
||||
newContent = `\nℹ️ ${event.type}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
setOutput((prev) => `${prev}${newContent}`);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, isBacklogPlan]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up
|
||||
const handleScroll = () => {
|
||||
@@ -369,7 +411,7 @@ export function AgentOutputModal({
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription
|
||||
className="mt-1 max-h-24 overflow-y-auto break-words"
|
||||
className="mt-1 max-h-24 overflow-y-auto wrap-break-word"
|
||||
data-testid="agent-output-description"
|
||||
>
|
||||
{featureDescription}
|
||||
@@ -377,11 +419,13 @@ export function AgentOutputModal({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Task Progress Panel - shows when tasks are being executed */}
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={projectPath}
|
||||
className="flex-shrink-0 mx-3 my-2"
|
||||
/>
|
||||
{!isBacklogPlan && (
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={projectPath}
|
||||
className="shrink-0 mx-3 my-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveViewMode === 'changes' ? (
|
||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
@@ -423,11 +467,11 @@ export function AgentOutputModal({
|
||||
) : effectiveViewMode === 'parsed' ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
|
||||
<div className="whitespace-pre-wrap wrap-break-word text-zinc-300">{output}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
<div className="text-xs text-muted-foreground text-center shrink-0">
|
||||
{autoScrollRef.current
|
||||
? 'Auto-scrolling enabled'
|
||||
: 'Scroll to bottom to enable auto-scroll'}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -43,16 +44,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model string from PhaseModelEntry or string
|
||||
*/
|
||||
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
|
||||
if (typeof entry === 'string') {
|
||||
return entry as ModelAlias | CursorModelId;
|
||||
}
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
interface BacklogPlanDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -80,6 +71,7 @@ export function BacklogPlanDialog({
|
||||
setIsGeneratingPlan,
|
||||
currentBranch,
|
||||
}: BacklogPlanDialogProps) {
|
||||
const logger = createLogger('BacklogPlanDialog');
|
||||
const [mode, setMode] = useState<DialogMode>('input');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
|
||||
@@ -110,11 +102,17 @@ export function BacklogPlanDialog({
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) {
|
||||
logger.warn('Backlog plan API not available');
|
||||
toast.error('API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start generation in background
|
||||
logger.debug('Starting backlog plan generation', {
|
||||
projectPath,
|
||||
promptLength: prompt.length,
|
||||
hasModelOverride: Boolean(modelOverride),
|
||||
});
|
||||
setIsGeneratingPlan(true);
|
||||
|
||||
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
|
||||
@@ -122,12 +120,20 @@ export function BacklogPlanDialog({
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
|
||||
if (!result.success) {
|
||||
logger.error('Backlog plan generation failed to start', {
|
||||
error: result.error,
|
||||
projectPath,
|
||||
});
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(result.error || 'Failed to start plan generation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show toast and close dialog - generation runs in background
|
||||
logger.debug('Backlog plan generation started', {
|
||||
projectPath,
|
||||
model: effectiveModel,
|
||||
});
|
||||
toast.info('Generating plan... This will be ready soon!', {
|
||||
duration: 3000,
|
||||
});
|
||||
@@ -194,10 +200,15 @@ export function BacklogPlanDialog({
|
||||
currentBranch,
|
||||
]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const handleDiscard = useCallback(async () => {
|
||||
setPendingPlanResult(null);
|
||||
setMode('input');
|
||||
}, [setPendingPlanResult]);
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (api?.backlogPlan) {
|
||||
await api.backlogPlan.clear(projectPath);
|
||||
}
|
||||
}, [setPendingPlanResult, projectPath]);
|
||||
|
||||
const toggleChangeExpanded = (index: number) => {
|
||||
setExpandedChanges((prev) => {
|
||||
@@ -260,11 +271,11 @@ export function BacklogPlanDialog({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Describe the changes you want to make to your backlog. The AI will analyze your
|
||||
current features and propose additions, updates, or deletions.
|
||||
Describe the changes you want to make across your features. The AI will analyze your
|
||||
current feature list and propose additions, updates, deletions, or restructuring.
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="e.g., Add authentication features with login, signup, and password reset. Also add a dashboard feature that depends on authentication."
|
||||
placeholder="e.g., Refactor onboarding into smaller features, add a dashboard feature that depends on authentication, and remove the legacy tour task."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[150px] resize-none"
|
||||
@@ -283,7 +294,7 @@ export function BacklogPlanDialog({
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'review':
|
||||
case 'review': {
|
||||
if (!pendingPlanResult) return null;
|
||||
|
||||
const additions = pendingPlanResult.changes.filter((c) => c.type === 'add');
|
||||
@@ -389,6 +400,7 @@ export function BacklogPlanDialog({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'applying':
|
||||
return (
|
||||
@@ -402,7 +414,6 @@ export function BacklogPlanDialog({
|
||||
|
||||
// Get effective model entry (override or global default)
|
||||
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
@@ -410,12 +421,12 @@ export function BacklogPlanDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="w-5 h-5 text-primary" />
|
||||
{mode === 'review' ? 'Review Plan' : 'Plan Backlog Changes'}
|
||||
{mode === 'review' ? 'Review Plan' : 'Plan Feature Changes'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'review'
|
||||
? 'Select which changes to apply to your backlog'
|
||||
: 'Use AI to add, update, or remove features from your backlog'}
|
||||
? 'Select which changes to apply to your features'
|
||||
: 'Use AI to add, update, remove, or restructure your features'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -447,7 +458,7 @@ export function BacklogPlanDialog({
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Generate Plan
|
||||
Apply Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import { DependencySelector } from '@/components/ui/dependency-selector';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -63,6 +64,8 @@ interface EditFeatureDialogProps {
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: EnhancementMode,
|
||||
@@ -127,6 +130,21 @@ export function EditFeatureDialog({
|
||||
feature?.descriptionHistory ?? []
|
||||
);
|
||||
|
||||
// Dependency state
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>(
|
||||
feature?.dependencies ?? []
|
||||
);
|
||||
// Child dependencies are features that have this feature in their dependencies
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>(() => {
|
||||
if (!feature) return [];
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
// Track original child dependencies to detect changes
|
||||
const [originalChildDependencies, setOriginalChildDependencies] = useState<string[]>(() => {
|
||||
if (!feature) return [];
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (feature) {
|
||||
@@ -145,13 +163,23 @@ export function EditFeatureDialog({
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
// Reset dependency state
|
||||
setParentDependencies(feature.dependencies ?? []);
|
||||
const childDeps = allFeatures
|
||||
.filter((f) => f.dependencies?.includes(feature.id))
|
||||
.map((f) => f.id);
|
||||
setChildDependencies(childDeps);
|
||||
setOriginalChildDependencies(childDeps);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
setOriginalChildDependencies([]);
|
||||
}
|
||||
}, [feature]);
|
||||
}, [feature, allFeatures]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
@@ -180,6 +208,12 @@ export function EditFeatureDialog({
|
||||
// For 'custom' mode, use the specified branch name
|
||||
const finalBranchName = workMode === 'custom' ? editingFeature.branchName || '' : '';
|
||||
|
||||
// Check if child dependencies changed
|
||||
const childDepsChanged =
|
||||
childDependencies.length !== originalChildDependencies.length ||
|
||||
childDependencies.some((id) => !originalChildDependencies.includes(id)) ||
|
||||
originalChildDependencies.some((id) => !childDependencies.includes(id));
|
||||
|
||||
const updates = {
|
||||
title: editingFeature.title ?? '',
|
||||
category: editingFeature.category,
|
||||
@@ -195,6 +229,8 @@ export function EditFeatureDialog({
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
workMode,
|
||||
dependencies: parentDependencies,
|
||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||
};
|
||||
|
||||
// Determine if description changed and what source to use
|
||||
@@ -547,6 +583,40 @@ export function EditFeatureDialog({
|
||||
testIdPrefix="edit-feature-work-mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{allFeatures.length > 1 && (
|
||||
<div className="pt-2 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Parent Dependencies (this feature depends on)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
currentFeatureId={editingFeature.id}
|
||||
value={parentDependencies}
|
||||
onChange={setParentDependencies}
|
||||
features={allFeatures}
|
||||
type="parent"
|
||||
placeholder="Select features this depends on..."
|
||||
data-testid="edit-feature-parent-deps"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Child Dependencies (features that depend on this)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
currentFeatureId={editingFeature.id}
|
||||
value={childDependencies}
|
||||
onChange={setChildDependencies}
|
||||
features={allFeatures}
|
||||
type="child"
|
||||
placeholder="Select features that depend on this..."
|
||||
data-testid="edit-feature-child-deps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export function useBoardActions({
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
}) => {
|
||||
const workMode = featureData.workMode || 'current';
|
||||
@@ -189,6 +190,21 @@ export function useBoardActions({
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
|
||||
// Handle child dependencies - update other features to depend on this new feature
|
||||
if (featureData.childDependencies && featureData.childDependencies.length > 0) {
|
||||
for (const childId of featureData.childDependencies) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
if (!childDeps.includes(createdFeature.id)) {
|
||||
const newDeps = [...childDeps, createdFeature.id];
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate title in the background if needed (non-blocking)
|
||||
if (needsTitleGeneration) {
|
||||
const api = getElectronAPI();
|
||||
@@ -230,6 +246,7 @@ export function useBoardActions({
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
currentWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -250,6 +267,8 @@ export function useBoardActions({
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
@@ -303,8 +322,11 @@ export function useBoardActions({
|
||||
}
|
||||
}
|
||||
|
||||
// Separate child dependencies from the main updates (they affect other features)
|
||||
const { childDependencies, ...restUpdates } = updates;
|
||||
|
||||
const finalUpdates = {
|
||||
...updates,
|
||||
...restUpdates,
|
||||
title: updates.title,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
@@ -317,6 +339,45 @@ export function useBoardActions({
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Handle child dependency changes
|
||||
// This updates other features' dependencies arrays
|
||||
if (childDependencies !== undefined) {
|
||||
// Find current child dependencies (features that have this feature in their dependencies)
|
||||
const currentChildDeps = features
|
||||
.filter((f) => f.dependencies?.includes(featureId))
|
||||
.map((f) => f.id);
|
||||
|
||||
// Find features to add this feature as a dependency (new child deps)
|
||||
const toAdd = childDependencies.filter((id) => !currentChildDeps.includes(id));
|
||||
// Find features to remove this feature as a dependency (removed child deps)
|
||||
const toRemove = currentChildDeps.filter((id) => !childDependencies.includes(id));
|
||||
|
||||
// Add this feature as a dependency to new child features
|
||||
for (const childId of toAdd) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
if (!childDeps.includes(featureId)) {
|
||||
const newDeps = [...childDeps, featureId];
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this feature as a dependency from removed child features
|
||||
for (const childId of toRemove) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
const newDeps = childDeps.filter((depId) => depId !== featureId);
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
@@ -330,6 +391,7 @@ export function useBoardActions({
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Calling API features.update', { featureId, updates });
|
||||
const result = await api.features.update(
|
||||
currentProject.path,
|
||||
featureId,
|
||||
@@ -39,8 +40,14 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
logger.info('API features.update result', {
|
||||
success: result.success,
|
||||
feature: result.feature,
|
||||
});
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
} else if (!result.success) {
|
||||
logger.error('API features.update failed', result);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature update:', error);
|
||||
|
||||
@@ -31,6 +31,7 @@ export function ModelSelector({
|
||||
codexModelsLoading,
|
||||
codexModelsError,
|
||||
fetchCodexModels,
|
||||
disabledProviders,
|
||||
} = useAppStore();
|
||||
const { cursorCliStatus, codexCliStatus } = useSetupStore();
|
||||
|
||||
@@ -69,9 +70,8 @@ export function ModelSelector({
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
|
||||
const cursorModelId = stripProviderPrefix(model.id);
|
||||
return enabledCursorModels.includes(cursorModelId as any);
|
||||
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
|
||||
return enabledCursorModels.includes(model.id as any);
|
||||
});
|
||||
|
||||
const handleProviderChange = (provider: ModelProvider) => {
|
||||
@@ -89,59 +89,79 @@ export function ModelSelector({
|
||||
}
|
||||
};
|
||||
|
||||
// Check which providers are disabled
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
|
||||
// Count available providers
|
||||
const availableProviders = [
|
||||
!isClaudeDisabled && 'claude',
|
||||
!isCursorDisabled && 'cursor',
|
||||
!isCodexDisabled && 'codex',
|
||||
].filter(Boolean) as ModelProvider[];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{availableProviders.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
{!isClaudeDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-claude`}
|
||||
>
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-claude`}
|
||||
>
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{!isCursorDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{!isCodexDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Models */}
|
||||
{selectedProvider === 'claude' && (
|
||||
{selectedProvider === 'claude' && !isClaudeDisabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -179,7 +199,7 @@ export function ModelSelector({
|
||||
)}
|
||||
|
||||
{/* Cursor Models */}
|
||||
{selectedProvider === 'cursor' && (
|
||||
{selectedProvider === 'cursor' && !isCursorDisabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Cursor CLI is not available */}
|
||||
{!isCursorAvailable && (
|
||||
@@ -248,7 +268,7 @@ export function ModelSelector({
|
||||
)}
|
||||
|
||||
{/* Codex Models */}
|
||||
{selectedProvider === 'codex' && (
|
||||
{selectedProvider === 'codex' && !isCodexDisabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Codex CLI is not available */}
|
||||
{!isCodexAvailable && (
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
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 {
|
||||
EditFeatureDialog,
|
||||
AddFeatureDialog,
|
||||
AgentOutputModal,
|
||||
BacklogPlanDialog,
|
||||
} from './board-view/dialogs';
|
||||
import {
|
||||
useBoardFeatures,
|
||||
useBoardActions,
|
||||
useBoardBackground,
|
||||
useBoardPersistence,
|
||||
} from './board-view/hooks';
|
||||
import { useWorktrees } from './board-view/worktree-panel/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';
|
||||
import { toast } from 'sonner';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('GraphViewPage');
|
||||
|
||||
@@ -29,8 +37,14 @@ export function GraphViewPage() {
|
||||
setWorktrees,
|
||||
setCurrentWorktree,
|
||||
defaultSkipTests,
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
planUseSelectedWorktreeBranch,
|
||||
setPlanUseSelectedWorktreeBranch,
|
||||
} = useAppStore();
|
||||
|
||||
// Ensure worktrees are loaded when landing directly on graph view
|
||||
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
||||
|
||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||
const worktrees = useMemo(
|
||||
() =>
|
||||
@@ -62,6 +76,9 @@ export function GraphViewPage() {
|
||||
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
|
||||
const [showOutputModal, setShowOutputModal] = useState(false);
|
||||
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
|
||||
const [showPlanDialog, setShowPlanDialog] = useState(false);
|
||||
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
|
||||
const [isGeneratingPlan, setIsGeneratingPlan] = useState(false);
|
||||
|
||||
// Worktree refresh key
|
||||
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
||||
@@ -116,6 +133,71 @@ export function GraphViewPage() {
|
||||
fetchBranches();
|
||||
}, [currentProject, worktreeRefreshKey]);
|
||||
|
||||
// Listen for backlog plan events (for background generation)
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) {
|
||||
logger.debug('Backlog plan API not available for event subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.backlogPlan.onEvent(
|
||||
(event: { type: string; result?: BacklogPlanResult; error?: string }) => {
|
||||
logger.debug('Backlog plan event received', {
|
||||
type: event.type,
|
||||
hasResult: Boolean(event.result),
|
||||
hasError: Boolean(event.error),
|
||||
});
|
||||
if (event.type === 'backlog_plan_complete') {
|
||||
setIsGeneratingPlan(false);
|
||||
if (event.result && event.result.changes?.length > 0) {
|
||||
setPendingBacklogPlan(event.result);
|
||||
toast.success('Plan ready! Click to review.', {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'Review',
|
||||
onClick: () => setShowPlanDialog(true),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.info('No changes generated. Try again with a different prompt.');
|
||||
}
|
||||
} else if (event.type === 'backlog_plan_error') {
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(`Plan generation failed: ${event.error}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Load any saved plan from disk when opening the graph view
|
||||
useEffect(() => {
|
||||
if (!currentProject || pendingBacklogPlan) return;
|
||||
|
||||
let isActive = true;
|
||||
const loadSavedPlan = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const result = await api.backlogPlan.status(currentProject.path);
|
||||
if (
|
||||
isActive &&
|
||||
result.success &&
|
||||
result.savedPlan?.result &&
|
||||
result.savedPlan.result.changes?.length > 0
|
||||
) {
|
||||
setPendingBacklogPlan(result.savedPlan.result);
|
||||
}
|
||||
};
|
||||
|
||||
loadSavedPlan();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [currentProject, pendingBacklogPlan]);
|
||||
|
||||
// Branch card counts
|
||||
const branchCardCounts = useMemo(() => {
|
||||
return hookFeatures.reduce(
|
||||
@@ -156,6 +238,17 @@ export function GraphViewPage() {
|
||||
});
|
||||
}, [hookFeatures, runningAutoTasks]);
|
||||
|
||||
// Simple feature update handler for graph view (dependencies, etc.)
|
||||
const handleGraphUpdateFeature = useCallback(
|
||||
async (featureId: string, updates: Partial<Feature>) => {
|
||||
logger.info('handleGraphUpdateFeature called', { featureId, updates });
|
||||
updateFeature(featureId, updates);
|
||||
await persistFeatureUpdate(featureId, updates);
|
||||
logger.info('handleGraphUpdateFeature completed');
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
// Board actions hook
|
||||
const {
|
||||
handleAddFeature,
|
||||
@@ -261,13 +354,17 @@ export function GraphViewPage() {
|
||||
onStartTask={handleStartImplementation}
|
||||
onStopTask={handleForceStopFeature}
|
||||
onResumeTask={handleResumeFeature}
|
||||
onUpdateFeature={updateFeature}
|
||||
onUpdateFeature={handleGraphUpdateFeature}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
hasPendingPlan={Boolean(pendingBacklogPlan)}
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
|
||||
/>
|
||||
|
||||
{/* Edit Feature Dialog */}
|
||||
@@ -303,6 +400,14 @@ export function GraphViewPage() {
|
||||
isMaximized={false}
|
||||
parentFeature={spawnParentFeature}
|
||||
allFeatures={hookFeatures}
|
||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||
selectedNonMainWorktreeBranch={
|
||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||
? currentWorktreeBranch || undefined
|
||||
: undefined
|
||||
}
|
||||
// When the worktree setting is disabled, force 'current' branch mode
|
||||
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
|
||||
/>
|
||||
|
||||
{/* Agent Output Modal */}
|
||||
@@ -314,6 +419,19 @@ export function GraphViewPage() {
|
||||
featureStatus={outputFeature?.status}
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
/>
|
||||
|
||||
{/* Backlog Plan Dialog */}
|
||||
<BacklogPlanDialog
|
||||
open={showPlanDialog}
|
||||
onClose={() => setShowPlanDialog(false)}
|
||||
projectPath={currentProject.path}
|
||||
onPlanApplied={loadFeatures}
|
||||
pendingPlanResult={pendingBacklogPlan}
|
||||
setPendingPlanResult={setPendingBacklogPlan}
|
||||
isGeneratingPlan={isGeneratingPlan}
|
||||
setIsGeneratingPlan={setIsGeneratingPlan}
|
||||
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,16 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
edgeData?.onDeleteDependency?.(source, target);
|
||||
console.log('Edge delete button clicked', {
|
||||
source,
|
||||
target,
|
||||
hasCallback: !!edgeData?.onDeleteDependency,
|
||||
});
|
||||
if (edgeData?.onDeleteDependency) {
|
||||
edgeData.onDeleteDependency(source, target);
|
||||
} else {
|
||||
console.error('onDeleteDependency callback is not defined on edge data');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Panel } from '@xyflow/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -43,6 +45,8 @@ interface GraphFilterControlsProps {
|
||||
filterState: GraphFilterState;
|
||||
availableCategories: string[];
|
||||
hasActiveFilter: boolean;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onCategoriesChange: (categories: string[]) => void;
|
||||
onStatusesChange: (statuses: string[]) => void;
|
||||
onNegativeFilterChange: (isNegative: boolean) => void;
|
||||
@@ -53,6 +57,8 @@ export function GraphFilterControls({
|
||||
filterState,
|
||||
availableCategories,
|
||||
hasActiveFilter,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onCategoriesChange,
|
||||
onStatusesChange,
|
||||
onNegativeFilterChange,
|
||||
@@ -114,6 +120,30 @@ export function GraphFilterControls({
|
||||
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
|
||||
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
className="h-8 w-48 pl-8 pr-8 text-sm bg-background/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearchQueryChange('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* Category Filter Dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
|
||||
@@ -60,13 +60,6 @@ const statusConfig = {
|
||||
borderClass: 'border-[var(--status-success)]',
|
||||
bgClass: 'bg-[var(--status-success-bg)]',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
label: 'Completed',
|
||||
colorClass: 'text-[var(--status-success)]',
|
||||
borderClass: 'border-[var(--status-success)]/50',
|
||||
bgClass: 'bg-[var(--status-success-bg)]/50',
|
||||
},
|
||||
};
|
||||
|
||||
const priorityConfig = {
|
||||
@@ -95,8 +88,13 @@ function getCardBorderStyle(
|
||||
|
||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||
// Handle pipeline statuses by treating them like in_progress
|
||||
// Treat completed (archived) as verified for display
|
||||
const status = data.status || 'backlog';
|
||||
const statusKey = status.startsWith('pipeline_') ? 'in_progress' : status;
|
||||
const statusKey = status.startsWith('pipeline_')
|
||||
? 'in_progress'
|
||||
: status === 'completed'
|
||||
? 'verified'
|
||||
: status;
|
||||
const config = statusConfig[statusKey as keyof typeof statusConfig] || statusConfig.backlog;
|
||||
const StatusIcon = config.icon;
|
||||
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ConnectionMode,
|
||||
Node,
|
||||
Connection,
|
||||
Edge,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
@@ -35,8 +36,9 @@ import {
|
||||
} from './hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDebounceValue } from 'usehooks-ts';
|
||||
import { SearchX, Plus } from 'lucide-react';
|
||||
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
|
||||
|
||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -65,11 +67,46 @@ interface GraphCanvasProps {
|
||||
nodeActionCallbacks?: NodeActionCallbacks;
|
||||
onCreateDependency?: (sourceId: string, targetId: string) => Promise<boolean>;
|
||||
onAddFeature?: () => void;
|
||||
onOpenPlanDialog?: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
planUseSelectedWorktreeBranch?: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange?: (value: boolean) => void;
|
||||
backgroundStyle?: React.CSSProperties;
|
||||
backgroundSettings?: BackgroundSettings;
|
||||
className?: string;
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
// Helper to get session storage key for viewport
|
||||
const getViewportStorageKey = (projectPath: string) => `graph-viewport:${projectPath}`;
|
||||
|
||||
// Helper to save viewport to session storage
|
||||
const saveViewportToStorage = (
|
||||
projectPath: string,
|
||||
viewport: { x: number; y: number; zoom: number }
|
||||
) => {
|
||||
try {
|
||||
sessionStorage.setItem(getViewportStorageKey(projectPath), JSON.stringify(viewport));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to load viewport from session storage
|
||||
const loadViewportFromStorage = (
|
||||
projectPath: string
|
||||
): { x: number; y: number; zoom: number } | null => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(getViewportStorageKey(projectPath));
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function GraphCanvasInner({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
@@ -79,12 +116,38 @@ function GraphCanvasInner({
|
||||
nodeActionCallbacks,
|
||||
onCreateDependency,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
backgroundStyle,
|
||||
backgroundSettings,
|
||||
className,
|
||||
projectPath,
|
||||
}: GraphCanvasProps) {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||
const { setViewport, getViewport, fitView } = useReactFlow();
|
||||
|
||||
// Refs for tracking layout and viewport state
|
||||
const hasRestoredViewport = useRef(false);
|
||||
const lastProjectPath = useRef(projectPath);
|
||||
const hasInitialLayout = useRef(false);
|
||||
const prevNodeIds = useRef<Set<string>>(new Set());
|
||||
const prevLayoutVersion = useRef<number>(0);
|
||||
const hasLayoutWithEdges = useRef(false);
|
||||
|
||||
// Reset flags when project changes
|
||||
useEffect(() => {
|
||||
if (projectPath !== lastProjectPath.current) {
|
||||
hasRestoredViewport.current = false;
|
||||
hasLayoutWithEdges.current = false;
|
||||
hasInitialLayout.current = false;
|
||||
prevNodeIds.current = new Set();
|
||||
prevLayoutVersion.current = 0;
|
||||
lastProjectPath.current = projectPath;
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
// Determine React Flow color mode based on current theme
|
||||
const effectiveTheme = useAppStore((state) => state.getEffectiveTheme());
|
||||
@@ -145,7 +208,7 @@ function GraphCanvasInner({
|
||||
});
|
||||
|
||||
// Apply layout
|
||||
const { layoutedNodes, layoutedEdges, runLayout } = useGraphLayout({
|
||||
const { layoutedNodes, layoutedEdges, layoutVersion, runLayout } = useGraphLayout({
|
||||
nodes: initialNodes,
|
||||
edges: initialEdges,
|
||||
});
|
||||
@@ -154,24 +217,22 @@ function GraphCanvasInner({
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||
|
||||
// Track if initial layout has been applied
|
||||
const hasInitialLayout = useRef(false);
|
||||
// Track the previous node IDs to detect new nodes
|
||||
const prevNodeIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Update nodes/edges when features change, but preserve user positions
|
||||
useEffect(() => {
|
||||
const currentNodeIds = new Set(layoutedNodes.map((n) => n.id));
|
||||
const isInitialRender = !hasInitialLayout.current;
|
||||
// Detect if a fresh layout was computed (structure changed)
|
||||
const layoutWasRecomputed = layoutVersion !== prevLayoutVersion.current;
|
||||
|
||||
// Check if there are new nodes that need layout
|
||||
const hasNewNodes = layoutedNodes.some((n) => !prevNodeIds.current.has(n.id));
|
||||
|
||||
if (isInitialRender) {
|
||||
// Apply full layout for initial render
|
||||
if (isInitialRender || layoutWasRecomputed) {
|
||||
// Apply full layout for initial render OR when layout was recomputed due to structure change
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(layoutedEdges);
|
||||
hasInitialLayout.current = true;
|
||||
prevLayoutVersion.current = layoutVersion;
|
||||
} else if (hasNewNodes) {
|
||||
// New nodes added - need to re-layout but try to preserve existing positions
|
||||
setNodes((currentNodes) => {
|
||||
@@ -197,15 +258,55 @@ function GraphCanvasInner({
|
||||
|
||||
// Update prev node IDs for next comparison
|
||||
prevNodeIds.current = currentNodeIds;
|
||||
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||
|
||||
// Restore viewport from session storage after initial layout
|
||||
if (isInitialRender && projectPath && !hasRestoredViewport.current) {
|
||||
const savedViewport = loadViewportFromStorage(projectPath);
|
||||
if (savedViewport) {
|
||||
// Use setTimeout to ensure React Flow has finished rendering
|
||||
setTimeout(() => {
|
||||
setViewport(savedViewport, { duration: 0 });
|
||||
}, 50);
|
||||
}
|
||||
hasRestoredViewport.current = true;
|
||||
}
|
||||
}, [layoutedNodes, layoutedEdges, layoutVersion, setNodes, setEdges, projectPath, setViewport]);
|
||||
|
||||
// Force layout recalculation on initial mount when edges are available
|
||||
// This fixes timing issues when navigating directly to the graph route
|
||||
useEffect(() => {
|
||||
// Only run once: when we have nodes and edges but haven't done a layout with edges yet
|
||||
if (!hasLayoutWithEdges.current && layoutedNodes.length > 0 && layoutedEdges.length > 0) {
|
||||
hasLayoutWithEdges.current = true;
|
||||
// Small delay to ensure React Flow is mounted and ready
|
||||
const timeoutId = setTimeout(() => {
|
||||
const { nodes: relayoutedNodes, edges: relayoutedEdges } = runLayout('LR');
|
||||
setNodes(relayoutedNodes);
|
||||
setEdges(relayoutedEdges);
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [layoutedNodes.length, layoutedEdges.length, runLayout, setNodes, setEdges, fitView]);
|
||||
|
||||
// Save viewport when user pans or zooms
|
||||
const handleMoveEnd = useCallback(() => {
|
||||
if (projectPath) {
|
||||
const viewport = getViewport();
|
||||
saveViewportToStorage(projectPath, viewport);
|
||||
}
|
||||
}, [projectPath, getViewport]);
|
||||
|
||||
// Handle layout direction change
|
||||
const handleRunLayout = useCallback(
|
||||
(direction: 'LR' | 'TB') => {
|
||||
setLayoutDirection(direction);
|
||||
runLayout(direction);
|
||||
const { nodes: relayoutedNodes, edges: relayoutedEdges } = runLayout(direction);
|
||||
setNodes(relayoutedNodes);
|
||||
setEdges(relayoutedEdges);
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
},
|
||||
[runLayout]
|
||||
[runLayout, setNodes, setEdges, fitView]
|
||||
);
|
||||
|
||||
// Handle clear all filters
|
||||
@@ -247,9 +348,6 @@ function GraphCanvasInner({
|
||||
[]
|
||||
);
|
||||
|
||||
// Get fitView from React Flow for orientation change handling
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Handle orientation changes on mobile devices
|
||||
// When rotating from landscape to portrait, the view may incorrectly zoom in
|
||||
// This effect listens for orientation changes and calls fitView to correct the viewport
|
||||
@@ -323,6 +421,23 @@ function GraphCanvasInner({
|
||||
};
|
||||
}, [fitView]);
|
||||
|
||||
// Handle edge deletion (when user presses delete key or uses other deletion methods)
|
||||
const handleEdgesDelete = useCallback(
|
||||
(deletedEdges: Edge[]) => {
|
||||
console.log('onEdgesDelete triggered', deletedEdges);
|
||||
deletedEdges.forEach((edge) => {
|
||||
if (nodeActionCallbacks?.onDeleteDependency) {
|
||||
console.log('Calling onDeleteDependency from onEdgesDelete', {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
});
|
||||
nodeActionCallbacks.onDeleteDependency(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
[nodeActionCallbacks]
|
||||
);
|
||||
|
||||
// MiniMap node color based on status
|
||||
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||
const data = node.data as TaskNodeData | undefined;
|
||||
@@ -349,7 +464,9 @@ function GraphCanvasInner({
|
||||
edges={edges}
|
||||
onNodesChange={isLocked ? undefined : onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={handleEdgesDelete}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
onConnect={handleConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -392,6 +509,8 @@ function GraphCanvasInner({
|
||||
filterState={filterState}
|
||||
availableCategories={filterResult.availableCategories}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
onStatusesChange={setSelectedStatuses}
|
||||
onNegativeFilterChange={setIsNegativeFilter}
|
||||
@@ -402,10 +521,42 @@ function GraphCanvasInner({
|
||||
|
||||
{/* Add Feature Button */}
|
||||
<Panel position="top-right">
|
||||
<Button variant="default" size="sm" onClick={onAddFeature} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenPlanDialog && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-border bg-secondary/60 px-2 py-1 shadow-sm">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center text-emerald-500 hover:text-emerald-400 transition-colors"
|
||||
data-testid="graph-plan-review-button"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onOpenPlanDialog}
|
||||
className="gap-1.5"
|
||||
data-testid="graph-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
Plan
|
||||
</Button>
|
||||
{onPlanUseSelectedWorktreeBranchChange &&
|
||||
planUseSelectedWorktreeBranch !== undefined && (
|
||||
<PlanSettingsPopover
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="default" size="sm" onClick={onAddFeature} className="gap-1.5">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Empty state when all nodes are filtered out */}
|
||||
|
||||
@@ -23,6 +23,10 @@ interface GraphViewProps {
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onDeleteTask?: (feature: Feature) => void;
|
||||
onAddFeature?: () => void;
|
||||
onOpenPlanDialog?: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
planUseSelectedWorktreeBranch?: boolean;
|
||||
onPlanUseSelectedWorktreeBranchChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
@@ -42,6 +46,10 @@ export function GraphView({
|
||||
onSpawnTask,
|
||||
onDeleteTask,
|
||||
onAddFeature,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
planUseSelectedWorktreeBranch,
|
||||
onPlanUseSelectedWorktreeBranchChange,
|
||||
}: GraphViewProps) {
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
@@ -53,9 +61,6 @@ export function GraphView({
|
||||
const effectiveBranch = currentWorktreeBranch;
|
||||
|
||||
return features.filter((f) => {
|
||||
// Skip completed features (they're in archive)
|
||||
if (f.status === 'completed') return false;
|
||||
|
||||
const featureBranch = f.branchName as string | undefined;
|
||||
|
||||
if (!featureBranch) {
|
||||
@@ -178,15 +183,26 @@ export function GraphView({
|
||||
},
|
||||
onDeleteDependency: (sourceId: string, targetId: string) => {
|
||||
// Find the target feature and remove the source from its dependencies
|
||||
console.log('onDeleteDependency called', { sourceId, targetId });
|
||||
const targetFeature = features.find((f) => f.id === targetId);
|
||||
if (!targetFeature) return;
|
||||
if (!targetFeature) {
|
||||
console.error('Target feature not found:', targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDeps = (targetFeature.dependencies as string[] | undefined) || [];
|
||||
console.log('Current dependencies:', currentDeps);
|
||||
const newDeps = currentDeps.filter((depId) => depId !== sourceId);
|
||||
console.log('New dependencies:', newDeps);
|
||||
|
||||
onUpdateFeature?.(targetId, {
|
||||
dependencies: newDeps,
|
||||
});
|
||||
if (onUpdateFeature) {
|
||||
console.log('Calling onUpdateFeature');
|
||||
onUpdateFeature(targetId, {
|
||||
dependencies: newDeps,
|
||||
});
|
||||
} else {
|
||||
console.error('onUpdateFeature is not defined!');
|
||||
}
|
||||
|
||||
toast.success('Dependency removed');
|
||||
},
|
||||
@@ -215,8 +231,13 @@ export function GraphView({
|
||||
nodeActionCallbacks={nodeActionCallbacks}
|
||||
onCreateDependency={handleCreateDependency}
|
||||
onAddFeature={onAddFeature}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
hasPendingPlan={hasPendingPlan}
|
||||
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
|
||||
onPlanUseSelectedWorktreeBranchChange={onPlanUseSelectedWorktreeBranchChange}
|
||||
backgroundStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
projectPath={projectPath}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,11 +89,16 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
|
||||
|
||||
/**
|
||||
* Gets the effective status of a feature (accounting for running state)
|
||||
* Treats completed (archived) as verified
|
||||
*/
|
||||
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
|
||||
if (feature.status === 'in_progress') {
|
||||
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
|
||||
}
|
||||
// Treat completed (archived) as verified
|
||||
if (feature.status === 'completed') {
|
||||
return 'verified';
|
||||
}
|
||||
return feature.status as StatusFilterValue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import dagre from 'dagre';
|
||||
import { Node, Edge, useReactFlow } from '@xyflow/react';
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import { TaskNode, DependencyEdge } from './use-graph-nodes';
|
||||
|
||||
const NODE_WIDTH = 280;
|
||||
@@ -16,11 +16,11 @@ interface UseGraphLayoutProps {
|
||||
* Dependencies flow left-to-right
|
||||
*/
|
||||
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
const { fitView, setNodes } = useReactFlow();
|
||||
|
||||
// Cache the last computed positions to avoid recalculating layout
|
||||
const positionCache = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const lastStructureKey = useRef<string>('');
|
||||
// Track layout version to signal when fresh layout was computed
|
||||
const layoutVersion = useRef<number>(0);
|
||||
|
||||
const getLayoutedElements = useCallback(
|
||||
(
|
||||
@@ -71,31 +71,39 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Create a stable structure key based only on node IDs (not edge changes)
|
||||
// Edges changing shouldn't trigger re-layout
|
||||
// Create a stable structure key based on node IDs AND edge connections
|
||||
// Layout must recalculate when the dependency graph structure changes
|
||||
const structureKey = useMemo(() => {
|
||||
const nodeIds = nodes
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
return nodeIds;
|
||||
}, [nodes]);
|
||||
// Include edge structure (source->target pairs) to ensure layout recalculates
|
||||
// when dependencies change, not just when nodes are added/removed
|
||||
const edgeConnections = edges
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
return `${nodeIds}|${edgeConnections}`;
|
||||
}, [nodes, edges]);
|
||||
|
||||
// Initial layout - only recalculate when node structure changes (new nodes added/removed)
|
||||
// Initial layout - recalculate when graph structure changes (nodes added/removed OR edges/dependencies change)
|
||||
const layoutedElements = useMemo(() => {
|
||||
if (nodes.length === 0) {
|
||||
positionCache.current.clear();
|
||||
lastStructureKey.current = '';
|
||||
return { nodes: [], edges: [] };
|
||||
return { nodes: [], edges: [], didRelayout: false };
|
||||
}
|
||||
|
||||
// Check if structure changed (new nodes added or removed)
|
||||
// Check if structure changed (nodes added/removed OR dependencies changed)
|
||||
const structureChanged = structureKey !== lastStructureKey.current;
|
||||
|
||||
if (structureChanged) {
|
||||
// Structure changed - run full layout
|
||||
lastStructureKey.current = structureKey;
|
||||
return getLayoutedElements(nodes, edges, 'LR');
|
||||
layoutVersion.current += 1;
|
||||
const result = getLayoutedElements(nodes, edges, 'LR');
|
||||
return { ...result, didRelayout: true };
|
||||
} else {
|
||||
// Structure unchanged - preserve cached positions, just update node data
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
@@ -107,26 +115,22 @@ export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||
sourcePosition: 'right',
|
||||
} as TaskNode;
|
||||
});
|
||||
return { nodes: layoutedNodes, edges };
|
||||
return { nodes: layoutedNodes, edges, didRelayout: false };
|
||||
}
|
||||
}, [nodes, edges, structureKey, getLayoutedElements]);
|
||||
|
||||
// Manual re-layout function
|
||||
const runLayout = useCallback(
|
||||
(direction: 'LR' | 'TB' = 'LR') => {
|
||||
const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, direction);
|
||||
setNodes(layoutedNodes);
|
||||
// Fit view after layout with a small delay to allow DOM updates
|
||||
setTimeout(() => {
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, 50);
|
||||
return getLayoutedElements(nodes, edges, direction);
|
||||
},
|
||||
[nodes, edges, getLayoutedElements, setNodes, fitView]
|
||||
[nodes, edges, getLayoutedElements]
|
||||
);
|
||||
|
||||
return {
|
||||
layoutedNodes: layoutedElements.nodes,
|
||||
layoutedEdges: layoutedElements.edges,
|
||||
layoutVersion: layoutVersion.current,
|
||||
runLayout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,10 +22,20 @@ export function RunningAgentsView() {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (api.runningAgents) {
|
||||
logger.debug('Fetching running agents list');
|
||||
const result = await api.runningAgents.getAll();
|
||||
if (result.success && result.runningAgents) {
|
||||
logger.debug('Running agents list fetched', {
|
||||
count: result.runningAgents.length,
|
||||
});
|
||||
setRunningAgents(result.runningAgents);
|
||||
} else {
|
||||
logger.debug('Running agents list fetch returned empty/failed', {
|
||||
success: result.success,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.debug('Running agents API not available');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching running agents:', error);
|
||||
@@ -52,9 +62,15 @@ export function RunningAgentsView() {
|
||||
// Subscribe to auto-mode events to update in real-time
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.autoMode) return;
|
||||
if (!api.autoMode) {
|
||||
logger.debug('Auto mode API not available for running agents view');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
logger.debug('Auto mode event in running agents view', {
|
||||
type: event.type,
|
||||
});
|
||||
// When a feature completes or errors, refresh the list
|
||||
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
|
||||
fetchRunningAgents();
|
||||
@@ -67,18 +83,29 @@ export function RunningAgentsView() {
|
||||
}, [fetchRunningAgents]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
logger.debug('Manual refresh requested for running agents');
|
||||
setRefreshing(true);
|
||||
fetchRunningAgents();
|
||||
}, [fetchRunningAgents]);
|
||||
|
||||
const handleStopAgent = useCallback(
|
||||
async (featureId: string) => {
|
||||
async (agent: RunningAgent) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
|
||||
if (isBacklogPlan && api.backlogPlan) {
|
||||
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
|
||||
await api.backlogPlan.stop();
|
||||
fetchRunningAgents();
|
||||
return;
|
||||
}
|
||||
if (api.autoMode) {
|
||||
await api.autoMode.stopFeature(featureId);
|
||||
logger.debug('Stopping running agent', { featureId: agent.featureId });
|
||||
await api.autoMode.stopFeature(agent.featureId);
|
||||
// Refresh list after stopping
|
||||
fetchRunningAgents();
|
||||
} else {
|
||||
logger.debug('Auto mode API not available to stop agent', { featureId: agent.featureId });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error stopping agent:', error);
|
||||
@@ -92,14 +119,27 @@ export function RunningAgentsView() {
|
||||
// Find the project by path
|
||||
const project = projects.find((p) => p.path === agent.projectPath);
|
||||
if (project) {
|
||||
logger.debug('Navigating to running agent project', {
|
||||
projectPath: agent.projectPath,
|
||||
featureId: agent.featureId,
|
||||
});
|
||||
setCurrentProject(project);
|
||||
navigate({ to: '/board' });
|
||||
} else {
|
||||
logger.debug('Project not found for running agent', {
|
||||
projectPath: agent.projectPath,
|
||||
featureId: agent.featureId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[projects, setCurrentProject, navigate]
|
||||
);
|
||||
|
||||
const handleViewLogs = useCallback((agent: RunningAgent) => {
|
||||
logger.debug('Opening running agent logs', {
|
||||
featureId: agent.featureId,
|
||||
projectPath: agent.projectPath,
|
||||
});
|
||||
setSelectedAgent(agent);
|
||||
}, []);
|
||||
|
||||
@@ -195,7 +235,7 @@ export function RunningAgentsView() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -213,11 +253,7 @@ export function RunningAgentsView() {
|
||||
>
|
||||
View Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleStopAgent(agent.featureId)}
|
||||
>
|
||||
<Button variant="destructive" size="sm" onClick={() => handleStopAgent(agent)}>
|
||||
<Square className="h-3.5 w-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
|
||||
@@ -6,9 +6,19 @@ import type { Project } from '@/lib/electron';
|
||||
import type { NavigationItem, NavigationGroup } from '../config/navigation';
|
||||
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
|
||||
const PROVIDERS_DROPDOWN_KEY = 'settings-providers-dropdown-open';
|
||||
|
||||
// Map navigation item IDs to provider types for checking disabled state
|
||||
const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
|
||||
'claude-provider': 'claude',
|
||||
'cursor-provider': 'cursor',
|
||||
'codex-provider': 'codex',
|
||||
'opencode-provider': 'opencode',
|
||||
};
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
navItems: NavigationItem[];
|
||||
activeSection: SettingsViewId;
|
||||
@@ -73,6 +83,8 @@ function NavItemWithSubItems({
|
||||
activeSection: SettingsViewId;
|
||||
onNavigate: (sectionId: SettingsViewId) => void;
|
||||
}) {
|
||||
const disabledProviders = useAppStore((state) => state.disabledProviders);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(PROVIDERS_DROPDOWN_KEY);
|
||||
@@ -123,6 +135,9 @@ function NavItemWithSubItems({
|
||||
{item.subItems.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
const isSubActive = subItem.id === activeSection;
|
||||
// Check if this provider is disabled
|
||||
const provider = NAV_ID_TO_PROVIDER[subItem.id];
|
||||
const isDisabled = provider && disabledProviders.includes(provider);
|
||||
return (
|
||||
<button
|
||||
key={subItem.id}
|
||||
@@ -141,7 +156,9 @@ function NavItemWithSubItems({
|
||||
'hover:bg-accent/50',
|
||||
'border border-transparent hover:border-border/40',
|
||||
],
|
||||
'hover:scale-[1.01] active:scale-[0.98]'
|
||||
'hover:scale-[1.01] active:scale-[0.98]',
|
||||
// Gray out disabled providers
|
||||
isDisabled && !isSubActive && 'opacity-40'
|
||||
)}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
@@ -153,7 +170,9 @@ function NavItemWithSubItems({
|
||||
'w-4 h-4 shrink-0 transition-all duration-200',
|
||||
isSubActive
|
||||
? 'text-brand-500'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110'
|
||||
: 'group-hover:text-brand-400 group-hover:scale-110',
|
||||
// Gray out icon for disabled providers
|
||||
isDisabled && !isSubActive && 'opacity-60'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{subItem.label}</span>
|
||||
|
||||
@@ -168,6 +168,7 @@ export function PhaseModelSelector({
|
||||
dynamicOpencodeModels,
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
disabledProviders,
|
||||
} = useAppStore();
|
||||
|
||||
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||
@@ -278,8 +279,8 @@ export function PhaseModelSelector({
|
||||
|
||||
// Filter Cursor models to only show enabled ones
|
||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||
return enabledCursorModels.includes(cursorId);
|
||||
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
|
||||
return enabledCursorModels.includes(model.id as CursorModelId);
|
||||
});
|
||||
|
||||
// Helper to find current selected model details
|
||||
@@ -298,9 +299,7 @@ export function PhaseModelSelector({
|
||||
};
|
||||
}
|
||||
|
||||
const cursorModel = availableCursorModels.find(
|
||||
(m) => stripProviderPrefix(m.id) === selectedModel
|
||||
);
|
||||
const cursorModel = availableCursorModels.find((m) => m.id === selectedModel);
|
||||
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
|
||||
|
||||
// Check if selectedModel is part of a grouped model
|
||||
@@ -400,7 +399,7 @@ export function PhaseModelSelector({
|
||||
return [...staticModels, ...uniqueDynamic];
|
||||
}, [dynamicOpencodeModels]);
|
||||
|
||||
// Group models
|
||||
// Group models (filtering out disabled providers)
|
||||
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
|
||||
const favs: typeof CLAUDE_MODELS = [];
|
||||
const cModels: typeof CLAUDE_MODELS = [];
|
||||
@@ -408,41 +407,54 @@ export function PhaseModelSelector({
|
||||
const codModels: typeof transformedCodexModels = [];
|
||||
const ocModels: ModelOption[] = [];
|
||||
|
||||
// Process Claude Models
|
||||
CLAUDE_MODELS.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
cModels.push(model);
|
||||
}
|
||||
});
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||
|
||||
// Process Cursor Models
|
||||
availableCursorModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
curModels.push(model);
|
||||
}
|
||||
});
|
||||
// Process Claude Models (skip if provider is disabled)
|
||||
if (!isClaudeDisabled) {
|
||||
CLAUDE_MODELS.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
cModels.push(model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process Codex Models
|
||||
transformedCodexModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
codModels.push(model);
|
||||
}
|
||||
});
|
||||
// Process Cursor Models (skip if provider is disabled)
|
||||
if (!isCursorDisabled) {
|
||||
availableCursorModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
curModels.push(model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process OpenCode Models (including dynamic)
|
||||
allOpencodeModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
ocModels.push(model);
|
||||
}
|
||||
});
|
||||
// Process Codex Models (skip if provider is disabled)
|
||||
if (!isCodexDisabled) {
|
||||
transformedCodexModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
codModels.push(model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process OpenCode Models (skip if provider is disabled)
|
||||
if (!isOpencodeDisabled) {
|
||||
allOpencodeModels.forEach((model) => {
|
||||
if (favoriteModels.includes(model.id)) {
|
||||
favs.push(model);
|
||||
} else {
|
||||
ocModels.push(model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
favorites: favs,
|
||||
@@ -451,7 +463,13 @@ export function PhaseModelSelector({
|
||||
codex: codModels,
|
||||
opencode: ocModels,
|
||||
};
|
||||
}, [favoriteModels, availableCursorModels, transformedCodexModels, allOpencodeModels]);
|
||||
}, [
|
||||
favoriteModels,
|
||||
availableCursorModels,
|
||||
transformedCodexModels,
|
||||
allOpencodeModels,
|
||||
disabledProviders,
|
||||
]);
|
||||
|
||||
// Group OpenCode models by model type for better organization
|
||||
const opencodeSections = useMemo(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings';
|
||||
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
|
||||
import { SkillsSection } from './claude-settings-tab/skills-section';
|
||||
import { SubagentsSection } from './claude-settings-tab/subagents-section';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
export function ClaudeSettingsTab() {
|
||||
@@ -24,6 +25,9 @@ export function ClaudeSettingsTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Provider Visibility Toggle */}
|
||||
<ProviderToggle provider="claude" providerLabel="Claude" />
|
||||
|
||||
{/* Usage Info */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
|
||||
<Info className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CodexCliStatus } from '../cli-status/codex-cli-status';
|
||||
import { CodexSettings } from '../codex/codex-settings';
|
||||
import { CodexUsageSection } from '../codex/codex-usage-section';
|
||||
import { CodexModelConfiguration } from './codex-model-configuration';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||
@@ -162,6 +163,9 @@ export function CodexSettingsTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Provider Visibility Toggle */}
|
||||
<ProviderToggle provider="codex" providerLabel="Codex" />
|
||||
|
||||
<CodexCliStatus
|
||||
status={codexCliStatus}
|
||||
authStatus={authStatusToDisplay}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useCursorStatus } from '../hooks/use-cursor-status';
|
||||
import { useCursorPermissions } from '../hooks/use-cursor-permissions';
|
||||
import { CursorPermissionsSection } from './cursor-permissions-section';
|
||||
import { CursorModelConfiguration } from './cursor-model-configuration';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
|
||||
export function CursorSettingsTab() {
|
||||
// Global settings from store
|
||||
@@ -73,6 +74,9 @@ export function CursorSettingsTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Provider Visibility Toggle */}
|
||||
<ProviderToggle provider="cursor" providerLabel="Cursor" />
|
||||
|
||||
{/* CLI Status */}
|
||||
<CursorCliStatus status={status} isChecking={isLoading} onRefresh={loadData} />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
||||
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||
@@ -290,6 +291,9 @@ export function OpencodeSettingsTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Provider Visibility Toggle */}
|
||||
<ProviderToggle provider="opencode" providerLabel="OpenCode" />
|
||||
|
||||
<OpencodeCliStatus
|
||||
status={cliStatus}
|
||||
authStatus={authStatus}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { EyeOff, Eye } from 'lucide-react';
|
||||
|
||||
interface ProviderToggleProps {
|
||||
provider: ModelProvider;
|
||||
providerLabel: string;
|
||||
}
|
||||
|
||||
export function ProviderToggle({ provider, providerLabel }: ProviderToggleProps) {
|
||||
const { disabledProviders, toggleProviderDisabled } = useAppStore();
|
||||
const isDisabled = disabledProviders.includes(provider);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-accent/20 border border-border/30">
|
||||
<div className="flex items-center gap-3">
|
||||
{isDisabled ? (
|
||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Show {providerLabel} in model dropdowns</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{isDisabled
|
||||
? `${providerLabel} models are hidden from all model selectors`
|
||||
: `${providerLabel} models appear in model selection dropdowns`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={(checked) => toggleProviderDisabled(provider, !checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderToggle;
|
||||
@@ -160,6 +160,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
opencodeDefaultModel: state.opencodeDefaultModel as GlobalSettings['opencodeDefaultModel'],
|
||||
enabledDynamicModelIds:
|
||||
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
|
||||
disabledProviders: (state.disabledProviders ?? []) as GlobalSettings['disabledProviders'],
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
|
||||
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||
@@ -574,6 +575,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
disabledProviders: settings.disabledProviders ?? [],
|
||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
||||
skipSandboxWarning: settings.skipSandboxWarning ?? false,
|
||||
keyboardShortcuts: {
|
||||
@@ -628,6 +630,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
validationModel: state.validationModel,
|
||||
phaseModels: state.phaseModels,
|
||||
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||
disabledProviders: state.disabledProviders,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
skipSandboxWarning: state.skipSandboxWarning,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
|
||||
@@ -52,6 +52,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'enabledOpencodeModels',
|
||||
'opencodeDefaultModel',
|
||||
'enabledDynamicModelIds',
|
||||
'disabledProviders',
|
||||
'autoLoadClaudeMd',
|
||||
'keyboardShortcuts',
|
||||
'mcpServers',
|
||||
@@ -477,6 +478,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
||||
keyboardShortcuts: {
|
||||
...currentAppState.keyboardShortcuts,
|
||||
|
||||
@@ -639,7 +639,30 @@ export interface ElectronAPI {
|
||||
model?: string
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
status: () => Promise<{ success: boolean; isRunning?: boolean; error?: string }>;
|
||||
status: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
savedPlan?: {
|
||||
savedAt: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
result: {
|
||||
changes: Array<{
|
||||
type: 'add' | 'update' | 'delete';
|
||||
featureId?: string;
|
||||
feature?: Record<string, unknown>;
|
||||
reason: string;
|
||||
}>;
|
||||
summary: string;
|
||||
dependencyUpdates: Array<{
|
||||
featureId: string;
|
||||
removedDependencies: string[];
|
||||
addedDependencies: string[];
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
error?: string;
|
||||
}>;
|
||||
apply: (
|
||||
projectPath: string,
|
||||
plan: {
|
||||
@@ -658,6 +681,7 @@ export interface ElectronAPI {
|
||||
},
|
||||
branchName?: string
|
||||
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
|
||||
clear: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onEvent: (callback: (data: unknown) => void) => () => void;
|
||||
};
|
||||
// Setup API surface is implemented by the main process and mirrored by HttpApiClient.
|
||||
|
||||
@@ -2325,8 +2325,32 @@ export class HttpApiClient implements ElectronAPI {
|
||||
stop: (): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/backlog-plan/stop', {}),
|
||||
|
||||
status: (): Promise<{ success: boolean; isRunning?: boolean; error?: string }> =>
|
||||
this.get('/api/backlog-plan/status'),
|
||||
status: (
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
isRunning?: boolean;
|
||||
savedPlan?: {
|
||||
savedAt: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
result: {
|
||||
changes: Array<{
|
||||
type: 'add' | 'update' | 'delete';
|
||||
featureId?: string;
|
||||
feature?: Record<string, unknown>;
|
||||
reason: string;
|
||||
}>;
|
||||
summary: string;
|
||||
dependencyUpdates: Array<{
|
||||
featureId: string;
|
||||
removedDependencies: string[];
|
||||
addedDependencies: string[];
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
error?: string;
|
||||
}> => this.get(`/api/backlog-plan/status?projectPath=${encodeURIComponent(projectPath)}`),
|
||||
|
||||
apply: (
|
||||
projectPath: string,
|
||||
@@ -2348,6 +2372,9 @@ export class HttpApiClient implements ElectronAPI {
|
||||
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
|
||||
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
|
||||
|
||||
clear: (projectPath: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post('/api/backlog-plan/clear', { projectPath }),
|
||||
|
||||
onEvent: (callback: (data: unknown) => void): (() => void) => {
|
||||
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
|
||||
},
|
||||
|
||||
@@ -606,6 +606,9 @@ export interface AppState {
|
||||
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
|
||||
opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch
|
||||
|
||||
// Provider Visibility Settings
|
||||
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
|
||||
|
||||
// Claude Agent SDK Settings
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
|
||||
@@ -1021,6 +1024,11 @@ export interface AppActions {
|
||||
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
||||
) => void;
|
||||
|
||||
// Provider Visibility Settings actions
|
||||
setDisabledProviders: (providers: ModelProvider[]) => void;
|
||||
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
|
||||
isProviderDisabled: (provider: ModelProvider) => boolean;
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||
@@ -1264,6 +1272,7 @@ const initialState: AppState = {
|
||||
opencodeModelsError: null,
|
||||
opencodeModelsLastFetched: null,
|
||||
opencodeModelsLastFailedAt: null,
|
||||
disabledProviders: [], // No providers disabled by default
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
@@ -2154,6 +2163,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
),
|
||||
}),
|
||||
|
||||
// Provider Visibility Settings actions
|
||||
setDisabledProviders: (providers) => set({ disabledProviders: providers }),
|
||||
toggleProviderDisabled: (provider, disabled) =>
|
||||
set((state) => ({
|
||||
disabledProviders: disabled
|
||||
? [...state.disabledProviders, provider]
|
||||
: state.disabledProviders.filter((p) => p !== provider),
|
||||
})),
|
||||
isProviderDisabled: (provider) => get().disabledProviders.includes(provider),
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: async (enabled) => {
|
||||
const previous = get().autoLoadClaudeMd;
|
||||
|
||||
Reference in New Issue
Block a user