diff --git a/.changeset/fn-7163-footer-heartbeat-blink.md b/.changeset/fn-7163-footer-heartbeat-blink.md new file mode 100644 index 0000000000..f575096b75 --- /dev/null +++ b/.changeset/fn-7163-footer-heartbeat-blink.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Footer no longer blinks and the concurrency panel stays open across status refreshes. +category: fix +dev: Keeps executor stats loading initial-only and guards the footer loading branch after populated render. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c6dff8ce52..aef13d275e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1013,7 +1013,8 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. Use the visible **Close engine controls** X button, Escape, or outside-click to dismiss it. The global and current-project concurrency sliders also show how many agents are running, including actively-triaging planners (`triage` + `planning`, not paused), and a dot on the slider track for current use, clamped to the track when usage exceeds the configured cap. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required. -Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures. + +Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. Routine executor stats heartbeats also keep the populated footer mounted after initial load, so an open engine/concurrency popover stays open while counts refresh. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures. ### Engine status banner diff --git a/packages/dashboard/app/components/ExecutorStatusBar.tsx b/packages/dashboard/app/components/ExecutorStatusBar.tsx index c089766222..f1e55b7701 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.tsx +++ b/packages/dashboard/app/components/ExecutorStatusBar.tsx @@ -1,5 +1,5 @@ import "./ExecutorStatusBar.css"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { @@ -121,6 +121,13 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs); const [isProjectPathVisible, setIsProjectPathVisible] = useState(false); const engineControlMenuRef = useRef(null); + const hasRenderedPopulatedStatsRef = useRef(false); + + useEffect(() => { + if (!loading && !error) { + hasRenderedPopulatedStatsRef.current = true; + } + }, [error, loading]); const stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]); @@ -173,7 +180,11 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH ); } - if (loading && stats.runningTaskCount === 0) { + /* + * FNXC:ExecutorStatusBar 2026-06-27-00:00: + * The loading subtree is initial-load-only. Once the populated footer has mounted, a heartbeat poll must keep this branch suppressed so EngineControlMenu stays mounted and an open concurrency popover survives routine stats refreshes (FN-7163). + */ + if (loading && stats.runningTaskCount === 0 && !hasRenderedPopulatedStatsRef.current) { return (
diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx index caea16de61..03b578a8f0 100644 --- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx @@ -532,6 +532,82 @@ describe("ExecutorStatusBar", () => { expect(statusBar).toHaveClass("executor-status-bar--loading"); }); + it("renders the populated idle footer instead of loading when loaded data has zero running tasks", () => { + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: { ...defaultStats, executorState: "idle", runningTaskCount: 0, queuedTaskCount: 0 }, + loading: false, + error: null, + refresh: vi.fn(), + }); + + render(); + + const statusBar = screen.getByRole("status"); + expect(statusBar).toHaveTextContent("Idle"); + expect(statusBar).toHaveTextContent("Queued"); + expect(statusBar).not.toHaveClass("executor-status-bar--loading"); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.getByTestId("engine-control-menu-trigger")).toBeInTheDocument(); + }); + + it("keeps an open concurrency popover mounted across idle heartbeat rerenders", async () => { + const user = userEvent.setup(); + const idleStats = { ...defaultStats, executorState: "idle" as const, runningTaskCount: 0, queuedTaskCount: 0 }; + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: idleStats, + loading: false, + error: null, + refresh: vi.fn(), + }); + + const { rerender } = render(); + + await user.click(screen.getByTestId("engine-control-menu-trigger")); + expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument(); + + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: { ...idleStats, lastActivityAt: "2026-06-27T21:40:00.000Z" }, + loading: false, + error: null, + refresh: vi.fn(), + }); + rerender(); + + expect(screen.getByRole("status")).not.toHaveClass("executor-status-bar--loading"); + expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument(); + }); + + it("does not swap to loading or close the popover if a future idle heartbeat reports loading with data", async () => { + viewportModeMock.value = "mobile"; + const user = userEvent.setup(); + const idleStats = { ...defaultStats, executorState: "idle" as const, runningTaskCount: 0, queuedTaskCount: 0 }; + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: idleStats, + loading: false, + error: null, + refresh: vi.fn(), + }); + + const { rerender } = render(); + + await user.click(screen.getByTestId("engine-control-menu-trigger")); + expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument(); + + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: { ...idleStats, lastActivityAt: "2026-06-27T21:45:00.000Z" }, + loading: true, + error: null, + refresh: vi.fn(), + }); + rerender(); + + const statusBar = screen.getByRole("status"); + expect(statusBar).toHaveTextContent("Idle"); + expect(statusBar).not.toHaveClass("executor-status-bar--loading"); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.getByTestId("engine-control-menu")).toBeInTheDocument(); + }); + it("does not show loading text when not loading", () => { render(); diff --git a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts index 74f0950f4e..95f8fe9993 100644 --- a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts @@ -16,6 +16,16 @@ vi.mock("../../api", async () => { describe("useExecutorStats", () => { const mockFetchExecutorStats = apiModule.fetchExecutorStats as ReturnType; + function createDeferredStats() { + let resolve!: (value: Awaited>) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise>>((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; + } + beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); @@ -389,6 +399,83 @@ describe("useExecutorStats", () => { expect(mockFetchExecutorStats).toHaveBeenCalledWith(undefined); }); + + it("treats a project switch as a fresh initial load without showing prior project stats", async () => { + const projectBFetch = createDeferredStats(); + mockFetchExecutorStats + .mockResolvedValueOnce({ + globalPause: false, + enginePaused: false, + maxConcurrent: 9, + lastActivityAt: "2026-04-01T12:00:00.000Z", + }) + .mockReturnValueOnce(projectBFetch.promise); + + const { result, rerender } = renderHook( + ({ projectId }) => useExecutorStats([], projectId), + { initialProps: { projectId: "project-a" } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(result.current.loading).toBe(false); + expect(result.current.stats.maxConcurrent).toBe(9); + + rerender({ projectId: "project-b" }); + + expect(result.current.loading).toBe(true); + expect(result.current.stats.maxConcurrent).toBe(2); + expect(result.current.stats.lastActivityAt).toBeUndefined(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(mockFetchExecutorStats).toHaveBeenLastCalledWith("project-b"); + expect(result.current.loading).toBe(true); + + await act(async () => { + projectBFetch.resolve({ + globalPause: false, + enginePaused: false, + maxConcurrent: 6, + lastActivityAt: "2026-04-01T12:05:00.000Z", + }); + await projectBFetch.promise; + }); + + expect(result.current.loading).toBe(false); + expect(result.current.stats.maxConcurrent).toBe(6); + expect(result.current.stats.lastActivityAt).toBe("2026-04-01T12:05:00.000Z"); + }); + + it("does not inherit transient-failure debounce state across project switches", async () => { + mockFetchExecutorStats + .mockResolvedValueOnce({ + globalPause: false, + enginePaused: false, + maxConcurrent: 9, + }) + .mockRejectedValueOnce(new Error("Load failed")); + + const { result, rerender } = renderHook( + ({ projectId }) => useExecutorStats([], projectId), + { initialProps: { projectId: "project-a" } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(result.current.error).toBeNull(); + + rerender({ projectId: "project-b" }); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe("Load failed"); + }); }); describe("reactive task updates", () => { @@ -423,6 +510,66 @@ describe("useExecutorStats", () => { }); describe("refresh function", () => { + it("reports loading only for the initial unresolved fetch", async () => { + const initialFetch = createDeferredStats(); + mockFetchExecutorStats.mockReturnValueOnce(initialFetch.promise); + + const { result } = renderHook(() => useExecutorStats([])); + + expect(result.current.loading).toBe(true); + + await act(async () => { + initialFetch.resolve({ + globalPause: false, + enginePaused: false, + maxConcurrent: 4, + lastActivityAt: "2026-04-01T12:00:00.000Z", + }); + await initialFetch.promise; + }); + + expect(result.current.loading).toBe(false); + }); + + it("keeps loading false during post-success background heartbeat refreshes", async () => { + const backgroundFetch = createDeferredStats(); + mockFetchExecutorStats + .mockResolvedValueOnce({ + globalPause: false, + enginePaused: false, + maxConcurrent: 4, + lastActivityAt: "2026-04-01T12:00:00.000Z", + }) + .mockReturnValueOnce(backgroundFetch.promise); + + const { result } = renderHook(() => useExecutorStats([])); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(result.current.loading).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + expect(mockFetchExecutorStats).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); + + await act(async () => { + backgroundFetch.resolve({ + globalPause: false, + enginePaused: false, + maxConcurrent: 7, + lastActivityAt: "2026-04-01T12:05:00.000Z", + }); + await backgroundFetch.promise; + }); + + expect(result.current.loading).toBe(false); + expect(result.current.stats.maxConcurrent).toBe(7); + }); + it("manually refreshes stats", async () => { const { result } = renderHook(() => useExecutorStats([])); diff --git a/packages/dashboard/app/hooks/useExecutorStats.ts b/packages/dashboard/app/hooks/useExecutorStats.ts index 62e9e5249b..2daf6fae8f 100644 --- a/packages/dashboard/app/hooks/useExecutorStats.ts +++ b/packages/dashboard/app/hooks/useExecutorStats.ts @@ -111,24 +111,28 @@ function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFe * - Derives executorState from globalPause and enginePaused flags, with globalPause mapping to "stopped" * - Returns ExecutorStats object with reactive updates */ +const DEFAULT_API_DATA: Pick & { + globalPause: boolean; + enginePaused: boolean; +} = { + globalPause: false, + enginePaused: false, + maxConcurrent: 2, +}; + export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): UseExecutorStatsResult { - const [apiData, setApiData] = useState<{ - globalPause: boolean; - enginePaused: boolean; - maxConcurrent: number; - lastActivityAt?: string; - }>({ - globalPause: false, - enginePaused: false, - maxConcurrent: 2, - }); + const [apiDataState, setApiDataState] = useState<{ + projectId?: string; + data: typeof DEFAULT_API_DATA; + } | null>(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [errorState, setErrorState] = useState<{ projectId?: string; message: string } | null>(null); const intervalRef = useRef(null); const abortRef = useRef(null); const hasFetchedStatsRef = useRef(false); const consecutiveFailuresRef = useRef(0); + const activeProjectIdRef = useRef(projectId); const visibilitySuspension = useTabVisibilitySuspension(); const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { @@ -143,13 +147,35 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim abortRef.current = new AbortController(); try { - setLoading(true); - const data = await fetchExecutorStats(projectId); + if (activeProjectIdRef.current !== projectId) { + activeProjectIdRef.current = projectId; + hasFetchedStatsRef.current = false; + consecutiveFailuresRef.current = 0; + setErrorState(null); + } + /* + * FNXC:ExecutorStatusBar 2026-06-27-00:00: + * Routine 5s heartbeat polls must not re-enter the loading state after the first successful stats fetch. The footer loading branch swaps the root subtree while idle, which unmounts EngineControlMenu, closes an open concurrency popover, and makes the footer blink (FN-7163). + * + * FNXC:ExecutorStatusBar 2026-06-27-17:30: + * Project switches are a new initial load, not a heartbeat. Reset the per-project fetched/failure guard before fetching so project B cannot inherit project A's stats or transient-error debounce state. + */ + if (!hasFetchedStatsRef.current) { + setLoading(true); + } + const requestProjectId = projectId; + const data = await fetchExecutorStats(requestProjectId); + if (activeProjectIdRef.current !== requestProjectId) { + return; + } consecutiveFailuresRef.current = 0; hasFetchedStatsRef.current = true; - setError(null); - setApiData(data); + setErrorState(null); + setApiDataState({ projectId: requestProjectId, data }); } catch (err) { + if (activeProjectIdRef.current !== projectId) { + return; + } if (err instanceof Error && err.name === "AbortError") { // Ignore abort errors return; @@ -161,12 +187,12 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim if (hasFetchedStatsRef.current && isLikelyTabSuspensionError(errorMessage)) { consecutiveFailuresRef.current += 1; if (consecutiveFailuresRef.current >= TRANSIENT_FAILURE_THRESHOLD) { - setError(errorMessage); + setErrorState({ projectId, message: errorMessage }); } return; } consecutiveFailuresRef.current = 0; - setError(errorMessage); + setErrorState({ projectId, message: errorMessage }); } finally { setLoading(false); } @@ -203,6 +229,11 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim }; }, [refresh]); + const currentProjectApiDataState = apiDataState && apiDataState.projectId === projectId ? apiDataState : null; + const apiData = currentProjectApiDataState?.data ?? DEFAULT_API_DATA; + const error = errorState && errorState.projectId === projectId ? errorState.message : null; + const effectiveLoading = loading || (!error && !currentProjectApiDataState); + // Derive stats from tasks and API data const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs, lastFetchTimeMs); const executorState = deriveExecutorState( @@ -220,7 +251,7 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim return { stats, - loading, + loading: effectiveLoading, error, refresh, };