diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f44313b9..e3b8c29c 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -2125,6 +2125,23 @@ select:disabled, border-radius: 0; } +/* The escape hatch below the form: low-emphasis on purpose, since + connecting a provider is still the primary path — this is only for the + account that genuinely has nothing to paste yet. */ +.onboarding-credential-skip { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + margin-top: -0.25rem; +} + +.onboarding-credential-skip-hint { + margin: 0; + font-size: 0.8125rem; + color: var(--muted-foreground); +} + /* The one-click paths sit above the key forms, side by side in a two-column row so both fit without stacking the page tall — each card stays square-cornered like the rest of the wizard, with the shell diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 5dfd06c4..fda1c7b7 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -337,6 +337,18 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { [urlValue], ); + // Not every account has a provider ready the moment they land here — an + // Ollama instance that is not running yet is the exact case this build + // exists for. Provisioning already treats that as an anticipated state + // (`bench_unseeded`, not an error), so the wizard should not be the one + // hard-blocking control: skipping just hands off to `/` the same way a + // confirmed credential does, and the no-usable-model banner there (CL-6568) + // is what tells them, honestly, that a connection still needs finishing — + // this screen does not need to be the only place that can say so. + const handleSkip = useCallback(() => { + navigate("/"); + }, [navigate]); + const handleSubmitCredential = useCallback( (event: FormEvent) => { event.preventDefault(); @@ -598,6 +610,21 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { : "Connect this key"} +
+ +

+ No provider ready yet? You can connect one anytime from Settings → + AI providers. +

+
diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index bb9b79c8..1749b9c8 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -10,7 +10,10 @@ import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; -import { supportedCredentialProviders } from "@workbench/hub-client/credential-test"; +import { + OLLAMA_PLACEHOLDER_SECRET, + supportedCredentialProviders, +} from "@workbench/hub-client/credential-test"; import { App } from "../src/app"; import { NavigationProvider } from "../src/navigation"; @@ -1123,3 +1126,163 @@ describe("OnboardingPage resuming a bench_unseeded account", () => { ); }); }); + +describe("connecting a local Ollama instance from onboarding", () => { + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + function findByRoleText( + container: HTMLElement, + role: string, + text: string, + ): HTMLElement { + const match = Array.from( + container.querySelectorAll(`[role="${role}"]`), + ).find((el) => el.textContent?.includes(text)); + if (match === undefined) { + throw new Error(`no [role="${role}"] element containing "${text}"`); + } + return match; + } + + function findButtonByText(container: HTMLElement, text: string): HTMLElement { + const match = Array.from(container.querySelectorAll("button")).find((el) => + el.textContent?.includes(text), + ); + if (match === undefined) { + throw new Error(`no button containing "${text}"`); + } + return match; + } + + test("picking the Ollama card prefills its base URL, and submitting connects it and marks the tenant as having a usable model", async () => { + let completeRequestBody: unknown = null; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + if (url === "/api/onboarding/provision") { + return json({ kind: "existing-member", seeded: false }); + } + if (url === "/api/onboarding/complete") { + completeRequestBody = JSON.parse((init?.body as string) ?? "{}"); + // The hub's own connect route is what actually marks the tenant + // as having a usable model (persisting the credential + seeding + // its catalog) — this response is what that route answers once + // it has. The onboarding page's job, proven below, is sending + // the URL as `baseURL` with the fixed Ollama placeholder secret, + // and moving on once this comes back. + return json({ + kind: "ready", + tenantId: "ten_1", + tenantSlug: "ada-user1", + deployed: ["echo"], + pending: [], + }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const { navigate, calls } = trackedNavigate(); + try { + act(() => { + root.render( + , + ); + }); + await settle(); + + act(() => { + findByRoleText(container, "radio", "Ollama (local)").click(); + }); + + const urlInput = container.querySelector( + "#onboarding-provider-url", + ); + expect(urlInput).not.toBeNull(); + expect(urlInput?.value).toBe("http://localhost:11434"); + + act(() => { + findButtonByText(container, "Connect this address").click(); + }); + await settle(); + + expect(completeRequestBody).toEqual({ + provider: "ollama", + apiKey: OLLAMA_PLACEHOLDER_SECRET, + baseURL: "http://localhost:11434", + }); + expect(calls).toEqual(["/"]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); +}); + +describe("skipping the onboarding credential step", () => { + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + function findButtonByText(container: HTMLElement, text: string): HTMLElement { + const match = Array.from(container.querySelectorAll("button")).find((el) => + el.textContent?.includes(text), + ); + if (match === undefined) { + throw new Error(`no button containing "${text}"`); + } + return match; + } + + test("skipping hands off to the workbench without connecting a provider", async () => { + // A bench with no provider ready yet — this is the anticipated + // `bench_unseeded` state, not an error (see `handleSkip`'s own + // comment): skipping must never call the credential-complete route. + globalThis.fetch = (async (url: string) => { + if (url === "/api/onboarding/provision") { + return json({ kind: "existing-member", seeded: false }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const { navigate, calls } = trackedNavigate(); + try { + act(() => { + root.render( + , + ); + }); + await settle(); + + act(() => { + findButtonByText(container, "Skip for now").click(); + }); + + expect(calls).toEqual(["/"]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); +});