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