FN-7163: keep executor footer stable during stats refreshes
Keep the executor footer mounted while heartbeat polling refreshes stats.\n\n- Make executor stats loading initial-only per project so routine refreshes preserve populated footer state.\n- Keep the engine controls popover mounted after the first successful stats render.\n- Add regression coverage for heartbeat refreshes, project switches, and transient failures.\n- Document the stable footer behavior and add a patch changeset.\n\nFiles changed:\n .changeset/fn-7163-footer-heartbeat-blink.md | 7 +\n docs/dashboard-guide.md | 3 +-\n .../dashboard/app/components/ExecutorStatusBar.tsx | 15 ++-\n .../__tests__/ExecutorStatusBar.test.tsx | 76 +++++++++++\n .../app/hooks/__tests__/useExecutorStats.test.ts | 147 +++++++++++++++++++++\n packages/dashboard/app/hooks/useExecutorStats.ts | 67 +++++++---\n 6 files changed, 294 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-7163 Fusion-Task-Lineage: ad1d7eb2-f531-42b3-b588-fbb69d09d0f3 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7163-footer-heartbeat-blink.md
Normal file
7
.changeset/fn-7163-footer-heartbeat-blink.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
<!-- FNXC:ExecutorStatusBar 2026-06-27-00:00: FN-7163 makes footer stats loading initial-only so routine heartbeat refreshes keep the populated footer and open concurrency popover mounted instead of blinking to the loading branch. -->
|
||||
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
|
||||
|
||||
|
||||
@@ -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<EngineControlMenuHandle>(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 (
|
||||
<div className="executor-status-bar executor-status-bar--loading" role="status" aria-label={t("executor.status", "Executor status")}>
|
||||
<LoadingSpinner className="executor-status-bar__loading-text" label={t("executor.loading", "Loading...")} />
|
||||
|
||||
@@ -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(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
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(<ExecutorStatusBar tasks={emptyTasks} lastFetchTimeMs={1000} />);
|
||||
|
||||
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(<ExecutorStatusBar tasks={[...emptyTasks]} lastFetchTimeMs={6000} />);
|
||||
|
||||
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(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
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(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
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(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
|
||||
@@ -16,6 +16,16 @@ vi.mock("../../api", async () => {
|
||||
describe("useExecutorStats", () => {
|
||||
const mockFetchExecutorStats = apiModule.fetchExecutorStats as ReturnType<typeof vi.fn>;
|
||||
|
||||
function createDeferredStats() {
|
||||
let resolve!: (value: Awaited<ReturnType<typeof apiModule.fetchExecutorStats>>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<Awaited<ReturnType<typeof apiModule.fetchExecutorStats>>>((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([]));
|
||||
|
||||
|
||||
@@ -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<ExecutorStats, "maxConcurrent" | "lastActivityAt"> & {
|
||||
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<string | null>(null);
|
||||
const [errorState, setErrorState] = useState<{ projectId?: string; message: string } | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user