diff --git a/apps/web/src/chat/strings.ts b/apps/web/src/chat/strings.ts index 7404a1a47..5465a4f08 100644 --- a/apps/web/src/chat/strings.ts +++ b/apps/web/src/chat/strings.ts @@ -290,7 +290,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 000000000..bd0a0e440 --- /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 e8e3e05c7..303bc085d 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -12,6 +12,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 "./new-workbench.css"; @@ -98,6 +99,7 @@ export function NewWorkbenchPickerRoute() { ) : ( <> +

What should this workbench do?

({ 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 d58c536a7..06b3ae700 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -40,6 +40,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"; @@ -298,6 +299,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 000000000..86e212d25 --- /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 a8598d68d..6d34d9bfb 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 180610221..9f1424e27 100644 --- a/apps/web/src/settings/credentials-section.tsx +++ b/apps/web/src/settings/credentials-section.tsx @@ -35,6 +35,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 { deleteCredential, listCredentials, @@ -90,6 +92,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); @@ -133,6 +136,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 000000000..e6f9359f8 --- /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); +});