Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/browser/features/SplashScreens/SplashScreenProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useState,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
type ReactNode,
} from "react";
Expand All @@ -16,13 +17,32 @@ import {
import { useAPI, type APIClient } from "@/browser/contexts/API";
import { isConnectionLostError } from "@/browser/contexts/connectionLink";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import { FIRST_RUN_VIEW_ID } from "@/common/constants/firstRunView";
import { ONBOARDING_VIEWED_KEY } from "@/common/constants/storage";

/** 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();
}

/** Sets the early script's paint cache once these splash ids include the onboarding wizard. */
function cacheViewedWizard(viewedIds: readonly string[]): void {
if (viewedIds.includes(ONBOARDING_WIZARD_SPLASH_ID)) {
updatePersistedState(ONBOARDING_VIEWED_KEY, true);
}
}

/**
* The static first-run view shows the wizard's gateway step. Once the queue is known and the
* wizard is not first (already viewed, read failed, splashes off, another splash), it must go.
*/
function useFirstRunViewQueueGuard(loaded: boolean, wizardIsFirst: boolean): void {
useLayoutEffect(() => {
if (loaded && !wizardIsFirst) removeFirstRunView();
}, [loaded, wizardIsFirst]);
}

const SplashScreenActiveContext = createContext(false);

interface PausedOnboardingState {
Expand Down Expand Up @@ -63,6 +83,8 @@ function getActiveSplashQueue(viewedIds: readonly string[]): SplashConfig[] {
async function markViewed(api: APIClient, splashId: string): Promise<boolean> {
try {
await api.splashScreens.markSplashScreenViewed({ splashId });
// Only a saved view may set the paint cache: markViewed also returns true after other errors.
cacheViewedWizard([splashId]);
} catch (error) {
if (isConnectionLostError(error)) return false;
console.error("Failed to mark splash screen as viewed:", error);
Expand All @@ -89,6 +111,7 @@ export function SplashScreenProvider({ children }: { children: ReactNode }) {
void (async () => {
try {
const viewedIds = await api.splashScreens.getViewedSplashScreens();
cacheViewedWizard(viewedIds);
setQueue(getActiveSplashQueue(viewedIds));
} catch (error) {
// A lost connection is no load failure: keep the shown splash for the reconnect.
Expand Down Expand Up @@ -116,6 +139,7 @@ export function SplashScreenProvider({ children }: { children: ReactNode }) {
const currentSplash = queue[0] ?? null;
const isOnboardingSplash = currentSplash?.id === ONBOARDING_WIZARD_SPLASH_ID;
const isPaused = isOnboardingSplash && onboardingPaused;
useFirstRunViewQueueGuard(loaded, isOnboardingSplash);

useEffect(() => {
if (!isOnboardingSplash || onboardingPaused || pausedOnboarding == null) {
Expand Down
8 changes: 8 additions & 0 deletions src/common/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export function isNonWorkspaceScopeId(scopeId: string): boolean {
*/
export const UI_THEME_KEY = "uiTheme";

/**
* Paint cache for the static first-run view (T3 #5971, C step 2): true once this browser saw the
* onboarding wizard marked viewed, so the server's early script loads the app without asking.
*/
export const ONBOARDING_VIEWED_KEY = "onboardingWizardViewed";

/**
* Get the localStorage key for the last selected provider when adding custom models (global)
* Format: "lastCustomModelProvider"
Expand Down Expand Up @@ -870,6 +876,8 @@ export const PERSISTED_KEY_REGISTRY: readonly PersistedKeyRegistration[] = [
// Global UI state.
// Paint cache of appearance.theme for index.html, written by ThemeProvider.
globalKey(UI_THEME_KEY, "cache", 64),
// Paint cache for the first-run view's early script, written by SplashScreenProvider.
globalKey(ONBOARDING_VIEWED_KEY, "cache", 8),
globalKey(ARTIFACTS_SELECTION_KEY, "ui", ARTIFACTS_SELECTION_MAX_CHARS),
globalKey(LAST_CUSTOM_MODEL_PROVIDER_KEY, "ui", 128),
// { workspaceId } (older builds also stored paths; readers use only the id).
Expand Down
132 changes: 132 additions & 0 deletions tests/ui/onboarding/providerHandoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* SplashScreenProvider and the static first-run view (T3 #5971, C step 2): every queue in which the
* wizard is not first removes #xum-pre, and a viewed wizard sets the early script's paint cache.
*/
import "../dom";
import React from "react";
import { act, cleanup, fireEvent, render } from "@testing-library/react";

const mockApi = {
splashScreens: {
getViewedSplashScreens: jest.fn<Promise<string[]>, []>(),
markSplashScreenViewed: jest.fn(() => Promise.resolve()),
},
};
let mockApiReady = true;
let mockOtherSplashFirst = false;

jest.mock("@/browser/contexts/API", () => ({
useAPI: () => ({ api: mockApiReady ? mockApi : null }),
}));
jest.mock("@/browser/contexts/SettingsContext", () => ({
useSettings: () => ({ registerOnClose: () => () => undefined }),
}));
jest.mock("@/browser/features/SplashScreens/index", () => {
const actual = jest.requireActual<typeof import("@/browser/features/SplashScreens/index")>(
"@/browser/features/SplashScreens/index"
);
const { createElement } = jest.requireActual<typeof import("react")>("react");
const splash = (id: string, priority: number) => ({
id,
priority,
component: (props: { onDismiss: () => void }) =>
createElement("button", { onClick: props.onDismiss }, `splash ${id}`),
});
return {
...actual,
// Test mode turns splash screens off (index.ts), and this suite needs them on.
DISABLE_SPLASH_SCREENS: false,
get SPLASH_REGISTRY() {
const wizard = splash(actual.ONBOARDING_WIZARD_SPLASH_ID, 5);
return mockOtherSplashFirst ? [wizard, splash("other", 6)] : [wizard];
},
};
});

import { SplashScreenProvider } from "@/browser/features/SplashScreens/SplashScreenProvider";
import { ONBOARDING_WIZARD_SPLASH_ID } from "@/browser/features/SplashScreens/index";
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { ONBOARDING_VIEWED_KEY } from "@/common/constants/storage";

const firstRunView = () => document.getElementById("xum-pre");
const viewedCached = () => readPersistedState(ONBOARDING_VIEWED_KEY, false);

async function renderProvider() {
const view = render(React.createElement(SplashScreenProvider, null, "app"));
await act(async () => await Promise.resolve());
return view;
}

beforeEach(() => {
const view = document.createElement("div");
view.id = "xum-pre";
document.body.append(view);
localStorage.clear();
mockApiReady = true;
mockOtherSplashFirst = false;
mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([]);
jest.spyOn(console, "error").mockImplementation(() => undefined); // the failed-read row logs
});

afterEach(() => {
jest.restoreAllMocks();
cleanup();
document.body.innerHTML = "";
});

test("keeps the static view while the wizard is first in the queue", async () => {
await renderProvider();
expect(firstRunView()).not.toBeNull();
expect(viewedCached()).toBe(false);
});

test.each([
[
"the wizard was viewed",
() =>
mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([ONBOARDING_WIZARD_SPLASH_ID]),
],
[
"the viewed list cannot be read",
() => mockApi.splashScreens.getViewedSplashScreens.mockRejectedValue(new Error("read failed")),
],
["another splash comes first", () => (mockOtherSplashFirst = true)],
["there is no API", () => (mockApiReady = false)],
])("removes the static view when %s", async (_name, arrange) => {
arrange();
await renderProvider();
expect(firstRunView()).toBeNull();
});

test("caches a viewed wizard for the early script, on load and on dismiss", async () => {
mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([ONBOARDING_WIZARD_SPLASH_ID]);
await renderProvider();
expect(viewedCached()).toBe(true);
cleanup();

localStorage.clear();
mockApi.splashScreens.getViewedSplashScreens.mockResolvedValue([]);
const view = await renderProvider();
expect(viewedCached()).toBe(false);
fireEvent.click(view.getByText(`splash ${ONBOARDING_WIZARD_SPLASH_ID}`));
await act(async () => await Promise.resolve());
expect(viewedCached()).toBe(true);
});

// Catches the cache write moving out of markViewed's try (for example to after
// `if (!(await markViewed(...))) return;` in dismiss): markViewed also returns true when the save
// failed for a reason other than a lost connection, and only a saved view may set the cache.
test.each([
["fails with an error other than a lost connection", false],
["succeeds", true],
])("on dismiss, a save that %s sets the paint cache: %s", async (_name, saved) => {
mockApi.splashScreens.markSplashScreenViewed.mockClear();
if (!saved) {
mockApi.splashScreens.markSplashScreenViewed.mockRejectedValueOnce(new Error("save failed"));
}
const view = await renderProvider();
fireEvent.click(view.getByText(`splash ${ONBOARDING_WIZARD_SPLASH_ID}`));
await act(async () => await new Promise((resolve) => setTimeout(resolve, 0)));
expect(mockApi.splashScreens.markSplashScreenViewed).toHaveBeenCalled();
expect(viewedCached()).toBe(saved);
});
Loading