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); }