From 79fb0aac72add7cd7e748d6501aab6494e3d0383 Mon Sep 17 00:00:00 2001 From: Kian Date: Wed, 4 Mar 2026 16:35:44 -0600 Subject: [PATCH 1/8] feat: add auto-follow toggle to batch progress banner (#347) Add "Follow active task" checkbox to the Auto Run batch progress banner that auto-selects the currently running document when the batch document index changes. Includes state tracking refs for detecting batch start and document transitions. --- src/renderer/components/RightPanel.tsx | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 0cdf3f91c..c7914f115 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -235,6 +235,11 @@ export const RightPanel = memo( } }, [autoRunContent, autoRunContentVersion, session?.id, session?.autoRunSelectedFile]); + // Auto-follow: automatically select the active document during batch runs + const [autoFollowEnabled, setAutoFollowEnabled] = useState(false); + const prevBatchDocIndexRef = useRef(-1); + const prevIsRunningRef = useRef(false); + // Expanded modal state for Auto Run const [autoRunExpanded, setAutoRunExpanded] = useState(false); const handleExpandAutoRun = useCallback(() => setAutoRunExpanded(true), []); @@ -293,6 +298,42 @@ export const RightPanel = memo( return () => clearInterval(interval); }, [currentSessionBatchState?.isRunning, currentSessionBatchState?.startTime, formatElapsed]); + // Auto-follow: select active document when batch document index changes + useEffect(() => { + const isRunning = currentSessionBatchState?.isRunning ?? false; + const currentDocumentIndex = currentSessionBatchState?.currentDocumentIndex ?? -1; + const documents = currentSessionBatchState?.documents; + + // Detect batch start + const batchJustStarted = isRunning && !prevIsRunningRef.current; + + // Detect document transition + const docChanged = currentDocumentIndex !== prevBatchDocIndexRef.current; + + // Auto-follow on batch start or document transition + if (autoFollowEnabled && (batchJustStarted || docChanged)) { + const activeDoc = documents?.[currentDocumentIndex]; + if (activeDoc && activeDoc !== session?.autoRunSelectedFile) { + onAutoRunSelectDocument(activeDoc); + } + } + + // Reset on batch end + if (!isRunning) { + prevBatchDocIndexRef.current = -1; + } else { + prevBatchDocIndexRef.current = currentDocumentIndex ?? -1; + } + prevIsRunningRef.current = !!isRunning; + }, [ + currentSessionBatchState?.isRunning, + currentSessionBatchState?.currentDocumentIndex, + currentSessionBatchState?.documents, + autoFollowEnabled, + onAutoRunSelectDocument, + session?.autoRunSelectedFile, + ]); + // Expose methods to parent useImperativeHandle( ref, @@ -741,6 +782,20 @@ export const RightPanel = memo( )} +
+ +
)} From 079d74c60e692b29dc707b665994cd3b04b52b38 Mon Sep 17 00:00:00 2001 From: Kian Date: Wed, 4 Mar 2026 16:39:32 -0600 Subject: [PATCH 2/8] feat: add scroll-to-active-task and focus-safe auto-follow in AutoRun (#347) - Add autoFollowEnabled prop to AutoRunProps interface - Guard both focus-stealing useEffects (mode change + document selection) to skip .focus() when autoFollowEnabled && batchRunState.isRunning - Add useEffect that scrolls to first unchecked checkbox in preview mode using scrollIntoView (focus-safe) with 150ms render delay - Pass autoFollowEnabled through RightPanel autoRunSharedProps - Add autoFollowEnabled to React.memo comparison function --- src/renderer/components/AutoRun.tsx | 46 ++++++++++++++++++++++++-- src/renderer/components/RightPanel.tsx | 1 + 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index 706b19bce..d560c8a78 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -113,6 +113,9 @@ interface AutoRunProps { batchRunState?: BatchRunState; onOpenBatchRunner?: () => void; onStopBatchRun?: (sessionId?: string) => void; + + // Auto-follow: when enabled during a batch run, suppresses focus-stealing and scrolls to active task + autoFollowEnabled?: boolean; // Error handling callbacks (Phase 5.10) onSkipCurrentDocument?: () => void; onAbortBatchOnError?: () => void; @@ -482,6 +485,7 @@ const AutoRunInner = forwardRef(function AutoRunInn batchRunState, onOpenBatchRunner, onStopBatchRun, + autoFollowEnabled, // Error handling callbacks (Phase 5.10) onSkipCurrentDocument: _onSkipCurrentDocument, onAbortBatchOnError, @@ -939,12 +943,15 @@ const AutoRunInner = forwardRef(function AutoRunInn // Auto-focus the active element after mode change useEffect(() => { + // Skip focus when auto-follow is driving changes during a batch run + if (autoFollowEnabled && batchRunState?.isRunning) return; + if (mode === 'edit' && textareaRef.current) { textareaRef.current.focus(); } else if (mode === 'preview' && previewRef.current) { previewRef.current.focus(); } - }, [mode]); + }, [mode, autoFollowEnabled, batchRunState?.isRunning]); // Handle document selection change - focus the appropriate element // Note: Content syncing and editing state reset is handled by the main sync effect above @@ -957,6 +964,9 @@ const AutoRunInner = forwardRef(function AutoRunInn prevFocusSelectedFileRef.current = selectedFile; if (isNewDocument) { + // Skip focus when auto-follow is driving changes during a batch run + if (autoFollowEnabled && batchRunState?.isRunning) return; + // Focus on document change requestAnimationFrame(() => { if (mode === 'edit' && textareaRef.current) { @@ -966,7 +976,35 @@ const AutoRunInner = forwardRef(function AutoRunInn } }); } - }, [selectedFile, mode]); + }, [selectedFile, mode, autoFollowEnabled, batchRunState?.isRunning]); + + // Auto-follow: scroll to the first unchecked task when batch is running + useEffect(() => { + if (!autoFollowEnabled || !batchRunState?.isRunning || mode !== 'preview') return; + + const timeout = setTimeout(() => { + if (!previewRef.current) return; + + const checkboxes = previewRef.current.querySelectorAll('input[type="checkbox"]'); + for (const checkbox of checkboxes) { + if (!(checkbox as HTMLInputElement).checked) { + const li = (checkbox as HTMLElement).closest('li'); + if (li) { + li.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + break; + } + } + }, 150); + + return () => clearTimeout(timeout); + }, [ + batchRunState?.currentDocumentIndex, + batchRunState?.currentTaskIndex, + batchRunState?.isRunning, + autoFollowEnabled, + mode, + ]); // Debounced preview scroll handler to avoid triggering re-renders on every scroll event // We only save scroll position to ref immediately (for local use), but delay parent notification @@ -2231,7 +2269,9 @@ export const AutoRun = memo(AutoRunInner, (prevProps, nextProps) => { // UI control props prevProps.hideTopControls === nextProps.hideTopControls && // External change detection - prevProps.contentVersion === nextProps.contentVersion + prevProps.contentVersion === nextProps.contentVersion && + // Auto-follow state + prevProps.autoFollowEnabled === nextProps.autoFollowEnabled // Note: initialCursorPosition, initialEditScrollPos, initialPreviewScrollPos // are intentionally NOT compared - they're only used on mount // Note: documentTree is derived from documentList, comparing documentList is sufficient diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index c7914f115..9198cc98e 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -416,6 +416,7 @@ export const RightPanel = memo( onOpenMarketplace, onLaunchWizard, onShowFlash, + autoFollowEnabled, }; return ( From f600cc4589986a571517efb7826e9d42297d8bb5 Mon Sep 17 00:00:00 2001 From: Kian Date: Wed, 4 Mar 2026 16:46:00 -0600 Subject: [PATCH 3/8] feat: auto-switch Right Bar to autorun tab and preview mode on batch start (#347) When auto-follow is enabled and a batch run starts, automatically: - Switch the Right Bar to the Auto Run tab - Open the right panel if it was closed - Switch to preview mode if currently in edit mode This only triggers on batch START (not on subsequent document transitions), so users can freely switch tabs mid-run without being overridden. --- src/renderer/components/RightPanel.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 9198cc98e..a25c40c14 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -318,6 +318,18 @@ export const RightPanel = memo( } } + // On batch start with auto-follow: switch to autorun tab, open panel, switch to preview mode + if (autoFollowEnabled && batchJustStarted) { + setActiveRightTab('autorun'); + if (!rightPanelOpen) { + setRightPanelOpen(true); + } + // Switch to preview mode so the user sees rendered markdown with scrolling tasks + if (session?.autoRunMode === 'edit') { + onAutoRunModeChange('preview'); + } + } + // Reset on batch end if (!isRunning) { prevBatchDocIndexRef.current = -1; @@ -332,6 +344,11 @@ export const RightPanel = memo( autoFollowEnabled, onAutoRunSelectDocument, session?.autoRunSelectedFile, + setActiveRightTab, + rightPanelOpen, + setRightPanelOpen, + onAutoRunModeChange, + session?.autoRunMode, ]); // Expose methods to parent From 9ab29403476cb05d8b0433b14ad5526ba5b1b309 Mon Sep 17 00:00:00 2001 From: Kian Date: Wed, 4 Mar 2026 17:11:42 -0600 Subject: [PATCH 4/8] refactor: extract auto-follow logic into useAutoRunAutoFollow hook (#347) Move auto-follow state, refs, and useEffect from RightPanel.tsx into a dedicated hook for testability. Add 8 test cases covering document auto-selection, tab switching, panel opening, and ref reset behavior. --- .../hooks/useAutoRunAutoFollow.test.ts | 322 ++++++++++++++++++ src/renderer/components/RightPanel.tsx | 67 +--- src/renderer/hooks/batch/index.ts | 4 + .../hooks/batch/useAutoRunAutoFollow.ts | 90 +++++ 4 files changed, 427 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts create mode 100644 src/renderer/hooks/batch/useAutoRunAutoFollow.ts diff --git a/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts new file mode 100644 index 000000000..dc63fccc6 --- /dev/null +++ b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAutoRunAutoFollow } from '../../../renderer/hooks/batch/useAutoRunAutoFollow'; +import type { UseAutoRunAutoFollowDeps } from '../../../renderer/hooks/batch/useAutoRunAutoFollow'; +import type { BatchRunState } from '../../../renderer/types'; + +function createBatchState(overrides: Partial = {}): BatchRunState { + return { + isRunning: false, + isStopping: false, + documents: [], + lockedDocuments: [], + currentDocumentIndex: 0, + currentDocTasksTotal: 0, + currentDocTasksCompleted: 0, + totalTasksAcrossAllDocs: 0, + completedTasksAcrossAllDocs: 0, + loopEnabled: false, + loopIteration: 0, + folderPath: '/tmp', + worktreeActive: false, + totalTasks: 0, + completedTasks: 0, + currentTaskIndex: 0, + originalContent: '', + sessionIds: [], + ...overrides, + }; +} + +function createDeps(overrides: Partial = {}): UseAutoRunAutoFollowDeps { + return { + currentSessionBatchState: null, + onAutoRunSelectDocument: vi.fn(), + selectedFile: null, + setActiveRightTab: vi.fn(), + rightPanelOpen: true, + setRightPanelOpen: vi.fn(), + toggleRightPanel: vi.fn(), + onAutoRunModeChange: vi.fn(), + currentMode: 'preview', + ...overrides, + }; +} + +describe('useAutoRunAutoFollow', () => { + it('should not auto-select when autoFollowEnabled is false', () => { + const onAutoRunSelectDocument = vi.fn(); + const deps = createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b'], + currentDocumentIndex: 0, + }), + }); + + renderHook(() => useAutoRunAutoFollow(deps)); + + expect(onAutoRunSelectDocument).not.toHaveBeenCalled(); + }); + + it('should auto-select document when batch starts and autoFollow is enabled', () => { + const onAutoRunSelectDocument = vi.fn(); + const deps = createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: null, + }); + + const { result, rerender } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: deps } + ); + + // Enable auto-follow + act(() => { + result.current.setAutoFollowEnabled(true); + }); + + // Simulate batch start + const runningDeps = createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b'], + currentDocumentIndex: 0, + }), + }); + + rerender(runningDeps); + + expect(onAutoRunSelectDocument).toHaveBeenCalledWith('doc-a'); + }); + + it('should auto-select next document on index change', () => { + const onAutoRunSelectDocument = vi.fn(); + const initialDeps = createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b'], + currentDocumentIndex: 0, + }), + }); + + const { result, rerender } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: initialDeps } + ); + + // Enable auto-follow + act(() => { + result.current.setAutoFollowEnabled(true); + }); + + // Move to next document + const nextDeps = createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b'], + currentDocumentIndex: 1, + }), + }); + + rerender(nextDeps); + + expect(onAutoRunSelectDocument).toHaveBeenCalledWith('doc-b'); + }); + + it('should not auto-select if already on correct document', () => { + const onAutoRunSelectDocument = vi.fn(); + const deps = createDeps({ + onAutoRunSelectDocument, + selectedFile: 'doc-a', + currentSessionBatchState: null, + }); + + const { result, rerender } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: deps } + ); + + // Enable auto-follow + act(() => { + result.current.setAutoFollowEnabled(true); + }); + + // Start batch with doc-a at index 0, but selectedFile is already doc-a + const runningDeps = createDeps({ + onAutoRunSelectDocument, + selectedFile: 'doc-a', + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b'], + currentDocumentIndex: 0, + }), + }); + + rerender(runningDeps); + + expect(onAutoRunSelectDocument).not.toHaveBeenCalled(); + }); + + it('should switch to autorun tab on batch start when autoFollow enabled', () => { + const setActiveRightTab = vi.fn(); + const deps = createDeps({ + setActiveRightTab, + currentSessionBatchState: createBatchState({ isRunning: false }), + }); + + const { result, rerender } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: deps } + ); + + // Enable auto-follow + act(() => { + result.current.setAutoFollowEnabled(true); + }); + + // Transition to running + const runningDeps = createDeps({ + setActiveRightTab, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a'], + currentDocumentIndex: 0, + }), + }); + + rerender(runningDeps); + + expect(setActiveRightTab).toHaveBeenCalledWith('autorun'); + }); + + it('should not switch tab when autoFollow is disabled', () => { + const setActiveRightTab = vi.fn(); + const deps = createDeps({ + setActiveRightTab, + currentSessionBatchState: createBatchState({ isRunning: false }), + }); + + const { rerender } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: deps } + ); + + // Leave auto-follow off, transition to running + const runningDeps = createDeps({ + setActiveRightTab, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a'], + currentDocumentIndex: 0, + }), + }); + + rerender(runningDeps); + + expect(setActiveRightTab).not.toHaveBeenCalled(); + }); + + it('should open right panel on batch start when closed and autoFollow enabled', () => { + const setRightPanelOpen = vi.fn(); + const deps = createDeps({ + rightPanelOpen: false, + setRightPanelOpen, + currentSessionBatchState: createBatchState({ isRunning: false }), + }); + + const { result, rerender } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: deps } + ); + + // Enable auto-follow + act(() => { + result.current.setAutoFollowEnabled(true); + }); + + // Transition to running with panel closed + const runningDeps = createDeps({ + rightPanelOpen: false, + setRightPanelOpen, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a'], + currentDocumentIndex: 0, + }), + }); + + rerender(runningDeps); + + expect(setRightPanelOpen).toHaveBeenCalledWith(true); + }); + + it('should reset refs when batch ends', () => { + const onAutoRunSelectDocument = vi.fn(); + const deps = createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b', 'doc-c'], + currentDocumentIndex: 0, + }), + }); + + const { result, rerender } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: deps } + ); + + // Enable auto-follow + act(() => { + result.current.setAutoFollowEnabled(true); + }); + + // Advance to index 2 + rerender( + createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b', 'doc-c'], + currentDocumentIndex: 2, + }), + }) + ); + + expect(onAutoRunSelectDocument).toHaveBeenCalledWith('doc-c'); + onAutoRunSelectDocument.mockClear(); + + // End batch + rerender( + createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: false, + documents: [], + currentDocumentIndex: 0, + }), + }) + ); + + onAutoRunSelectDocument.mockClear(); + + // Start new batch from index 0 — should auto-select again + rerender( + createDeps({ + onAutoRunSelectDocument, + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-x', 'doc-y'], + currentDocumentIndex: 0, + }), + }) + ); + + expect(onAutoRunSelectDocument).toHaveBeenCalledWith('doc-x'); + }); +}); diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index a25c40c14..2d6dd240b 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -24,6 +24,7 @@ import { AutoRunExpandedModal } from './AutoRunExpandedModal'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { ConfirmModal } from './ConfirmModal'; import { useResizablePanel } from '../hooks'; +import { useAutoRunAutoFollow } from '../hooks/batch/useAutoRunAutoFollow'; import { useUIStore } from '../stores/uiStore'; import { useSettingsStore } from '../stores/settingsStore'; import { useFileExplorerStore } from '../stores/fileExplorerStore'; @@ -236,9 +237,16 @@ export const RightPanel = memo( }, [autoRunContent, autoRunContentVersion, session?.id, session?.autoRunSelectedFile]); // Auto-follow: automatically select the active document during batch runs - const [autoFollowEnabled, setAutoFollowEnabled] = useState(false); - const prevBatchDocIndexRef = useRef(-1); - const prevIsRunningRef = useRef(false); + const { autoFollowEnabled, setAutoFollowEnabled } = useAutoRunAutoFollow({ + currentSessionBatchState, + onAutoRunSelectDocument, + selectedFile: session?.autoRunSelectedFile ?? null, + setActiveRightTab, + rightPanelOpen, + setRightPanelOpen, + onAutoRunModeChange, + currentMode: session?.autoRunMode, + }); // Expanded modal state for Auto Run const [autoRunExpanded, setAutoRunExpanded] = useState(false); @@ -298,59 +306,6 @@ export const RightPanel = memo( return () => clearInterval(interval); }, [currentSessionBatchState?.isRunning, currentSessionBatchState?.startTime, formatElapsed]); - // Auto-follow: select active document when batch document index changes - useEffect(() => { - const isRunning = currentSessionBatchState?.isRunning ?? false; - const currentDocumentIndex = currentSessionBatchState?.currentDocumentIndex ?? -1; - const documents = currentSessionBatchState?.documents; - - // Detect batch start - const batchJustStarted = isRunning && !prevIsRunningRef.current; - - // Detect document transition - const docChanged = currentDocumentIndex !== prevBatchDocIndexRef.current; - - // Auto-follow on batch start or document transition - if (autoFollowEnabled && (batchJustStarted || docChanged)) { - const activeDoc = documents?.[currentDocumentIndex]; - if (activeDoc && activeDoc !== session?.autoRunSelectedFile) { - onAutoRunSelectDocument(activeDoc); - } - } - - // On batch start with auto-follow: switch to autorun tab, open panel, switch to preview mode - if (autoFollowEnabled && batchJustStarted) { - setActiveRightTab('autorun'); - if (!rightPanelOpen) { - setRightPanelOpen(true); - } - // Switch to preview mode so the user sees rendered markdown with scrolling tasks - if (session?.autoRunMode === 'edit') { - onAutoRunModeChange('preview'); - } - } - - // Reset on batch end - if (!isRunning) { - prevBatchDocIndexRef.current = -1; - } else { - prevBatchDocIndexRef.current = currentDocumentIndex ?? -1; - } - prevIsRunningRef.current = !!isRunning; - }, [ - currentSessionBatchState?.isRunning, - currentSessionBatchState?.currentDocumentIndex, - currentSessionBatchState?.documents, - autoFollowEnabled, - onAutoRunSelectDocument, - session?.autoRunSelectedFile, - setActiveRightTab, - rightPanelOpen, - setRightPanelOpen, - onAutoRunModeChange, - session?.autoRunMode, - ]); - // Expose methods to parent useImperativeHandle( ref, diff --git a/src/renderer/hooks/batch/index.ts b/src/renderer/hooks/batch/index.ts index 0bc5e3015..1040251df 100644 --- a/src/renderer/hooks/batch/index.ts +++ b/src/renderer/hooks/batch/index.ts @@ -142,5 +142,9 @@ export type { UseAutoRunAchievementsDeps } from './useAutoRunAchievements'; export { useAutoRunDocumentLoader } from './useAutoRunDocumentLoader'; export type { UseAutoRunDocumentLoaderReturn } from './useAutoRunDocumentLoader'; +// Auto Run auto-follow (document tracking during batch runs) +export { useAutoRunAutoFollow } from './useAutoRunAutoFollow'; +export type { UseAutoRunAutoFollowDeps, UseAutoRunAutoFollowReturn } from './useAutoRunAutoFollow'; + // Re-export ExistingDocument type from existingDocsDetector for convenience export type { ExistingDocument } from '../../utils/existingDocsDetector'; diff --git a/src/renderer/hooks/batch/useAutoRunAutoFollow.ts b/src/renderer/hooks/batch/useAutoRunAutoFollow.ts new file mode 100644 index 000000000..f0620867e --- /dev/null +++ b/src/renderer/hooks/batch/useAutoRunAutoFollow.ts @@ -0,0 +1,90 @@ +import { useState, useRef, useEffect } from 'react'; +import type { BatchRunState, RightPanelTab } from '../../types'; + +export interface UseAutoRunAutoFollowDeps { + currentSessionBatchState: BatchRunState | null | undefined; + onAutoRunSelectDocument: (filename: string) => void | Promise; + selectedFile: string | null; + setActiveRightTab: (tab: RightPanelTab) => void; + rightPanelOpen: boolean; + setRightPanelOpen?: (open: boolean) => void; + toggleRightPanel?: () => void; + onAutoRunModeChange?: (mode: 'edit' | 'preview') => void; + currentMode?: 'edit' | 'preview'; +} + +export interface UseAutoRunAutoFollowReturn { + autoFollowEnabled: boolean; + setAutoFollowEnabled: (enabled: boolean) => void; +} + +export function useAutoRunAutoFollow(deps: UseAutoRunAutoFollowDeps): UseAutoRunAutoFollowReturn { + const { + currentSessionBatchState, + onAutoRunSelectDocument, + selectedFile, + setActiveRightTab, + rightPanelOpen, + setRightPanelOpen, + onAutoRunModeChange, + currentMode, + } = deps; + + const [autoFollowEnabled, setAutoFollowEnabled] = useState(false); + const prevBatchDocIndexRef = useRef(-1); + const prevIsRunningRef = useRef(false); + + useEffect(() => { + const isRunning = currentSessionBatchState?.isRunning ?? false; + const currentDocumentIndex = currentSessionBatchState?.currentDocumentIndex ?? -1; + const documents = currentSessionBatchState?.documents; + + // Detect batch start + const batchJustStarted = isRunning && !prevIsRunningRef.current; + + // Detect document transition + const docChanged = currentDocumentIndex !== prevBatchDocIndexRef.current; + + // Auto-follow on batch start or document transition + if (autoFollowEnabled && (batchJustStarted || docChanged)) { + const activeDoc = documents?.[currentDocumentIndex]; + if (activeDoc && activeDoc !== selectedFile) { + onAutoRunSelectDocument(activeDoc); + } + } + + // On batch start with auto-follow: switch to autorun tab, open panel, switch to preview mode + if (autoFollowEnabled && batchJustStarted) { + setActiveRightTab('autorun'); + if (!rightPanelOpen) { + setRightPanelOpen?.(true); + } + // Switch to preview mode so the user sees rendered markdown with scrolling tasks + if (currentMode === 'edit') { + onAutoRunModeChange?.('preview'); + } + } + + // Reset on batch end + if (!isRunning) { + prevBatchDocIndexRef.current = -1; + } else { + prevBatchDocIndexRef.current = currentDocumentIndex ?? -1; + } + prevIsRunningRef.current = !!isRunning; + }, [ + currentSessionBatchState?.isRunning, + currentSessionBatchState?.currentDocumentIndex, + currentSessionBatchState?.documents, + autoFollowEnabled, + onAutoRunSelectDocument, + selectedFile, + setActiveRightTab, + rightPanelOpen, + setRightPanelOpen, + onAutoRunModeChange, + currentMode, + ]); + + return { autoFollowEnabled, setAutoFollowEnabled }; +} From d406bbbbbc5ef39d7fd51f7e13d6590d4ee43c1f Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 5 Mar 2026 12:35:06 -0600 Subject: [PATCH 5/8] feat: add Follow active task toggle to Auto Run config modal (#347) - Lift autoFollowEnabled state from local useState to zustand uiStore - Add checkbox in BatchRunnerModal footer, applied on Go - Immediately jump to active task when toggling on during a running batch --- .../hooks/useAutoRunAutoFollow.test.ts | 8 +++- src/renderer/components/BatchRunnerModal.tsx | 45 +++++++++++++++---- .../hooks/batch/useAutoRunAutoFollow.ts | 38 +++++++++++++++- src/renderer/stores/uiStore.ts | 9 ++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts index dc63fccc6..e189e504d 100644 --- a/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts +++ b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useAutoRunAutoFollow } from '../../../renderer/hooks/batch/useAutoRunAutoFollow'; import type { UseAutoRunAutoFollowDeps } from '../../../renderer/hooks/batch/useAutoRunAutoFollow'; import type { BatchRunState } from '../../../renderer/types'; +import { useUIStore } from '../../../renderer/stores/uiStore'; function createBatchState(overrides: Partial = {}): BatchRunState { return { @@ -44,6 +45,11 @@ function createDeps(overrides: Partial = {}): UseAutoR } describe('useAutoRunAutoFollow', () => { + beforeEach(() => { + // Reset the zustand store between tests + useUIStore.setState({ autoFollowEnabled: false }); + }); + it('should not auto-select when autoFollowEnabled is false', () => { const onAutoRunSelectDocument = vi.fn(); const deps = createDeps({ diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index e1ff1bef9..a3cc81043 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -25,6 +25,7 @@ import { AgentPromptComposerModal } from './AgentPromptComposerModal'; import { DocumentsPanel } from './DocumentsPanel'; import { WorktreeRunSection } from './WorktreeRunSection'; import { useSessionStore } from '../stores/sessionStore'; +import { useUIStore } from '../stores/uiStore'; import { getModalActions } from '../stores/modalStore'; import { usePlaybookManagement, @@ -100,6 +101,11 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { onOpenMarketplace, } = props; + // Auto-follow state (initialized from store) + const autoFollowFromStore = useUIStore((s) => s.autoFollowEnabled); + const setAutoFollowStore = useUIStore((s) => s.setAutoFollowEnabled); + const [autoFollowEnabled, setAutoFollowEnabled] = useState(autoFollowFromStore); + // Worktree run target state const [worktreeTarget, setWorktreeTarget] = useState(null); const [isPreparingWorktree, setIsPreparingWorktree] = useState(false); @@ -360,6 +366,9 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { }; const handleGo = async () => { + // Apply auto-follow setting to the store + setAutoFollowStore(autoFollowEnabled); + // Also save when running onSave(prompt); @@ -811,15 +820,35 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { className="p-4 border-t flex items-center justify-between shrink-0" style={{ borderColor: theme.colors.border }} > - {/* Left side: Hint */} -
- + +
- {formatMetaKey()} + Drag - - to copy document + + {formatMetaKey()} + Drag + + to copy document +
{/* Right side: Buttons */} diff --git a/src/renderer/hooks/batch/useAutoRunAutoFollow.ts b/src/renderer/hooks/batch/useAutoRunAutoFollow.ts index f0620867e..b18895628 100644 --- a/src/renderer/hooks/batch/useAutoRunAutoFollow.ts +++ b/src/renderer/hooks/batch/useAutoRunAutoFollow.ts @@ -1,5 +1,6 @@ -import { useState, useRef, useEffect } from 'react'; +import { useRef, useEffect, useCallback } from 'react'; import type { BatchRunState, RightPanelTab } from '../../types'; +import { useUIStore } from '../../stores/uiStore'; export interface UseAutoRunAutoFollowDeps { currentSessionBatchState: BatchRunState | null | undefined; @@ -30,10 +31,43 @@ export function useAutoRunAutoFollow(deps: UseAutoRunAutoFollowDeps): UseAutoRun currentMode, } = deps; - const [autoFollowEnabled, setAutoFollowEnabled] = useState(false); + const autoFollowEnabled = useUIStore((s) => s.autoFollowEnabled); + const setAutoFollowStoreRaw = useUIStore((s) => s.setAutoFollowEnabled); const prevBatchDocIndexRef = useRef(-1); const prevIsRunningRef = useRef(false); + // Wrap setAutoFollowEnabled to immediately jump to active task when toggling on during a running batch + const setAutoFollowEnabled = useCallback( + (enabled: boolean) => { + setAutoFollowStoreRaw(enabled); + if (enabled && currentSessionBatchState?.isRunning) { + const currentDocumentIndex = currentSessionBatchState.currentDocumentIndex ?? -1; + const activeDoc = currentSessionBatchState.documents?.[currentDocumentIndex]; + if (activeDoc && activeDoc !== selectedFile) { + onAutoRunSelectDocument(activeDoc); + } + setActiveRightTab('autorun'); + if (!rightPanelOpen) { + setRightPanelOpen?.(true); + } + if (currentMode === 'edit') { + onAutoRunModeChange?.('preview'); + } + } + }, + [ + setAutoFollowStoreRaw, + currentSessionBatchState, + selectedFile, + onAutoRunSelectDocument, + setActiveRightTab, + rightPanelOpen, + setRightPanelOpen, + onAutoRunModeChange, + currentMode, + ] + ); + useEffect(() => { const isRunning = currentSessionBatchState?.isRunning ?? false; const currentDocumentIndex = currentSessionBatchState?.currentDocumentIndex ?? -1; diff --git a/src/renderer/stores/uiStore.ts b/src/renderer/stores/uiStore.ts index e21e52260..f5207dfaa 100644 --- a/src/renderer/stores/uiStore.ts +++ b/src/renderer/stores/uiStore.ts @@ -58,6 +58,9 @@ export interface UIStoreState { // Editing (inline renaming in sidebar) editingGroupId: string | null; editingSessionId: string | null; + + // Auto-follow active task during batch runs + autoFollowEnabled: boolean; } export interface UIStoreActions { @@ -113,6 +116,9 @@ export interface UIStoreActions { // Editing setEditingGroupId: (id: string | null | ((prev: string | null) => string | null)) => void; setEditingSessionId: (id: string | null | ((prev: string | null) => string | null)) => void; + + // Auto-follow + setAutoFollowEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void; } export type UIStore = UIStoreState & UIStoreActions; @@ -147,6 +153,7 @@ export const useUIStore = create()((set) => ({ draggingSessionId: null, editingGroupId: null, editingSessionId: null, + autoFollowEnabled: false, // --- Actions --- setLeftSidebarOpen: (v) => set((s) => ({ leftSidebarOpen: resolve(v, s.leftSidebarOpen) })), @@ -194,4 +201,6 @@ export const useUIStore = create()((set) => ({ setEditingGroupId: (v) => set((s) => ({ editingGroupId: resolve(v, s.editingGroupId) })), setEditingSessionId: (v) => set((s) => ({ editingSessionId: resolve(v, s.editingSessionId) })), + + setAutoFollowEnabled: (v) => set((s) => ({ autoFollowEnabled: resolve(v, s.autoFollowEnabled) })), })); From 586006813686b853754fc7a14c7bdda942fd15d0 Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 5 Mar 2026 13:25:48 -0600 Subject: [PATCH 6/8] fix: add isRunning guard to auto-follow and remove unused toggleRightPanel (#347) - Gate auto-select on isRunning to prevent spurious document navigation when toggling auto-follow after a batch ends - Remove unused toggleRightPanel from UseAutoRunAutoFollowDeps interface - Early-return in scroll-to-task effect when preview has no checkboxes --- src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts | 1 - src/renderer/components/AutoRun.tsx | 1 + src/renderer/hooks/batch/useAutoRunAutoFollow.ts | 5 ++--- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts index e189e504d..8679d374b 100644 --- a/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts +++ b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts @@ -37,7 +37,6 @@ function createDeps(overrides: Partial = {}): UseAutoR setActiveRightTab: vi.fn(), rightPanelOpen: true, setRightPanelOpen: vi.fn(), - toggleRightPanel: vi.fn(), onAutoRunModeChange: vi.fn(), currentMode: 'preview', ...overrides, diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index d560c8a78..24261a6a2 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -986,6 +986,7 @@ const AutoRunInner = forwardRef(function AutoRunInn if (!previewRef.current) return; const checkboxes = previewRef.current.querySelectorAll('input[type="checkbox"]'); + if (checkboxes.length === 0) return; for (const checkbox of checkboxes) { if (!(checkbox as HTMLInputElement).checked) { const li = (checkbox as HTMLElement).closest('li'); diff --git a/src/renderer/hooks/batch/useAutoRunAutoFollow.ts b/src/renderer/hooks/batch/useAutoRunAutoFollow.ts index b18895628..077a3cd68 100644 --- a/src/renderer/hooks/batch/useAutoRunAutoFollow.ts +++ b/src/renderer/hooks/batch/useAutoRunAutoFollow.ts @@ -9,7 +9,6 @@ export interface UseAutoRunAutoFollowDeps { setActiveRightTab: (tab: RightPanelTab) => void; rightPanelOpen: boolean; setRightPanelOpen?: (open: boolean) => void; - toggleRightPanel?: () => void; onAutoRunModeChange?: (mode: 'edit' | 'preview') => void; currentMode?: 'edit' | 'preview'; } @@ -79,8 +78,8 @@ export function useAutoRunAutoFollow(deps: UseAutoRunAutoFollowDeps): UseAutoRun // Detect document transition const docChanged = currentDocumentIndex !== prevBatchDocIndexRef.current; - // Auto-follow on batch start or document transition - if (autoFollowEnabled && (batchJustStarted || docChanged)) { + // Auto-follow on batch start or document transition (only while running) + if (autoFollowEnabled && isRunning && (batchJustStarted || docChanged)) { const activeDoc = documents?.[currentDocumentIndex]; if (activeDoc && activeDoc !== selectedFile) { onAutoRunSelectDocument(activeDoc); From 991fcdaaf7e915600e7478a4822a795f5f00610d Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 5 Mar 2026 21:04:19 -0600 Subject: [PATCH 7/8] fix: address PR review comments for auto-follow (#347) - Remove local autoFollowEnabled state in BatchRunnerModal to prevent stale overwrite of store value on Go action - Wrap scroll-to-task setTimeout with requestAnimationFrame for more reliable DOM targeting after React render - Add test for enabling auto-follow during an already-running batch --- .../hooks/useAutoRunAutoFollow.test.ts | 31 +++++++++++++++++++ src/renderer/components/AutoRun.tsx | 25 ++++++++------- src/renderer/components/BatchRunnerModal.tsx | 10 ++---- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts index 8679d374b..1e804268a 100644 --- a/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts +++ b/src/__tests__/renderer/hooks/useAutoRunAutoFollow.test.ts @@ -260,6 +260,37 @@ describe('useAutoRunAutoFollow', () => { expect(setRightPanelOpen).toHaveBeenCalledWith(true); }); + it('should immediately jump to active document when enabling during a running batch', () => { + const onAutoRunSelectDocument = vi.fn(); + const setActiveRightTab = vi.fn(); + const setRightPanelOpen = vi.fn(); + const deps = createDeps({ + onAutoRunSelectDocument, + setActiveRightTab, + rightPanelOpen: false, + setRightPanelOpen, + currentMode: 'edit', + currentSessionBatchState: createBatchState({ + isRunning: true, + documents: ['doc-a', 'doc-b'], + currentDocumentIndex: 1, + }), + }); + + const { result } = renderHook( + (props: UseAutoRunAutoFollowDeps) => useAutoRunAutoFollow(props), + { initialProps: deps } + ); + + act(() => { + result.current.setAutoFollowEnabled(true); + }); + + expect(onAutoRunSelectDocument).toHaveBeenCalledWith('doc-b'); + expect(setActiveRightTab).toHaveBeenCalledWith('autorun'); + expect(setRightPanelOpen).toHaveBeenCalledWith(true); + }); + it('should reset refs when batch ends', () => { const onAutoRunSelectDocument = vi.fn(); const deps = createDeps({ diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index 24261a6a2..3e652ec98 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -983,19 +983,22 @@ const AutoRunInner = forwardRef(function AutoRunInn if (!autoFollowEnabled || !batchRunState?.isRunning || mode !== 'preview') return; const timeout = setTimeout(() => { - if (!previewRef.current) return; - - const checkboxes = previewRef.current.querySelectorAll('input[type="checkbox"]'); - if (checkboxes.length === 0) return; - for (const checkbox of checkboxes) { - if (!(checkbox as HTMLInputElement).checked) { - const li = (checkbox as HTMLElement).closest('li'); - if (li) { - li.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Wait for React to commit new content before querying the DOM + requestAnimationFrame(() => { + if (!previewRef.current) return; + + const checkboxes = previewRef.current.querySelectorAll('input[type="checkbox"]'); + if (checkboxes.length === 0) return; + for (const checkbox of checkboxes) { + if (!(checkbox as HTMLInputElement).checked) { + const li = (checkbox as HTMLElement).closest('li'); + if (li) { + li.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + break; } - break; } - } + }); }, 150); return () => clearTimeout(timeout); diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index a3cc81043..35875f743 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -101,10 +101,9 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { onOpenMarketplace, } = props; - // Auto-follow state (initialized from store) - const autoFollowFromStore = useUIStore((s) => s.autoFollowEnabled); - const setAutoFollowStore = useUIStore((s) => s.setAutoFollowEnabled); - const [autoFollowEnabled, setAutoFollowEnabled] = useState(autoFollowFromStore); + // Auto-follow state (read/write directly from store to avoid stale local copy) + const autoFollowEnabled = useUIStore((s) => s.autoFollowEnabled); + const setAutoFollowEnabled = useUIStore((s) => s.setAutoFollowEnabled); // Worktree run target state const [worktreeTarget, setWorktreeTarget] = useState(null); @@ -366,9 +365,6 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { }; const handleGo = async () => { - // Apply auto-follow setting to the store - setAutoFollowStore(autoFollowEnabled); - // Also save when running onSave(prompt); From b910183ab104e5b0a67f5ade8f12e25dc7752620 Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 12 Mar 2026 18:02:27 +0100 Subject: [PATCH 8/8] fix: prevent focus-stealing on batch run stop by reading isRunning from ref (#347) --- src/renderer/components/AutoRun.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index 3e652ec98..50a52bd3b 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -511,6 +511,10 @@ const AutoRunInner = forwardRef(function AutoRunInn false; const isAgentBusy = sessionState === 'busy' || sessionState === 'connecting'; const isAutoRunActive = batchRunState?.isRunning || false; + const isRunningRef = useRef(isAutoRunActive); + useEffect(() => { + isRunningRef.current = isAutoRunActive; + }, [isAutoRunActive]); const isStopping = batchRunState?.isStopping || false; // Error state (Phase 5.10) const isErrorPaused = batchRunState?.errorPaused || false; @@ -944,14 +948,14 @@ const AutoRunInner = forwardRef(function AutoRunInn // Auto-focus the active element after mode change useEffect(() => { // Skip focus when auto-follow is driving changes during a batch run - if (autoFollowEnabled && batchRunState?.isRunning) return; + if (autoFollowEnabled && isRunningRef.current) return; if (mode === 'edit' && textareaRef.current) { textareaRef.current.focus(); } else if (mode === 'preview' && previewRef.current) { previewRef.current.focus(); } - }, [mode, autoFollowEnabled, batchRunState?.isRunning]); + }, [mode, autoFollowEnabled]); // Handle document selection change - focus the appropriate element // Note: Content syncing and editing state reset is handled by the main sync effect above @@ -965,7 +969,7 @@ const AutoRunInner = forwardRef(function AutoRunInn if (isNewDocument) { // Skip focus when auto-follow is driving changes during a batch run - if (autoFollowEnabled && batchRunState?.isRunning) return; + if (autoFollowEnabled && isRunningRef.current) return; // Focus on document change requestAnimationFrame(() => { @@ -976,7 +980,7 @@ const AutoRunInner = forwardRef(function AutoRunInn } }); } - }, [selectedFile, mode, autoFollowEnabled, batchRunState?.isRunning]); + }, [selectedFile, mode, autoFollowEnabled]); // Auto-follow: scroll to the first unchecked task when batch is running useEffect(() => {