From 72d7c0bf262dde715045dbfe2382271313842060 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Nov 2026 06:57:16 +0100 Subject: [PATCH] fix(app): hold the loading screen until first-run setup is decided A new user saw the empty home, the sidebar and a logo jump before setup popped in. Startup now keeps the splash while setup may still open over the empty home, for at most 3 s and only while the setup host is reachable. Remembered-workspace restore does not wait. Web dialogs now fade in as they already fade out. (cherry picked from commit a0a7620b6289808847ca55f4c2f053acee287467) --- packages/app/e2e/browser/onboarding.spec.ts | 69 +++++++++++++++++++ .../app/e2e/support/helpers/onboarding.ts | 48 +++++++++++++ packages/app/src/app/_layout.tsx | 35 +++++++++- packages/app/src/app/index.tsx | 1 + .../src/components/adaptive-modal-sheet.tsx | 17 +++++ .../navigation/host-runtime-bootstrap.test.ts | 23 +++++++ .../src/navigation/host-runtime-bootstrap.ts | 8 +++ packages/app/src/onboarding/index.ts | 6 +- .../onboarding/use-setup-decision-pending.ts | 24 +++++++ 9 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/onboarding/use-setup-decision-pending.ts diff --git a/packages/app/e2e/browser/onboarding.spec.ts b/packages/app/e2e/browser/onboarding.spec.ts index 68d54687a9..8d3fd15217 100644 --- a/packages/app/e2e/browser/onboarding.spec.ts +++ b/packages/app/e2e/browser/onboarding.spec.ts @@ -12,11 +12,15 @@ import { openAppAsNewUser, openRelayPairingFromSetup, closeRelayPairing, + emptyHome, + loadingScreen, openTailscaleDocsFromSetup, paseoToolsSwitch, pickTheme, projectSuggestion, projectSuggestionRow, + readFirstLaunch, + recordFirstLaunch, setupModal, setupPrimaryButton, showSetupAgainFromSettings, @@ -99,6 +103,71 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => { } }); +test("a new user goes from the loading screen straight into setup", async ({ page }) => { + const host = await startSetupHost("first-launch"); + + try { + await recordFirstLaunch(page); + await openAppAsNewUser(page, host); + await expectSetupStep(page, "Welcome to Paseo"); + + const launch = await readFirstLaunch(page); + expect(launch.appBeforeSetup).toBe(false); + expect(launch.setupOpacityOnArrival).toBeLessThan(1); + await expect(page.getByTestId("onboarding-modal")).toHaveCSS("opacity", "1"); + } finally { + await host.close(); + } +}); + +test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => { + const host = await startSetupHost("slow-workspaces"); + const gate = await installDaemonWebSocketGate(page, { port: host.port }); + + try { + await recordFirstLaunch(page); + gate.holdNextClientRequest("fetch_workspaces_request"); + await openAppAsNewUser(page, host); + await gate.waitForHeldClientRequest(); + await expect(loadingScreen(page)).toBeVisible(); + await expect(emptyHome(page)).toHaveCount(0); + + gate.releaseHeldClientRequest(); + await expectSetupStep(page, "Welcome to Paseo"); + expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false); + } finally { + await host.close(); + } +}); + +test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => { + const host = await startSetupHost("stuck-workspaces"); + const gate = await installDaemonWebSocketGate(page, { port: host.port }); + + try { + gate.holdNextClientRequest("fetch_workspaces_request"); + await openAppAsNewUser(page, host); + await gate.waitForHeldClientRequest(); + await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 }); + await expect(setupModal(page)).toHaveCount(0); + + // Setup still opens once the host answers. + gate.releaseHeldClientRequest(); + await expectSetupStep(page, "Welcome to Paseo"); + } finally { + await host.close(); + } +}); + +test("an unreachable setup host lands on the home without setup", async ({ page }) => { + const host = await startSetupHost("unreachable"); + await host.close(); + + await openAppAsNewUser(page, host); + await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 }); + await expect(setupModal(page)).toHaveCount(0); +}); + test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => { const repo = await createTempGitRepo("onboarding-history-"); const repoName = path.basename(repo.path); diff --git a/packages/app/e2e/support/helpers/onboarding.ts b/packages/app/e2e/support/helpers/onboarding.ts index 6f1d4c53b0..eeda0113d3 100644 --- a/packages/app/e2e/support/helpers/onboarding.ts +++ b/packages/app/e2e/support/helpers/onboarding.ts @@ -34,6 +34,54 @@ export async function openAppAsNewUser( await page.goto("/"); } +interface FirstLaunchRecord { + appBeforeSetup: boolean; + setupOpacityOnArrival: number | null; +} + +/** + * Watches the page from its first paint: whether the app's sidebar or home showed before setup, + * and how opaque setup was when it arrived. Call before `openAppAsNewUser`. + */ +export async function recordFirstLaunch(page: Page): Promise { + await page.addInitScript(() => { + const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null }; + Object.assign(window, { __firstLaunch: record }); + const observer = new MutationObserver(() => { + const setup = document.querySelector('[data-testid="onboarding-modal"]'); + if (setup) { + record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity); + observer.disconnect(); + return; + } + if ( + document.querySelector( + '[data-testid="open-project-submit"], [data-testid="sidebar-settings"]', + ) + ) { + record.appBeforeSetup = true; + } + }); + observer.observe(document, { childList: true, subtree: true }); + }); +} + +export async function readFirstLaunch(page: Page): Promise { + return page.evaluate( + () => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch, + ); +} + +/** The loading screen the app shows while it starts. */ +export function loadingScreen(page: Page): Locator { + return page.getByTestId("startup-splash"); +} + +/** The empty home a host with no projects lands on. */ +export function emptyHome(page: Page): Locator { + return page.getByTestId("open-project-submit"); +} + export function setupModal(page: Page): Locator { return page.getByTestId("onboarding-modal").getByRole("dialog"); } diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 1b260368d8..996cbed429 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -35,7 +35,7 @@ import { DownloadToast } from "@/components/download-toast"; import { QuittingOverlay } from "@/components/quitting-overlay"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { ChangelogHost } from "@/changelog"; -import { OnboardingHost } from "@/onboarding"; +import { OnboardingHost, useIsSetupDecisionPending } from "@/onboarding"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; @@ -138,6 +138,7 @@ export interface HostRuntimeBootstrapState { splashError: string | null; retry: () => void; hasGivenUpWaitingForHost: boolean; + isWaitingForSetup: boolean; storeReady: boolean; startupBlocker: StartupBlocker; } @@ -146,6 +147,7 @@ const HostRuntimeBootstrapContext = createContext({ splashError: null, retry: () => {}, hasGivenUpWaitingForHost: false, + isWaitingForSetup: false, storeReady: false, startupBlocker: { kind: "none" }, }); @@ -242,6 +244,9 @@ function useDaemonStartIsRunning(): boolean { } const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000; +// How long the splash waits on top of host startup to learn whether setup opens. A slow host +// lands on the home instead, and setup opens over it once it is decided. +const STARTUP_SETUP_WAIT_TIMEOUT_MS = 3_000; async function shouldStartBuiltInDaemon(): Promise { if (!shouldUseDesktopDaemon()) { @@ -303,6 +308,23 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { }; }, [shouldRunGiveUpTimer]); + const isSetupDecisionPending = useIsSetupDecisionPending(); + const [hasGivenUpWaitingForSetup, setHasGivenUpWaitingForSetup] = useState(false); + const isWaitingForSetup = isSetupDecisionPending && !hasGivenUpWaitingForSetup; + const shouldRunSetupWaitTimer = isWaitingForSetup && startupBlocker.kind === "none"; + + useEffect(() => { + if (!shouldRunSetupWaitTimer) { + return; + } + const handle = setTimeout(() => { + setHasGivenUpWaitingForSetup(true); + }, STARTUP_SETUP_WAIT_TIMEOUT_MS); + return () => { + clearTimeout(handle); + }; + }, [shouldRunSetupWaitTimer]); + const retry = useCallback(() => { const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon }); @@ -313,8 +335,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { const storeReady = resolveStartupNavigationReady({ startupBlocker }); const state = useMemo( - () => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }), - [splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker], + () => ({ + splashError, + retry, + hasGivenUpWaitingForHost, + isWaitingForSetup, + storeReady, + startupBlocker, + }), + [splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker], ); return ( diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index e0649f65a1..e3e810895d 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -45,6 +45,7 @@ export default function Index() { }), isWorkspaceSelectionLoaded, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, + isWaitingForSetup: bootstrapState.isWaitingForSetup, }); if (startupRoute.kind === "redirect") { diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 33e61f6ec6..06529b4063 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -236,6 +236,20 @@ const styles = StyleSheet.create((theme) => ({ })); const WEB_EXIT_DURATION_MS = 160; +const WEB_ENTER_DURATION_MS = 160; +const WEB_ENTER_ANIMATION_NAME = "paseo-modal-enter"; + +// The desktop overlay fades in as it fades out. A CSS animation runs from the overlay's first +// paint, so a dialog that opens on its own, like first-run setup, never pops in. +function ensureWebEnterKeyframes() { + if (!isWeb || typeof document === "undefined") return; + if (document.getElementById(WEB_ENTER_ANIMATION_NAME)) return; + const style = document.createElement("style"); + style.id = WEB_ENTER_ANIMATION_NAME; + style.textContent = `@keyframes ${WEB_ENTER_ANIMATION_NAME} { from { opacity: 0; } to { opacity: 1; } }`; + document.head.appendChild(style); +} +ensureWebEnterKeyframes(); /** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */ export function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -675,6 +689,9 @@ export function AdaptiveModalSheet({ isWeb && { zIndex: modalLayer, opacity: isWebClosing ? 0 : 1, + animationName: WEB_ENTER_ANIMATION_NAME, + animationDuration: `${WEB_ENTER_DURATION_MS}ms`, + animationTimingFunction: "ease-out", transitionDuration: `${WEB_EXIT_DURATION_MS}ms`, transitionProperty: "opacity", transitionTimingFunction: "ease", diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index 6e1e130090..fe7687886c 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -177,6 +177,7 @@ describe("resolveStartupRoute", () => { workspaceSelectionStatus: "unknown" as const, isWorkspaceSelectionLoaded: true, hasGivenUpWaitingForHost: false, + isWaitingForSetup: false, }; const baseHostInput = { route: { kind: "host" as const, serverId: "server-saved" }, @@ -278,6 +279,28 @@ describe("resolveStartupRoute", () => { ).toEqual({ kind: "redirect", href: "/h/srv-desktop" }); }); + it("holds the splash over the empty home while setup may still open", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "srv-desktop" }], + anyOnlineHostServerId: "srv-desktop", + isWaitingForSetup: true, + }), + ).toEqual({ kind: "splash" }); + }); + + it("restores a saved workspace without waiting for setup", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "server-1" }], + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + isWaitingForSetup: true, + }), + ).toEqual({ kind: "redirect", href: "/h/server-1" }); + }); + it("keeps a known connecting host in app-owned routing instead of showing welcome", () => { expect( resolveStartupRoute({ diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 8ee1f7bfb2..c78b40c1cc 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -117,6 +117,8 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn workspaceSelectionStatus: WorkspaceSelectionStatus; isWorkspaceSelectionLoaded: boolean; hasGivenUpWaitingForHost: boolean; + /** Setup may still open over the empty home; see `useIsSetupDecisionPending`. */ + isWaitingForSetup: boolean; } export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput { @@ -198,6 +200,12 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St }; } + // Without a workspace to restore, startup lands on the empty home. A new user's setup opens + // over it, so hold the splash until that is known instead of flashing the home first. + if (input.isWaitingForSetup) { + return { kind: "splash" }; + } + if (input.anyOnlineHostServerId) { return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) }; } diff --git a/packages/app/src/onboarding/index.ts b/packages/app/src/onboarding/index.ts index 68c80d8f0d..78d3da6016 100644 --- a/packages/app/src/onboarding/index.ts +++ b/packages/app/src/onboarding/index.ts @@ -2,8 +2,10 @@ * First-run setup: a one-time modal that walks a new user through the surfaces Settings * already has — appearance, providers, projects, Paseo tools and phone pairing. * - * The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has, - * and how completion is remembered stay inside; import from `@/onboarding` only. + * The app shell mounts the host, startup waits for its decision, and Settings reopens it. Whether + * it opens, which steps it has, and how completion is remembered stay inside; import from + * `@/onboarding` only. */ export { OnboardingHost } from "./onboarding-host"; export { useOpenOnboarding } from "./use-open-onboarding"; +export { useIsSetupDecisionPending } from "./use-setup-decision-pending"; diff --git a/packages/app/src/onboarding/use-setup-decision-pending.ts b/packages/app/src/onboarding/use-setup-decision-pending.ts new file mode 100644 index 0000000000..5918833ce6 --- /dev/null +++ b/packages/app/src/onboarding/use-setup-decision-pending.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; +import { isNative } from "@/constants/platform"; +import { useHostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import { getOnboardingModel } from "./internal/onboarding-instance"; +import { useSetupHostServerId } from "./internal/use-setup-host"; + +/** + * True while this launch may still open setup: it has not been decided, and the setup host is + * connecting, or connected and loading its workspaces. Startup keeps its loading screen up + * meanwhile, so a new user lands on setup rather than on the empty home first. False on phones, + * where setup never opens, when there is no setup host, and while the host is unreachable. + */ +export const useIsSetupDecisionPending: () => boolean = isNative + ? () => false + : useIsSetupDecisionPendingOnDesktop; + +function useIsSetupDecisionPendingOnDesktop(): boolean { + const model = getOnboardingModel(); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const serverId = useSetupHostServerId(); + const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? ""); + if (state.status !== "deciding" || !serverId) return false; + return connectionStatus !== "offline" && connectionStatus !== "error"; +}