From 03436103d18224d1a35f0ef032b6f840492dd657 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Thu, 15 Jan 2026 22:21:46 -0500 Subject: [PATCH] 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. --- apps/server/src/routes/backlog-plan/common.ts | 75 ++++++ .../src/routes/backlog-plan/generate-plan.ts | 9 +- apps/server/src/routes/backlog-plan/index.ts | 4 +- .../src/routes/backlog-plan/routes/apply.ts | 4 +- .../src/routes/backlog-plan/routes/clear.ts | 25 ++ .../routes/backlog-plan/routes/generate.ts | 14 +- .../src/routes/backlog-plan/routes/status.ts | 8 +- .../src/routes/running-agents/routes/index.ts | 17 +- .../project-switcher/project-switcher.tsx | 45 +++- apps/ui/src/components/layout/sidebar.tsx | 4 +- .../sidebar/components/sidebar-footer.tsx | 64 +---- .../sidebar/hooks/use-running-agents.ts | 30 +++ .../src/components/ui/dependency-selector.tsx | 245 ++++++++++++++++++ apps/ui/src/components/views/board-view.tsx | 28 ++ .../views/board-view/board-header.tsx | 15 +- .../board-view/dialogs/add-feature-dialog.tsx | 57 +++- .../board-view/dialogs/agent-output-modal.tsx | 66 ++++- .../dialogs/backlog-plan-dialog.tsx | 53 ++-- .../dialogs/edit-feature-dialog.tsx | 72 ++++- .../board-view/hooks/use-board-actions.ts | 64 ++++- .../board-view/hooks/use-board-persistence.ts | 7 + .../board-view/shared/model-selector.tsx | 118 +++++---- .../src/components/views/graph-view-page.tsx | 122 ++++++++- .../graph-view/components/dependency-edge.tsx | 11 +- .../components/graph-filter-controls.tsx | 30 +++ .../views/graph-view/components/task-node.tsx | 14 +- .../views/graph-view/graph-canvas.tsx | 189 ++++++++++++-- .../views/graph-view/graph-view.tsx | 35 ++- .../graph-view/hooks/use-graph-filter.ts | 5 + .../graph-view/hooks/use-graph-layout.ts | 42 +-- .../components/views/running-agents-view.tsx | 54 +++- .../components/settings-navigation.tsx | 23 +- .../model-defaults/phase-model-selector.tsx | 96 ++++--- .../providers/claude-settings-tab.tsx | 4 + .../providers/codex-settings-tab.tsx | 4 + .../providers/cursor-settings-tab.tsx | 4 + .../providers/opencode-settings-tab.tsx | 4 + .../providers/provider-toggle.tsx | 41 +++ apps/ui/src/hooks/use-settings-migration.ts | 3 + apps/ui/src/hooks/use-settings-sync.ts | 2 + apps/ui/src/lib/electron.ts | 26 +- apps/ui/src/lib/http-api-client.ts | 31 ++- apps/ui/src/store/app-store.ts | 19 ++ docs/docker-isolation.md | 146 ----------- graph-layout-bug.md | 203 +++++++++++++++ libs/types/src/settings.ts | 5 + 46 files changed, 1719 insertions(+), 418 deletions(-) create mode 100644 apps/server/src/routes/backlog-plan/routes/clear.ts create mode 100644 apps/ui/src/components/ui/dependency-selector.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/provider-toggle.tsx delete mode 100644 docs/docker-isolation.md create mode 100644 graph-layout-bug.md diff --git a/apps/server/src/routes/backlog-plan/common.ts b/apps/server/src/routes/backlog-plan/common.ts index 74fb44c8..98142c30 100644 --- a/apps/server/src/routes/backlog-plan/common.ts +++ b/apps/server/src/routes/backlog-plan/common.ts @@ -3,12 +3,31 @@ */ import { createLogger } from '@automaker/utils'; +import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform'; +import * as secureFs from '../../lib/secure-fs.js'; +import path from 'path'; +import type { BacklogPlanResult } from '@automaker/types'; const logger = createLogger('BacklogPlan'); // State for tracking running generation let isRunning = false; let currentAbortController: AbortController | null = null; +let runningDetails: { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; +} | null = null; + +const BACKLOG_PLAN_FILENAME = 'backlog-plan.json'; + +export interface StoredBacklogPlan { + savedAt: string; + prompt: string; + model?: string; + result: BacklogPlanResult; +} export function getBacklogPlanStatus(): { isRunning: boolean } { return { isRunning }; @@ -16,11 +35,67 @@ export function getBacklogPlanStatus(): { isRunning: boolean } { export function setRunningState(running: boolean, abortController?: AbortController | null): void { isRunning = running; + if (!running) { + runningDetails = null; + } if (abortController !== undefined) { currentAbortController = abortController; } } +export function setRunningDetails( + details: { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; + } | null +): void { + runningDetails = details; +} + +export function getRunningDetails(): { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; +} | null { + return runningDetails; +} + +function getBacklogPlanPath(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME); +} + +export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise { + 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 { + 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 { + try { + const filePath = getBacklogPlanPath(projectPath); + await secureFs.unlink(filePath); + } catch { + // ignore missing file + } +} + export function getAbortController(): AbortController | null { return currentAbortController; } diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index d8235e50..bf99d2a4 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -17,7 +17,7 @@ import { resolvePhaseModel } from '@automaker/model-resolver'; import { FeatureLoader } from '../../services/feature-loader.js'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; -import { logger, setRunningState, getErrorMessage } from './common.js'; +import { logger, setRunningState, getErrorMessage, saveBacklogPlan } from './common.js'; import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js'; @@ -200,6 +200,13 @@ ${userPrompt}`; // Parse the response const result = parsePlanResponse(responseText); + await saveBacklogPlan(projectPath, { + savedAt: new Date().toISOString(), + prompt, + model: effectiveModel, + result, + }); + events.emit('backlog-plan:event', { type: 'backlog_plan_complete', result, diff --git a/apps/server/src/routes/backlog-plan/index.ts b/apps/server/src/routes/backlog-plan/index.ts index 393296df..4ab9e71d 100644 --- a/apps/server/src/routes/backlog-plan/index.ts +++ b/apps/server/src/routes/backlog-plan/index.ts @@ -9,6 +9,7 @@ import { createGenerateHandler } from './routes/generate.js'; import { createStopHandler } from './routes/stop.js'; import { createStatusHandler } from './routes/status.js'; import { createApplyHandler } from './routes/apply.js'; +import { createClearHandler } from './routes/clear.js'; import type { SettingsService } from '../../services/settings-service.js'; export function createBacklogPlanRoutes( @@ -23,8 +24,9 @@ export function createBacklogPlanRoutes( createGenerateHandler(events, settingsService) ); router.post('/stop', createStopHandler()); - router.get('/status', createStatusHandler()); + router.get('/status', validatePathParams('projectPath'), createStatusHandler()); router.post('/apply', validatePathParams('projectPath'), createApplyHandler()); + router.post('/clear', validatePathParams('projectPath'), createClearHandler()); return router; } diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index b6c257a0..d2b45f40 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -5,7 +5,7 @@ import type { Request, Response } from 'express'; import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types'; import { FeatureLoader } from '../../../services/feature-loader.js'; -import { getErrorMessage, logError, logger } from '../common.js'; +import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js'; const featureLoader = new FeatureLoader(); @@ -151,6 +151,8 @@ export function createApplyHandler() { success: true, appliedChanges, }); + + await clearBacklogPlan(projectPath); } catch (error) { logError(error, 'Apply backlog plan failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/backlog-plan/routes/clear.ts b/apps/server/src/routes/backlog-plan/routes/clear.ts new file mode 100644 index 00000000..855dc507 --- /dev/null +++ b/apps/server/src/routes/backlog-plan/routes/clear.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/routes/generate.ts b/apps/server/src/routes/backlog-plan/routes/generate.ts index b596576f..59438538 100644 --- a/apps/server/src/routes/backlog-plan/routes/generate.ts +++ b/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -4,7 +4,13 @@ import type { Request, Response } from 'express'; import type { EventEmitter } from '../../../lib/events.js'; -import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js'; +import { + getBacklogPlanStatus, + setRunningState, + setRunningDetails, + getErrorMessage, + logError, +} from '../common.js'; import { generateBacklogPlan } from '../generate-plan.js'; import type { SettingsService } from '../../../services/settings-service.js'; @@ -37,6 +43,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se } setRunningState(true); + setRunningDetails({ + projectPath, + prompt, + model, + startedAt: new Date().toISOString(), + }); const abortController = new AbortController(); setRunningState(true, abortController); diff --git a/apps/server/src/routes/backlog-plan/routes/status.ts b/apps/server/src/routes/backlog-plan/routes/status.ts index 3b1684d3..5f20f1e2 100644 --- a/apps/server/src/routes/backlog-plan/routes/status.ts +++ b/apps/server/src/routes/backlog-plan/routes/status.ts @@ -3,13 +3,15 @@ */ import type { Request, Response } from 'express'; -import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js'; +import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js'; export function createStatusHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { const status = getBacklogPlanStatus(); - res.json({ success: true, ...status }); + const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : ''; + const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null; + res.json({ success: true, ...status, savedPlan }); } catch (error) { logError(error, 'Get backlog plan status failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index 693eba8b..955ac93d 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -4,12 +4,27 @@ import type { Request, Response } from 'express'; import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js'; +import path from 'path'; import { getErrorMessage, logError } from '../common.js'; export function createIndexHandler(autoModeService: AutoModeService) { return async (_req: Request, res: Response): Promise => { try { - const runningAgents = await autoModeService.getRunningAgents(); + const runningAgents = [...(await autoModeService.getRunningAgents())]; + const backlogPlanStatus = getBacklogPlanStatus(); + const backlogPlanDetails = getRunningDetails(); + + if (backlogPlanStatus.isRunning && backlogPlanDetails) { + runningAgents.push({ + featureId: `backlog-plan:${backlogPlanDetails.projectPath}`, + projectPath: backlogPlanDetails.projectPath, + projectName: path.basename(backlogPlanDetails.projectPath), + isAutoMode: false, + title: 'Backlog plan', + description: backlogPlanDetails.prompt, + }); + } res.json({ success: true, diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index 442413fd..0713df72 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; -import { Plus, Bug, FolderOpen } from 'lucide-react'; -import { useNavigate } from '@tanstack/react-router'; +import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react'; +import { useNavigate, useLocation } from '@tanstack/react-router'; import { cn } from '@/lib/utils'; import { useAppStore, type ThemeMode } from '@/store/app-store'; import { useOSDetection } from '@/hooks/use-os-detection'; @@ -10,6 +10,7 @@ import { EditProjectDialog } from './components/edit-project-dialog'; import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; +import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants'; import type { Project } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; @@ -31,6 +32,9 @@ function getOSAbbreviation(os: string): string { export function ProjectSwitcher() { const navigate = useNavigate(); + const location = useLocation(); + const { hideWiki } = SIDEBAR_FEATURE_FLAGS; + const isWikiActive = location.pathname === '/wiki'; const { projects, currentProject, @@ -124,6 +128,10 @@ export function ProjectSwitcher() { api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues'); }, []); + const handleWikiClick = useCallback(() => { + navigate({ to: '/wiki' }); + }, [navigate]); + /** * Opens the system folder selection dialog and initializes the selected project. */ @@ -405,8 +413,37 @@ export function ProjectSwitcher() { )} - {/* Bug Report Button at the very bottom */} -
+ {/* Wiki and Bug Report Buttons at the very bottom */} +
+ {/* Wiki Button */} + {!hideWiki && ( + + )} + {/* Bug Report Button */} -
- )} {/* Running Agents Link */} {!hideRunningAgents && (
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts index 2e88fec5..e6e79cd8 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-running-agents.ts @@ -12,10 +12,20 @@ export function useRunningAgents() { try { const api = getElectronAPI(); if (api.runningAgents) { + logger.debug('Fetching running agents count'); const result = await api.runningAgents.getAll(); if (result.success && result.runningAgents) { + logger.debug('Running agents count fetched', { + count: result.runningAgents.length, + }); setRunningAgentsCount(result.runningAgents.length); + } else { + logger.debug('Running agents count fetch returned empty/failed', { + success: result.success, + }); } + } else { + logger.debug('Running agents API not available'); } } catch (error) { logger.error('Error fetching running agents count:', error); @@ -26,6 +36,7 @@ export function useRunningAgents() { useEffect(() => { const api = getElectronAPI(); if (!api.autoMode) { + logger.debug('Auto mode API not available for running agents hook'); // If autoMode is not available, still fetch initial count fetchRunningAgentsCount(); return; @@ -35,6 +46,9 @@ export function useRunningAgents() { fetchRunningAgentsCount(); const unsubscribe = api.autoMode.onEvent((event) => { + logger.debug('Auto mode event for running agents hook', { + type: event.type, + }); // When a feature starts, completes, or errors, refresh the count if ( event.type === 'auto_mode_feature_complete' || @@ -50,6 +64,22 @@ export function useRunningAgents() { }; }, [fetchRunningAgentsCount]); + // Subscribe to backlog plan events to update running agents count + useEffect(() => { + const api = getElectronAPI(); + if (!api.backlogPlan) return; + + fetchRunningAgentsCount(); + + const unsubscribe = api.backlogPlan.onEvent(() => { + fetchRunningAgentsCount(); + }); + + return () => { + unsubscribe(); + }; + }, [fetchRunningAgentsCount]); + return { runningAgentsCount, }; diff --git a/apps/ui/src/components/ui/dependency-selector.tsx b/apps/ui/src/components/ui/dependency-selector.tsx new file mode 100644 index 00000000..d2a51e74 --- /dev/null +++ b/apps/ui/src/components/ui/dependency-selector.tsx @@ -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(0); + const triggerRef = React.useRef(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 ( +
+ + + + + e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > + + + + No features found. + + {filteredFeatures.map((feature) => { + const willCreateCycle = wouldCreateCycle(feature.id); + const label = getFeatureLabel(feature); + + return ( + { + if (!willCreateCycle) { + handleSelect(feature.id); + } + }} + disabled={willCreateCycle} + className={cn(willCreateCycle && 'opacity-50 cursor-not-allowed')} + data-testid={`${testId}-option-${feature.id}`} + > + + {label} + {willCreateCycle && ( + (circular) + )} + {feature.status && ( + + {feature.status} + + )} + + ); + })} + + + + + + + {/* Selected items as badges */} + {selectedFeatures.length > 0 && ( +
+ {selectedFeatures.map((feature) => ( + + {getFeatureLabel(feature)} + + + ))} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 20b9e258..046ab4bd 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -951,6 +951,32 @@ export function BoardView() { return unsubscribe; }, []); + // Load any saved plan from disk when opening the board + useEffect(() => { + if (!currentProject || pendingBacklogPlan) return; + + let isActive = true; + const loadSavedPlan = async () => { + const api = getElectronAPI(); + if (!api?.backlogPlan) return; + + const result = await api.backlogPlan.status(currentProject.path); + if ( + isActive && + result.success && + result.savedPlan?.result && + result.savedPlan.result.changes?.length > 0 + ) { + setPendingBacklogPlan(result.savedPlan.result); + } + }; + + loadSavedPlan(); + return () => { + isActive = false; + }; + }, [currentProject, pendingBacklogPlan]); + useEffect(() => { logger.info( '[AutoMode] Effect triggered - isRunning:', @@ -1384,6 +1410,8 @@ export function BoardView() { } }} onOpenPlanDialog={() => setShowPlanDialog(true)} + hasPendingPlan={Boolean(pendingBacklogPlan)} + onOpenPendingPlan={() => setShowPlanDialog(true)} isMounted={isMounted} searchQuery={searchQuery} onSearchChange={setSearchQuery} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index a5fc9961..29dce66a 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Wand2, GitBranch } from 'lucide-react'; +import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react'; import { UsagePopover } from '@/components/usage-popover'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; @@ -25,6 +25,8 @@ interface BoardHeaderProps { isAutoModeRunning: boolean; onAutoModeToggle: (enabled: boolean) => void; onOpenPlanDialog: () => void; + hasPendingPlan?: boolean; + onOpenPendingPlan?: () => void; isMounted: boolean; // Search bar props searchQuery: string; @@ -50,6 +52,8 @@ export function BoardHeader({ isAutoModeRunning, onAutoModeToggle, onOpenPlanDialog, + hasPendingPlan, + onOpenPendingPlan, isMounted, searchQuery, onSearchChange, @@ -192,6 +196,15 @@ export function BoardHeader({ {/* Plan Button with Settings - only show on desktop, mobile has it in the menu */} {isMounted && !isMobile && (
+ {hasPendingPlan && ( + + )}
+ + {/* Dependencies - only show when not in spawn mode */} + {!isSpawnMode && allFeatures.length > 0 && ( +
+
+ + +
+
+ + +
+
+ )}
diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index ab2b0732..84870675 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -40,6 +40,7 @@ export function AgentOutputModal({ onNumberKeyPress, projectPath: projectPathProp, }: AgentOutputModalProps) { + const isBacklogPlan = featureId.startsWith('backlog-plan:'); const [output, setOutput] = useState(''); const [isLoading, setIsLoading] = useState(true); const [viewMode, setViewMode] = useState(null); @@ -83,6 +84,11 @@ export function AgentOutputModal({ projectPathRef.current = resolvedProjectPath; setProjectPath(resolvedProjectPath); + if (isBacklogPlan) { + setOutput(''); + return; + } + // Use features API to get agent output if (api.features) { const result = await api.features.getAgentOutput(resolvedProjectPath, featureId); @@ -104,14 +110,14 @@ export function AgentOutputModal({ }; loadOutput(); - }, [open, featureId, projectPathProp]); + }, [open, featureId, projectPathProp, isBacklogPlan]); // Listen to auto mode events and update output useEffect(() => { if (!open) return; const api = getElectronAPI(); - if (!api?.autoMode) return; + if (!api?.autoMode || isBacklogPlan) return; console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId); @@ -272,7 +278,43 @@ export function AgentOutputModal({ return () => { unsubscribe(); }; - }, [open, featureId]); + }, [open, featureId, isBacklogPlan]); + + // Listen to backlog plan events and update output + useEffect(() => { + if (!open || !isBacklogPlan) return; + + const api = getElectronAPI(); + if (!api?.backlogPlan) return; + + const unsubscribe = api.backlogPlan.onEvent((event: any) => { + if (!event?.type) return; + + let newContent = ''; + switch (event.type) { + case 'backlog_plan_progress': + newContent = `\n🧭 ${event.content || 'Backlog plan progress update'}\n`; + break; + case 'backlog_plan_error': + newContent = `\nāŒ Backlog plan error: ${event.error || 'Unknown error'}\n`; + break; + case 'backlog_plan_complete': + newContent = `\nāœ… Backlog plan completed\n`; + break; + default: + newContent = `\nā„¹ļø ${event.type}\n`; + break; + } + + if (newContent) { + setOutput((prev) => `${prev}${newContent}`); + } + }); + + return () => { + unsubscribe(); + }; + }, [open, isBacklogPlan]); // Handle scroll to detect if user scrolled up const handleScroll = () => { @@ -369,7 +411,7 @@ export function AgentOutputModal({ {featureDescription} @@ -377,11 +419,13 @@ export function AgentOutputModal({ {/* Task Progress Panel - shows when tasks are being executed */} - + {!isBacklogPlan && ( + + )} {effectiveViewMode === 'changes' ? (
@@ -423,11 +467,11 @@ export function AgentOutputModal({ ) : effectiveViewMode === 'parsed' ? ( ) : ( -
{output}
+
{output}
)}
-
+
{autoScrollRef.current ? 'Auto-scrolling enabled' : 'Scroll to bottom to enable auto-scroll'} diff --git a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx index 4f909e55..c82b7157 100644 --- a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; +import { createLogger } from '@automaker/utils/logger'; import { Dialog, DialogContent, @@ -43,16 +44,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry { return entry; } -/** - * Extract model string from PhaseModelEntry or string - */ -function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId { - if (typeof entry === 'string') { - return entry as ModelAlias | CursorModelId; - } - return entry.model; -} - interface BacklogPlanDialogProps { open: boolean; onClose: () => void; @@ -80,6 +71,7 @@ export function BacklogPlanDialog({ setIsGeneratingPlan, currentBranch, }: BacklogPlanDialogProps) { + const logger = createLogger('BacklogPlanDialog'); const [mode, setMode] = useState('input'); const [prompt, setPrompt] = useState(''); const [expandedChanges, setExpandedChanges] = useState>(new Set()); @@ -110,11 +102,17 @@ export function BacklogPlanDialog({ const api = getElectronAPI(); if (!api?.backlogPlan) { + logger.warn('Backlog plan API not available'); toast.error('API not available'); return; } // Start generation in background + logger.debug('Starting backlog plan generation', { + projectPath, + promptLength: prompt.length, + hasModelOverride: Boolean(modelOverride), + }); setIsGeneratingPlan(true); // Use model override if set, otherwise use global default (extract model string from PhaseModelEntry) @@ -122,12 +120,20 @@ export function BacklogPlanDialog({ const effectiveModel = effectiveModelEntry.model; const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel); if (!result.success) { + logger.error('Backlog plan generation failed to start', { + error: result.error, + projectPath, + }); setIsGeneratingPlan(false); toast.error(result.error || 'Failed to start plan generation'); return; } // Show toast and close dialog - generation runs in background + logger.debug('Backlog plan generation started', { + projectPath, + model: effectiveModel, + }); toast.info('Generating plan... This will be ready soon!', { duration: 3000, }); @@ -194,10 +200,15 @@ export function BacklogPlanDialog({ currentBranch, ]); - const handleDiscard = useCallback(() => { + const handleDiscard = useCallback(async () => { setPendingPlanResult(null); setMode('input'); - }, [setPendingPlanResult]); + + const api = getElectronAPI(); + if (api?.backlogPlan) { + await api.backlogPlan.clear(projectPath); + } + }, [setPendingPlanResult, projectPath]); const toggleChangeExpanded = (index: number) => { setExpandedChanges((prev) => { @@ -260,11 +271,11 @@ export function BacklogPlanDialog({ return (
- Describe the changes you want to make to your backlog. The AI will analyze your - current features and propose additions, updates, or deletions. + Describe the changes you want to make across your features. The AI will analyze your + current feature list and propose additions, updates, deletions, or restructuring.