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:
webdevcody
2026-01-15 22:21:46 -05:00
parent cb544e0011
commit 03436103d1
46 changed files with 1719 additions and 418 deletions

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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) });

View 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) });
}
};
}

View File

@@ -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);

View File

@@ -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) });

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 }}

View File

@@ -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">

View File

@@ -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,
};

View 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>
);
}

View File

@@ -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}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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'}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
]
);

View File

@@ -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);

View File

@@ -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 && (

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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" />

View File

@@ -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}

View File

@@ -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} />

View File

@@ -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}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.

View File

@@ -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);
},

View File

@@ -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;

View File

@@ -1,146 +0,0 @@
# Docker Isolation Guide
This guide covers running Automaker in a fully isolated Docker container. For background on why isolation matters, see the [Security Disclaimer](../DISCLAIMER.md).
## Quick Start
1. **Set your API key** (create a `.env` file in the project root):
```bash
# Linux/Mac
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
# Windows PowerShell
Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
```
2. **Build and run**:
```bash
docker-compose up -d
```
3. **Access Automaker** at `http://localhost:3007`
4. **Stop**:
```bash
docker-compose down
```
## How Isolation Works
The default `docker-compose.yml` configuration:
- Uses only Docker-managed volumes (no host filesystem access)
- Server runs as a non-root user
- Has no privileged access to your system
Projects created in the UI are stored inside the container at `/projects` and persist across restarts via Docker volumes.
## Mounting a Specific Project
If you need to work on a host project, create `docker-compose.project.yml`:
```yaml
services:
server:
volumes:
- ./my-project:/projects/my-project:ro # :ro = read-only
```
Then run:
```bash
docker-compose -f docker-compose.yml -f docker-compose.project.yml up -d
```
**Tip**: Use `:ro` (read-only) when possible for extra safety.
### Fixing File Permission Issues
When mounting host directories, files created by the container may be owned by UID 1001 (the default container user), causing permission mismatches with your host user. To fix this, rebuild the image with your host UID/GID:
```bash
# Rebuild with your user's UID/GID
UID=$(id -u) GID=$(id -g) docker-compose build
# Then start normally
docker-compose up -d
```
This creates the container user with the same UID/GID as your host user, so files in mounted volumes have correct ownership.
## CLI Authentication (macOS)
On macOS, OAuth tokens are stored in Keychain (Claude) and SQLite (Cursor). Use these scripts to extract and pass them to the container:
### Claude CLI
```bash
# Extract and add to .env
echo "CLAUDE_OAUTH_CREDENTIALS=$(./scripts/get-claude-token.sh)" >> .env
```
### Cursor CLI
```bash
# Extract and add to .env (extracts from macOS Keychain)
echo "CURSOR_AUTH_TOKEN=$(./scripts/get-cursor-token.sh)" >> .env
```
**Note**: The cursor-agent CLI stores its OAuth tokens separately from the Cursor IDE:
- **macOS**: Tokens are stored in Keychain (service: `cursor-access-token`)
- **Linux**: Tokens are stored in `~/.config/cursor/auth.json` (not `~/.cursor`)
### OpenCode CLI
OpenCode stores its configuration and auth at `~/.local/share/opencode/`. To share your host authentication with the container:
```yaml
# In docker-compose.override.yml
volumes:
- ~/.local/share/opencode:/home/automaker/.local/share/opencode
```
### Apply to container
```bash
# Restart with new credentials
docker-compose down && docker-compose up -d
```
**Note**: Tokens expire periodically. If you get authentication errors, re-run the extraction scripts.
## CLI Authentication (Linux/Windows)
On Linux/Windows, cursor-agent stores credentials in files, so you can either:
**Option 1: Extract tokens to environment variables (recommended)**
```bash
# Linux: Extract tokens to .env
echo "CURSOR_AUTH_TOKEN=$(jq -r '.accessToken' ~/.config/cursor/auth.json)" >> .env
```
**Option 2: Bind mount credential directories directly**
```yaml
# In docker-compose.override.yml
volumes:
- ~/.claude:/home/automaker/.claude
- ~/.config/cursor:/home/automaker/.config/cursor
- ~/.local/share/opencode:/home/automaker/.local/share/opencode
```
## Troubleshooting
| Problem | Solution |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
| Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. |
| OpenCode not detected | Mount `~/.local/share/opencode` to `/home/automaker/.local/share/opencode`. Make sure you've run `opencode auth login` on your host first. |
| File permission errors | Rebuild with `UID=$(id -u) GID=$(id -g) docker-compose build` to match container user to your host user. See [Fixing File Permission Issues](#fixing-file-permission-issues). |

203
graph-layout-bug.md Normal file
View File

@@ -0,0 +1,203 @@
# Graph View Layout Bug
## Problem
When navigating directly to the graph view route (e.g., refreshing on `/graph` or opening the app on that route), all feature cards appear in a single vertical column instead of being properly arranged in a hierarchical dependency graph.
**Works correctly when:** User navigates to Kanban view first, then to Graph view.
**Broken when:** User loads the graph route directly (refresh, direct URL, app opens on that route).
## Expected Behavior
Nodes should be positioned by the dagre layout algorithm in a hierarchical DAG based on their dependency relationships (edges).
## Actual Behavior
All nodes appear stacked in a single column/row, as if dagre computed the layout with no edges.
## Technology Stack
- React 19
- @xyflow/react (React Flow) for graph rendering
- dagre for layout algorithm
- Zustand for state management
## Architecture
### Data Flow
1. `GraphViewPage` loads features via `useBoardFeatures` hook
2. Shows loading spinner while `isLoading === true`
3. When loaded, renders `GraphView``GraphCanvas`
4. `GraphCanvas` uses three hooks:
- `useGraphNodes`: Transforms features → React Flow nodes and edges (edges from `feature.dependencies`)
- `useGraphLayout`: Applies dagre layout to position nodes based on edges
- `useNodesState`/`useEdgesState`: React Flow's state management
### Key Files
- `apps/ui/src/components/views/graph-view-page.tsx` - Page component with loading state
- `apps/ui/src/components/views/graph-view/graph-canvas.tsx` - React Flow integration
- `apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts` - Dagre layout logic
- `apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts` - Feature → node/edge transformation
- `apps/ui/src/components/views/board-view/hooks/use-board-features.ts` - Data fetching
## Relevant Code
### use-graph-layout.ts (layout computation)
```typescript
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
const positionCache = useRef<Map<string, { x: number; y: number }>>(new Map());
const lastStructureKey = useRef<string>('');
const layoutVersion = useRef<number>(0);
const getLayoutedElements = useCallback((inputNodes, inputEdges, direction = 'LR') => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100 });
inputNodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: 280, height: 120 });
});
inputEdges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target); // THIS IS WHERE EDGES MATTER
});
dagre.layout(dagreGraph);
// ... returns positioned nodes
}, []);
// Structure key includes both nodes AND edges
const structureKey = useMemo(() => {
const nodeIds = nodes
.map((n) => n.id)
.sort()
.join(',');
const edgeConnections = edges
.map((e) => `${e.source}->${e.target}`)
.sort()
.join(',');
return `${nodeIds}|${edgeConnections}`;
}, [nodes, edges]);
const layoutedElements = useMemo(() => {
if (nodes.length === 0) return { nodes: [], edges: [] };
const structureChanged = structureKey !== lastStructureKey.current;
if (structureChanged) {
lastStructureKey.current = structureKey;
layoutVersion.current += 1;
return getLayoutedElements(nodes, edges, 'LR'); // Full layout with edges
} else {
// Use cached positions
}
}, [nodes, edges, structureKey, getLayoutedElements]);
return { layoutedNodes, layoutedEdges, layoutVersion: layoutVersion.current, runLayout };
}
```
### graph-canvas.tsx (React Flow integration)
```typescript
function GraphCanvasInner({ features, ... }) {
// Transform features to nodes/edges
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({ features, ... });
// Apply layout
const { layoutedNodes, layoutedEdges, layoutVersion, runLayout } = useGraphLayout({
nodes: initialNodes,
edges: initialEdges,
});
// React Flow state - INITIALIZES with layoutedNodes
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Effect to update nodes when layout changes
useEffect(() => {
// ... updates nodes/edges state when layoutedNodes/layoutedEdges change
}, [layoutedNodes, layoutedEdges, layoutVersion, ...]);
// Attempted fix: Force layout after mount when edges are available
useEffect(() => {
if (!hasLayoutWithEdges.current && layoutedNodes.length > 0 && layoutedEdges.length > 0) {
hasLayoutWithEdges.current = true;
setTimeout(() => runLayout('LR'), 100);
}
}, [layoutedNodes.length, layoutedEdges.length, runLayout]);
return <ReactFlow nodes={nodes} edges={edges} fitView ... />;
}
```
### use-board-features.ts (data loading)
```typescript
export function useBoardFeatures({ currentProject }) {
const { features, setFeatures } = useAppStore(); // From Zustand store
const [isLoading, setIsLoading] = useState(true);
const loadFeatures = useCallback(async () => {
setIsLoading(true);
const result = await api.features.getAll(currentProject.path);
if (result.success) {
const featuresWithIds = result.features.map((f) => ({
...f, // dependencies come from here via spread
id: f.id || `...`,
status: f.status || 'backlog',
}));
setFeatures(featuresWithIds); // Updates Zustand store
}
setIsLoading(false);
}, [currentProject, setFeatures]);
useEffect(() => { loadFeatures(); }, [loadFeatures]);
return { features, isLoading, ... }; // features is from useAppStore()
}
```
### graph-view-page.tsx (loading gate)
```typescript
export function GraphViewPage() {
const { features: hookFeatures, isLoading } = useBoardFeatures({ currentProject });
if (isLoading) {
return <Spinner />; // Graph doesn't render until loading is done
}
return <GraphView features={hookFeatures} ... />;
}
```
## What I've Tried
1. **Added edges to structureKey** - So layout recalculates when dependencies change, not just when nodes change
2. **Added layoutVersion tracking** - To signal when a fresh layout was computed vs cached positions used
3. **Track layoutVersion in GraphCanvas** - To detect when to apply fresh positions instead of preserving old ones
4. **Force runLayout after mount** - Added useEffect that calls `runLayout('LR')` after 100ms when nodes and edges are available
5. **Reset all refs on project change** - Clear layout state when switching projects
## Hypothesis
The issue appears to be a timing/race condition where:
- When going Kanban → Graph: Features are already in Zustand store, so graph mounts with complete data
- When loading Graph directly: Something causes the initial layout to compute before edges are properly available, or the layout result isn't being applied to React Flow's state correctly
The fact that clicking Kanban then Graph works suggests the data IS correct, just something about the initial render timing when loading the route directly.
## Questions to Investigate
1. Is `useNodesState(layoutedNodes)` capturing stale initial positions?
2. Is there a React 19 / StrictMode double-render issue with the refs?
3. Is React Flow's `fitView` prop interfering with initial positions?
4. Is there a race between Zustand store updates and React renders?
5. Should the graph component not render until layout is definitively computed with edges?

View File

@@ -418,6 +418,10 @@ export interface GlobalSettings {
/** Which dynamic OpenCode models are enabled (empty = all discovered) */
enabledDynamicModelIds?: string[];
// Provider Visibility Settings
/** Providers that are disabled and should not appear in model dropdowns */
disabledProviders?: ModelProvider[];
// Input Configuration
/** User's keyboard shortcut bindings */
keyboardShortcuts: KeyboardShortcuts;
@@ -730,6 +734,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
enabledDynamicModelIds: [],
disabledProviders: [],
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
projects: [],
trashedProjects: [],