feat(FN-1238): add dashboard loading screen with animated loader component

- Create DashboardLoader component with pulsing Fusion logo and progress bar animation
- Add CSS keyframe animations for smooth loading states
- Gate initial App render with loader until dashboard data is ready
- Add comprehensive tests for DashboardLoader component and App loading behavior
- Update existing App tests to account for loader wrapper
This commit is contained in:
gsxdsm
2026-04-08 19:38:45 -07:00
parent 7e8a99a9ce
commit 2842088216
5 changed files with 370 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import type { TaskDetail } from "@fusion/core";
import { Header, useViewportMode } from "./components/Header";
import { Board } from "./components/Board";
@@ -8,10 +8,12 @@ import { AgentsView } from "./components/AgentsView";
import { MissionManager } from "./components/MissionManager";
import { NodesView } from "./components/NodesView";
import { AppModals } from "./components/AppModals";
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
import { MobileNavBar } from "./components/MobileNavBar";
import { QuickChatFAB } from "./components/QuickChatFAB";
import { ToastContainer } from "./components/ToastContainer";
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects";
@@ -43,6 +45,32 @@ function AppInner() {
currentProject ? { projectId: currentProject.id } : undefined
);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const loadingStage = useMemo<DashboardLoaderStage>(() => {
if (projectsLoading) return "projects";
if (currentProjectLoading) return "project";
return "tasks";
}, [projectsLoading, currentProjectLoading]);
useEffect(() => {
if (initialLoadComplete) {
return;
}
if (projectsLoading || currentProjectLoading) {
return;
}
const settleTimer = window.setTimeout(() => {
setInitialLoadComplete(true);
}, 200);
return () => {
window.clearTimeout(settleTimer);
};
}, [initialLoadComplete, projectsLoading, currentProjectLoading]);
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
@@ -320,6 +348,15 @@ function AppInner() {
);
};
if (!initialLoadComplete) {
return (
<>
<DashboardLoader stage={loadingStage} />
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}
return (
<>
<Header

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { DashboardLoader } from "./DashboardLoader";
function getStep(label: string): HTMLElement {
const step = screen.getByText(label).closest("li");
if (!step) {
throw new Error(`Could not find step for label: ${label}`);
}
return step;
}
describe("DashboardLoader", () => {
it("renders projects stage with active first step and pending remaining steps", () => {
render(<DashboardLoader stage="projects" />);
expect(screen.getByText("Fusion")).toBeInTheDocument();
expect(getStep("Loading projects").className).toContain("dashboard-loader__step--active");
expect(getStep("Selecting project").className).toContain("dashboard-loader__step--pending");
expect(getStep("Fetching tasks").className).toContain("dashboard-loader__step--pending");
});
it("marks previous stages done when current stage is tasks", () => {
render(<DashboardLoader stage="tasks" />);
expect(getStep("Loading projects").className).toContain("dashboard-loader__step--done");
expect(getStep("Selecting project").className).toContain("dashboard-loader__step--done");
expect(getStep("Fetching tasks").className).toContain("dashboard-loader__step--active");
});
it("marks all steps done when stage is ready", () => {
render(<DashboardLoader stage="ready" />);
expect(getStep("Loading projects").className).toContain("dashboard-loader__step--done");
expect(getStep("Selecting project").className).toContain("dashboard-loader__step--done");
expect(getStep("Fetching tasks").className).toContain("dashboard-loader__step--done");
});
it("announces loading state for assistive technologies", () => {
render(<DashboardLoader stage="project" />);
const statusRegion = screen.getByRole("status", { name: "Loading Fusion dashboard" });
expect(statusRegion).toHaveAttribute("aria-live", "polite");
expect(screen.getByLabelText("Dashboard loading progress")).toBeInTheDocument();
});
it("keeps stage visuals stable across all stages", () => {
const stages = ["projects", "project", "tasks", "ready"] as const;
const snapshots = stages.map((stage) => {
const { container, unmount } = render(<DashboardLoader stage={stage} />);
const steps = Array.from(container.querySelectorAll<HTMLElement>(".dashboard-loader__step")).map((step) => ({
className: step.className,
label: step.querySelector(".dashboard-loader__step-label")?.textContent,
iconText: step.querySelector(".dashboard-loader__step-icon")?.textContent?.trim() ?? "",
hasSpinner: Boolean(step.querySelector(".dashboard-loader__spinner")),
}));
unmount();
return { stage, steps };
});
expect(snapshots).toMatchInlineSnapshot(`
[
{
"stage": "projects",
"steps": [
{
"className": "dashboard-loader__step dashboard-loader__step--active",
"hasSpinner": true,
"iconText": "",
"label": "Loading projects",
},
{
"className": "dashboard-loader__step dashboard-loader__step--pending",
"hasSpinner": false,
"iconText": "•",
"label": "Selecting project",
},
{
"className": "dashboard-loader__step dashboard-loader__step--pending",
"hasSpinner": false,
"iconText": "•",
"label": "Fetching tasks",
},
],
},
{
"stage": "project",
"steps": [
{
"className": "dashboard-loader__step dashboard-loader__step--done",
"hasSpinner": false,
"iconText": "✓",
"label": "Loading projects",
},
{
"className": "dashboard-loader__step dashboard-loader__step--active",
"hasSpinner": true,
"iconText": "",
"label": "Selecting project",
},
{
"className": "dashboard-loader__step dashboard-loader__step--pending",
"hasSpinner": false,
"iconText": "•",
"label": "Fetching tasks",
},
],
},
{
"stage": "tasks",
"steps": [
{
"className": "dashboard-loader__step dashboard-loader__step--done",
"hasSpinner": false,
"iconText": "✓",
"label": "Loading projects",
},
{
"className": "dashboard-loader__step dashboard-loader__step--done",
"hasSpinner": false,
"iconText": "✓",
"label": "Selecting project",
},
{
"className": "dashboard-loader__step dashboard-loader__step--active",
"hasSpinner": true,
"iconText": "",
"label": "Fetching tasks",
},
],
},
{
"stage": "ready",
"steps": [
{
"className": "dashboard-loader__step dashboard-loader__step--done",
"hasSpinner": false,
"iconText": "✓",
"label": "Loading projects",
},
{
"className": "dashboard-loader__step dashboard-loader__step--done",
"hasSpinner": false,
"iconText": "✓",
"label": "Selecting project",
},
{
"className": "dashboard-loader__step dashboard-loader__step--done",
"hasSpinner": false,
"iconText": "✓",
"label": "Fetching tasks",
},
],
},
]
`);
});
});

View File

@@ -0,0 +1,79 @@
import { Loader2 } from "lucide-react";
export type DashboardLoaderStage = "projects" | "project" | "tasks" | "ready";
interface DashboardLoaderProps {
stage: DashboardLoaderStage;
}
interface LoaderStep {
id: Exclude<DashboardLoaderStage, "ready">;
label: string;
}
const LOADER_STEPS: LoaderStep[] = [
{ id: "projects", label: "Loading projects" },
{ id: "project", label: "Selecting project" },
{ id: "tasks", label: "Fetching tasks" },
];
function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "done" | "active" | "pending" {
if (stage === "ready") {
return "done";
}
const currentStageIndex = LOADER_STEPS.findIndex((step) => step.id === stage);
const stepIndex = LOADER_STEPS.findIndex((step) => step.id === stepId);
if (stepIndex < currentStageIndex) {
return "done";
}
if (stepIndex === currentStageIndex) {
return "active";
}
return "pending";
}
export function DashboardLoader({ stage }: DashboardLoaderProps) {
return (
<div
className="dashboard-loader"
role="status"
aria-live="polite"
aria-label="Loading Fusion dashboard"
data-stage={stage}
>
<div className="dashboard-loader__content">
<h1 className="dashboard-loader__logo">Fusion</h1>
<p className="dashboard-loader__message">Initializing dashboard...</p>
<ol className="dashboard-loader__steps" aria-label="Dashboard loading progress">
{LOADER_STEPS.map((step) => {
const stepState = getStepState(step.id, stage);
return (
<li
key={step.id}
className={`dashboard-loader__step dashboard-loader__step--${stepState}`}
data-testid={`dashboard-loader-step-${step.id}`}
>
<span className="dashboard-loader__step-icon" aria-hidden="true">
{stepState === "done" ? (
"✓"
) : stepState === "active" ? (
<Loader2 className="dashboard-loader__spinner animate-spin" size={14} />
) : (
"•"
)}
</span>
<span className="dashboard-loader__step-label">{step.label}</span>
</li>
);
})}
</ol>
</div>
</div>
);
}

View File

@@ -1330,10 +1330,11 @@ describe("App footer-safe project layout", () => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wrapper should have project-content but NOT project-content--with-footer
const wrapper = document.querySelector(".project-content");
expect(wrapper).toBeTruthy();
expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false);
await waitFor(() => {
const wrapper = document.querySelector(".project-content");
expect(wrapper).toBeTruthy();
expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false);
});
});
it("adds and removes footer class when switching between project and overview", async () => {

View File

@@ -2241,6 +2241,93 @@ body {
.project-card--skeleton:nth-child(5) .project-skeleton--title { animation-delay: 0.4s; }
.project-card--skeleton:nth-child(6) .project-skeleton--title { animation-delay: 0.5s; }
/* --- Dashboard Loader --- */
@keyframes dashboard-loader-pulse {
0%,
100% {
opacity: 0.75;
}
50% {
opacity: 1;
}
}
.dashboard-loader {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl);
background: var(--bg);
}
.dashboard-loader__content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
min-width: min(100%, 320px);
}
.dashboard-loader__logo {
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--text);
animation: dashboard-loader-pulse 1.8s ease-in-out infinite;
}
.dashboard-loader__message {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
}
.dashboard-loader__steps {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.dashboard-loader__step {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 14px;
line-height: 1.4;
}
.dashboard-loader__step-icon {
display: inline-flex;
width: 16px;
min-width: 16px;
justify-content: center;
}
.dashboard-loader__step--done {
color: var(--success, #22c55e);
}
.dashboard-loader__step--active {
color: var(--text);
}
.dashboard-loader__step--pending {
color: var(--text-secondary);
opacity: 0.5;
}
.dashboard-loader__spinner {
display: block;
}
/* === ActivityFeed Component === */
.activity-feed {
display: flex;