refactor(ui): migrate board view to React Query

- Replace manual fetching in use-board-features with useFeatures query
- Migrate use-board-actions to use mutation hooks
- Update kanban-card and agent-info-panel to use query hooks
- Migrate agent-output-modal to useAgentOutput query
- Migrate create-pr-dialog to useCreatePR mutation
- Remove manual loading/error state management
- Add proper cache invalidation on mutations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-15 16:21:23 +01:00
parent d08ef472a3
commit 3411256366
7 changed files with 160 additions and 402 deletions

View File

@@ -79,6 +79,9 @@ import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
import { usePipelineConfig } from '@/hooks/queries';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -109,8 +112,9 @@ export function BoardView() {
getPrimaryWorktreeBranch,
setPipelineConfig,
} = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
// Fetch pipeline config via React Query
const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
const queryClient = useQueryClient();
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
@@ -241,25 +245,6 @@ export function BoardView() {
setFeaturesWithContext,
});
// Load pipeline config when project changes
useEffect(() => {
if (!currentProject?.path) return;
const loadPipelineConfig = async () => {
try {
const api = getHttpApiClient();
const result = await api.pipeline.getConfig(currentProject.path);
if (result.success && result.config) {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
logger.error('Failed to load pipeline config:', error);
}
};
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
@@ -1131,9 +1116,7 @@ export function BoardView() {
});
// Build columnFeaturesMap for ListView
const pipelineConfig = currentProject?.path
? pipelineConfigByProject[currentProject.path] || null
: null;
// pipelineConfig is now from usePipelineConfig React Query hook at the top
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record<string, typeof hookFeatures> = {};
@@ -1585,6 +1568,11 @@ export function BoardView() {
if (!result.success) {
throw new Error(result.error || 'Failed to save pipeline config');
}
// Invalidate React Query cache to refetch updated config
queryClient.invalidateQueries({
queryKey: queryKeys.pipeline.config(currentProject.path),
});
// Also update Zustand for backward compatibility
setPipelineConfig(currentProject.path, config);
}}
/>

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
import { useEffect, useState, useMemo } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
@@ -24,6 +23,7 @@ import {
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
import { useFeature, useAgentOutput } from '@/hooks/queries';
/**
* Formats thinking level for compact display
@@ -58,6 +58,7 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
interface AgentInfoPanelProps {
feature: Feature;
projectPath: string;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
@@ -65,23 +66,54 @@ interface AgentInfoPanelProps {
export function AgentInfoPanel({
feature,
projectPath,
contextContent,
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events
const [taskStatusMap, setTaskStatusMap] = useState<
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Fresh planSpec data fetched from API (store data is stale for task progress)
const [freshPlanSpec, setFreshPlanSpec] = useState<{
tasks?: ParsedTask[];
tasksCompleted?: number;
currentTaskId?: string;
} | null>(null);
// Determine if we should poll for updates
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
const shouldFetchData = feature.status !== 'backlog';
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
});
// Fetch agent output for parsing
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
enabled: shouldFetchData && !contextContent,
pollingInterval: shouldPoll ? 3000 : false,
});
// Parse agent output into agentInfo
const agentInfo = useMemo(() => {
if (contextContent) {
return parseAgentContext(contextContent);
}
if (agentOutputContent) {
return parseAgentContext(agentOutputContent);
}
return null;
}, [contextContent, agentOutputContent]);
// Fresh planSpec data from API (more accurate than store data for task progress)
const freshPlanSpec = useMemo(() => {
if (!freshFeature?.planSpec) return null;
return {
tasks: freshFeature.planSpec.tasks,
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
currentTaskId: freshFeature.planSpec.currentTaskId,
};
}, [freshFeature?.planSpec]);
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
@@ -133,73 +165,6 @@ export function AgentInfoPanel({
taskStatusMap,
]);
useEffect(() => {
const loadContext = async () => {
if (contextContent) {
const info = parseAgentContext(contextContent);
setAgentInfo(info);
return;
}
if (feature.status === 'backlog') {
setAgentInfo(null);
setFreshPlanSpec(null);
return;
}
try {
const api = getElectronAPI();
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
// Fetch fresh feature data to get up-to-date planSpec (store data is stale)
try {
const featureResult = await api.features.get(currentProject.path, feature.id);
const freshFeature: any = (featureResult as any).feature;
if (featureResult.success && freshFeature?.planSpec) {
setFreshPlanSpec({
tasks: freshFeature.planSpec.tasks,
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
currentTaskId: freshFeature.planSpec.currentTaskId,
});
}
} catch {
// Ignore errors fetching fresh planSpec
}
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} else {
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
console.debug('[KanbanCard] No context file for feature:', feature.id);
}
};
loadContext();
// Poll for updates when feature is in_progress (not just isCurrentAutoTask)
// This ensures planSpec progress stays in sync
if (isCurrentAutoTask || feature.status === 'in_progress') {
const interval = setInterval(loadContext, 3000);
return () => {
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Listen to WebSocket events for real-time task status updates
// This ensures the Kanban card shows the same progress as the Agent Output modal
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask

View File

@@ -97,7 +97,7 @@ export const KanbanCard = memo(function KanbanCard({
isSelected = false,
onToggleSelect,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const { useWorktrees, currentProject } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
@@ -213,6 +213,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
projectPath={currentProject?.path ?? ''}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}

View File

@@ -14,6 +14,7 @@ import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { Markdown } from '@/components/ui/markdown';
import { useAppStore } from '@/store/app-store';
import { extractSummary } from '@/lib/log-parser';
import { useAgentOutput } from '@/hooks/queries';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
@@ -40,10 +41,29 @@ export function AgentOutputModal({
onNumberKeyPress,
projectPath: projectPathProp,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
// Resolve project path - prefer prop, fallback to window.__currentProject
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
// Track additional content from WebSocket events (appended to query data)
const [streamedContent, setStreamedContent] = useState<string>('');
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [projectPath, setProjectPath] = useState<string>('');
// Use React Query for initial output loading
const { data: initialOutput = '', isLoading } = useAgentOutput(
resolvedProjectPath,
featureId,
open && !!resolvedProjectPath
);
// Reset streamed content when modal opens or featureId changes
useEffect(() => {
if (open) {
setStreamedContent('');
}
}, [open, featureId]);
// Combine initial output from query with streamed content from WebSocket
const output = initialOutput + streamedContent;
// Extract summary from output
const summary = useMemo(() => extractSummary(output), [output]);
@@ -52,7 +72,6 @@ export function AgentOutputModal({
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -62,50 +81,6 @@ export function AgentOutputModal({
}
}, [output]);
// Load existing output from file
useEffect(() => {
if (!open) return;
const loadOutput = async () => {
const api = getElectronAPI();
if (!api) return;
setIsLoading(true);
try {
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
if (!resolvedProjectPath) {
setIsLoading(false);
return;
}
projectPathRef.current = resolvedProjectPath;
setProjectPath(resolvedProjectPath);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
if (result.success) {
setOutput(result.content || '');
} else {
setOutput('');
}
} else {
setOutput('');
}
} catch (error) {
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
}
};
loadOutput();
}, [open, featureId, projectPathProp]);
// Listen to auto mode events and update output
useEffect(() => {
if (!open) return;
@@ -264,8 +239,8 @@ export function AgentOutputModal({
}
if (newContent) {
// Only update local state - server is the single source of truth for file writes
setOutput((prev) => prev + newContent);
// Append new content from WebSocket to streamed content
setStreamedContent((prev) => prev + newContent);
}
});
@@ -379,15 +354,15 @@ export function AgentOutputModal({
{/* Task Progress Panel - shows when tasks are being executed */}
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
projectPath={resolvedProjectPath}
className="flex-shrink-0 mx-3 my-2"
/>
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
{resolvedProjectPath ? (
<GitDiffPanel
projectPath={projectPath}
projectPath={resolvedProjectPath}
featureId={featureId}
compact={false}
useWorktrees={useWorktrees}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -16,6 +16,7 @@ import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
interface WorktreeInfo {
path: string;
@@ -53,12 +54,21 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Branch fetching state
const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Use React Query for branch fetching - only enabled when dialog is open
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
open ? worktree?.path : undefined,
true // Include remote branches for PR base branch selection
);
// Filter out current worktree branch from the list
const branches = useMemo(() => {
if (!branchesData?.branches) return [];
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
}, [branchesData?.branches, worktree?.branch]);
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
@@ -71,44 +81,13 @@ export function CreatePRDialog({
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
setBranches([]);
}, [defaultBaseBranch]);
// Fetch branches for autocomplete
const fetchBranches = useCallback(async () => {
if (!worktree?.path) return;
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
return;
}
// Fetch both local and remote branches for PR base branch selection
const result = await api.worktree.listBranches(worktree.path, true);
if (result.success && result.result) {
// Extract branch names, filtering out the current worktree branch
const branchNames = result.result.branches
.map((b) => b.name)
.filter((name) => name !== worktree.branch);
setBranches(branchNames);
}
} catch {
// Silently fail - branches will default to main only
} finally {
setIsLoadingBranches(false);
}
}, [worktree?.path, worktree?.branch]);
// Reset state when dialog opens or worktree changes
useEffect(() => {
// Reset all state on both open and close
resetState();
if (open) {
// Fetch fresh branches when dialog opens
fetchBranches();
}
}, [open, worktree?.path, resetState, fetchBranches]);
}, [open, worktree?.path, resetState]);
const handleCreate = async () => {
if (!worktree) return;

View File

@@ -14,6 +14,7 @@ import { getElectronAPI } from '@/lib/electron';
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
@@ -94,6 +95,10 @@ export function useBoardActions({
} = useAppStore();
const autoMode = useAutoMode();
// React Query mutations for feature operations
const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? '');
const resumeFeatureMutation = useResumeFeature(currentProject?.path ?? '');
// Worktrees are created when adding/editing features with a branch name
// This ensures the worktree exists before the feature starts execution
@@ -480,28 +485,9 @@ export function useBoardActions({
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
try {
const api = getElectronAPI();
if (!api?.autoMode) {
logger.error('Auto mode API not available');
return;
}
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
if (result.success) {
logger.info('Feature verification started successfully');
} else {
logger.error('Failed to verify feature:', result.error);
await loadFeatures();
}
} catch (error) {
logger.error('Error verifying feature:', error);
await loadFeatures();
}
verifyFeatureMutation.mutate(feature.id);
},
[currentProject, loadFeatures]
[currentProject, verifyFeatureMutation]
);
const handleResumeFeature = useCallback(
@@ -511,40 +497,9 @@ export function useBoardActions({
logger.error('No current project');
return;
}
try {
const api = getElectronAPI();
if (!api?.autoMode) {
logger.error('Auto mode API not available');
return;
}
logger.info('Calling resumeFeature API...', {
projectPath: currentProject.path,
featureId: feature.id,
useWorktrees,
});
const result = await api.autoMode.resumeFeature(
currentProject.path,
feature.id,
useWorktrees
);
logger.info('resumeFeature result:', result);
if (result.success) {
logger.info('Feature resume started successfully');
} else {
logger.error('Failed to resume feature:', result.error);
await loadFeatures();
}
} catch (error) {
logger.error('Error resuming feature:', error);
await loadFeatures();
}
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
},
[currentProject, loadFeatures, useWorktrees]
[currentProject, resumeFeatureMutation, useWorktrees]
);
const handleManualVerify = useCallback(

View File

@@ -1,8 +1,18 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
/**
* Board Features Hook
*
* React Query-based hook for managing features on the board view.
* Handles feature loading, categories, and auto-mode event notifications.
*/
import { useState, useCallback, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { useFeatures } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardFeatures');
@@ -11,105 +21,15 @@ interface UseBoardFeaturesProps {
}
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { features, setFeatures } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const queryClient = useQueryClient();
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
// Track previous project path to detect project switches
const prevProjectPathRef = useRef<string | null>(null);
const isInitialLoadRef = useRef(true);
const isSwitchingProjectRef = useRef(false);
// Load features using features API
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
const loadFeatures = useCallback(async () => {
if (!currentProject) return;
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
// If project switched, mark it but don't clear features yet
// We'll clear after successful API load to prevent data loss
if (isProjectSwitch) {
logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
isSwitchingProjectRef.current = true;
isInitialLoadRef.current = true;
}
// Update the ref to track current project
prevProjectPathRef.current = currentPath;
// Only show loading spinner on initial load to prevent board flash during reloads
if (isInitialLoadRef.current) {
setIsLoading(true);
}
try {
const api = getElectronAPI();
if (!api.features) {
logger.error('Features API not available');
// Keep cached features if API is unavailable
return;
}
const result = await api.features.getAll(currentProject.path);
if (result.success && result.features) {
const featuresWithIds = result.features.map((f: any, index: number) => ({
...f,
id: f.id || `feature-${index}-${Date.now()}`,
status: f.status || 'backlog',
startedAt: f.startedAt, // Preserve startedAt timestamp
// Ensure model and thinkingLevel are set for backward compatibility
model: f.model || 'opus',
thinkingLevel: f.thinkingLevel || 'none',
}));
// Successfully loaded features - now safe to set them
setFeatures(featuresWithIds);
// Only clear categories on project switch AFTER successful load
if (isProjectSwitch) {
setPersistedCategories([]);
}
// Check for interrupted features and resume them
// This handles server restarts where features were in pipeline steps
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (resumeError) {
logger.warn('Failed to check for interrupted features:', resumeError);
}
}
} else if (!result.success && result.error) {
logger.error('API returned error:', result.error);
// If it's a new project or the error indicates no features found,
// that's expected - start with empty array
if (isProjectSwitch) {
setFeatures([]);
setPersistedCategories([]);
}
// Otherwise keep cached features
}
} catch (error) {
logger.error('Failed to load features:', error);
// On error, keep existing cached features for the current project
// Only clear on project switch if we have no features from server
if (isProjectSwitch && cachedFeatures.length === 0) {
setFeatures([]);
setPersistedCategories([]);
}
} finally {
setIsLoading(false);
isInitialLoadRef.current = false;
isSwitchingProjectRef.current = false;
}
}, [currentProject, setFeatures]);
// Use React Query for features
const {
data: features = [],
isLoading,
refetch: loadFeatures,
} = useFeatures(currentProject?.path);
// Load persisted categories from file
const loadCategories = useCallback(async () => {
@@ -125,12 +45,9 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(parsed);
}
} else {
// File doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
} catch (error) {
logger.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
} catch {
setPersistedCategories([]);
}
}, [currentProject]);
@@ -142,22 +59,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
// Read existing categories
let categories: string[] = [...persistedCategories];
// Add new category if it doesn't exist
if (!categories.includes(category)) {
categories.push(category);
categories.sort(); // Keep sorted
categories.sort();
// Write back to file
await api.writeFile(
`${currentProject.path}/.automaker/categories.json`,
JSON.stringify(categories, null, 2)
);
// Update state
setPersistedCategories(categories);
}
} catch (error) {
@@ -167,29 +79,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
[currentProject, persistedCategories]
);
// Subscribe to spec regeneration complete events to refresh kanban board
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event) => {
// Refresh the kanban board when spec regeneration completes for the current project
if (
event.type === 'spec_regeneration_complete' &&
currentProject &&
event.projectPath === currentProject.path
) {
logger.info('Spec regeneration complete, refreshing features');
loadFeatures();
}
});
return () => {
unsubscribe();
};
}, [currentProject, loadFeatures]);
// Listen for auto mode feature completion and errors to reload features
// Subscribe to auto mode events for notifications (ding sound, toasts)
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !currentProject) return;
@@ -198,42 +89,22 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const projectId = currentProject.id;
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
logger.info('Feature completed, reloading features...');
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
const audio = new Audio('/sounds/ding.mp3');
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
}
} else if (event.type === 'plan_approval_required') {
// Reload features when plan is generated and requires approval
// This ensures the feature card shows the "Approve Plan" button
logger.info('Plan approval required, reloading features...');
loadFeatures();
} else if (event.type === 'pipeline_step_started') {
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
// Reload so the card moves into the correct pipeline column immediately.
logger.info('Pipeline step started, reloading features...');
loadFeatures();
} else if (event.type === 'auto_mode_error') {
// Reload features when an error occurs (feature moved to waiting_approval)
logger.info('Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
// Remove from running tasks
if (event.featureId) {
removeRunningTask(eventProjectId, event.featureId);
}
loadFeatures();
// Check for authentication errors and show a more helpful message
// Show error toast
const isAuthError =
event.errorType === 'authentication' ||
(event.error &&
@@ -255,22 +126,46 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
});
return unsubscribe;
}, [loadFeatures, currentProject]);
}, [currentProject]);
// Check for interrupted features on mount
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
if (!currentProject) return;
// Load persisted categories on mount
const checkInterrupted = async () => {
const api = getElectronAPI();
if (api.autoMode?.resumeInterrupted) {
try {
await api.autoMode.resumeInterrupted(currentProject.path);
logger.info('Checked for interrupted features');
} catch (error) {
logger.warn('Failed to check for interrupted features:', error);
}
}
};
checkInterrupted();
}, [currentProject]);
// Load persisted categories on mount/project change
useEffect(() => {
loadCategories();
}, [loadCategories]);
// Clear categories when project changes
useEffect(() => {
setPersistedCategories([]);
}, [currentProject?.path]);
return {
features,
isLoading,
persistedCategories,
loadFeatures,
loadFeatures: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
});
},
loadCategories,
saveCategory,
};