FN-8703: render project overview before health telemetry

Render registered project cards immediately while optional health telemetry hydrates in the background.

- Publish completed health batches progressively and prevent stale responses from overwriting current state.
- Keep project controls usable while health metrics load and indicate per-card pending telemetry.
- Add coverage and operator documentation for progressive overview hydration.

Files changed:
 .changeset/fn-8703-project-overview-load.md        |   7 ++
 docs/dashboard-guide.md                            |   3 +-
 packages/dashboard/app/components/ProjectCard.tsx  |  12 ++-
 .../dashboard/app/components/ProjectOverview.tsx   |  15 +--
 .../components/__tests__/ProjectOverview.test.tsx  | 101 ++++++++++++++++++++
 .../app/hooks/__tests__/useProjectHealth.test.ts   |  98 ++++++++++++++++++++
 packages/dashboard/app/hooks/useProjectHealth.ts   | 102 ++++++++++-----------
 7 files changed, 277 insertions(+), 61 deletions(-)

Fusion-Task-Id: FN-8703

Fusion-Task-Lineage: eb816c9d-c808-4d52-a3f0-28bb9de5f149

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 09:53:36 -07:00
parent 5209e7fb8f
commit b8bde05ca6
7 changed files with 277 additions and 61 deletions

View File

@@ -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.

View File

@@ -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**.
<!-- FNXC:DashboardResponsiveDocs 2026-07-25-22:57: Project overview has no task destinations, so the mobile navigation and its published height must be absent rather than leaving dead bottom space. Document the 320px responsive and token conventions alongside the operator-visible behavior. -->
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.
<!-- FNXC:ProjectOverviewHealthHydration 2026-08-01-15:40: Registered projects are the navigation-critical content of the all-projects view, so optional per-project health telemetry hydrates progressively rather than delaying cards and their controls. -->
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)

View File

@@ -16,6 +16,8 @@ export interface ProjectCardProps {
onResume: (project: RegisteredProject) => void;
onRemove: (project: RegisteredProject) => void;
availabilityMappings?: Array<ProjectNodeAvailability & { displayName: string }>;
/** 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 && (
<div className="project-card-metric project-card-metric-empty">
<span className="project-card-metric-label">{t("projectCard.noHealthData", "No health data available")}</span>
<span className="project-card-metric-label">
{healthLoading
? t("projectCard.healthLoading", "Loading health metrics")
: t("projectCard.noHealthData", "No health data available")}
</span>
</div>
)}
</div>

View File

@@ -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 */}
<div className="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}

View File

@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<ProjectHealth>()] as const;
}),
);
const onSelectProject = vi.fn();
mockFetchProjectHealth.mockImplementation((projectId: string) => pendingHealth.get(projectId)!.promise);
render(
<ProjectOverview
projects={Array.from({ length: 6 }, (_, index) => 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();
});
});

View File

@@ -145,6 +145,104 @@ describe("useProjectHealth", () => {
});
});
it("publishes each completed batch before a later batch settles", async () => {
const firstBatch = Array.from({ length: 5 }, () => deferred<ProjectHealth>());
const sixthProject = deferred<ProjectHealth>();
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<ProjectHealth>();
const currentHealth = deferred<ProjectHealth>();
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<ProjectHealth>();
const newerHealth = deferred<ProjectHealth>();
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<ProjectHealth>();

View File

@@ -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<Record<string, ProjectHealth | null>>({});
const [loading, setLoading] = useState(true); // Start true for initial load
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<string, ProjectHealth | null> = {};
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,