feat(FN-4779): merge fusion/fn-4779
This commit is contained in:
5
.changeset/FN-4779-dashboard-boot-gate.md
Normal file
5
.changeset/FN-4779-dashboard-boot-gate.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Dashboard reload no longer briefly hides the UI behind a full-screen loader when project data is already cached.
|
||||||
@@ -18,6 +18,7 @@ import { PageErrorBoundary } from "./components/ErrorBoundary";
|
|||||||
import { AppModals } from "./components/AppModals";
|
import { AppModals } from "./components/AppModals";
|
||||||
import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorPage";
|
import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorPage";
|
||||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||||
|
import { TopProgressBar } from "./components/TopProgressBar";
|
||||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||||
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
||||||
@@ -90,7 +91,6 @@ import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
|
|||||||
import "./components/ChatView.css";
|
import "./components/ChatView.css";
|
||||||
|
|
||||||
const IS_TEST_ENV = import.meta.env.MODE === "test";
|
const IS_TEST_ENV = import.meta.env.MODE === "test";
|
||||||
const DASHBOARD_READY_SETTLE_DELAY_MS = IS_TEST_ENV ? 0 : 200;
|
|
||||||
|
|
||||||
const AgentsView = lazy(() => import("./components/AgentsView").then((m) => ({ default: m.AgentsView })));
|
const AgentsView = lazy(() => import("./components/AgentsView").then((m) => ({ default: m.AgentsView })));
|
||||||
const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView })));
|
const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView })));
|
||||||
@@ -222,6 +222,10 @@ export function requiresNativeShellOnboarding(
|
|||||||
return !shellState.activeProfileId;
|
return !shellState.activeProfileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectCount: number): boolean {
|
||||||
|
return projectsLoading && projectCount === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const { toasts, addToast, removeToast } = useToast();
|
const { toasts, addToast, removeToast } = useToast();
|
||||||
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
|
const { shellApi, state: shellState, ready: shellReady, openConnectionManagerSignal } = useShellConnection();
|
||||||
@@ -404,11 +408,11 @@ function AppInner() {
|
|||||||
|
|
||||||
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
|
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
|
||||||
|
|
||||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
|
||||||
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
|
const [researchReadinessVersion, setResearchReadinessVersion] = useState(0);
|
||||||
const mountTimeRef = useRef(performance.now());
|
const mountTimeRef = useRef(performance.now());
|
||||||
const projectsReadyLoggedRef = useRef(false);
|
const projectsReadyLoggedRef = useRef(false);
|
||||||
const projectReadyLoggedRef = useRef(false);
|
const projectReadyLoggedRef = useRef(false);
|
||||||
|
const dashboardReadyLoggedRef = useRef(false);
|
||||||
|
|
||||||
const loadingStage = useMemo<DashboardLoaderStage>(() => {
|
const loadingStage = useMemo<DashboardLoaderStage>(() => {
|
||||||
if (projectsLoading) return "projects";
|
if (projectsLoading) return "projects";
|
||||||
@@ -433,28 +437,19 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
}, [projectsLoading, currentProjectLoading]);
|
}, [projectsLoading, currentProjectLoading]);
|
||||||
|
|
||||||
|
const initialLoadComplete = !projectsLoading && !currentProjectLoading;
|
||||||
|
const isFirstEverBoot = shouldShowFirstEverBootLoader(projectsLoading, projects.length);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialLoadComplete) {
|
if (!initialLoadComplete || dashboardReadyLoggedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dashboardReadyLoggedRef.current = true;
|
||||||
if (projectsLoading || currentProjectLoading) {
|
const msg = `dashboard ready at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount`;
|
||||||
return;
|
if (!IS_TEST_ENV) {
|
||||||
|
console.log(`[App] ${msg}`);
|
||||||
}
|
}
|
||||||
|
}, [initialLoadComplete]);
|
||||||
const settleStart = performance.now();
|
|
||||||
const settleTimer = window.setTimeout(() => {
|
|
||||||
const msg = `dashboard ready at ${Math.round(performance.now() - mountTimeRef.current)}ms from mount (settle delay=${Math.round(performance.now() - settleStart)}ms)`;
|
|
||||||
if (!IS_TEST_ENV) {
|
|
||||||
console.log(`[App] ${msg}`);
|
|
||||||
}
|
|
||||||
setInitialLoadComplete(true);
|
|
||||||
}, DASHBOARD_READY_SETTLE_DELAY_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(settleTimer);
|
|
||||||
};
|
|
||||||
}, [initialLoadComplete, projectsLoading, currentProjectLoading]);
|
|
||||||
|
|
||||||
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||||
// Keyboard visibility controls both MobileNavBar rendering and whether
|
// Keyboard visibility controls both MobileNavBar rendering and whether
|
||||||
@@ -1652,13 +1647,14 @@ function AppInner() {
|
|||||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
||||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||||||
{!initialLoadComplete ? (
|
{isFirstEverBoot ? (
|
||||||
<>
|
<>
|
||||||
<DashboardLoader stage={loadingStage} />
|
<DashboardLoader stage={loadingStage} />
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<TopProgressBar visible={projectsLoading || currentProjectLoading} />
|
||||||
<Header
|
<Header
|
||||||
shellHost={shellHost.host}
|
shellHost={shellHost.host}
|
||||||
onOpenSettings={openSettingsWithNav}
|
onOpenSettings={openSettingsWithNav}
|
||||||
|
|||||||
10
packages/dashboard/app/__tests__/App.boot-gate.test.tsx
Normal file
10
packages/dashboard/app/__tests__/App.boot-gate.test.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldShowFirstEverBootLoader } from "../App";
|
||||||
|
|
||||||
|
describe("App boot gate", () => {
|
||||||
|
it("shows loader only when projects are loading and no projects are present", () => {
|
||||||
|
expect(shouldShowFirstEverBootLoader(true, 0)).toBe(true);
|
||||||
|
expect(shouldShowFirstEverBootLoader(false, 1)).toBe(false);
|
||||||
|
expect(shouldShowFirstEverBootLoader(true, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
60
packages/dashboard/app/components/TopProgressBar.css
Normal file
60
packages/dashboard/app/components/TopProgressBar.css
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
.top-progress-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
z-index: 101;
|
||||||
|
background: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-progress-bar[data-visible="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-progress-bar__indicator {
|
||||||
|
width: 40%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: top-progress-slide 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes top-progress-slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(250%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.top-progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.top-progress-bar__indicator {
|
||||||
|
width: 100%;
|
||||||
|
animation: top-progress-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes top-progress-pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/dashboard/app/components/TopProgressBar.tsx
Normal file
19
packages/dashboard/app/components/TopProgressBar.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import "./TopProgressBar.css";
|
||||||
|
|
||||||
|
interface TopProgressBarProps {
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopProgressBar({ visible }: TopProgressBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="top-progress-bar"
|
||||||
|
data-visible={visible ? "true" : "false"}
|
||||||
|
role="progressbar"
|
||||||
|
aria-busy={visible}
|
||||||
|
aria-label="Loading"
|
||||||
|
>
|
||||||
|
<div className="top-progress-bar__indicator" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -693,6 +693,19 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FN-4250 FileBrowserProvider coverage", () => {
|
describe("FN-4250 FileBrowserProvider coverage", () => {
|
||||||
|
it("FN-4779: renders app shell immediately when project data is ready", () => {
|
||||||
|
mockProjectsState.loading = false;
|
||||||
|
mockProjectsState.projects = [
|
||||||
|
{ id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" },
|
||||||
|
];
|
||||||
|
mockCurrentProjectState.loading = false;
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(screen.getByTitle("Settings")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("fb-probe-loader")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("FN-4250: ChatView branch is inside FileBrowserProvider", async () => {
|
it("FN-4250: ChatView branch is inside FileBrowserProvider", async () => {
|
||||||
localStorage.setItem(taskViewStorageKey(), "chat");
|
localStorage.setItem(taskViewStorageKey(), "chat");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
const mockConsumeVersionUpdateFlag = vi.fn(() => false);
|
||||||
|
|
||||||
|
vi.mock("../../versionCheck", () => ({
|
||||||
|
consumeVersionUpdateFlag: () => mockConsumeVersionUpdateFlag(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { DashboardLoader } from "../DashboardLoader";
|
import { DashboardLoader } from "../DashboardLoader";
|
||||||
|
|
||||||
function getStep(label: string): HTMLElement {
|
function getStep(label: string): HTMLElement {
|
||||||
@@ -11,6 +18,11 @@ function getStep(label: string): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("DashboardLoader", () => {
|
describe("DashboardLoader", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConsumeVersionUpdateFlag.mockReset();
|
||||||
|
mockConsumeVersionUpdateFlag.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders projects stage with active first step and pending remaining steps", () => {
|
it("renders projects stage with active first step and pending remaining steps", () => {
|
||||||
render(<DashboardLoader stage="projects" />);
|
render(<DashboardLoader stage="projects" />);
|
||||||
|
|
||||||
@@ -45,6 +57,14 @@ describe("DashboardLoader", () => {
|
|||||||
expect(screen.getByLabelText("Dashboard loading progress")).toBeInTheDocument();
|
expect(screen.getByLabelText("Dashboard loading progress")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the version update copy when the flag is consumed", () => {
|
||||||
|
mockConsumeVersionUpdateFlag.mockReturnValue(true);
|
||||||
|
render(<DashboardLoader stage="projects" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Updating to a new frontend version...")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status", { name: "Updating Fusion dashboard" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps stage visuals stable across all stages", () => {
|
it("keeps stage visuals stable across all stages", () => {
|
||||||
const stages = ["projects", "project", "tasks", "ready"] as const;
|
const stages = ["projects", "project", "tasks", "ready"] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { TopProgressBar } from "../TopProgressBar";
|
||||||
|
|
||||||
|
describe("TopProgressBar", () => {
|
||||||
|
it("renders visible progressbar semantics", () => {
|
||||||
|
render(<TopProgressBar visible={true} />);
|
||||||
|
|
||||||
|
const progressBar = screen.getByRole("progressbar", { name: "Loading" });
|
||||||
|
expect(progressBar).toHaveAttribute("aria-busy", "true");
|
||||||
|
expect(progressBar).toHaveAttribute("data-visible", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays mounted but hidden when not visible", () => {
|
||||||
|
render(<TopProgressBar visible={false} />);
|
||||||
|
|
||||||
|
const progressBar = screen.getByRole("progressbar", { name: "Loading" });
|
||||||
|
expect(progressBar).toHaveAttribute("aria-busy", "false");
|
||||||
|
expect(progressBar).toHaveAttribute("data-visible", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user