diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index f2e84d14a..2188b9f2a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -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 = { - 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 () => { diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 73439ee9d..82965e95d 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -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>(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(() => { diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index b847be820..cd0cc1e57 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -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, diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts index 203b30173..944472345 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts @@ -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 && diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 446ac08ff..f3b1c6034 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -281,6 +281,10 @@ function VirtualizedList({ ); } +// 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(); + 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, diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index faf123437..25772b68f 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -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) + const worktreeKey = useMemo( + () => (projectId ? getWorktreeKey(projectId, branchName) : null), + [projectId, branchName, getWorktreeKey] + ); + + // 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 { - isRunning: false, - runningTasks: [], - branchName: null, - maxConcurrency: DEFAULT_MAX_CONCURRENCY, + worktreeIsRunning: wt.isRunning, + worktreeRunningTasksRaw: wt.runningTasks, + worktreeMaxConcurrency: wt.maxConcurrency, }; - const key = getWorktreeKey(projectId, branchName); - return ( - autoModeByWorktree[key] || { - isRunning: false, - runningTasks: [], - branchName, - maxConcurrency: DEFAULT_MAX_CONCURRENCY, - } - ); - }, [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. + }) + ); + // 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(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[0][]>([]); + const flushTimerRef = useRef | null>(null); + const batchedAddAutoModeActivity = useCallback( + (activity: Parameters[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 { diff --git a/apps/ui/src/hooks/use-query-invalidation.ts b/apps/ui/src/hooks/use-query-invalidation.ts index 241538e3d..2876d7cc0 100644 --- a/apps/ui/src/hooks/use-query-invalidation.ts +++ b/apps/ui/src/hooks/use-query-invalidation.ts @@ -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), }); diff --git a/apps/ui/src/lib/feature-transition-state.ts b/apps/ui/src/lib/feature-transition-state.ts new file mode 100644 index 000000000..ac72aed1a --- /dev/null +++ b/apps/ui/src/lib/feature-transition-state.ts @@ -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(); + +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; +} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6bf808ed0..8e4918d19 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1044,6 +1044,9 @@ export const useAppStore = create()((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()((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 }),