From 0d0c1eb855cb828c706ecd2e9bf4121d7c7a4078 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:02:19 -0500 Subject: [PATCH] fix(analysis): the run outlives leaving Discover, and a queued run is not cancelled by auto-advance A finished batch scheduled a jump to Organize 2 s later. Files added during that batch start as the next run right away, and the jump fired anyway: Discover unmounted and aborted the queued run 1.5 s in (logged as "Analysis interrupted by navigation", 70 files, 0 processed). Returning to Discover restarted all 70, and because the run lived in Discover, analysis locked every phase tab for the whole run, so the earlier batch could not be organized meanwhile. - Auto-advance skips when another run is active or queued (read at fire time). - The run moves out of DiscoverPhase into AnalysisRunProvider, mounted in PhaseRenderer above every phase, together with the stuck-run watchdog and the total-failure flag. Discover reads it through useAnalysisRun(). - A running analysis no longer pauses the phase tabs, Alt+Arrow, dashboard jump links or Discover's Continue (once a file is ready). File moves, loading and undo/redo still pause; switching to the dashboard still waits, since the dashboard replaces PhaseRenderer. - The run's final merge drops results for files organized mid-run, so a moved file's old-path result is not re-added. --- src/renderer/components/NavigationBar.jsx | 29 ++- src/renderer/components/PhaseRenderer.jsx | 7 +- src/renderer/phases/DiscoverPhase.jsx | 134 ++----------- .../phases/discover/AnalysisRunProvider.jsx | 177 ++++++++++++++++++ src/renderer/phases/discover/index.js | 1 + src/renderer/phases/discover/useAnalysis.js | 104 ++++++---- src/renderer/utils/phaseNavigation.js | 24 +-- test/components/AnalysisRunProvider.test.js | 153 +++++++++++++++ test/components/DiscoverPhase.test.js | 50 +++-- test/components/PhaseRenderer.test.js | 6 + .../dashboard/DashboardView.test.js | 18 +- test/renderer/phaseNavigation.test.js | 5 +- test/renderer/usePhaseJump.test.js | 12 +- test/useAnalysis.test.js | 58 ++++++ test/useKeyboardShortcuts.phaseGating.test.js | 10 +- 15 files changed, 580 insertions(+), 208 deletions(-) create mode 100644 src/renderer/phases/discover/AnalysisRunProvider.jsx create mode 100644 test/components/AnalysisRunProvider.test.js diff --git a/src/renderer/components/NavigationBar.jsx b/src/renderer/components/NavigationBar.jsx index 9219a02..6c01fac 100644 --- a/src/renderer/components/NavigationBar.jsx +++ b/src/renderer/components/NavigationBar.jsx @@ -267,7 +267,7 @@ const NavTab = memo(function NavTab({ title={ !canNavigate && !isActive ? blockReason || - 'Navigation is paused while analysis or file moves run. Wait for the step to finish, or cancel from the active phase.' + 'Navigation is paused while files are being moved. Wait for the move to finish, or cancel from the active phase.' : `Step ${(metadata?.step ?? 0) + 1} of ${PHASE_ORDER.length} • ${metadata?.description || metadata?.title}` } style={{ WebkitAppRegion: 'no-drag' }} @@ -339,8 +339,9 @@ const DASHBOARD_WAITS_FOR_ANALYSIS = * dashboard overview. Subscribes to ui.viewMode directly and dispatches * setViewMode, so it is not gated by isBlockedByOperation the way the phase * tabs are: the way back to Guided is always open. The way into the dashboard - * waits for a running analysis, because Discover owns the run and App.js - * unmounts Discover to show the dashboard, which would stop it. + * waits for a running analysis: the run lives in AnalysisRunProvider inside + * PhaseRenderer, and App.js replaces PhaseRenderer with the dashboard, which + * would stop it. (Phase tabs do not wait: the run outlives a phase change.) */ const ViewModeToggle = memo(function ViewModeToggle() { const dispatch = useAppDispatch(); @@ -842,9 +843,10 @@ function NavigationBar() { }; }, []); - // Keep top-level navigation responsive; analysis state can become stale. - const isBlockedByOperation = - isOrganizing || isLoading || Boolean(isAnalyzing) || Boolean(isUndoRedoExecuting); + // File moves, loading and undo/redo pause the step tabs. A running analysis + // does not: the run lives in AnalysisRunProvider above the phases, so the + // user can organize finished files while the rest keep analyzing. + const isBlockedByOperation = isOrganizing || isLoading || Boolean(isUndoRedoExecuting); // Phase navigation handler const handlePhaseChange = useCallback( @@ -871,9 +873,7 @@ function NavigationBar() { showInfo( isOrganizing ? 'Files are still moving. Stay on this step until the move finishes or cancel from Organize.' - : isAnalyzing - ? 'Analysis is still running. You can keep reviewing this step while processing finishes.' - : 'Navigation is paused until the current operation finishes.', + : 'Navigation is paused until the current operation finishes.', 5200 ); return; @@ -891,11 +891,11 @@ function NavigationBar() { showInfo( isOrganizing ? 'Navigation is paused while files are moving. You can still open Settings from the gear.' - : 'Navigation is paused while analysis is running. You can still open Settings from the gear.', + : 'Navigation is paused until the current operation finishes. You can still open Settings from the gear.', 5200 ); }, - [actions, currentPhase, isAnalyzing, isBlockedByOperation, isOrganizing, showInfo] + [actions, currentPhase, isBlockedByOperation, isOrganizing, showInfo] ); // Settings handler @@ -905,7 +905,8 @@ function NavigationBar() { // Return to the dashboard the user deep-linked out of. A view switch, not a // phase transition, so moves and undo/redo do not gate it. A running - // analysis does: it belongs to Discover, which unmounts for the dashboard. + // analysis does: its provider lives in PhaseRenderer, which the dashboard + // replaces. const handleReturnToDashboard = useCallback(() => { if (isAnalyzing) { showInfo(DASHBOARD_WAITS_FOR_ANALYSIS, 5200); @@ -1052,9 +1053,7 @@ function NavigationBar() { ? executionStatus?.label ? `${executionStatus.label} Step navigation is paused until file moves finish.` : 'Step navigation is paused while undo/redo moves files.' - : isAnalyzing - ? 'Step navigation is paused while analysis is running.' - : 'Step navigation is paused. Please wait for the current operation to finish.'} + : 'Step navigation is paused. Please wait for the current operation to finish.'} )} diff --git a/src/renderer/components/PhaseRenderer.jsx b/src/renderer/components/PhaseRenderer.jsx index 811f665..c8d65b8 100644 --- a/src/renderer/components/PhaseRenderer.jsx +++ b/src/renderer/components/PhaseRenderer.jsx @@ -6,6 +6,7 @@ import { LazyLoadingSpinner } from './ui/LoadingSkeleton'; import { logger } from '../../shared/logger'; import { PHASES, PHASE_METADATA } from '../../shared/constants'; import perfMarks from '../../shared/perfMarks'; +import { AnalysisRunProvider } from '../phases/discover/AnalysisRunProvider'; // The settings overlay and useKeyboardShortcuts are deliberately NOT here: // they live in App.js so they stay active on the dashboard surface, which @@ -222,8 +223,10 @@ function PhaseRenderer() { } }; + // AnalysisRunProvider wraps every phase so an analysis run outlives the + // Discover screen: the user can organize finished files mid-run. return ( - <> +
}> @@ -242,7 +245,7 @@ function PhaseRenderer() {
- +
); } diff --git a/src/renderer/phases/DiscoverPhase.jsx b/src/renderer/phases/DiscoverPhase.jsx index 0bbc57d..0f0b286 100644 --- a/src/renderer/phases/DiscoverPhase.jsx +++ b/src/renderer/phases/DiscoverPhase.jsx @@ -18,13 +18,12 @@ import { AnalysisProgress } from '../components/discover'; import { FileListSkeleton } from '../components/ui/LoadingSkeleton'; -import store from '../store'; import { useAppSelector } from '../store/hooks'; import { selectOrganizedCount } from '../store/selectors'; import { toCanonicalPath } from '../utils/canonicalPath'; import { useDiscoverState, - useAnalysis, + useAnalysisRun, useFileHandlers, useFileActions, getFileStateDisplayInfo @@ -46,13 +45,9 @@ function DiscoverPhase() { dateFormat, caseConvention, separator, - namingSettings, setSelectedFiles, addSelectedFiles, setAnalysisResults, - setIsAnalyzing, - setAnalysisProgress, - setCurrentAnalysisFile, setNamingConvention, setDateFormat, setCaseConvention, @@ -61,7 +56,6 @@ function DiscoverPhase() { updateFileState, resetAnalysisState, actions, - getCurrentPhase, organizedFiles, organizedPathIndex, fileStats @@ -72,7 +66,6 @@ function DiscoverPhase() { const { openSearchModal } = useFloatingSearch(); const [showNamingSettings, setShowNamingSettings] = useState(false); - const [totalAnalysisFailure, setTotalAnalysisFailure] = useState(false); const [showEmbeddingPrompt, setShowEmbeddingPrompt] = useState(false); const [isRebuildingEmbeddings, setIsRebuildingEmbeddings] = useState(false); const isMountedRef = useRef(true); @@ -280,45 +273,17 @@ function DiscoverPhase() { // and it's never set in this context, so use a stable empty object const stablePhaseData = useRef({}).current; - const extendedActions = useMemo( - () => ({ - ...actions, - setPhaseData: (key, value) => { - if (key === 'totalAnalysisFailure') { - setTotalAnalysisFailure(value); - } else { - actions.setPhaseData(key, value); - } - } - }), - [actions] - ); - + // The run lives in AnalysisRunProvider (PhaseRenderer), not here, so it + // keeps going when the user moves to Organize or Complete. const { analyzeFiles, cancelAnalysis, clearAnalysisQueue, retryFailedFiles, - pruneBatchResultsForPath - } = useAnalysis({ - selectedFiles, - fileStates, - analysisResults, - isAnalyzing, - analysisProgress, - namingSettings, - setters: { - setIsAnalyzing, - setAnalysisProgress, - setCurrentAnalysisFile, - setAnalysisResults, - setFileStates - }, - updateFileState, - addNotification, - actions: extendedActions, - getCurrentPhase - }); + pruneBatchResultsForPath, + totalAnalysisFailure, + setTotalAnalysisFailure + } = useAnalysisRun(); const { isScanning, @@ -354,7 +319,6 @@ function DiscoverPhase() { ); const { isDragging, dragProps } = useDragAndDrop(handleFileDrop); - const initialStuckCheckRef = useRef(false); useSettingsSubscription( useCallback( @@ -447,67 +411,7 @@ function DiscoverPhase() { }; }, []); - const analysisProgressRef = useRef(analysisProgress); - analysisProgressRef.current = analysisProgress; - - useEffect(() => { - if (initialStuckCheckRef.current) return; - initialStuckCheckRef.current = true; - - if (isAnalyzing) { - const lastActivity = analysisProgress?.lastActivity || Date.now(); - const timeSinceActivity = Date.now() - lastActivity; - const isStuck = timeSinceActivity > 2 * 60 * 1000; - - if (isStuck) { - logger.info('Detected stuck analysis state on mount, resetting'); - resetAnalysisState('Stuck analysis detected on mount'); - } - } - }, [isAnalyzing, analysisProgress, resetAnalysisState]); - - useEffect(() => { - if (!isAnalyzing) return; - - const checkStalled = () => { - // Use Redux store directly to prevent stale reads when the window is minimized - // (React suspends rendering when minimized, so refs bound to props become stale) - const state = store.getState(); - const isActuallyAnalyzing = state.analysis.isAnalyzing; - - if (!isActuallyAnalyzing) return; // Already finished, ignore - - const progress = state.analysis.analysisProgress || analysisProgressRef.current; - const lastActivity = progress?.lastActivity || Date.now(); - const timeSinceActivity = Date.now() - lastActivity; - const current = progress?.current || 0; - const total = progress?.total || 0; - - if (current === 0 && total > 0 && timeSinceActivity > TIMEOUTS.STUCK_ANALYSIS_CHECK) { - addNotification('Analysis paused. Restarting...', 'info', 3000, 'analysis-stalled'); - resetAnalysisState('Analysis stalled with no progress after 2 minutes'); - return; - } - - if (timeSinceActivity > TIMEOUTS.ANALYSIS_LOCK) { - addNotification('Analysis timed out. Ready to retry.', 'info', 3000, 'analysis-auto-reset'); - resetAnalysisState('Stuck analysis state after 5 minutes of inactivity'); - } - }; - - // Defer state updates so the interval handler returns quickly (avoids Long Task violations) - const checkStalledDeferred = () => { - setTimeout(checkStalled, 0); - }; - - // Check immediately on dependency change - checkStalled(); - - // Also check periodically in case dependencies stop updating (the exact stall scenario). - // Defer so the interval handler returns quickly (avoids Long Task violations). - const intervalId = setInterval(checkStalledDeferred, TIMEOUTS.SERVICE_STARTUP); - return () => clearInterval(intervalId); - }, [isAnalyzing, addNotification, resetAnalysisState]); + // Stuck-run checks live in AnalysisRunProvider with the run itself. const fileStatesRef = useRef(fileStates); fileStatesRef.current = fileStates; @@ -938,27 +842,25 @@ function DiscoverPhase() { // one analyzed file, so there would be nothing to organize there. // Organized history also opens Organize, the same rule as the tab // (uiSlice getPhaseNavigation), so a returning user can reach it. + // A running analysis does not hold this back: the run lives above + // the phases (AnalysisRunProvider), so files already analyzed can be + // organized while the rest keep analyzing. const canContinue = visibleReadyCount > 0 || organizedCount > 0; - const disabledBecauseAnalyzing = isAnalyzing; - const disabledBecauseNoReady = !canContinue; let disabledReason = ''; - if (disabledBecauseAnalyzing) { - disabledReason = 'Analysis is in progress.'; - } else if (disabledBecauseNoReady) { - disabledReason = - visibleFailedCount > 0 + if (!canContinue) { + disabledReason = isAnalyzing + ? 'Organize opens as soon as the first file is analyzed.' + : visibleFailedCount > 0 ? 'All files failed analysis. Retry them, or clear the queue and try again.' : 'Analyze at least one file first.'; + } else if (isAnalyzing) { + disabledReason = 'Analysis keeps running while you organize the files that are ready.'; } return (