diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2b48f662f..cc0c3fbc1 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -598,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => { // Subscribe to all events and forward to this client const unsubscribe = events.subscribe((type, payload) => { - logger.info('Event received:', { + // Use debug level for high-frequency events to avoid log spam + // that causes progressive memory growth and server slowdown + const isHighFrequency = + type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress'; + const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger); + + log('Event received:', { type, hasPayload: !!payload, - payloadKeys: payload ? Object.keys(payload) : [], wsReadyState: ws.readyState, - wsOpen: ws.readyState === WebSocket.OPEN, }); if (ws.readyState === WebSocket.OPEN) { const message = JSON.stringify({ type, payload }); - logger.info('Sending event to client:', { - type, - messageLength: message.length, - sessionId: (payload as Record)?.sessionId, - }); ws.send(message); } else { - logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState); + logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState); } }); diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 6cef17dc8..319e9895f 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -88,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ }, ]; -// Throttle output to prevent overwhelming WebSocket under heavy load -const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback -const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency +// Throttle output to prevent overwhelming WebSocket under heavy load. +// 100ms (~10fps) is sufficient for readable log streaming while keeping +// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated +// up to 250 events/sec which caused progressive browser slowdown from +// accumulated console logs, JSON serialization overhead, and React re-renders. +const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate +const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency export interface DevServerInfo { worktreePath: string; diff --git a/apps/server/tests/unit/services/dev-server-event-types.test.ts b/apps/server/tests/unit/services/dev-server-event-types.test.ts index 95fdfd943..5bf4dcbfa 100644 --- a/apps/server/tests/unit/services/dev-server-event-types.test.ts +++ b/apps/server/tests/unit/services/dev-server-event-types.test.ts @@ -90,8 +90,8 @@ describe('DevServerService Event Types', () => { // 2. Output & URL Detected mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n')); - // Throttled output needs a bit of time - await new Promise((resolve) => setTimeout(resolve, 100)); + // Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms) + await new Promise((resolve) => setTimeout(resolve, 250)); expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1); expect(emittedEvents['dev-server:url-detected'].length).toBe(1); expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/'); 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 9068af89a..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 @@ -32,11 +32,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { const isRestoring = useIsRestoring(); // Use React Query for features - const { - data: features = [], - isLoading: isQueryLoading, - refetch: loadFeatures, - } = useFeatures(currentProject?.path); + const { data: features = [], isLoading: isQueryLoading } = useFeatures(currentProject?.path); // Don't report loading while IDB cache restore is in progress — // features will appear momentarily once the restore completes. @@ -91,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(); @@ -130,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 && @@ -159,7 +133,6 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { }); return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps -- loadFeatures is a stable ref from React Query }, [currentProject]); // Check for interrupted features on mount 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/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts index 2d5dd8abb..797f55c43 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-server-logs.ts @@ -74,6 +74,27 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS // Keep track of whether we've fetched initial logs const hasFetchedInitialLogs = useRef(false); + // Buffer for batching rapid output events into fewer setState calls. + // Content accumulates here and is flushed via requestAnimationFrame, + // ensuring at most one React re-render per animation frame (~60fps max). + // A fallback setTimeout ensures the buffer is flushed even when RAF is + // throttled (e.g., when the tab is in the background). + const pendingOutputRef = useRef(''); + const rafIdRef = useRef(null); + const timerIdRef = useRef | null>(null); + + const resetPendingOutput = useCallback(() => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } + pendingOutputRef.current = ''; + }, []); + /** * Fetch buffered logs from the server */ @@ -130,6 +151,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS * Clear logs and reset state */ const clearLogs = useCallback(() => { + resetPendingOutput(); setState({ logs: '', logsVersion: 0, @@ -144,13 +166,19 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS serverError: null, }); hasFetchedInitialLogs.current = false; - }, []); + }, [resetPendingOutput]); + + const flushPendingOutput = useCallback(() => { + // Clear both scheduling handles to prevent duplicate flushes + rafIdRef.current = null; + if (timerIdRef.current !== null) { + clearTimeout(timerIdRef.current); + timerIdRef.current = null; + } + const content = pendingOutputRef.current; + if (!content) return; + pendingOutputRef.current = ''; - /** - * Append content to logs, enforcing a maximum buffer size to prevent - * unbounded memory growth and progressive UI lag. - */ - const appendLogs = useCallback((content: string) => { setState((prev) => { const combined = prev.logs + content; const didTrim = combined.length > MAX_LOG_BUFFER_SIZE; @@ -170,6 +198,48 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS }); }, []); + /** + * Append content to logs, enforcing a maximum buffer size to prevent + * unbounded memory growth and progressive UI lag. + * + * Uses requestAnimationFrame to batch rapid output events into at most + * one React state update per frame, preventing excessive re-renders. + * A fallback setTimeout(250ms) ensures the buffer is flushed even when + * RAF is throttled (e.g., when the tab is in the background). + * If the pending buffer reaches MAX_LOG_BUFFER_SIZE, flushes immediately + * to prevent unbounded memory growth. + */ + const appendLogs = useCallback( + (content: string) => { + pendingOutputRef.current += content; + + // Flush immediately if buffer has reached the size limit + if (pendingOutputRef.current.length >= MAX_LOG_BUFFER_SIZE) { + flushPendingOutput(); + return; + } + + // Schedule a RAF flush if not already scheduled + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(flushPendingOutput); + } + + // Schedule a fallback timer flush if not already scheduled, + // to handle cases where RAF is throttled (background tab) + if (timerIdRef.current === null) { + timerIdRef.current = setTimeout(flushPendingOutput, 250); + } + }, + [flushPendingOutput] + ); + + // Clean up pending RAF on unmount to prevent state updates after unmount + useEffect(() => { + return () => { + resetPendingOutput(); + }; + }, [resetPendingOutput]); + // Fetch initial logs when worktreePath changes useEffect(() => { if (worktreePath && autoSubscribe) { @@ -196,6 +266,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS switch (event.type) { case 'dev-server:started': { + resetPendingOutput(); const { payload } = event; logger.info('Dev server started:', payload); setState((prev) => ({ @@ -245,7 +316,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS }); return unsubscribe; - }, [worktreePath, autoSubscribe, appendLogs]); + }, [worktreePath, autoSubscribe, appendLogs, resetPendingOutput]); return { ...state, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts index 9499ee15e..ccf2caa9e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts @@ -4,13 +4,17 @@ import { getElectronAPI } from '@/lib/electron'; import { normalizePath } from '@/lib/utils'; import { toast } from 'sonner'; import type { DevServerInfo, WorktreeInfo } from '../types'; +import { useEventRecencyStore } from '@/hooks/use-event-recency'; const logger = createLogger('DevServers'); // Timeout (ms) for port detection before showing a warning to the user const PORT_DETECTION_TIMEOUT_MS = 30_000; -// Interval (ms) for periodic state reconciliation with the backend -const STATE_RECONCILE_INTERVAL_MS = 5_000; +// Interval (ms) for periodic state reconciliation with the backend. +// 30 seconds is sufficient since WebSocket events handle real-time updates; +// reconciliation is only a fallback for missed events (PWA restart, WS gaps). +// The previous 5-second interval added unnecessary HTTP pressure. +const STATE_RECONCILE_INTERVAL_MS = 30_000; interface UseDevServersOptions { projectPath: string; @@ -322,12 +326,24 @@ export function useDevServers({ projectPath }: UseDevServersOptions) { return () => clearInterval(intervalId); }, [clearPortDetectionTimer, startPortDetectionTimer]); + // Record global events so smart polling knows WebSocket is healthy. + // Without this, dev-server events don't suppress polling intervals, + // causing all queries (features, worktrees, running-agents) to poll + // at their default rates even though the WebSocket is actively connected. + const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent); + // Subscribe to all dev server lifecycle events for reactive state updates useEffect(() => { const api = getElectronAPI(); if (!api?.worktree?.onDevServerLogEvent) return; const unsubscribe = api.worktree.onDevServerLogEvent((event) => { + // Record that WS is alive (but only for lifecycle events, not output - + // output fires too frequently and would trigger unnecessary store updates) + if (event.type !== 'dev-server:output') { + recordGlobalEvent(); + } + if (event.type === 'dev-server:starting') { const { worktreePath } = event.payload; const key = normalizePath(worktreePath); @@ -424,7 +440,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) { }); return unsubscribe; - }, [clearPortDetectionTimer, startPortDetectionTimer]); + }, [clearPortDetectionTimer, startPortDetectionTimer, recordGlobalEvent]); // Cleanup all port detection timers on unmount useEffect(() => { 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/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d3dc9fb92..9b06d75ef 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -923,17 +923,20 @@ export class HttpApiClient implements ElectronAPI { this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); - logger.info( - 'WebSocket message:', - data.type, - 'hasPayload:', - !!data.payload, - 'callbacksRegistered:', - this.eventCallbacks.has(data.type) - ); + // Only log non-high-frequency events to avoid progressive memory growth + // from accumulated console entries. High-frequency events (dev-server output, + // test runner output, agent progress) fire 10+ times/sec and would generate + // thousands of console entries per minute. + const isHighFrequency = + data.type === 'dev-server:output' || + data.type === 'test-runner:output' || + data.type === 'feature:progress' || + (data.type === 'auto-mode:event' && data.payload?.type === 'auto_mode_progress'); + if (!isHighFrequency) { + logger.info('WebSocket message:', data.type); + } const callbacks = this.eventCallbacks.get(data.type); if (callbacks) { - logger.info('Dispatching to', callbacks.size, 'callbacks'); callbacks.forEach((cb) => cb(data.payload)); } } catch (error) { @@ -2760,6 +2763,21 @@ export class HttpApiClient implements ElectronAPI { headers?: Record; enabled?: boolean; }>; + eventHooks?: Array<{ + id: string; + trigger: string; + enabled: boolean; + action: Record; + name?: string; + }>; + ntfyEndpoints?: Array<{ + id: string; + name: string; + serverUrl: string; + topic: string; + authType: string; + enabled: boolean; + }>; }; error?: string; }> => this.get('/api/settings/global'), diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 6f5d0758e..379c7b427 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -600,10 +600,7 @@ function RootLayoutContent() { // so updating them won't cause a visible re-render flash. const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? []; const currentHooks = useAppStore.getState().eventHooks; - if ( - JSON.stringify(serverHooks) !== JSON.stringify(currentHooks) && - serverHooks.length > 0 - ) { + if (JSON.stringify(serverHooks) !== JSON.stringify(currentHooks)) { logger.info( `[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})` ); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 6bf808ed0..e4b96eb27 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 }), @@ -1496,7 +1506,11 @@ export const useAppStore = create()((set, get) => ({ set({ eventHooks: hooks }); try { const httpApi = getHttpApiClient(); - await httpApi.settings.updateGlobal({ eventHooks: hooks }); + await httpApi.settings.updateGlobal({ + eventHooks: hooks, + // Signal the server that an empty array is intentional (not a wipe from stale state) + ...(hooks.length === 0 ? { __allowEmptyEventHooks: true } : {}), + }); } catch (error) { logger.error('Failed to sync event hooks:', error); } @@ -1507,7 +1521,11 @@ export const useAppStore = create()((set, get) => ({ set({ ntfyEndpoints: endpoints }); try { const httpApi = getHttpApiClient(); - await httpApi.settings.updateGlobal({ ntfyEndpoints: endpoints }); + await httpApi.settings.updateGlobal({ + ntfyEndpoints: endpoints, + // Signal the server that an empty array is intentional (not a wipe from stale state) + ...(endpoints.length === 0 ? { __allowEmptyNtfyEndpoints: true } : {}), + }); } catch (error) { logger.error('Failed to sync ntfy endpoints:', error); } diff --git a/apps/ui/vite.config.mts b/apps/ui/vite.config.mts index 2cd413578..a24caefad 100644 --- a/apps/ui/vite.config.mts +++ b/apps/ui/vite.config.mts @@ -238,24 +238,36 @@ export default defineConfig(({ command }) => { // Inject build hash into sw.js CACHE_NAME for automatic cache busting swCacheBuster(), ], + // Keep Vite dep-optimization cache local to apps/ui so each worktree gets + // its own pre-bundled dependencies. Shared cache state across worktrees can + // produce duplicate React instances (notably with @xyflow/react) and trigger + // "Invalid hook call" in the graph view. + cacheDir: path.resolve(__dirname, 'node_modules/.vite'), resolve: { alias: [ { find: '@', replacement: path.resolve(__dirname, './src') }, // Force ALL React imports (including from nested deps like zustand@4 inside - // @xyflow/react) to resolve to the single copy in the workspace root node_modules. - // This prevents "Cannot read properties of null (reading 'useState')" caused by - // react-dom setting the hooks dispatcher on one React instance while component - // code reads it from a different instance. + // @xyflow/react) to resolve to a single copy. + // Explicit subpath aliases must come BEFORE the broad regex so Vite's + // first-match-wins resolution applies the specific match first. { find: /^react-dom(\/|$)/, replacement: path.resolve(__dirname, '../../node_modules/react-dom') + '/', }, + { + find: 'react/jsx-runtime', + replacement: path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js'), + }, + { + find: 'react/jsx-dev-runtime', + replacement: path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'), + }, { find: /^react(\/|$)/, replacement: path.resolve(__dirname, '../../node_modules/react') + '/', }, ], - dedupe: ['react', 'react-dom'], + dedupe: ['react', 'react-dom', 'zustand', 'use-sync-external-store', '@xyflow/react'], }, server: { host: process.env.HOST || '0.0.0.0', @@ -355,8 +367,12 @@ export default defineConfig(({ command }) => { include: [ 'react', 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', 'use-sync-external-store', + 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector', + 'zustand', '@xyflow/react', ], },