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
17 changes: 17 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -598,6 +610,21 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
: "Connect this key"}
</Button>
</form>
<div className="onboarding-credential-skip">
<Button
type="button"
variant="link"
size="sm"
disabled={submitting}
onClick={handleSkip}
>
Skip for now
</Button>
<p className="onboarding-credential-skip-hint">
No provider ready yet? You can connect one anytime from Settings →
AI providers.
</p>
</div>
</div>
</div>
</OnboardingLayout>
Expand Down
165 changes: 164 additions & 1 deletion apps/web/test/onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLElement>(`[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(
<App
path={ONBOARDING_PATH}
navigate={navigate}
session={signedIn}
onSignedIn={noop}
onSignOut={noop}
onRetry={noop}
/>,
);
});
await settle();

act(() => {
findByRoleText(container, "radio", "Ollama (local)").click();
});

const urlInput = container.querySelector<HTMLInputElement>(
"#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(
<App
path={ONBOARDING_PATH}
navigate={navigate}
session={signedIn}
onSignedIn={noop}
onSignOut={noop}
onRetry={noop}
/>,
);
});
await settle();

act(() => {
findButtonByText(container, "Skip for now").click();
});

expect(calls).toEqual(["/"]);
} finally {
act(() => root.unmount());
container.remove();
}
});
});
Loading