diff --git a/.changeset/fn-8703-project-overview-load.md b/.changeset/fn-8703-project-overview-load.md new file mode 100644 index 0000000000..ddb7485159 --- /dev/null +++ b/.changeset/fn-8703-project-overview-load.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show registered projects immediately while dashboard health metrics load. +category: fix +dev: Project health batches now hydrate cards and aggregate metrics progressively. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 47569e1999..55c2d89862 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -182,7 +182,8 @@ The active nav-item highlight and the resize-handle hover/focus accent track the On mobile viewports (`<=768px`), the sidebar is not rendered even when the default-on setting is enabled. The existing bottom `MobileNavBar` remains the navigation surface on project task screens, with mobile-only More-sheet entries for compact tools such as Git Manager, Terminal, Files, and **Import from GitHub**. -On the **All Projects** overview, Fusion suppresses the mobile bottom nav because task tabs are not active there. It also clears `--mobile-nav-height`, so the overview does not reserve bottom-bar space. The overview reflows through 320px: headers, filters, stats, and card actions stack or wrap, while long project names and paths truncate rather than overflowing. Dashboard component styles use literal pixel values for media-query breakpoints (including `480px` and `380px`); declaration values within those blocks stay token-based. + +On the **All Projects** overview, Fusion renders registered project cards, navigation, filters, and controls as soon as the project list is available. Per-project health metrics hydrate progressively as bounded background requests complete; pending or failed telemetry never replaces the grid with a full loading skeleton. Fusion continues refreshing those metrics in the background and suspends polling while the tab is hidden. It suppresses the mobile bottom nav because task tabs are not active there and clears `--mobile-nav-height`, so the overview does not reserve bottom-bar space. The overview reflows through 320px: headers, filters, stats, and card actions stack or wrap, while long project names and paths truncate rather than overflowing. Dashboard component styles use literal pixel values for media-query breakpoints (including `480px` and `380px`); declaration values within those blocks stay token-based. ## Right Dock (experimental, default on) diff --git a/packages/dashboard/app/components/ProjectCard.tsx b/packages/dashboard/app/components/ProjectCard.tsx index 8727f6015a..b39b268320 100644 --- a/packages/dashboard/app/components/ProjectCard.tsx +++ b/packages/dashboard/app/components/ProjectCard.tsx @@ -16,6 +16,8 @@ export interface ProjectCardProps { onResume: (project: RegisteredProject) => void; onRemove: (project: RegisteredProject) => void; availabilityMappings?: Array; + /** Health is being fetched, while project navigation and controls remain available. */ + healthLoading?: boolean; isLoading?: boolean; } @@ -62,7 +64,8 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP if (previous.project.path !== next.project.path) return false; if (previous.project.lastActivityAt !== next.project.lastActivityAt) return false; if (previous.isLoading !== next.isLoading) return false; - + if (previous.healthLoading !== next.healthLoading) return false; + // Compare health const prevHealth = previous.health; const nextHealth = next.health; @@ -101,6 +104,7 @@ function ProjectCardInner({ onResume, onRemove, availabilityMappings = [], + healthLoading = false, isLoading = false, }: ProjectCardProps) { const { t } = useTranslation("app"); @@ -205,7 +209,11 @@ function ProjectCardInner({ )} {!health && (
- {t("projectCard.noHealthData", "No health data available")} + + {healthLoading + ? t("projectCard.healthLoading", "Loading health metrics") + : t("projectCard.noHealthData", "No health data available")} +
)} diff --git a/packages/dashboard/app/components/ProjectOverview.tsx b/packages/dashboard/app/components/ProjectOverview.tsx index 7568b19721..8fd1596f39 100644 --- a/packages/dashboard/app/components/ProjectOverview.tsx +++ b/packages/dashboard/app/components/ProjectOverview.tsx @@ -230,12 +230,13 @@ export function ProjectOverview({ onSelectProject(project); }, [onSelectProject, recentProjectIds]); - // Determine if we need to show skeleton - // Show skeleton for initial load if: - // 1. Projects list is still loading, OR - // 2. Projects exist but we haven't fetched health data yet (healthLoading with no data) - // Don't show skeleton during background health polling when health data already exists - const needsInitialSkeleton = loading || (healthLoading && projects.length > 0 && Object.keys(healthMap).length === 0); + /* + FNXC:ProjectOverviewHealthHydration 2026-08-01-15:40: + Registered project metadata and controls are the navigation-critical surface, while health is optional, + potentially slow per-project telemetry. Keep the grid visible as soon as the list resolves; cards mark only + their own pending health rather than replacing the whole overview until every batch has completed. + */ + const needsInitialSkeleton = loading; /* FNXC:DashboardHeader 2026-06-22-16:42: The Project Dashboard overview (projects, stats, filters, and charts/overview content) owns the shared top header. The Board view must stay headerless because its columns already consume the full board surface. @@ -439,6 +440,7 @@ export function ProjectOverview({ {/* Project grid */}
{sortedProjects.map(({ project, health }) => { + const healthPending = healthLoading && !Object.hasOwn(healthMap, project.id); const availabilityMappings: DisplayMapping[] = getNodeMappingsForProject(project) .filter((mapping) => mapping.available) .map((mapping) => ({ @@ -451,6 +453,7 @@ export function ProjectOverview({ key={project.id} project={project} health={health} + healthLoading={healthPending} availabilityMappings={availabilityMappings} onSelect={handleSelectProject} onPause={onPauseProject} diff --git a/packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx b/packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx new file mode 100644 index 0000000000..43b612215f --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ProjectOverview.test.tsx @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ProjectOverview } from "../ProjectOverview"; +import * as api from "../../api"; +import type { ProjectHealth, ProjectInfoWithSource } from "../../api"; + +vi.mock("../../api", () => ({ + fetchProjectHealth: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }), +})); + +const mockFetchProjectHealth = vi.mocked(api.fetchProjectHealth); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function createProject(id: string): ProjectInfoWithSource { + return { + id, + name: `Project ${id}`, + path: `/workspace/${id}`, + status: "active", + isolationMode: "in-process", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function createHealth(projectId: string): ProjectHealth { + return { + projectId, + status: "active", + activeTaskCount: 3, + inFlightAgentCount: 1, + totalTasksCompleted: 12, + totalTasksFailed: 0, + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ProjectOverview health hydration", () => { + it("keeps registered project cards and controls usable while batched health resolves progressively", async () => { + const pendingHealth = new Map( + Array.from({ length: 6 }, (_, index) => { + const id = `p${index + 1}`; + return [id, deferred()] as const; + }), + ); + const onSelectProject = vi.fn(); + mockFetchProjectHealth.mockImplementation((projectId: string) => pendingHealth.get(projectId)!.promise); + + render( + createProject(`p${index + 1}`))} + onSelectProject={onSelectProject} + onAddProject={vi.fn()} + onPauseProject={vi.fn()} + onResumeProject={vi.fn()} + onRemoveProject={vi.fn()} + />, + ); + + await waitFor(() => { + expect(mockFetchProjectHealth).toHaveBeenCalledTimes(5); + }); + + expect(screen.getByText("Project p1")).toBeTruthy(); + expect(screen.getByText("Project p6")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Add Project" })).toBeTruthy(); + expect(screen.getByRole("combobox", { name: "Sort projects" })).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "Open project" })).toHaveLength(6); + + fireEvent.click(screen.getByText("Project p1")); + expect(onSelectProject).toHaveBeenCalledWith(expect.objectContaining({ id: "p1" })); + + await act(async () => { + for (let index = 1; index <= 5; index += 1) { + pendingHealth.get(`p${index}`)!.resolve(createHealth(`p${index}`)); + } + }); + + await waitFor(() => { + expect(screen.getAllByText("12")).toHaveLength(5); + expect(mockFetchProjectHealth).toHaveBeenCalledTimes(6); + }); + + expect(screen.getByText("Project p6")).toBeTruthy(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useProjectHealth.test.ts b/packages/dashboard/app/hooks/__tests__/useProjectHealth.test.ts index 843ca3297d..d962e56458 100644 --- a/packages/dashboard/app/hooks/__tests__/useProjectHealth.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useProjectHealth.test.ts @@ -145,6 +145,104 @@ describe("useProjectHealth", () => { }); }); + it("publishes each completed batch before a later batch settles", async () => { + const firstBatch = Array.from({ length: 5 }, () => deferred()); + const sixthProject = deferred(); + mockFetchProjectHealth.mockImplementation((id: string) => { + const index = Number(id.slice(1)) - 1; + return index < 5 ? firstBatch[index].promise : sixthProject.promise; + }); + + const { result } = renderUseProjectHealth(["p1", "p2", "p3", "p4", "p5", "p6"]); + + await waitFor(() => { + expect(mockFetchProjectHealth).toHaveBeenCalledTimes(5); + }); + + await act(async () => { + firstBatch.forEach((pending, index) => pending.resolve(createHealth(`p${index + 1}`))); + }); + + await waitFor(() => { + expect(result.current.healthMap).toEqual({ + p1: createHealth("p1"), + p2: createHealth("p2"), + p3: createHealth("p3"), + p4: createHealth("p4"), + p5: createHealth("p5"), + }); + expect(result.current.loading).toBe(true); + expect(mockFetchProjectHealth).toHaveBeenCalledTimes(6); + }); + + sixthProject.resolve(createHealth("p6")); + await waitFor(() => { + expect(result.current.healthMap.p6).toEqual(createHealth("p6")); + expect(result.current.loading).toBe(false); + }); + }); + + it("deduplicates project IDs before fetching health", async () => { + const { result } = renderUseProjectHealth(["p1", "p1", "p2", "p2"]); + + await waitFor(() => { + expect(result.current.healthMap).toEqual({ + p1: createHealth("p1"), + p2: createHealth("p2"), + }); + }); + + expect(mockFetchProjectHealth.mock.calls.map(([id]) => id)).toEqual(["p1", "p2"]); + }); + + it("does not publish an older project list after IDs change", async () => { + const oldHealth = deferred(); + const currentHealth = deferred(); + mockFetchProjectHealth.mockImplementation((id: string) => id === "old" ? oldHealth.promise : currentHealth.promise); + + const { result, rerender } = renderUseProjectHealth(["old"]); + await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledWith("old")); + + rerender({ ids: ["current"] }); + await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledWith("current")); + + currentHealth.resolve(createHealth("current")); + await waitFor(() => expect(result.current.healthMap).toEqual({ current: createHealth("current") })); + + oldHealth.resolve(createHealth("old", { activeTaskCount: 99 })); + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.healthMap).toEqual({ current: createHealth("current") }); + }); + + it("does not let an older manual refresh overwrite newer health", async () => { + const olderHealth = deferred(); + const newerHealth = deferred(); + mockFetchProjectHealth + .mockReturnValueOnce(olderHealth.promise) + .mockReturnValueOnce(newerHealth.promise); + + const { result } = renderUseProjectHealth(["p1"]); + await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1)); + + await act(async () => { + void result.current.refresh(); + }); + await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledTimes(2)); + + newerHealth.resolve(createHealth("p1", { activeTaskCount: 2 })); + await waitFor(() => expect(result.current.healthMap.p1?.activeTaskCount).toBe(2)); + + olderHealth.resolve(createHealth("p1", { activeTaskCount: 99 })); + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.healthMap.p1?.activeTaskCount).toBe(2); + }); + it("refresh aborts in-flight requests when called again", async () => { const abortSpy = vi.spyOn(AbortController.prototype, "abort"); const pending = deferred(); diff --git a/packages/dashboard/app/hooks/useProjectHealth.ts b/packages/dashboard/app/hooks/useProjectHealth.ts index 42a35ce332..ca2884e449 100644 --- a/packages/dashboard/app/hooks/useProjectHealth.ts +++ b/packages/dashboard/app/hooks/useProjectHealth.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import type { ProjectHealth } from "../api"; import { fetchProjectHealth } from "../api"; import { isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension"; @@ -32,13 +32,16 @@ const BATCH_SIZE = 5; // Number of concurrent health fetches * skeleton flicker and scroll position resets during periodic updates. */ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthResult { + // The caller can provide duplicate IDs from overlapping node sources; fetch and publish each logical project once. + const projectIdsKey = projectIds.join("\u0000"); + const uniqueProjectIds = useMemo(() => [...new Set(projectIds)], [projectIdsKey]); const [healthMap, setHealthMap] = useState>({}); - const [loading, setLoading] = useState(true); // Start true for initial load + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const abortRef = useRef(null); + const requestVersionRef = useRef(0); const healthMapRef = useRef(healthMap); const visibilitySuspension = useTabVisibilitySuspension(); - // Track if we've completed the initial load const initialLoadCompleteRef = useRef(false); useEffect(() => { @@ -49,44 +52,31 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes return Object.keys(healthMapRef.current).length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); }, [visibilitySuspension]); - /** - * Refresh health data for all projects. - * This is called both for initial load and for background polling. - * Background polling does NOT set loading=true to avoid UI flicker. - */ const refresh = useCallback(async () => { - // Handle empty projectIds - clear health state and complete initial load - if (projectIds.length === 0) { + if (uniqueProjectIds.length === 0) { + abortRef.current?.abort(); + requestVersionRef.current += 1; setHealthMap({}); - // Mark initial load complete (there's nothing to fetch) - if (!initialLoadCompleteRef.current) { - initialLoadCompleteRef.current = true; - } + initialLoadCompleteRef.current = true; setLoading(false); + setError(null); return; } - // Cancel any in-flight requests - if (abortRef.current) { - abortRef.current.abort(); - } - abortRef.current = new AbortController(); - - // Determine if this is the initial load + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const requestVersion = ++requestVersionRef.current; const isInitial = !initialLoadCompleteRef.current; + if (isInitial) { setLoading(true); } setError(null); try { - // Fetch health in batches - const newHealthMap: Record = {}; - - for (let i = 0; i < projectIds.length; i += BATCH_SIZE) { - const batch = projectIds.slice(i, i + BATCH_SIZE); - - // Fetch this batch concurrently + for (let index = 0; index < uniqueProjectIds.length; index += BATCH_SIZE) { + const batch = uniqueProjectIds.slice(index, index + BATCH_SIZE); const batchResults = await Promise.allSettled( batch.map(async (id) => { try { @@ -94,43 +84,55 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes } catch { return null; } - }) + }), ); - batch.forEach((id, index) => { - const result = batchResults[index]; - newHealthMap[id] = result.status === "fulfilled" ? result.value : null; - }); - - // Check for cancellation between batches - if (abortRef.current?.signal.aborted) { + if (controller.signal.aborted || requestVersion !== requestVersionRef.current) { return; } + + const completedBatch = Object.fromEntries(batch.map((id, batchIndex) => { + const result = batchResults[batchIndex]; + return [id, result.status === "fulfilled" ? result.value : null]; + })); + + /* + FNXC:ProjectHealthProgress 2026-08-01-15:40: + Health metrics are independent, optional telemetry. Publish each completed bounded batch immediately + so ProjectOverview hydrates visible cards and aggregate values progressively; a slower later batch must + not hold completed project data behind the initial-load gate. + */ + setHealthMap((previous) => ({ ...previous, ...completedBatch })); } - setHealthMap(newHealthMap); - initialLoadCompleteRef.current = true; + if (requestVersion === requestVersionRef.current && !controller.signal.aborted) { + initialLoadCompleteRef.current = true; + } } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - // Ignore abort errors + if (controller.signal.aborted || requestVersion !== requestVersionRef.current || (err instanceof Error && err.name === "AbortError")) { return; } + const errorMessage = err instanceof Error ? err.message : "Failed to fetch health data"; if (!shouldSuppressVisibilityResumeError(errorMessage)) { setError(errorMessage); } - // Mark initial load complete even on error so we don't stay in loading state initialLoadCompleteRef.current = true; } finally { - setLoading(false); + if (requestVersion === requestVersionRef.current && !controller.signal.aborted) { + setLoading(false); + } } - }, [projectIds, shouldSuppressVisibilityResumeError]); + }, [shouldSuppressVisibilityResumeError, uniqueProjectIds]); const refreshProject = useCallback(async (projectId: string) => { + const requestVersion = requestVersionRef.current; try { const health = await fetchProjectHealth(projectId); - setHealthMap((prev) => ({ - ...prev, + if (requestVersion !== requestVersionRef.current) return; + + setHealthMap((previous) => ({ + ...previous, [projectId]: health, })); } catch (err) { @@ -138,17 +140,13 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes } }, []); - // Initial fetch and when project IDs change useEffect(() => { - // Reset initial load state when projectIds changes initialLoadCompleteRef.current = false; - + setHealthMap({}); void refresh(); return () => { - if (abortRef.current) { - abortRef.current.abort(); - } + abortRef.current?.abort(); }; }, [refresh]); @@ -158,7 +156,7 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes page from ever going idle, which is what makes mobile browsers reclaim the tab and force a cold reload on return; one refresh fires on the hidden -> visible edge so health badges are not stale when seen. */ - useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: projectIds.length > 0 }); + useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: uniqueProjectIds.length > 0 }); return { healthMap,