mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash * fix: Prevent cascading re-renders and crashes from high-frequency WS events
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
// @ts-nocheck - feature update logic with partial updates and image/file handling
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Feature,
|
||||
FeatureImage,
|
||||
@@ -18,7 +17,10 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||
import { truncateDescription } from '@/lib/utils';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import {
|
||||
markFeatureTransitioning,
|
||||
unmarkFeatureTransitioning,
|
||||
} from '@/lib/feature-transition-state';
|
||||
|
||||
const logger = createLogger('BoardActions');
|
||||
|
||||
@@ -116,8 +118,6 @@ export function useBoardActions({
|
||||
currentWorktreeBranch,
|
||||
stopFeature,
|
||||
}: UseBoardActionsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
|
||||
// subscribing to the entire store. Bare useAppStore() causes the host component
|
||||
// (BoardView) to re-render on EVERY store change, which cascades through effects
|
||||
@@ -125,7 +125,6 @@ export function useBoardActions({
|
||||
const addFeature = useAppStore((s) => s.addFeature);
|
||||
const updateFeature = useAppStore((s) => s.updateFeature);
|
||||
const removeFeature = useAppStore((s) => s.removeFeature);
|
||||
const moveFeature = useAppStore((s) => s.moveFeature);
|
||||
const worktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
|
||||
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
|
||||
@@ -707,8 +706,7 @@ export function useBoardActions({
|
||||
try {
|
||||
const result = await verifyFeatureMutation.mutateAsync(feature.id);
|
||||
if (result.passes) {
|
||||
// Immediately move card to verified column (optimistic update)
|
||||
moveFeature(feature.id, 'verified');
|
||||
// persistFeatureUpdate handles the optimistic RQ cache update internally
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: 'verified',
|
||||
justFinishedAt: undefined,
|
||||
@@ -725,7 +723,7 @@ export function useBoardActions({
|
||||
// Error toast is already shown by the mutation's onError handler
|
||||
}
|
||||
},
|
||||
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
|
||||
[currentProject, verifyFeatureMutation, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
@@ -742,7 +740,6 @@ export function useBoardActions({
|
||||
|
||||
const handleManualVerify = useCallback(
|
||||
(feature: Feature) => {
|
||||
moveFeature(feature.id, 'verified');
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: 'verified',
|
||||
justFinishedAt: undefined,
|
||||
@@ -751,7 +748,7 @@ export function useBoardActions({
|
||||
description: `Marked as verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[moveFeature, persistFeatureUpdate]
|
||||
[persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleMoveBackToInProgress = useCallback(
|
||||
@@ -760,13 +757,12 @@ export function useBoardActions({
|
||||
status: 'in_progress' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
toast.info('Feature moved back', {
|
||||
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
[persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleOpenFollowUp = useCallback(
|
||||
@@ -885,7 +881,6 @@ export function useBoardActions({
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
moveFeature(feature.id, 'verified');
|
||||
persistFeatureUpdate(feature.id, { status: 'verified' });
|
||||
toast.success('Feature committed', {
|
||||
description: `Committed and verified: ${truncateDescription(feature.description)}`,
|
||||
@@ -907,7 +902,7 @@ export function useBoardActions({
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
|
||||
[currentProject, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleMergeFeature = useCallback(
|
||||
@@ -951,17 +946,12 @@ export function useBoardActions({
|
||||
|
||||
const handleCompleteFeature = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: 'completed' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
persistFeatureUpdate(feature.id, { status: 'completed' as const });
|
||||
toast.success('Feature completed', {
|
||||
description: `Archived: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
[persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleUnarchiveFeature = useCallback(
|
||||
@@ -978,11 +968,7 @@ export function useBoardActions({
|
||||
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
|
||||
: featureBranch === currentWorktreeBranch;
|
||||
|
||||
const updates: Partial<Feature> = {
|
||||
status: 'verified' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, { status: 'verified' as const });
|
||||
|
||||
if (willBeVisibleOnCurrentView) {
|
||||
toast.success('Feature restored', {
|
||||
@@ -994,13 +980,7 @@ export function useBoardActions({
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
[persistFeatureUpdate, currentWorktreeBranch, projectPath, isPrimaryWorktreeBranch]
|
||||
);
|
||||
|
||||
const handleViewOutput = useCallback(
|
||||
@@ -1031,6 +1011,13 @@ export function useBoardActions({
|
||||
|
||||
const handleForceStopFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Mark this feature as transitioning so WebSocket-driven query invalidation
|
||||
// (useAutoModeQueryInvalidation) skips redundant cache invalidations while
|
||||
// persistFeatureUpdate is handling the optimistic update. Without this guard,
|
||||
// auto_mode_error / auto_mode_stopped WS events race with the optimistic
|
||||
// update and cause cache flip-flops that cascade through useBoardColumnFeatures,
|
||||
// triggering React error #185 on mobile.
|
||||
markFeatureTransitioning(feature.id);
|
||||
try {
|
||||
await stopFeature(feature.id);
|
||||
|
||||
@@ -1048,25 +1035,11 @@ export function useBoardActions({
|
||||
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
|
||||
}
|
||||
|
||||
// Optimistically update the React Query features cache so the board
|
||||
// moves the card immediately. Without this, the card stays in
|
||||
// "in_progress" until the next poll cycle (30s) because the async
|
||||
// refetch races with the persistFeatureUpdate write.
|
||||
if (currentProject) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(oldFeatures: Feature[] | undefined) => {
|
||||
if (!oldFeatures) return oldFeatures;
|
||||
return oldFeatures.map((f) =>
|
||||
f.id === feature.id ? { ...f, status: targetStatus } : f
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, targetStatus);
|
||||
// Must await to ensure file is written before user can restart
|
||||
// persistFeatureUpdate handles the optimistic RQ cache update, the
|
||||
// Zustand store update (on server response), and the final cache
|
||||
// invalidation internally — no need for separate queryClient.setQueryData
|
||||
// or moveFeature calls which would cause redundant re-renders.
|
||||
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
}
|
||||
|
||||
@@ -1083,9 +1056,15 @@ export function useBoardActions({
|
||||
toast.error('Failed to stop agent', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
} finally {
|
||||
// Delay unmarking so the refetch triggered by persistFeatureUpdate's
|
||||
// invalidateQueries() has time to settle before WS-driven invalidations
|
||||
// are allowed through again. Without this, a WS event arriving during
|
||||
// the refetch window would trigger a conflicting invalidation.
|
||||
setTimeout(() => unmarkFeatureTransitioning(feature.id), 500);
|
||||
}
|
||||
},
|
||||
[stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient]
|
||||
[stopFeature, persistFeatureUpdate, currentProject]
|
||||
);
|
||||
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck - column filtering logic with dependency resolution and status mapping
|
||||
import { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
createFeatureMap,
|
||||
@@ -177,9 +177,6 @@ export function useBoardColumnFeatures({
|
||||
(state) => state.clearRecentlyCompletedFeatures
|
||||
);
|
||||
|
||||
// Track previous feature IDs to detect when features list has been refreshed
|
||||
const prevFeatureIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Clear recently completed features when the cache refreshes with updated statuses.
|
||||
//
|
||||
// RACE CONDITION SCENARIO THIS PREVENTS:
|
||||
@@ -193,12 +190,16 @@ export function useBoardColumnFeatures({
|
||||
//
|
||||
// When the refetch completes with fresh data (status='verified'/'completed'),
|
||||
// this effect clears the recentlyCompletedFeatures set since it's no longer needed.
|
||||
// Clear recently completed features when the cache refreshes with updated statuses.
|
||||
// IMPORTANT: Only depend on `features` (not `recentlyCompletedFeatures`) to avoid a
|
||||
// re-trigger loop where clearing the set creates a new reference that re-fires this effect.
|
||||
// Read recentlyCompletedFeatures from the store directly to get the latest value without
|
||||
// subscribing to it as a dependency.
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(features.map((f) => f.id));
|
||||
const currentRecentlyCompleted = useAppStore.getState().recentlyCompletedFeatures;
|
||||
if (currentRecentlyCompleted.size === 0) return;
|
||||
|
||||
// Check if any recently completed features now have terminal statuses in the new data
|
||||
// If so, we can clear the tracking since the cache is now fresh
|
||||
const hasUpdatedStatus = Array.from(recentlyCompletedFeatures).some((featureId) => {
|
||||
const hasUpdatedStatus = Array.from(currentRecentlyCompleted).some((featureId) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
return feature && (feature.status === 'verified' || feature.status === 'completed');
|
||||
});
|
||||
@@ -206,9 +207,7 @@ export function useBoardColumnFeatures({
|
||||
if (hasUpdatedStatus) {
|
||||
clearRecentlyCompletedFeatures();
|
||||
}
|
||||
|
||||
prevFeatureIdsRef.current = currentIds;
|
||||
}, [features, recentlyCompletedFeatures, clearRecentlyCompletedFeatures]);
|
||||
}, [features, clearRecentlyCompletedFeatures]);
|
||||
|
||||
// Memoize column features to prevent unnecessary re-renders
|
||||
const columnFeaturesMap = useMemo(() => {
|
||||
|
||||
@@ -38,7 +38,6 @@ export function useBoardDragDrop({
|
||||
// subscribing to the entire store. Bare useAppStore() causes the host component
|
||||
// (BoardView) to re-render on EVERY store change, which cascades through effects
|
||||
// and triggers React error #185 (maximum update depth exceeded).
|
||||
const moveFeature = useAppStore((s) => s.moveFeature);
|
||||
const updateFeature = useAppStore((s) => s.updateFeature);
|
||||
|
||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||
@@ -207,7 +206,8 @@ export function useBoardDragDrop({
|
||||
if (targetStatus === draggedFeature.status) return;
|
||||
|
||||
// Handle different drag scenarios
|
||||
// Note: Worktrees are created server-side at execution time based on feature.branchName
|
||||
// Note: persistFeatureUpdate handles optimistic RQ cache update internally,
|
||||
// so no separate moveFeature() call is needed.
|
||||
if (draggedFeature.status === 'backlog' || draggedFeature.status === 'merge_conflict') {
|
||||
// From backlog
|
||||
if (targetStatus === 'in_progress') {
|
||||
@@ -215,7 +215,6 @@ export function useBoardDragDrop({
|
||||
// Server will derive workDir from feature.branchName
|
||||
await handleStartImplementation(draggedFeature);
|
||||
} else {
|
||||
moveFeature(featureId, targetStatus);
|
||||
persistFeatureUpdate(featureId, { status: targetStatus });
|
||||
}
|
||||
} else if (draggedFeature.status === 'waiting_approval') {
|
||||
@@ -223,7 +222,6 @@ export function useBoardDragDrop({
|
||||
// NOTE: This check must come BEFORE skipTests check because waiting_approval
|
||||
// features often have skipTests=true, and we want status-based handling first
|
||||
if (targetStatus === 'verified') {
|
||||
moveFeature(featureId, 'verified');
|
||||
// Clear justFinishedAt timestamp when manually verifying via drag
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: 'verified',
|
||||
@@ -237,7 +235,6 @@ export function useBoardDragDrop({
|
||||
});
|
||||
} else if (targetStatus === 'backlog') {
|
||||
// Allow moving waiting_approval cards back to backlog
|
||||
moveFeature(featureId, 'backlog');
|
||||
// Clear justFinishedAt timestamp when moving back to backlog
|
||||
persistFeatureUpdate(featureId, {
|
||||
status: 'backlog',
|
||||
@@ -269,7 +266,6 @@ export function useBoardDragDrop({
|
||||
});
|
||||
}
|
||||
}
|
||||
moveFeature(featureId, 'backlog');
|
||||
persistFeatureUpdate(featureId, { status: 'backlog' });
|
||||
toast.info(
|
||||
isRunningTask
|
||||
@@ -291,7 +287,6 @@ export function useBoardDragDrop({
|
||||
return;
|
||||
} else if (targetStatus === 'verified' && draggedFeature.skipTests) {
|
||||
// Manual verify via drag (only for skipTests features)
|
||||
moveFeature(featureId, 'verified');
|
||||
persistFeatureUpdate(featureId, { status: 'verified' });
|
||||
toast.success('Feature verified', {
|
||||
description: `Marked as verified: ${draggedFeature.description.slice(
|
||||
@@ -304,7 +299,6 @@ export function useBoardDragDrop({
|
||||
// skipTests feature being moved between verified and waiting_approval
|
||||
if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') {
|
||||
// Move verified feature back to waiting_approval
|
||||
moveFeature(featureId, 'waiting_approval');
|
||||
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
|
||||
toast.info('Feature moved back', {
|
||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||
@@ -314,7 +308,6 @@ export function useBoardDragDrop({
|
||||
});
|
||||
} else if (targetStatus === 'backlog') {
|
||||
// Allow moving skipTests cards back to backlog (from verified)
|
||||
moveFeature(featureId, 'backlog');
|
||||
persistFeatureUpdate(featureId, { status: 'backlog' });
|
||||
toast.info('Feature moved to backlog', {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
@@ -327,7 +320,6 @@ export function useBoardDragDrop({
|
||||
// Handle verified TDD (non-skipTests) features being moved back
|
||||
if (targetStatus === 'waiting_approval') {
|
||||
// Move verified feature back to waiting_approval
|
||||
moveFeature(featureId, 'waiting_approval');
|
||||
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
|
||||
toast.info('Feature moved back', {
|
||||
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
|
||||
@@ -337,7 +329,6 @@ export function useBoardDragDrop({
|
||||
});
|
||||
} else if (targetStatus === 'backlog') {
|
||||
// Allow moving verified cards back to backlog
|
||||
moveFeature(featureId, 'backlog');
|
||||
persistFeatureUpdate(featureId, { status: 'backlog' });
|
||||
toast.info('Feature moved to backlog', {
|
||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||
@@ -351,7 +342,6 @@ export function useBoardDragDrop({
|
||||
[
|
||||
features,
|
||||
runningAutoTasks,
|
||||
moveFeature,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
|
||||
@@ -87,37 +87,22 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
);
|
||||
|
||||
// Subscribe to auto mode events for notifications (ding sound, toasts)
|
||||
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
|
||||
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root.
|
||||
// Note: removeRunningTask is handled by useAutoMode — do NOT duplicate it here,
|
||||
// as duplicate Zustand mutations cause re-render cascades (React error #185).
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode || !currentProject) return;
|
||||
|
||||
const { removeRunningTask } = useAppStore.getState();
|
||||
const projectId = currentProject.id;
|
||||
const projectPath = currentProject.path;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Check if event is for the current project by matching projectPath
|
||||
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
|
||||
if (eventProjectPath && eventProjectPath !== projectPath) {
|
||||
// Event is for a different project, ignore it
|
||||
logger.debug(
|
||||
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// NOTE: auto_mode_feature_start and auto_mode_feature_complete are NOT handled here
|
||||
// for feature list reloading. That is handled by useAutoModeQueryInvalidation which
|
||||
// invalidates the features.all query on those events. Duplicate invalidation here
|
||||
// caused a re-render cascade through DndContext that triggered React error #185
|
||||
// (maximum update depth exceeded), crashing the board view with an infinite spinner
|
||||
// when a new feature was added and moved to in_progress.
|
||||
|
||||
if (event.type === 'auto_mode_feature_complete') {
|
||||
// Play ding sound when feature is done (unless muted)
|
||||
const { muteDoneSound } = useAppStore.getState();
|
||||
@@ -126,14 +111,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
|
||||
}
|
||||
} else if (event.type === 'auto_mode_error') {
|
||||
// Remove from running tasks
|
||||
if (event.featureId) {
|
||||
const eventBranchName =
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
}
|
||||
|
||||
// Show error toast
|
||||
// Show error toast (removeRunningTask is handled by useAutoMode, not here)
|
||||
const isAuthError =
|
||||
event.errorType === 'authentication' ||
|
||||
(event.error &&
|
||||
|
||||
@@ -281,6 +281,10 @@ function VirtualizedList<Item extends VirtualListItem>({
|
||||
);
|
||||
}
|
||||
|
||||
// Stable empty Set to use as default prop value. Using `new Set()` inline in
|
||||
// the destructuring creates a new reference on every render, defeating memo.
|
||||
const EMPTY_FEATURE_IDS = new Set<string>();
|
||||
|
||||
export const KanbanBoard = memo(function KanbanBoard({
|
||||
activeFeature,
|
||||
getColumnFeatures,
|
||||
@@ -317,7 +321,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||
onOpenPipelineSettings,
|
||||
isSelectionMode = false,
|
||||
selectionTarget = null,
|
||||
selectedFeatureIds = new Set(),
|
||||
selectedFeatureIds = EMPTY_FEATURE_IDS,
|
||||
onToggleFeatureSelection,
|
||||
onToggleSelectionMode,
|
||||
onAiSuggest,
|
||||
|
||||
@@ -22,6 +22,8 @@ function arraysEqual(a: string[], b: string[]): boolean {
|
||||
return a.every((id) => set.has(id));
|
||||
}
|
||||
const AUTO_MODE_POLLING_INTERVAL = 30000;
|
||||
// Stable empty array reference to avoid re-renders from `[] !== []`
|
||||
const EMPTY_TASKS: string[] = [];
|
||||
|
||||
/**
|
||||
* Generate a worktree key for session storage
|
||||
@@ -77,8 +79,12 @@ function isPlanApprovalEvent(
|
||||
* @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null)
|
||||
*/
|
||||
export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Subscribe to stable action functions and scalar state via useShallow.
|
||||
// IMPORTANT: Do NOT subscribe to autoModeByWorktree here. That object gets a
|
||||
// new reference on every Zustand mutation to ANY worktree, which would re-render
|
||||
// every useAutoMode consumer on every store change. Instead, we subscribe to the
|
||||
// specific worktree's state below using a targeted selector.
|
||||
const {
|
||||
autoModeByWorktree,
|
||||
setAutoModeRunning,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
@@ -93,7 +99,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
addRecentlyCompletedFeature,
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
autoModeByWorktree: state.autoModeByWorktree,
|
||||
setAutoModeRunning: state.setAutoModeRunning,
|
||||
addRunningTask: state.addRunningTask,
|
||||
removeRunningTask: state.removeRunningTask,
|
||||
@@ -144,41 +149,109 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
[projects]
|
||||
);
|
||||
|
||||
// Get worktree-specific auto mode state
|
||||
// Get worktree-specific auto mode state using a TARGETED selector with
|
||||
// VALUE-BASED equality. This is critical for preventing cascading re-renders
|
||||
// in board view, where DndContext amplifies every parent re-render.
|
||||
//
|
||||
// Why value-based equality matters: Every Zustand `set()` call (including
|
||||
// `addAutoModeActivity` which fires on every WS event) triggers all subscriber
|
||||
// selectors to re-run. Even our targeted selector that reads a specific key
|
||||
// would return a new object reference (from the spread in `removeRunningTask`
|
||||
// etc.), causing a re-render even when the actual values haven't changed.
|
||||
// By extracting primitives and comparing with a custom equality function,
|
||||
// we only re-render when isRunning/runningTasks/maxConcurrency actually change.
|
||||
const projectId = currentProject?.id;
|
||||
const worktreeAutoModeState = useMemo(() => {
|
||||
if (!projectId)
|
||||
return {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName: null,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
};
|
||||
const key = getWorktreeKey(projectId, branchName);
|
||||
return (
|
||||
autoModeByWorktree[key] || {
|
||||
isRunning: false,
|
||||
runningTasks: [],
|
||||
branchName,
|
||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||
}
|
||||
const worktreeKey = useMemo(
|
||||
() => (projectId ? getWorktreeKey(projectId, branchName) : null),
|
||||
[projectId, branchName, getWorktreeKey]
|
||||
);
|
||||
}, [autoModeByWorktree, projectId, branchName, getWorktreeKey]);
|
||||
|
||||
const isAutoModeRunning = worktreeAutoModeState.isRunning;
|
||||
const runningAutoTasks = worktreeAutoModeState.runningTasks;
|
||||
// Use the subscribed worktreeAutoModeState.maxConcurrency (from the reactive
|
||||
// autoModeByWorktree store slice) so canStartNewTask stays reactive when
|
||||
// refreshStatus updates worktree state or when the global setting changes.
|
||||
// Falls back to the subscribed globalMaxConcurrency (also reactive) when no
|
||||
// per-worktree value is set, and to DEFAULT_MAX_CONCURRENCY when no project.
|
||||
// Subscribe to this specific worktree's state using useShallow.
|
||||
// useShallow compares each property of the returned object with Object.is,
|
||||
// so primitive properties (isRunning: boolean, maxConcurrency: number) are
|
||||
// naturally stable. Only runningTasks (array) needs additional stabilization
|
||||
// since filter()/spread creates new array references even for identical content.
|
||||
const { worktreeIsRunning, worktreeRunningTasksRaw, worktreeMaxConcurrency } = useAppStore(
|
||||
useShallow((state) => {
|
||||
if (!worktreeKey) {
|
||||
return {
|
||||
worktreeIsRunning: false,
|
||||
worktreeRunningTasksRaw: EMPTY_TASKS,
|
||||
worktreeMaxConcurrency: undefined as number | undefined,
|
||||
};
|
||||
}
|
||||
const wt = state.autoModeByWorktree[worktreeKey];
|
||||
if (!wt) {
|
||||
return {
|
||||
worktreeIsRunning: false,
|
||||
worktreeRunningTasksRaw: EMPTY_TASKS,
|
||||
worktreeMaxConcurrency: undefined as number | undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
worktreeIsRunning: wt.isRunning,
|
||||
worktreeRunningTasksRaw: wt.runningTasks,
|
||||
worktreeMaxConcurrency: wt.maxConcurrency,
|
||||
};
|
||||
})
|
||||
);
|
||||
// Stabilize runningTasks: useShallow uses Object.is per property, but
|
||||
// runningTasks gets a new array ref after removeRunningTask/addRunningTask.
|
||||
// Cache the previous value and only update when content actually changes.
|
||||
const prevTasksRef = useRef<string[]>(EMPTY_TASKS);
|
||||
const worktreeRunningTasks = useMemo(() => {
|
||||
if (worktreeRunningTasksRaw === prevTasksRef.current) return prevTasksRef.current;
|
||||
if (arraysEqual(prevTasksRef.current, worktreeRunningTasksRaw)) return prevTasksRef.current;
|
||||
prevTasksRef.current = worktreeRunningTasksRaw;
|
||||
return worktreeRunningTasksRaw;
|
||||
}, [worktreeRunningTasksRaw]);
|
||||
|
||||
const isAutoModeRunning = worktreeIsRunning;
|
||||
const runningAutoTasks = worktreeRunningTasks;
|
||||
// Use worktreeMaxConcurrency (from the reactive per-key selector) so
|
||||
// canStartNewTask stays reactive when refreshStatus updates worktree state
|
||||
// or when the global setting changes.
|
||||
const maxConcurrency = projectId
|
||||
? (worktreeAutoModeState.maxConcurrency ?? globalMaxConcurrency)
|
||||
? (worktreeMaxConcurrency ?? globalMaxConcurrency)
|
||||
: DEFAULT_MAX_CONCURRENCY;
|
||||
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
// Batch addAutoModeActivity calls to reduce Zustand set() frequency.
|
||||
// Without batching, each WS event (especially auto_mode_progress which fires
|
||||
// rapidly during streaming) triggers a separate set() → all subscriber selectors
|
||||
// re-evaluate → on mobile this overwhelms React's batching → crash.
|
||||
// This batches activities in a ref and flushes them in a single set() call.
|
||||
const pendingActivitiesRef = useRef<Parameters<typeof addAutoModeActivity>[0][]>([]);
|
||||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const batchedAddAutoModeActivity = useCallback(
|
||||
(activity: Parameters<typeof addAutoModeActivity>[0]) => {
|
||||
pendingActivitiesRef.current.push(activity);
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
const batch = pendingActivitiesRef.current;
|
||||
pendingActivitiesRef.current = [];
|
||||
flushTimerRef.current = null;
|
||||
// Flush all pending activities in a single store update
|
||||
for (const act of batch) {
|
||||
addAutoModeActivity(act);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
[addAutoModeActivity]
|
||||
);
|
||||
|
||||
// Cleanup flush timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state
|
||||
// during start/stop transitions.
|
||||
const isTransitioningRef = useRef(false);
|
||||
@@ -498,7 +571,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
case 'auto_mode_feature_start':
|
||||
if (event.featureId) {
|
||||
addRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'start',
|
||||
message: `Started working on feature`,
|
||||
@@ -514,7 +587,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// briefly appear in backlog due to stale cache data
|
||||
addRecentlyCompletedFeature(event.featureId);
|
||||
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'complete',
|
||||
message: event.passes
|
||||
@@ -551,7 +624,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.`
|
||||
: event.error;
|
||||
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'error',
|
||||
message: errorMessage,
|
||||
@@ -568,7 +641,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
case 'auto_mode_progress':
|
||||
// Log progress updates (throttle to avoid spam)
|
||||
if (event.featureId && event.content && event.content.length > 10) {
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'progress',
|
||||
message: event.content.substring(0, 200), // Limit message length
|
||||
@@ -579,7 +652,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
case 'auto_mode_tool':
|
||||
// Log tool usage
|
||||
if (event.featureId && event.tool) {
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'tool',
|
||||
message: `Using tool: ${event.tool}`,
|
||||
@@ -592,7 +665,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Log phase transitions (Planning, Action, Verification)
|
||||
if (event.featureId && event.phase && event.message) {
|
||||
logger.debug(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: event.phase,
|
||||
message: event.message,
|
||||
@@ -618,7 +691,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Log when planning phase begins
|
||||
if (event.featureId && event.mode && event.message) {
|
||||
logger.debug(`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'planning',
|
||||
message: event.message,
|
||||
@@ -631,7 +704,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Log when plan is approved by user
|
||||
if (event.featureId) {
|
||||
logger.debug(`[AutoMode] Plan approved for ${event.featureId}`);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'action',
|
||||
message: event.hasEdits
|
||||
@@ -646,7 +719,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Log when plan is auto-approved (requirePlanApproval=false)
|
||||
if (event.featureId) {
|
||||
logger.debug(`[AutoMode] Plan auto-approved for ${event.featureId}`);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'action',
|
||||
message: 'Plan auto-approved, starting implementation...',
|
||||
@@ -665,7 +738,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
logger.debug(
|
||||
`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'planning',
|
||||
message: `Revising plan based on feedback (v${revisionEvent.planVersion})...`,
|
||||
@@ -681,7 +754,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
logger.debug(
|
||||
`[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'progress',
|
||||
message: `▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}`,
|
||||
@@ -696,7 +769,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
logger.debug(
|
||||
`[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'progress',
|
||||
message: `✓ ${taskEvent.taskId} done (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`,
|
||||
@@ -714,7 +787,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
logger.debug(
|
||||
`[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'action',
|
||||
message: `Phase ${phaseEvent.phaseNumber} completed`,
|
||||
@@ -742,7 +815,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
logger.debug(
|
||||
`[AutoMode] Summary saved for ${event.featureId}: ${summaryEvent.summary.substring(0, 100)}...`
|
||||
);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId: event.featureId,
|
||||
type: 'progress',
|
||||
message: `Summary: ${summaryEvent.summary.substring(0, 100)}...`,
|
||||
@@ -758,7 +831,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
branchName,
|
||||
addRunningTask,
|
||||
removeRunningTask,
|
||||
addAutoModeActivity,
|
||||
batchedAddAutoModeActivity,
|
||||
getProjectIdFromPath,
|
||||
setPendingPlanApproval,
|
||||
setAutoModeRunning,
|
||||
@@ -977,7 +1050,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
removeRunningTask(currentProject.id, branchName, featureId);
|
||||
|
||||
logger.info('Feature stopped successfully:', featureId);
|
||||
addAutoModeActivity({
|
||||
batchedAddAutoModeActivity({
|
||||
featureId,
|
||||
type: 'complete',
|
||||
message: 'Feature stopped by user',
|
||||
@@ -993,7 +1066,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentProject, branchName, removeRunningTask, addAutoModeActivity]
|
||||
[currentProject, branchName, removeRunningTask, batchedAddAutoModeActivity]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AutoModeEvent, SpecRegenerationEvent, StreamEvent } from '@/types/
|
||||
import type { IssueValidationEvent } from '@automaker/types';
|
||||
import { debounce, type DebouncedFunction } from '@automaker/utils/debounce';
|
||||
import { useEventRecencyStore } from './use-event-recency';
|
||||
import { isAnyFeatureTransitioning } from '@/lib/feature-transition-state';
|
||||
|
||||
/**
|
||||
* Debounce configuration for auto_mode_progress invalidations
|
||||
@@ -31,8 +32,10 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
|
||||
'auto_mode_feature_start',
|
||||
'auto_mode_feature_complete',
|
||||
'auto_mode_error',
|
||||
'auto_mode_started',
|
||||
'auto_mode_stopped',
|
||||
// NOTE: auto_mode_started and auto_mode_stopped are intentionally excluded.
|
||||
// These events signal auto-loop state changes, NOT feature data changes.
|
||||
// Including them caused unnecessary refetches that raced with optimistic
|
||||
// updates during start/stop cycles, triggering React error #185 on mobile.
|
||||
'plan_approval_required',
|
||||
'plan_approved',
|
||||
'plan_rejected',
|
||||
@@ -176,8 +179,12 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
// This allows polling to be disabled when WebSocket events are flowing
|
||||
recordGlobalEvent();
|
||||
|
||||
// Invalidate feature list for lifecycle events
|
||||
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) {
|
||||
// Invalidate feature list for lifecycle events.
|
||||
// Skip invalidation when a feature is mid-transition (e.g., being cancelled)
|
||||
// because persistFeatureUpdate already handles the optimistic cache update.
|
||||
// Without this guard, auto_mode_error / auto_mode_stopped WS events race
|
||||
// with the optimistic update and cause re-render cascades on mobile (React #185).
|
||||
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type) && !isAnyFeatureTransitioning()) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProjectPath),
|
||||
});
|
||||
|
||||
19
apps/ui/src/lib/feature-transition-state.ts
Normal file
19
apps/ui/src/lib/feature-transition-state.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Lightweight module-level state tracking which features are mid-transition
|
||||
* (e.g., being cancelled). Used by useAutoModeQueryInvalidation to skip
|
||||
* redundant cache invalidations while persistFeatureUpdate is in flight.
|
||||
*/
|
||||
|
||||
const transitioningFeatures = new Set<string>();
|
||||
|
||||
export function markFeatureTransitioning(featureId: string): void {
|
||||
transitioningFeatures.add(featureId);
|
||||
}
|
||||
|
||||
export function unmarkFeatureTransitioning(featureId: string): void {
|
||||
transitioningFeatures.delete(featureId);
|
||||
}
|
||||
|
||||
export function isAnyFeatureTransitioning(): boolean {
|
||||
return transitioningFeatures.size > 0;
|
||||
}
|
||||
@@ -1044,6 +1044,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
set((state) => {
|
||||
const current = state.autoModeByWorktree[key];
|
||||
if (!current) return state;
|
||||
// Idempotent: skip if task is not in the list to avoid creating new
|
||||
// object references that trigger unnecessary re-renders.
|
||||
if (!current.runningTasks.includes(taskId)) return state;
|
||||
return {
|
||||
autoModeByWorktree: {
|
||||
...state.autoModeByWorktree,
|
||||
@@ -1097,13 +1100,20 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
|
||||
addRecentlyCompletedFeature: (featureId: string) => {
|
||||
set((state) => {
|
||||
// Idempotent: skip if already tracked to avoid creating a new Set reference
|
||||
// that triggers unnecessary re-renders in useBoardColumnFeatures.
|
||||
if (state.recentlyCompletedFeatures.has(featureId)) return state;
|
||||
const newSet = new Set(state.recentlyCompletedFeatures);
|
||||
newSet.add(featureId);
|
||||
return { recentlyCompletedFeatures: newSet };
|
||||
});
|
||||
},
|
||||
|
||||
clearRecentlyCompletedFeatures: () => set({ recentlyCompletedFeatures: new Set() }),
|
||||
clearRecentlyCompletedFeatures: () => {
|
||||
// Idempotent: skip if already empty to avoid creating a new Set reference.
|
||||
if (get().recentlyCompletedFeatures.size === 0) return;
|
||||
set({ recentlyCompletedFeatures: new Set() });
|
||||
},
|
||||
|
||||
setMaxConcurrency: (max) => set({ maxConcurrency: max }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user