From f61c587a78315fbb27115ce2927ecdaeae4b0db0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:47:11 -0700 Subject: [PATCH] fix(web): skipping the provider step persists across reloads (CL-9579) A per-user client-side flag marks the provider step as skipped, so the first-login probe keeps the shell instead of routing back to onboarding. Where a model is needed, a Connect a provider banner links to Settings, and connecting a provider there finishes the deferred Myra deploy. --- apps/web/src/chat/strings.ts | 2 +- apps/web/src/deferred-myra-setup.ts | 47 +++++++++++++++++++ apps/web/src/pages/new-workbench-picker.tsx | 2 + apps/web/src/pages/onboarding-page.tsx | 13 +++-- apps/web/src/pages/workbench-page.tsx | 2 + apps/web/src/provider-skip-banner.tsx | 14 ++++++ apps/web/src/provider-skip.ts | 44 +++++++++++++++++ apps/web/src/root.tsx | 13 +++-- apps/web/src/settings/credentials-section.tsx | 5 ++ e2e/browser/onboarding-skip.test.ts | 40 ++++++++++++++++ 10 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/deferred-myra-setup.ts create mode 100644 apps/web/src/provider-skip-banner.tsx create mode 100644 apps/web/src/provider-skip.ts create mode 100644 e2e/browser/onboarding-skip.test.ts diff --git a/apps/web/src/chat/strings.ts b/apps/web/src/chat/strings.ts index e12239635f..88e040b147 100644 --- a/apps/web/src/chat/strings.ts +++ b/apps/web/src/chat/strings.ts @@ -283,7 +283,7 @@ export const CHAT_STRINGS = { turnCancelledTitle: (sender: string) => `You stopped ${sender}'s turn`, turnFailedSub: "No reply arrived — the agent may be unavailable.", noUsableModelBannerText: "No model is connected yet, so a reply here won't come through.", - noUsableModelBannerAction: "Connect a model", + noUsableModelBannerAction: "Connect a provider", rowMenuLabel: "Conversation actions", rowMenuRename: "Rename", rowMenuPin: "Pin", diff --git a/apps/web/src/deferred-myra-setup.ts b/apps/web/src/deferred-myra-setup.ts new file mode 100644 index 0000000000..bd0a0e440d --- /dev/null +++ b/apps/web/src/deferred-myra-setup.ts @@ -0,0 +1,47 @@ +// Finishes the setup a skipped provider step left undone, once a model +// exists: the same publish-and-install the onboarding page runs. + +import { reportError } from "@corbits/error-sink"; + +import { runPortableClientBootstrap } from "./client-bootstrap"; +import { deployMyraSource } from "./myra-deploy"; +import { createFetchStockHub, findOwnedTenants } from "./needs-converge"; +import { resolveExistingOffering } from "./onboarding/provider-connect-step"; +import { isProviderSkipped, setProviderSkipped } from "./provider-skip"; +import type { SessionUser } from "./session"; + +let inFlight: Promise | null = null; + +/** True once Myra is deployed and the skip flag is cleared; false while + * there is nothing to do yet (no skip, or no model connected). */ +export function finishDeferredMyraSetup(user: SessionUser): Promise { + if (!isProviderSkipped(user.id)) return Promise.resolve(false); + inFlight ??= run(user).finally(() => { + inFlight = null; + }); + return inFlight; +} + +async function run(user: SessionUser): Promise { + try { + const owned = await findOwnedTenants(createFetchStockHub()); + const primary = owned.find((tenant) => tenant.parentId === null); + if (primary === undefined) return false; + const offering = await resolveExistingOffering(primary.id); + if (offering === null) return false; + const myraDeploy = await deployMyraSource({ + tenantId: primary.id, + tenantDomain: primary.domain, + sourceOfferingIds: offering.sourceOfferingIds, + defaultSourceOfferingId: offering.defaultSourceOfferingId, + declaredSources: offering.declaredSources, + }); + const result = await runPortableClientBootstrap(user, { myraDeploy }); + if (result.kind !== "ready") return false; + setProviderSkipped(user.id, false); + return true; + } catch (cause) { + reportError(cause, { operation: "deferred_myra_setup" }); + return false; + } +} diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 5a70f3e4f8..d42a350808 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -15,6 +15,7 @@ import { useBench } from "../bench-context"; import { workbenchKeys } from "../chat-path"; import { createWorkbench, WorkbenchCreateError } from "../workbench-create"; import { useNavigate } from "../navigation"; +import { ProviderSkipBanner } from "../provider-skip-banner"; import { StageTopBar } from "../shell/stage-top-bar"; import { workbenchPath } from "../workbench-path"; import { CreateAgentPanel } from "./create-agent-panel"; @@ -171,6 +172,7 @@ export function NewWorkbenchPickerRoute() { ) : ( <> +

What do you want your Workbench to do?

Tell it what you're trying to get done. Takes about ten seconds. diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 0be66e7725..3513fd2419 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -11,6 +11,7 @@ import { deployMyraSource } from "../myra-deploy"; import { useNavigate } from "../navigation"; import { NEW_WORKBENCH_PATH } from "../routes"; import { triggerFirstLoginProvisioning } from "../onboarding"; +import { setProviderSkipped } from "../provider-skip"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; import { ProviderConnectStep, @@ -42,6 +43,12 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { const [state, setState] = useState({ phase: "checking" }); const installRef = useRef | null>(null); + // Remembered so a reload lands in the shell instead of back here. + function skipForNow() { + setProviderSkipped(user.id, true); + navigate(NEW_WORKBENCH_PATH); + } + // One status read per landing (plus each manual recheck): a hub that // already has tenants means setup is done; an empty hub starts the // converge loop immediately rather than showing a dead-end panel. @@ -228,7 +235,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { }); }} onError={(message) => setState({ phase: "error", message })} - onSkip={() => navigate(NEW_WORKBENCH_PATH)} + onSkip={skipForNow} /> @@ -262,7 +269,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { - @@ -297,7 +304,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { - diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 40affe61e2..95e8a48b89 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -35,6 +35,7 @@ import { createFetchStockHub } from "../needs-converge"; import { workbenchKeys } from "../chat-path"; import { tenantKeys } from "../query-client"; import { recordLastWorkbenchId } from "../last-workbench"; +import { ProviderSkipBanner } from "../provider-skip-banner"; import { redeployWorkbenchAgent } from "../workbench-create"; import { workbenchIdFromPath } from "../workbench-path"; @@ -257,6 +258,7 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }

+ navigate(PROVIDER_SETTINGS_PATH)} />; +} diff --git a/apps/web/src/provider-skip.ts b/apps/web/src/provider-skip.ts new file mode 100644 index 0000000000..86e212d25e --- /dev/null +++ b/apps/web/src/provider-skip.ts @@ -0,0 +1,44 @@ +// A person who skips the provider step has a working shell and no Myra yet. +// The flag is per user and client-side only; it never reaches the hub. + +import { reportError } from "@corbits/error-sink"; +import { useSyncExternalStore } from "react"; + +import { useSessionUser } from "./navigation"; + +const listeners = new Set<() => void>(); + +function keyFor(userId: string): string { + return `corbits-provider-skipped:${userId}`; +} + +export function isProviderSkipped(userId: string): boolean { + try { + return localStorage.getItem(keyFor(userId)) === "1"; + } catch (cause) { + reportError(cause, { operation: "provider_skip.read" }); + return false; + } +} + +export function setProviderSkipped(userId: string, skipped: boolean): void { + try { + if (skipped) localStorage.setItem(keyFor(userId), "1"); + else localStorage.removeItem(keyFor(userId)); + } catch (cause) { + reportError(cause, { operation: "provider_skip.write" }); + } + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useProviderSkipped(): boolean { + const user = useSessionUser(); + return useSyncExternalStore(subscribe, () => + user === undefined ? false : isProviderSkipped(user.id), + ); +} diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index a8598d68dc..6d34d9bfbc 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -8,7 +8,9 @@ import { useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore import { getLogger } from "@/lib/client-log"; import { App } from "./app"; import { validatedNextPath } from "./login-next"; +import { finishDeferredMyraSetup } from "./deferred-myra-setup"; import { triggerFirstLoginProvisioning } from "./onboarding"; +import { isProviderSkipped } from "./provider-skip"; import { getPath, navigateTo, subscribeToPath } from "./router-store"; import { ONBOARDING_PATH } from "./routes"; import { fetchSession, signOut } from "./session"; @@ -44,14 +46,19 @@ export function Root() { message: string; refId?: string | undefined; } | null>(null); - const provisionedUserId = session.kind === "signed-in" ? session.user.id : null; + const provisionedUser = session.kind === "signed-in" ? session.user : null; const runProvisioning = useCallback(() => { - if (provisionedUserId === null) return () => undefined; + if (provisionedUser === null) return () => undefined; let cancelled = false; setProvisioningError(null); void triggerFirstLoginProvisioning().then((result) => { if (cancelled) return; if (result.kind === "needs-onboarding") { + // A skipped provider step keeps the shell; Myra deploys once a model exists. + if (isProviderSkipped(provisionedUser.id)) { + void finishDeferredMyraSetup(provisionedUser); + return; + } navigate(ONBOARDING_PATH); } else if (result.kind === "error") { setProvisioningError({ message: result.message, refId: result.refId }); @@ -60,7 +67,7 @@ export function Root() { return () => { cancelled = true; }; - }, [provisionedUserId, navigate]); + }, [provisionedUser, navigate]); useEffect(runProvisioning, [runProvisioning]); const handleRetryProvisioning = useCallback(() => { runProvisioning(); diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx index 266df250c4..755f101c02 100644 --- a/apps/web/src/settings/credentials-section.tsx +++ b/apps/web/src/settings/credentials-section.tsx @@ -43,6 +43,8 @@ import { } from "@/settings/inference"; import { redeployMyraForModelChange } from "@/settings/myra-model-redeploy"; import { tenantKeys } from "@/query-client"; +import { finishDeferredMyraSetup } from "@/deferred-myra-setup"; +import { useSessionUser } from "@/navigation"; import { createCredential, createProvider, @@ -99,6 +101,7 @@ type CredentialsData = { export function CredentialsSection({ tenantId }: { readonly tenantId: string | null }) { const queryClient = useQueryClient(); + const user = useSessionUser(); const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [loginId, setLoginId] = useState(null); @@ -143,6 +146,8 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n function reload() { if (tenantId === null) return; + // A provider connected after a skipped onboarding finishes Myra's deploy. + if (user !== undefined) void finishDeferredMyraSetup(user); void queryClient.invalidateQueries({ queryKey: tenantKeys.credentials(tenantId), }); diff --git a/e2e/browser/onboarding-skip.test.ts b/e2e/browser/onboarding-skip.test.ts new file mode 100644 index 0000000000..e6f9359f85 --- /dev/null +++ b/e2e/browser/onboarding-skip.test.ts @@ -0,0 +1,40 @@ +// Skipping the provider step sticks: a reload lands in the shell, not back +// on /onboarding. +import { expect, test } from "bun:test"; +import { bootBrowserApp, browserGate } from "../lib/browser"; + +const describeBrowser = browserGate(import.meta.path); + +describeBrowser("onboarding skip", () => { + const app = bootBrowserApp(); + + test("a skipped provider step survives a reload", async () => { + const { page } = await app().newPage(); + await page.goto(app().origin, { waitUntil: "networkidle0" }); + await page.waitForSelector(".auth-switch", { timeout: 30_000 }); + await page.click(".auth-switch"); + await page.type( + "input:not([type=password]):not([type=hidden])", + `skip-${String(Date.now())}@example.com`, + ); + await page.type("input[type=password]", "correct-horse-battery-staple"); + await page.click("button[type=submit]"); + + await page.waitForFunction("location.pathname === '/onboarding'", { timeout: 30_000 }); + await page.waitForSelector("button::-p-text(Skip for now)", { timeout: 30_000 }); + // A synthetic click: the step's buttons animate and pointer clicks miss. + await page.evaluate( + "Array.from(document.querySelectorAll('button')).find((b) => b.innerText.includes('Skip for now')).click()", + ); + await page.waitForFunction("location.pathname === '/new'", { timeout: 30_000 }); + + await page.goto(app().origin, { waitUntil: "networkidle0" }); + // The provisioning probe runs after the session probe; give it time to + // (wrongly) redirect before asserting. + await new Promise((resolve) => setTimeout(resolve, 3_000)); + expect(new URL(page.url()).pathname).not.toBe("/onboarding"); + // Still signed in: the sign-in form is gone. + expect(await page.$("input[type=password]")).toBeNull(); + await page.close(); + }, 120_000); +});