diff --git a/src/browser/components/Dialog/Dialog.tsx b/src/browser/components/Dialog/Dialog.tsx index 393e6edba8e..b49763a2f7d 100644 --- a/src/browser/components/Dialog/Dialog.tsx +++ b/src/browser/components/Dialog/Dialog.tsx @@ -42,6 +42,8 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +const NO_ANIMATION: React.CSSProperties = { animation: "none" }; + const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { @@ -56,6 +58,8 @@ const DialogContent = React.forwardRef< * cancel an inline edit) instead of dismissing the dialog. */ allowEditableEscape?: boolean; + /** Open without the entry animation, when the same frame is already on screen. */ + skipEntryAnimation?: boolean; } >( ( @@ -66,6 +70,7 @@ const DialogContent = React.forwardRef< maxWidth, maxHeight, allowEditableEscape = false, + skipEntryAnimation = false, style, onEscapeKeyDown, ...props @@ -73,7 +78,7 @@ const DialogContent = React.forwardRef< ref ) => ( - + { @@ -101,7 +106,12 @@ const DialogContent = React.forwardRef< maxHeight && "overflow-y-auto", className )} - style={{ ...(maxWidth && { maxWidth }), ...(maxHeight && { maxHeight }), ...style }} + style={{ + ...(maxWidth && { maxWidth }), + ...(maxHeight && { maxHeight }), + ...(skipEntryAnimation && NO_ANIMATION), + ...style, + }} {...props} > {children} diff --git a/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx b/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx index b923e607055..35dc6ffebe6 100644 --- a/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx +++ b/src/browser/features/SplashScreens/OnboardingWizardSplash.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, Bot, @@ -9,7 +9,7 @@ import { Sparkles, } from "lucide-react"; import { SplashScreen } from "./SplashScreen"; -import { useOnboardingPause } from "./SplashScreenProvider"; +import { hasFirstRunView, removeFirstRunView, useOnboardingPause } from "./SplashScreenProvider"; import { DocsLink } from "@/browser/components/DocsLink/DocsLink"; import { ProviderWithIcon } from "@/browser/components/ProviderIcon/ProviderIcon"; import { @@ -193,6 +193,35 @@ function CommandPalettePreview(props: { shortcut: string }) { ); } +/** + * T3 #5971, C step 2: the server's static first-run view (#xum-pre) already shows the gateway step. + * The live wizard renders nothing while it loads under that view, then swaps it out in its first + * real commit (a layout effect runs before paint). The dialog and overlay never play their entry + * animation in this mount: they stay open, so clearing the override later would replay it. The + * step skips its entry animation only until the user moves on. A wizard that starts past the + * gateway step replaces the view too. + */ +function useFirstRunViewHandoff(isLoading: boolean) { + const [startsUnderFirstRunView] = useState(hasFirstRunView); + const [skipStepEntry, setSkipStepEntry] = useState(startsUnderFirstRunView); + useLayoutEffect(() => { + if (!isLoading) removeFirstRunView(); + }, [isLoading]); + return { + hideWhileLoading: isLoading && startsUnderFirstRunView, + skipDialogEntry: startsUnderFirstRunView, + skipStepEntry, + stopSkipping: () => setSkipStepEntry(false), + }; +} + +function stepEntryClassName(direction: Direction, skipEntryAnimation: boolean): string { + if (skipEntryAnimation) return ""; + return direction === "forward" + ? "animate-in fade-in-0 slide-in-from-right-2" + : "animate-in fade-in-0 slide-in-from-left-2"; +} + export function OnboardingWizardSplash(props: { onDismiss: () => void }) { const onboardingPause = useOnboardingPause(); const [stepIndex, setStepIndex] = useState(onboardingPause.paused?.stepIndex ?? 0); @@ -1043,6 +1072,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { const totalSteps = steps.length; const currentStep = steps[stepIndex] ?? steps[0]; + const firstRunView = useFirstRunViewHandoff(isLoading); useEffect(() => { if (currentStep?.key !== "mux-gateway" && muxGatewayLoginInProgress) { @@ -1050,7 +1080,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { } }, [cancelMuxGatewayLogin, currentStep?.key, muxGatewayLoginInProgress]); - if (!currentStep) { + if (!currentStep || firstRunView.hideWhileLoading) { return null; } @@ -1062,6 +1092,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { return; } setDirection("back"); + firstRunView.stopSkipping(); // Changing steps remounts the project form without its inline error, so // clear the stale flag or the "click Next" nudge stays hidden on return. setProjectFormHasError(false); @@ -1073,6 +1104,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { return; } setDirection("forward"); + firstRunView.stopSkipping(); setProjectFormHasError(false); setStepIndex((i) => Math.min(totalSteps - 1, i + 1)); }; @@ -1091,6 +1123,7 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) { props.onDismiss(); }} dismissLabel={null} + skipEntryAnimation={firstRunView.skipDialogEntry} footerClassName="justify-between" footer={ <> @@ -1152,11 +1185,8 @@ export function OnboardingWizardSplash(props: { onDismiss: () => void }) {
diff --git a/src/browser/features/SplashScreens/SplashScreen.tsx b/src/browser/features/SplashScreens/SplashScreen.tsx index 0ba4827a4d7..e11649200a3 100644 --- a/src/browser/features/SplashScreens/SplashScreen.tsx +++ b/src/browser/features/SplashScreens/SplashScreen.tsx @@ -25,6 +25,8 @@ interface SplashScreenProps { footer?: React.ReactNode; /** Optional class name for the DialogFooter container. */ footerClassName?: string; + /** Open without the dialog's entry animation (the static first-run view shows this frame). */ + skipEntryAnimation?: boolean; } export function SplashScreen(props: SplashScreenProps) { @@ -47,6 +49,7 @@ export function SplashScreen(props: SplashScreenProps) { e.preventDefault()} > diff --git a/src/browser/features/SplashScreens/SplashScreenProvider.tsx b/src/browser/features/SplashScreens/SplashScreenProvider.tsx index 29aa42e16ad..9f639cd71e6 100644 --- a/src/browser/features/SplashScreens/SplashScreenProvider.tsx +++ b/src/browser/features/SplashScreens/SplashScreenProvider.tsx @@ -21,6 +21,11 @@ import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView"; import { ONBOARDING_VIEWED_KEY } from "@/common/constants/storage"; +/** Whether the server's static first-run view is on screen (T3 #5971, C step 2). */ +export function hasFirstRunView(): boolean { + return document.getElementById(FIRST_RUN_VIEW_ID) != null; +} + /** Removes the static first-run view. Callers do it whenever the live wizard will not replace it. */ export function removeFirstRunView(): void { document.getElementById(FIRST_RUN_VIEW_ID)?.remove(); diff --git a/tests/ui/onboarding/wizardHandoff.test.ts b/tests/ui/onboarding/wizardHandoff.test.ts new file mode 100644 index 00000000000..cfe01647b81 --- /dev/null +++ b/tests/ui/onboarding/wizardHandoff.test.ts @@ -0,0 +1,155 @@ +/** + * The onboarding wizard and the static first-run view (T3 #5971, C step 2, plan section 5.6). + * The server will put a static copy of the wizard's gateway step in #xum-pre before the app JS runs. + * The live wizard takes over in one commit. Without #xum-pre, the wizard must render exactly as before. + */ +import "../dom"; +import React from "react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; + +let mockProviders: { config: Record | null; loading: boolean }; + +jest.mock("@/browser/hooks/useProvidersConfig", () => ({ + useProvidersConfig: () => ({ + ...mockProviders, + refresh: jest.fn(), + updateOptimistically: jest.fn(), + }), +})); +jest.mock("@/browser/features/SplashScreens/SplashScreenProvider", () => ({ + ...jest.requireActual( + "@/browser/features/SplashScreens/SplashScreenProvider" + ), + useOnboardingPause: () => ({ paused: null, pause: jest.fn(), resume: jest.fn() }), +})); +jest.mock("@/browser/contexts/SettingsContext", () => ({ + useSettings: () => ({ open: jest.fn(), registerOnClose: () => () => undefined }), +})); +jest.mock("@/browser/contexts/ProjectContext", () => ({ + useProjectContext: () => ({ addProject: jest.fn(), userProjects: new Map() }), +})); +// useLiveAPI is main's live-client hook (#6323): with no API it is null, like useAPI's api. +jest.mock("@/browser/contexts/API", () => ({ + useAPI: () => ({ api: null }), + useLiveAPI: () => null, +})); +jest.mock("@/browser/hooks/useRouting", () => ({ + useRouting: () => ({ navigateToProject: jest.fn() }), +})); +jest.mock("@/browser/hooks/useMuxGatewayAccountStatus", () => ({ + ...jest.requireActual( + "@/browser/hooks/useMuxGatewayAccountStatus" + ), + useMuxGatewayAccountStatus: () => ({ + data: null, + error: null, + isLoading: false, + refresh: jest.fn(), + }), +})); + +import { OnboardingWizardSplash } from "@/browser/features/SplashScreens/OnboardingWizardSplash"; + +// Steps are found by their key (data-onboarding-step), so rewording the copy does not break these tests. +const LOADING_STEP = "loading"; +const GATEWAY_STEP = "mux-gateway"; + +const dialog = () => document.body.querySelector("[role=dialog]"); +const overlay = () => document.body.querySelector("[data-modal-dialog-overlay]"); +const step = () => document.body.querySelector("[data-onboarding-step]"); +const shows = (key: string) => step()?.dataset.onboardingStep === key; +const animatesIn = () => + step()?.className.includes("animate-in fade-in-0 slide-in-from-right-2") === true; + +function renderWizard() { + return render(React.createElement(OnboardingWizardSplash, { onDismiss: jest.fn() })); +} + +afterEach(() => { + cleanup(); + document.body.innerHTML = ""; +}); + +describe("the onboarding wizard without a static first-run view", () => { + test("shows its loading step, then the gateway step, with the dialog's entry animation", () => { + mockProviders = { config: null, loading: true }; + const view = renderWizard(); + expect(shows(LOADING_STEP)).toBe(true); + expect(dialog()?.className).toContain("data-[state=open]:zoom-in-95"); + expect(overlay()?.className).toContain("data-[state=open]:fade-in-0"); + expect(dialog()?.style.animation).toBe(""); + expect(overlay()?.style.animation).toBe(""); + + mockProviders = { config: {}, loading: false }; + act(() => view.rerender(React.createElement(OnboardingWizardSplash, { onDismiss: jest.fn() }))); + expect(shows(LOADING_STEP)).toBe(false); + expect(shows(GATEWAY_STEP)).toBe(true); + expect(animatesIn()).toBe(true); + expect(dialog()?.style.animation).toBe(""); + }); + + test("animates each step it moves to", () => { + mockProviders = { config: {}, loading: false }; + renderWizard(); + fireEvent.click( + Array.from(dialog()!.querySelectorAll("button")).find((b) => b.textContent === "Next")! + ); + expect(shows(GATEWAY_STEP)).toBe(false); + expect(animatesIn()).toBe(true); + }); +}); + +describe("the onboarding wizard under the static first-run view (#xum-pre)", () => { + const firstRunView = () => document.getElementById("xum-pre"); + const clickNext = () => + fireEvent.click( + Array.from(dialog()!.querySelectorAll("button")).find((b) => b.textContent === "Next")! + ); + + beforeEach(() => { + const view = document.createElement("div"); + view.id = "xum-pre"; + document.body.append(view); + }); + + test("never paints its loading step, and no commit shows the gateway step and the static view together", () => { + // A Profiler's onRender runs in each commit of the wizard, after its layout effects and + // before passive effects: the point where the browser would paint. + const viewAtGatewayCommit: boolean[] = []; + const onRender = () => { + if (shows(GATEWAY_STEP)) viewAtGatewayCommit.push(firstRunView() != null); + }; + const tree = () => + React.createElement( + React.Profiler, + { id: "wizard", onRender }, + React.createElement(OnboardingWizardSplash, { onDismiss: jest.fn() }) + ); + mockProviders = { config: null, loading: true }; + const view = render(tree()); + expect(dialog()).toBeNull(); + expect(firstRunView()).not.toBeNull(); + + mockProviders = { config: {}, loading: false }; + act(() => view.rerender(tree())); + expect(viewAtGatewayCommit.length).toBeGreaterThan(0); + expect(viewAtGatewayCommit).not.toContain(true); + expect(firstRunView()).toBeNull(); + // The static view showed this frame, so nothing animates in, until the user moves on. + expect(dialog()?.style.animation).toBe("none"); + expect(overlay()?.style.animation).toBe("none"); + expect(animatesIn()).toBe(false); + clickNext(); + expect(animatesIn()).toBe(true); + // The dialog and overlay stay open, so dropping their override would replay their entry. + expect(dialog()?.style.animation).toBe("none"); + expect(overlay()?.style.animation).toBe("none"); + }); + + test("removes the static view when it opens past the gateway step", () => { + mockProviders = { config: { anthropic: { isConfigured: true } }, loading: false }; + renderWizard(); + expect(shows(GATEWAY_STEP)).toBe(false); + expect(firstRunView()).toBeNull(); + }); +});