diff --git a/.changeset/fn-7116-footer-connecting-flash.md b/.changeset/fn-7116-footer-connecting-flash.md
new file mode 100644
index 0000000000..244f591335
--- /dev/null
+++ b/.changeset/fn-7116-footer-connecting-flash.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Suppress brief footer Connecting flashes after one transient executor stats poll failure.
+category: fix
+dev: Debounces post-success suspension-like /api/executor/stats failures in useExecutorStats.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index abb77dcce0..012c3bac78 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -1002,6 +1002,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. 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.
+
### Engine status banner
When a project dashboard is open but no project engine is connected, Fusion shows a sticky **Engine disconnected** banner above the project content. This covers paused projects, failed or still-starting project engines, delayed reconciliation, and dashboard-only/dev launches where the UI is available before an engine manager is attached.
diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx
index 9d1c24808b..caea16de61 100644
--- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx
@@ -553,6 +553,27 @@ describe("ExecutorStatusBar", () => {
});
describe("error state", () => {
+ it.each(["desktop", "mobile"] as const)("keeps populated stats on %s when a transient stats-fetch blip is debounced", (viewportMode) => {
+ viewportModeMock.value = viewportMode;
+ vi.mocked(mockUseExecutorStats).mockReturnValue({
+ stats: defaultStats,
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ });
+
+ render();
+
+ const statusBar = screen.getByRole("status");
+ expect(statusBar).toHaveTextContent("Queued");
+ expect(statusBar).toHaveTextContent("Running");
+ expect(statusBar).toHaveTextContent("Blocked");
+ expect(statusBar).toHaveTextContent("In Review");
+ expect(statusBar).not.toHaveClass("executor-status-bar--connecting");
+ expect(statusBar.querySelector(".executor-status-bar--connecting")).toBeNull();
+ expect(screen.queryByText("Connecting…")).not.toBeInTheDocument();
+ });
+
it("shows error message when error is present", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
@@ -568,7 +589,8 @@ describe("ExecutorStatusBar", () => {
expect(statusBar).toHaveClass("executor-status-bar--error");
});
- it("shows connecting state instead of suspension error text", () => {
+ it.each(["desktop", "mobile"] as const)("shows connecting state for sustained suspension errors on %s", (viewportMode) => {
+ viewportModeMock.value = viewportMode;
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,
loading: false,
diff --git a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts
index f74fbea1b8..74f0950f4e 100644
--- a/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useExecutorStats.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { renderHook, act } from "@testing-library/react";
+import { renderHook, act, fireEvent } from "@testing-library/react";
import { useExecutorStats } from "../useExecutorStats";
import * as apiModule from "../../api";
import type { Task } from "@fusion/core";
@@ -19,6 +19,10 @@ describe("useExecutorStats", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ get: () => "visible",
+ });
mockFetchExecutorStats.mockResolvedValue({
globalPause: false,
enginePaused: false,
@@ -446,7 +450,7 @@ describe("useExecutorStats", () => {
describe("error handling", () => {
it("sets error state when API call fails", async () => {
- mockFetchExecutorStats.mockRejectedValue(new Error("Network error"));
+ mockFetchExecutorStats.mockRejectedValue(new Error("Request failed: 500"));
const { result } = renderHook(() => useExecutorStats([]));
@@ -454,11 +458,11 @@ describe("useExecutorStats", () => {
await vi.advanceTimersByTimeAsync(100);
});
- expect(result.current.error).toBe("Network error");
+ expect(result.current.error).toBe("Request failed: 500");
});
it("clears error on successful refresh", async () => {
- mockFetchExecutorStats.mockRejectedValueOnce(new Error("Network error"));
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Request failed: 500"));
const { result } = renderHook(() => useExecutorStats([]));
@@ -466,7 +470,7 @@ describe("useExecutorStats", () => {
await vi.advanceTimersByTimeAsync(100);
});
- expect(result.current.error).toBe("Network error");
+ expect(result.current.error).toBe("Request failed: 500");
mockFetchExecutorStats.mockResolvedValueOnce({
globalPause: false,
@@ -480,6 +484,124 @@ describe("useExecutorStats", () => {
expect(result.current.error).toBeNull();
});
+
+ it("surfaces a first-ever transient fetch failure when no last-good stats exist", async () => {
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
+
+ const { result } = renderHook(() => useExecutorStats([]));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100);
+ });
+
+ expect(result.current.error).toBe("Load failed");
+ });
+
+ it("suppresses a single transient failure after a prior success and keeps last-good stats", async () => {
+ const { result } = renderHook(() => useExecutorStats([]));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100);
+ });
+ expect(result.current.error).toBeNull();
+ expect(result.current.stats.maxConcurrent).toBe(4);
+
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
+
+ await act(async () => {
+ await result.current.refresh();
+ });
+
+ expect(result.current.error).toBeNull();
+ expect(result.current.stats.maxConcurrent).toBe(4);
+ });
+
+ it("surfaces sustained consecutive transient failures after the debounce threshold", async () => {
+ const { result } = renderHook(() => useExecutorStats([]));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100);
+ });
+
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.error).toBeNull();
+
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Failed to fetch"));
+ await act(async () => {
+ await result.current.refresh();
+ });
+
+ expect(result.current.error).toBe("Failed to fetch");
+ });
+
+ it("resets the transient failure counter after a successful refresh", async () => {
+ const { result } = renderHook(() => useExecutorStats([]));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100);
+ });
+
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.error).toBeNull();
+
+ mockFetchExecutorStats.mockResolvedValueOnce({
+ globalPause: false,
+ enginePaused: false,
+ maxConcurrent: 6,
+ });
+ await act(async () => {
+ await result.current.refresh();
+ });
+ expect(result.current.error).toBeNull();
+ expect(result.current.stats.maxConcurrent).toBe(6);
+
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Failed to fetch"));
+ await act(async () => {
+ await result.current.refresh();
+ });
+
+ expect(result.current.error).toBeNull();
+ });
+
+ it("keeps visibility-resume suppression ahead of transient failure debounce", async () => {
+ const { result } = renderHook(() => useExecutorStats([]));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(100);
+ });
+
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ get: () => "hidden",
+ });
+ act(() => {
+ fireEvent(document, new Event("visibilitychange"));
+ });
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ get: () => "visible",
+ });
+ act(() => {
+ fireEvent(document, new Event("visibilitychange"));
+ });
+
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
+ await act(async () => {
+ await result.current.refresh();
+ });
+ mockFetchExecutorStats.mockRejectedValueOnce(new Error("Failed to fetch"));
+ await act(async () => {
+ await result.current.refresh();
+ });
+
+ expect(result.current.error).toBeNull();
+ });
});
describe("board-sync regression", () => {
diff --git a/packages/dashboard/app/hooks/useExecutorStats.ts b/packages/dashboard/app/hooks/useExecutorStats.ts
index 089623c430..62e9e5249b 100644
--- a/packages/dashboard/app/hooks/useExecutorStats.ts
+++ b/packages/dashboard/app/hooks/useExecutorStats.ts
@@ -3,9 +3,14 @@ import type { Task } from "@fusion/core";
import { fetchExecutorStats } from "../api";
import type { ExecutorStats, ExecutorState } from "../api";
import { isTaskStuck } from "../utils/taskStuck";
-import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
+import { isLikelyTabSuspensionError, isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s
+/*
+ * FNXC:ExecutorStatusBar 2026-06-27-00:00:
+ * Executor stats polling can hit one-off tab-suspension-like fetch errors while the tab remains visible. Keep the last-good footer stats through one transient poll so the bottom bar does not flash "Connecting…"; only sustained consecutive transient failures may surface the connection state.
+ */
+const TRANSIENT_FAILURE_THRESHOLD = 2;
export interface UseExecutorStatsResult {
/** Aggregated executor statistics */
@@ -123,6 +128,7 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
const intervalRef = useRef(null);
const abortRef = useRef(null);
const hasFetchedStatsRef = useRef(false);
+ const consecutiveFailuresRef = useRef(0);
const visibilitySuspension = useTabVisibilitySuspension();
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
@@ -138,9 +144,10 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
try {
setLoading(true);
- setError(null);
const data = await fetchExecutorStats(projectId);
+ consecutiveFailuresRef.current = 0;
hasFetchedStatsRef.current = true;
+ setError(null);
setApiData(data);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
@@ -148,9 +155,18 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
return;
}
const errorMessage = err instanceof Error ? err.message : "Failed to fetch executor stats";
- if (!shouldSuppressVisibilityResumeError(errorMessage)) {
- setError(errorMessage);
+ if (shouldSuppressVisibilityResumeError(errorMessage)) {
+ return;
}
+ if (hasFetchedStatsRef.current && isLikelyTabSuspensionError(errorMessage)) {
+ consecutiveFailuresRef.current += 1;
+ if (consecutiveFailuresRef.current >= TRANSIENT_FAILURE_THRESHOLD) {
+ setError(errorMessage);
+ }
+ return;
+ }
+ consecutiveFailuresRef.current = 0;
+ setError(errorMessage);
} finally {
setLoading(false);
}