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.
This commit is contained in:
Mohamed Boudra
2026-11-05 06:57:16 +01:00
parent b920243c74
commit a0a7620b62
9 changed files with 226 additions and 5 deletions
@@ -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);
@@ -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<void> {
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<FirstLaunchRecord> {
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");
}
+32 -3
View File
@@ -46,7 +46,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";
@@ -151,6 +151,7 @@ export interface HostRuntimeBootstrapState {
splashError: string | null;
retry: () => void;
hasGivenUpWaitingForHost: boolean;
isWaitingForSetup: boolean;
storeReady: boolean;
startupBlocker: StartupBlocker;
}
@@ -159,6 +160,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
splashError: null,
retry: () => {},
hasGivenUpWaitingForHost: false,
isWaitingForSetup: false,
storeReady: false,
startupBlocker: { kind: "none" },
});
@@ -255,6 +257,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<boolean> {
if (!shouldUseDesktopDaemon()) {
@@ -316,6 +321,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 });
@@ -326,8 +348,15 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const storeReady = resolveStartupNavigationReady({ startupBlocker });
const state = useMemo<HostRuntimeBootstrapState>(
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
() => ({
splashError,
retry,
hasGivenUpWaitingForHost,
isWaitingForSetup,
storeReady,
startupBlocker,
}),
[splashError, retry, hasGivenUpWaitingForHost, isWaitingForSetup, storeReady, startupBlocker],
);
return (
+1
View File
@@ -45,6 +45,7 @@ export default function Index() {
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
isWaitingForSetup: bootstrapState.isWaitingForSetup,
});
if (startupRoute.kind === "redirect") {
@@ -232,6 +232,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) {
@@ -671,6 +685,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",
@@ -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({
@@ -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) };
}
+4 -2
View File
@@ -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";
@@ -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";
}