mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
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:
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user