diff --git a/.gitignore b/.gitignore
index 5abd1374e1..8f54df5a15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ node_modules/
**/node_modules
**/node_modules/
dist/
+dist-preview/
packages/paperclip-runner/runner/target/
ui/storybook-static/
.env
diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts
index e18b5a5b8f..6b6f5ec521 100644
--- a/tests/e2e/nux-phase4-screenshots.spec.ts
+++ b/tests/e2e/nux-phase4-screenshots.spec.ts
@@ -93,9 +93,17 @@ test.describe("NUX Phase 4 visual QA", () => {
await page.evaluate(() => window.localStorage.clear());
await openWizard(page);
// Reach the full-screen front door (step 0): either it shows directly or
- // "← Back to start" returns to it from the create step.
+ // the naming step's Back returns to it.
+ //
+ // That control used to be a "← Back to start" text link. The naming step now
+ // wears the same footer pair as the steps after it, so its Back is labelled
+ // like theirs — it still lands on the front door, because the front door is
+ // what sits behind step 1.
+ //
+ // Exact, because the progress strip's segments are buttons with their own
+ // labels and an unanchored /Back/ would match more than one.
if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) {
- await page.getByRole("button", { name: /Back to start/ }).click();
+ await page.getByRole("button", { name: "Back", exact: true }).click();
}
await expect(
page.getByRole("heading", { name: "Welcome to Paperclip" }),
diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts
index 724322003c..c53e5d99b2 100644
--- a/tests/e2e/onboarding.spec.ts
+++ b/tests/e2e/onboarding.spec.ts
@@ -222,12 +222,20 @@ test.describe("Onboarding wizard", () => {
// Step 4 (Connect a model): the default adapter is claude_local, and the
// signal above reports no ready credential, so the login panel must show
// with no button to reuse a saved login.
- await expect(page.getByText("Sign in to the environment")).toBeVisible({
+ //
+ // The panel names the provider rather than the plumbing it runs on, so this
+ // title is per-adapter. "Sign in to the environment" is now only the fallback
+ // for an adapter with no known provider name, which claude_local is not.
+ await expect(page.getByText("Sign in to Anthropic")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0);
- await page.getByRole("button", { name: /^Connect/ }).click();
+ // Exact, because the progress strip's segments are buttons too and one of
+ // them is labelled "Connect a model" for assistive tech. An unanchored
+ // /^Connect/ matches both it and this CTA, which is a strict-mode violation
+ // rather than a wrong click — Playwright refuses instead of guessing.
+ await page.getByRole("button", { name: "Connect", exact: true }).click();
// The failed test blocks the hire and shows its own checks.
await expect(page.getByText("The claude CLI was not found on this host.")).toBeVisible({
diff --git a/ui/connect-model-preview.html b/ui/connect-model-preview.html
new file mode 100644
index 0000000000..aa01f44dc4
--- /dev/null
+++ b/ui/connect-model-preview.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ Connect a model — Paperclip onboarding
+
+
+
+
+
+
+
+
+
diff --git a/ui/package.json b/ui/package.json
index 38ccf73e10..20ea33f78c 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -16,11 +16,12 @@
"scripts": {
"dev": "vite",
"build": "vite build",
+ "build:preview": "vite build --config vite.preview.config.mjs",
"storybook": "storybook dev -p 6006 -c storybook/.storybook",
"build-storybook": "storybook build -c storybook/.storybook -o storybook-static",
"preview": "vite preview",
"typecheck": "tsc -b",
- "clean": "rm -rf dist storybook-static tsconfig.tsbuildinfo",
+ "clean": "rm -rf dist dist-preview storybook-static tsconfig.tsbuildinfo",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
diff --git a/ui/public/brands/claude-color.svg b/ui/public/brands/claude-color.svg
new file mode 100644
index 0000000000..f54cffccf7
--- /dev/null
+++ b/ui/public/brands/claude-color.svg
@@ -0,0 +1,17 @@
+
diff --git a/ui/public/brands/codex-color.svg b/ui/public/brands/codex-color.svg
new file mode 100644
index 0000000000..1981b63e95
--- /dev/null
+++ b/ui/public/brands/codex-color.svg
@@ -0,0 +1,17 @@
+
diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx
index fb9f40d158..e6191c4cb5 100644
--- a/ui/src/components/AgentConfigForm.render.test.tsx
+++ b/ui/src/components/AgentConfigForm.render.test.tsx
@@ -611,7 +611,7 @@ async function runTest(container: HTMLElement) {
}
async function startLogin(container: HTMLElement) {
- await clickByText(container, "Log in");
+ await clickByText(container, "Sign in");
await flushReact();
}
@@ -1089,11 +1089,11 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderCodexSandbox();
roots.push(result.root);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("hides the Codex login for a provider without the login pseudo-terminal capability", async () => {
@@ -1118,7 +1118,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the login affordance and the displayed-code panel for a third adapter with a projected login capability", async () => {
@@ -1129,13 +1129,13 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderVendorSandbox();
roots.push(result.root);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
// The projected capability gates the login affordance on for the third
// adapter.
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
await startLogin(result.container);
@@ -1154,11 +1154,11 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderGrokSandbox();
roots.push(result.root);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
await startLogin(result.container);
@@ -1171,11 +1171,11 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderClaudeSandbox();
roots.push(result.root);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("hides the Login button for a Claude sandbox whose provider lacks the setup-token login capability", async () => {
@@ -1199,7 +1199,7 @@ describe("AgentConfigForm environment selector", () => {
// E2B does not advertise the setup-token login capability, so the panel
// stays hidden even after the auth-missing check.
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("hides the Login button for a Daytona sandbox while the capabilities report no setup-token support", async () => {
@@ -1234,7 +1234,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("gates a pseudo-terminal login on the provider pty capability for a non-Claude adapter", async () => {
@@ -1260,7 +1260,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows a pseudo-terminal login for a non-Claude adapter when the provider advertises pty support", async () => {
@@ -1285,7 +1285,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("shows the Login button when a parent lifts the test feedback and renders the panel from the descriptor", async () => {
@@ -1353,11 +1353,11 @@ describe("AgentConfigForm environment selector", () => {
});
await flushReact();
- expect(findButton(container, "Log in")).toBeFalsy();
+ expect(findButton(container, "Sign in")).toBeFalsy();
await runTest(container);
- expect(findButton(container, "Log in")).toBeTruthy();
+ expect(findButton(container, "Sign in")).toBeTruthy();
});
it("does not show the Login button when the Test result has no adapter_auth_missing check", async () => {
@@ -1366,7 +1366,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("does not show the Login button when the effective environment is Local", async () => {
@@ -1380,7 +1380,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the Login button for an agent with no own environment under the managed-sandbox-only policy", async () => {
@@ -1416,11 +1416,11 @@ describe("AgentConfigForm environment selector", () => {
);
roots.push(result.root);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("keeps the Login button hidden under the managed-sandbox-only policy when no managed sandbox is available", async () => {
@@ -1447,7 +1447,7 @@ describe("AgentConfigForm environment selector", () => {
);
roots.push(result.root);
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("starts a login session for the effective sandbox and shows the code and the authentication URL", async () => {
@@ -1561,8 +1561,8 @@ describe("AgentConfigForm environment selector", () => {
"codex_local",
"session-1",
);
- // The panel resets: the Log in button is available again and the code is gone.
- const login = findButton(result.container, "Log in");
+ // The panel resets: the Sign in button is available again and the code is gone.
+ const login = findButton(result.container, "Sign in");
expect(login?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("WXYZ-1234");
@@ -1603,7 +1603,7 @@ describe("AgentConfigForm environment selector", () => {
await runTest(result.container);
await startLogin(result.container);
- const startButton = findButton(result.container, "Log in");
+ const startButton = findButton(result.container, "Sign in");
expect(startButton).toBeTruthy();
expect(startButton?.disabled).toBe(true);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
@@ -1673,7 +1673,7 @@ describe("AgentConfigForm environment selector", () => {
roots.push(result.root);
await runTest(result.container);
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
const select = result.container.querySelector("select");
await act(async () => {
@@ -1685,7 +1685,7 @@ describe("AgentConfigForm environment selector", () => {
});
await flushReact();
- expect(findButton(result.container, "Log in")).toBeFalsy();
+ expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the authorization URL and a browser-code input for a Claude sandbox", async () => {
@@ -1821,7 +1821,7 @@ describe("AgentConfigForm environment selector", () => {
]);
roots.push(result.root);
- // Log in on the first sandbox. The stored state adds the fixed
+ // Sign in on the first sandbox. The stored state adds the fixed
// `CLAUDE_CODE_OAUTH_TOKEN` binding and the non-secret claim to the form.
await runTest(result.container);
await startLogin(result.container);
@@ -2036,7 +2036,7 @@ describe("AgentConfigForm environment selector", () => {
,
);
});
- await flushUntil(() => Boolean(findButton(container, "Log in")));
+ await flushUntil(() => Boolean(findButton(container, "Sign in")));
expect(findButton(container, "Use saved login")).toBeUndefined();
expect(onApplyStored).not.toHaveBeenCalled();
@@ -2063,7 +2063,7 @@ describe("AgentConfigForm environment selector", () => {
// The panel shows a fixed message and returns to its start state. The Log in
// button is available again.
expect(result.container.textContent).toContain("The login did not finish");
- expect(findButton(result.container, "Log in")?.disabled).toBe(false);
+ expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
// The panel never shows the provider failure message, which could carry a
// secret.
expect(result.container.textContent).not.toContain("the provider rejected the browser code");
@@ -2088,7 +2088,7 @@ describe("AgentConfigForm environment selector", () => {
);
expect(result.container.textContent).toContain("The login did not finish");
- expect(findButton(result.container, "Log in")?.disabled).toBe(false);
+ expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
});
it("shows a terminal failure and stops polling on a status 404 from server cleanup", async () => {
@@ -2119,7 +2119,7 @@ describe("AgentConfigForm environment selector", () => {
// The panel shows the fixed failure message and returns to its start state.
expect(result.container.textContent).toContain("The login did not finish");
- expect(findButton(result.container, "Log in")?.disabled).toBe(false);
+ expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
// The panel shows no credential material: no authorization URL and no
// browser-code input.
@@ -2156,9 +2156,9 @@ describe("AgentConfigForm environment selector", () => {
"company-1",
"claude-session-1",
);
- // The panel resets: the Log in button is available again, and the URL and the
+ // The panel resets: the Sign in button is available again, and the URL and the
// browser-code input are gone.
- expect(findButton(result.container, "Log in")?.disabled).toBe(false);
+ expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("https://claude.example.test/authorize");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
@@ -2190,10 +2190,10 @@ describe("AgentConfigForm environment selector", () => {
"company-1",
"claude-session-1",
);
- // The panel reset even though the cancel returned a 404: the Log in button is
+ // The panel reset even though the cancel returned a 404: the Sign in button is
// available again, and the URL and the browser-code input are gone. No error
// message remains.
- expect(findButton(result.container, "Log in")?.disabled).toBe(false);
+ expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("https://claude.example.test/authorize");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
@@ -2229,9 +2229,9 @@ describe("AgentConfigForm environment selector", () => {
const result = await renderClaudeSandbox();
await runTest(result.container);
- // The panel shows the Log in button but no session started, so no active
+ // The panel shows the Sign in button but no session started, so no active
// session exists to cancel.
- expect(findButton(result.container, "Log in")).toBeTruthy();
+ expect(findButton(result.container, "Sign in")).toBeTruthy();
await act(async () => {
result.root.unmount();
@@ -2335,7 +2335,7 @@ describe("AgentConfigForm environment selector", () => {
await flushFake();
await clickFake(container, "Test");
- await clickFake(container, "Log in");
+ await clickFake(container, "Sign in");
// The login is active: both polls have run at least once.
const statusCallsAtStart = mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length;
@@ -2374,7 +2374,7 @@ describe("AgentConfigForm environment selector", () => {
"claude-session-1",
);
// The Log in button is available again, and the Cancel button is gone.
- expect(findButton(container, "Log in")?.disabled).toBe(false);
+ expect(findButton(container, "Sign in")?.disabled).toBe(false);
expect(findButton(container, "Cancel")).toBeFalsy();
// Both polls stopped. A further ten seconds adds no new poll call.
@@ -2597,7 +2597,7 @@ describe("AgentConfigForm create-mode Claude OAuth binding", () => {
// The panel shows a fixed, non-secret message and returns to its start state.
expect(result.container.textContent).toContain("The login did not finish");
- expect(findButton(result.container, "Log in")?.disabled).toBe(false);
+ expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(result.container.textContent).not.toContain(
"the provider rejected the stored-session claim",
);
diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx
index e7065aa35d..b244f06839 100644
--- a/ui/src/components/AgentConfigForm.tsx
+++ b/ui/src/components/AgentConfigForm.tsx
@@ -2192,6 +2192,26 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
// The login panel dispatcher. It picks the panel from the projected panel mode,
// not from the adapter name. The `submitted_browser_code` mode shows the
// submitted-browser-code panel; every other mode shows the displayed-code panel.
+/**
+ * The account a source signs in to, named where one is known.
+ *
+ * "Sign in to the environment" describes the plumbing — a login performed inside
+ * a sandbox — and is the honest label when the provider is unknown. But for the
+ * two sources onboarding offers, the customer is signing in to Anthropic or to
+ * OpenAI, and naming that is what tells them which password manager entry to
+ * reach for. The generic wording stays for anything not listed, where a guess
+ * would be worse than a description.
+ */
+const ADAPTER_LOGIN_PROVIDER: Record = {
+ claude_local: "Anthropic",
+ codex_local: "OpenAI",
+};
+
+function adapterLoginTitle(adapterType: string): string {
+ const provider = ADAPTER_LOGIN_PROVIDER[adapterType];
+ return provider ? `Sign in to ${provider}` : "Sign in to the environment";
+}
+
export function AdapterLoginPanel(props: AdapterLoginPanelProps) {
const getCapabilities = useAdapterCapabilities();
const panelMode = getCapabilities(props.adapterType).login?.panelMode;
@@ -2266,9 +2286,14 @@ function DisplayedCodeLoginPanel({
const startDisabled = startLogin.isPending || isActive;
return (
-
+
+ {/* `gap`, not `space-y`: the live region below collapses to
+ `display: none` whenever it has nothing to announce, and
+ `space-y` would still put its 8px on the row above — dead space
+ inside the card that pushes the row off centre. A gap only
+ applies between children that render. */}
- Sign in to the environment
+ {adapterLoginTitle(adapterType)}
{isActive && (
@@ -2397,6 +2422,7 @@ const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login aga
// only the server `stored` state as success, and it never shows the OAuth token.
function SubmittedBrowserCodeLoginPanel({
companyId,
+ adapterType,
environmentId,
onStored,
onApplyStored,
@@ -2714,9 +2740,14 @@ function SubmittedBrowserCodeLoginPanel({
};
return (
-
+
+ {/* `gap`, not `space-y`: the live region below collapses to
+ `display: none` whenever it has nothing to announce, and
+ `space-y` would still put its 8px on the row above — dead space
+ inside the card that pushes the row off centre. A gap only
+ applies between children that render. */}
- Sign in to the environment
+ {adapterLoginTitle(adapterType)}
{isActive && (
diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx
index c83b1c24eb..f6067ac83a 100644
--- a/ui/src/components/OnboardingWizard.test.tsx
+++ b/ui/src/components/OnboardingWizard.test.tsx
@@ -91,6 +91,12 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
const mockApprovalsApi = vi.hoisted(() => ({
create: vi.fn(),
}));
+const mockSecretsApi = vi.hoisted(() => ({
+ listMyUserSecrets: vi.fn(),
+ createUserSecretDefinition: vi.fn(),
+ createMyUserSecret: vi.fn(),
+ rotateMyUserSecret: vi.fn(),
+}));
const mockIssuesApi = vi.hoisted(() => ({
create: vi.fn(),
}));
@@ -122,6 +128,7 @@ vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi }));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/approvals", () => ({ approvalsApi: mockApprovalsApi }));
+vi.mock("../api/secrets", () => ({ secretsApi: mockSecretsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi }));
@@ -134,7 +141,12 @@ vi.mock("../adapters/metadata", () => ({ isVisualAdapterChoice: () => true }));
vi.mock("../adapters/adapter-display-registry", () => ({
getAdapterDisplay: (type: string) => ({
type,
- recommended: false,
+ // Mirrors the real registry, where these two and only these two are
+ // `recommended`. A blanket `false` used to be harmless because every adapter
+ // then sat in the "Advanced settings" disclosure and was reachable anyway;
+ // with the step down to a tile row built from this flag, it made that row
+ // empty in every test and hid the surface under it.
+ recommended: type === "claude_local" || type === "codex_local",
label: type,
description: "",
icon: () => null,
@@ -649,6 +661,160 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
+ // The Connect handler reuses a passing probe instead of re-running it, so the
+ // effect that clears the cache has to name every input to the configuration
+ // the probe tested. `credentialMode` and `apiKey` were missing from it, and
+ // the gap is reachable: the probe and the hire share one try/catch, so a hire
+ // that throws leaves the pass in state. Switching to a key and pressing
+ // Connect again then hired against a key nothing had tested.
+ /**
+ * Typing a key into this step must not put the key into the agent's stored
+ * configuration. That configuration is persisted and revisioned, so a plain
+ * value there is a live credential at rest in every copy of it — which is
+ * what this step did before, and what the Claude token path has always
+ * avoided by holding a `user_secret_ref` instead.
+ */
+ describe("an API key typed on the step", () => {
+ const KEY = "sk-ant-typed-by-the-customer";
+
+ // The canvas holding the key field only opens once a source is selected,
+ // and the tile row that selects one is built from this registry. The
+ // suite's default is empty, which leaves the step with no tiles, no
+ // canvas, and no field to type into.
+ beforeEach(() => {
+ mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
+ // No definition and no stored value yet: the first customer to type a key.
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
+ mockSecretsApi.createUserSecretDefinition.mockResolvedValue({ id: "def-1" });
+ mockSecretsApi.createMyUserSecret.mockResolvedValue({ id: "secret-abc" });
+ mockSecretsApi.rotateMyUserSecret.mockResolvedValue({ id: "secret-existing" });
+ });
+
+ async function connectWithApiKey() {
+ const handles = await openConnectStep();
+ await handles.clickByText((t) => t.startsWith("Use API keys"));
+ const field = document.body.querySelector(
+ 'input[type="password"]',
+ ) as HTMLInputElement;
+ await act(async () => {
+ setControlledValue(field, KEY);
+ });
+ await flushReact();
+ await handles.clickByText((t) => t.startsWith("Connect"));
+ return handles;
+ }
+
+ it("is stored as the user's own secret and referenced, never carried in the hire", async () => {
+ const { root } = await connectWithApiKey();
+
+ expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
+ const [, createBody] = mockSecretsApi.createMyUserSecret.mock.calls.at(-1) as [
+ string,
+ { definitionKey: string; value: string },
+ ];
+ expect(createBody.definitionKey).toBe("ANTHROPIC_API_KEY");
+ expect(createBody.value).toBe(KEY);
+
+ const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as {
+ adapterConfig: { env?: Record };
+ };
+ // The same binding kind the subscription half of this step produces.
+ expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toEqual({
+ type: "user_secret_ref",
+ key: "ANTHROPIC_API_KEY",
+ version: "latest",
+ });
+ // The whole payload, not just that one field: the point is that the key
+ // is nowhere in what gets persisted, however it might be nested.
+ expect(JSON.stringify(hireBody)).not.toContain(KEY);
+
+ await act(async () => root.unmount());
+ });
+
+ // Onboarding is the first thing to need this definition, so it creates it.
+ it("creates the definition once, then reuses it", async () => {
+ await connectWithApiKey();
+ expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
+
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue([
+ { definition: { id: "def-1", key: "ANTHROPIC_API_KEY" }, secret: null },
+ ]);
+ const { root } = await connectWithApiKey();
+
+ expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledTimes(1);
+
+ await act(async () => root.unmount());
+ });
+
+ // A second value against one definition is what the server refuses, so a
+ // customer who already has a key stored must rotate rather than add.
+ it("rotates an existing value instead of storing a second one", async () => {
+ mockSecretsApi.listMyUserSecrets.mockResolvedValue([
+ {
+ definition: { id: "def-1", key: "ANTHROPIC_API_KEY" },
+ secret: { id: "secret-existing" },
+ },
+ ]);
+ const { root } = await connectWithApiKey();
+
+ expect(mockSecretsApi.rotateMyUserSecret).toHaveBeenCalledWith(
+ expect.any(String),
+ "secret-existing",
+ { value: KEY },
+ );
+ expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled();
+
+ await act(async () => root.unmount());
+ });
+
+ // The one outcome that must never happen is a hire that falls back to
+ // embedding the key because storing it failed.
+ it("blocks the hire when the key cannot be stored", async () => {
+ mockSecretsApi.createMyUserSecret.mockRejectedValue(new Error("vault unreachable"));
+ const { root } = await connectWithApiKey();
+
+ expect(mockAgentsApi.hire).not.toHaveBeenCalled();
+ expect(document.body.textContent).toContain("Could not store the API key");
+
+ await act(async () => root.unmount());
+ });
+
+ it("stores one secret when Connect is pressed twice with the same key", async () => {
+ mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
+ const { root, clickByText } = await connectWithApiKey();
+
+ await clickByText((t) => t.startsWith("Connect"));
+
+ expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
+
+ await act(async () => root.unmount());
+ });
+ });
+
+ it("re-probes rather than reusing a pass when the credential mode changes", async () => {
+ mockAgentsApi.testEnvironment.mockResolvedValue({
+ adapterType: "claude_local",
+ status: "pass" as const,
+ checks: [],
+ testedAt: new Date().toISOString(),
+ });
+ // The hire fails, which is what leaves the passing probe behind.
+ mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
+
+ const { root, clickByText } = await openConnectStep();
+
+ await clickByText((t) => t.startsWith("Connect"));
+ expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
+
+ // Switch to API keys, which changes the configuration the hire will send.
+ await clickByText((t) => t.startsWith("Use API keys"));
+ await clickByText((t) => t.startsWith("Connect"));
+
+ expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
+
+ await act(async () => root.unmount());
+ });
+
it("does not open the create path on a cached warn result that holds adapter_auth_missing", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
@@ -1550,21 +1716,21 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
it("shows the login panel for claude_local when the signal reports no ready credential", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
- expect(document.body.textContent).toContain("Sign in to the environment");
+ expect(document.body.textContent).toContain("Sign in to Anthropic");
await act(async () => root.unmount());
});
it("shows the login panel for codex_local when the signal cannot decide", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "codex_local" });
- expect(document.body.textContent).toContain("Sign in to the environment");
+ expect(document.body.textContent).toContain("Sign in to OpenAI");
await act(async () => root.unmount());
});
it("hides the login panel when the signal reports a ready credential", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
const { root } = await openStep4({ adapterType: "claude_local" });
- expect(document.body.textContent).not.toContain("Sign in to the environment");
+ expect(document.body.textContent).not.toContain("Sign in to Anthropic");
await act(async () => root.unmount());
});
@@ -1596,8 +1762,13 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await flushReact();
};
- await clickByText((t) => t.startsWith("Advanced settings"));
- await clickByText((t) => t === "codex_local");
+ // Straight to the tile. The adapter change used to be reached through an
+ // "Advanced settings" disclosure listing every non-recommended adapter;
+ // the step now offers Claude and Codex as tiles and spends that line on
+ // the credential switch instead. What is asserted below is unchanged —
+ // changing the source re-reads the signal — only the route there is.
+ // The tile's text is the label plus its credential tag, hence the prefix.
+ await clickByText((t) => t.startsWith("codex_local"));
expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith(
"company-new",
@@ -1613,7 +1784,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
- expect(document.body.textContent).not.toContain("Sign in to the environment");
+ expect(document.body.textContent).not.toContain("Sign in to Anthropic");
expect(mockAgentsApi.getAdapterAuthSignal).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index e960043176..ea13f62506 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState, useMemo, useRef } from "react";
-import type { CSSProperties } from "react";
+import type { ComponentType, CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { MotionConfig, motion } from "motion/react";
import type {
@@ -11,6 +11,7 @@ import type {
} from "@paperclipai/shared";
import { AGENT_ROLES, AGENT_ROLE_LABELS, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import { AdapterLoginPanel } from "./AgentConfigForm";
+import { secretsApi } from "../api/secrets";
import { Label } from "./ui/label";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@@ -83,8 +84,19 @@ import {
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { FrontDoor } from "./FrontDoor";
import { PillGuy } from "./onboarding/PillGuy";
-import { AGENT_ARC_WIZARD_STEPS, Stepper, agentArcStepFor } from "./onboarding/Stepper";
+import { SleepingZs } from "./onboarding/SleepingZs";
+import {
+ AGENT_ARC_WIZARD_STEPS,
+ ONBOARDING_STEP_LABELS,
+ ONBOARDING_WIZARD_STEPS,
+ Stepper,
+ agentArcStepFor,
+ onboardingStepPositionFor,
+} from "./onboarding/Stepper";
import { AgentPreview } from "./onboarding/AgentPreview";
+import { ModelSourceTiles, type CredentialMode } from "./onboarding/ModelSourceTiles";
+import { CredentialModeLink } from "./onboarding/CredentialModeLink";
+import { ApiKeyField, ConnectInputCanvas } from "./onboarding/ConnectInputCanvas";
import { FooterNav } from "./onboarding/FooterNav";
import { OnboardingHeading } from "./onboarding/OnboardingPrimitives";
import { DEFAULT_AGENT_ROLE } from "../lib/onboarding-agent-role";
@@ -176,6 +188,52 @@ function adapterConfigHasAnthropicApiKey(config: Record): boole
return binding.type === "secret_ref" || binding.type === "user_secret_ref";
}
+/**
+ * Full-colour brand marks for the sources this step offers.
+ *
+ * The registry's own icons are monochrome, drawn to sit in dense config UI
+ * where a row of saturated logos would be noise. This step is the opposite
+ * case: two large tiles carrying the whole choice, where the brand is the
+ * fastest thing to recognise.
+ *
+ * Keyed by adapter type with a fallback, so the row stays registry-driven. An
+ * adapter with no brand file here still renders — with its registry icon —
+ * rather than a gap where a tile should be.
+ */
+const MODEL_SOURCE_BRAND_MARKS: Record = {
+ claude_local: "/brands/claude-color.svg",
+ codex_local: "/brands/codex-color.svg",
+};
+
+/**
+ * The environment variable each source reads its key from.
+ *
+ * Named rather than described in the field above it, because the customer knows
+ * which key they are holding and does not know where this step will put it. The
+ * mapping already existed in this file as prose inside the environment-check
+ * hint; this is the same knowledge, in a form the key field can use.
+ */
+const API_KEY_ENV_KEYS: Record = {
+ claude_local: ANTHROPIC_API_KEY_ENV_KEY,
+ codex_local: "OPENAI_API_KEY",
+};
+
+function apiKeyEnvKeyFor(adapterType: string): string {
+ return API_KEY_ENV_KEYS[adapterType] ?? "API_KEY";
+}
+
+function ModelSourceMark({
+ type,
+ Fallback,
+}: {
+ type: string;
+ Fallback: ComponentType<{ className?: string }>;
+}) {
+ const brand = MODEL_SOURCE_BRAND_MARKS[type];
+ if (!brand) return ;
+ return ;
+}
+
// Exported so tests write/read the exact key the component uses, instead of
// duplicating the literal and silently drifting from it if it's ever renamed.
export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
@@ -516,6 +574,22 @@ function OnboardingWizardInner({
useState(false);
const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
const [showMoreAdapters, setShowMoreAdapters] = useState(false);
+ /**
+ * Whether the connect step is asking for a subscription sign-in or an API key.
+ *
+ * Restored from the draft like everything else on this step: someone who
+ * picked keys, left, and came back should not be handed a sign-in panel they
+ * already said no to.
+ */
+ const [credentialMode, setCredentialMode] = useState(
+ (saved?.credentialMode as CredentialMode) ?? "subscription",
+ );
+ /**
+ * The key itself, held only for as long as the wizard is open. It is written
+ * into the adapter config at hire time and never into the draft — a draft is
+ * `localStorage`, and a provider key does not belong there.
+ */
+ const [apiKey, setApiKey] = useState("");
// The owner's stored Claude subscription login, read right before the hire
// (see handleGiveHeartbeat). Onboarding applies it with no extra control,
// so nothing else reads this state yet.
@@ -565,6 +639,13 @@ function OnboardingWizardInner({
// the binding cannot answer for a config that now does — see the reuse
// check in `handleGiveHeartbeat`.
const adapterEnvResultAppliedStoredLoginRef = useRef(false);
+ /**
+ * The secret a key typed on this step was stored as, remembered for the key it
+ * holds. Connect can be pressed more than once — a hire that fails leaves the
+ * customer on the step to try again — and without this each press would store
+ * another copy of the same credential.
+ */
+ const apiKeySecretRef = useRef<{ key: string } | null>(null);
createdCompanyIdRef.current = createdCompanyId;
// The mission of the company actually in hand, which is not always the one
@@ -757,6 +838,8 @@ function OnboardingWizardInner({
const state = {
step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url,
+ // The mode, never the key: this blob is localStorage.
+ credentialMode,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
@@ -765,6 +848,7 @@ function OnboardingWizardInner({
}, [
effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url,
+ credentialMode,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
@@ -892,6 +976,17 @@ function OnboardingWizardInner({
const authSignalStatus = authSignalQuery.data?.status ?? null;
const showAdapterLoginPanel =
canShowAdapterLogin && (authSignalStatus === "absent" || authSignalStatus === "unknown");
+ /**
+ * The signal is being fetched and has not answered yet.
+ *
+ * Worth its own state rather than folding into "no panel to show". Until it
+ * answers, `authSignalStatus` is null and every not-signed-in customer looks
+ * momentarily identical to a signed-in one — so the card would assert that
+ * they are already signed in, for exactly as long as the request takes, and
+ * then replace it with a sign-in prompt. A reassurance that is wrong and then
+ * withdrawn is worse than saying nothing for a beat.
+ */
+ const authSignalUndecided = canShowAdapterLogin && authSignalStatus === null;
const isLocalAdapterCaps =
adapterCaps.supportsInstructionsBundle ||
@@ -924,6 +1019,25 @@ function OnboardingWizardInner({
};
}, [disabledTypes]);
+ /**
+ * A source chosen from the visible row. Read off the row rather than off
+ * `adapterType` alone, because a restored draft can name an adapter this step
+ * no longer offers — a selection the customer cannot see.
+ */
+ const sourceSelected = recommendedAdapters.some((opt) => opt.type === adapterType);
+
+ /**
+ * When the input canvas is open.
+ *
+ * A selected source is the ordinary reason — the card is the answer to the
+ * tile that was just pressed, so an untouched row leaves nothing under it. But
+ * it opens for a pending sign-in regardless of the row, because the adapter
+ * needing credentials does not depend on it having a tile: a restored draft
+ * naming an adapter this step no longer offers still cannot hire without one,
+ * and hiding the panel would leave that dead end with nothing to press.
+ */
+ const canvasOpen = sourceSelected || showAdapterLoginPanel;
+
// The default (or a saved) adapterType can name an adapter the server has
// since disabled — e.g. a cloud sandbox registry without claude_local. The
// grid hides it, so without this snap the wizard would silently keep an
@@ -970,12 +1084,22 @@ function OnboardingWizardInner({
command.trim() ||
(COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, ""));
+ // Throw the cached probe away whenever the thing it probed changes. Every
+ // input to `buildAdapterConfig` belongs in this list, `credentialMode` and
+ // `apiKey` included: the Connect handler reuses a passing result instead of
+ // re-probing, so a dependency missing here is a hire that skips the check.
+ //
+ // That is reachable rather than theoretical. The hire runs after the probe
+ // inside one try/catch, so a hire that fails — a network error, a server
+ // error — leaves the pass sitting in state. Switch to an API key, paste one,
+ // press Connect again, and without these two the wizard would hire against a
+ // key nothing ever tested.
useEffect(() => {
if (step !== 4) return;
setAdapterEnvResult(null);
adapterEnvResultAppliedStoredLoginRef.current = false;
setAdapterEnvError(null);
- }, [step, adapterType, model, command, args, url]);
+ }, [step, adapterType, model, command, args, url, credentialMode, apiKey]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
@@ -1192,7 +1316,67 @@ function OnboardingWizardInner({
}
}
- function buildAdapterConfig(): Record {
+ /**
+ * Store the typed key as the customer's own user secret, and report whether it
+ * is in place.
+ *
+ * A user secret rather than a company one, to match the subscription half of
+ * this very step: signing in stores the Claude token as a user secret and
+ * binds a `user_secret_ref`. Two credential modes on one step that scoped
+ * their secrets differently would be hard to justify and easy to get wrong
+ * later. It also keeps the key to the person who typed it instead of exposing
+ * it to everyone with company secret access, and agent runs still resolve it
+ * through the company's responsible user.
+ *
+ * A user secret needs a definition to hang off. The Claude token's is fixed
+ * and server-owned; there is no such definition for API keys, so onboarding
+ * creates one on first use. That needs company owner or admin rights, which
+ * whoever just created this company in onboarding has.
+ *
+ * Returns false on failure, having set the error. Callers must treat false as
+ * a stop: there is deliberately no path that hands the raw key back, because
+ * the only thing left to do with it would be to embed it.
+ */
+ async function storeApiKeyUserSecret(companyId: string): Promise {
+ const key = apiKey.trim();
+ const envKey = apiKeyEnvKeyFor(adapterType);
+ if (apiKeySecretRef.current?.key === key) return true;
+ try {
+ const entries = await secretsApi.listMyUserSecrets(companyId);
+ const existing = entries.find((entry) => entry.definition.key === envKey);
+ const definitionId =
+ existing?.definition.id ??
+ (
+ await secretsApi.createUserSecretDefinition(companyId, {
+ key: envKey,
+ name: `${envKey} for onboarding`,
+ description: "Created while connecting a model during onboarding.",
+ })
+ ).id;
+ // Rotate rather than create when a value is already stored, because
+ // creating a second value for one definition is what the server refuses.
+ if (existing?.secret) {
+ await secretsApi.rotateMyUserSecret(companyId, existing.secret.id, { value: key });
+ } else {
+ await secretsApi.createMyUserSecret(companyId, {
+ definitionId,
+ definitionKey: envKey,
+ value: key,
+ });
+ }
+ apiKeySecretRef.current = { key };
+ return true;
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? `Could not store the API key: ${err.message}`
+ : "Could not store the API key.",
+ );
+ return false;
+ }
+ }
+
+ function buildAdapterConfig(bindApiKey = false): Record {
const adapter = getUIAdapter(adapterType);
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
@@ -1227,6 +1411,35 @@ function OnboardingWizardInner({
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
}
+ // A key typed on this step is the credential the agent is being hired with,
+ // so it has to reach the configuration the hire sends — and the same one the
+ // environment test probes, or the test would pass on a config the hire does
+ // not use. Only when the mode asks for it: leaving a stale reference in the
+ // config after switching back to a subscription is what the server rejects
+ // alongside the Claude OAuth binding.
+ //
+ // A reference, never the key itself. The adapter configuration is
+ // persisted and revisioned, so a `{ type: "plain", value }` here would leave
+ // a live credential at rest in every copy of it. This mirrors
+ // `buildFixedClaudeOAuthBinding`, which holds a reference to the stored
+ // Claude token for the same reason.
+ //
+ // Guarded on the caller having stored the secret, not on the key being
+ // present. If storing failed this stays false, and the right outcome is a
+ // configuration with no credential — which the hire then blocks on — rather
+ // than one that quietly falls back to embedding the value.
+ if (credentialMode === "api" && bindApiKey) {
+ const env =
+ typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
+ ? { ...(config.env as Record) }
+ : {};
+ env[apiKeyEnvKeyFor(adapterType)] = {
+ type: "user_secret_ref",
+ key: apiKeyEnvKeyFor(adapterType),
+ version: "latest",
+ };
+ config.env = env;
+ }
return config;
}
@@ -1552,7 +1765,15 @@ function OnboardingWizardInner({
// configuration the hire sends — a config without the binding can
// report missing authentication for a user the binding would have
// covered.
- const baseAdapterConfig = buildAdapterConfig();
+ // Store the key before anything is built from it, so both the probe and the
+ // hire describe it the same way — as a reference. A failure here stops the
+ // hire rather than falling through to a configuration with no credential.
+ let apiKeyStored = false;
+ if (credentialMode === "api" && apiKey.trim()) {
+ apiKeyStored = await storeApiKeyUserSecret(createdCompanyId);
+ if (!apiKeyStored) return;
+ }
+ const baseAdapterConfig = buildAdapterConfig(apiKeyStored);
let storedClaudeLogin: ClaudeOAuthTokenStatusResponse | null = null;
if (
adapterType === "claude_local" &&
@@ -1832,13 +2053,24 @@ function OnboardingWizardInner({
>
@@ -1853,31 +2085,21 @@ function OnboardingWizardInner({
a segment for it would be one the run can never fill, and the
count would visibly skip from 1 to 3. */}
{!showsAgentArcStepper && (
-
+
+ canJumpToOnboardingStep({
+ targetStep: ONBOARDING_WIZARD_STEPS[target - 1]!,
+ currentStep: step,
+ entryStep,
+ })
+ }
+ onJumpToStep={(target) =>
+ setStep(ONBOARDING_WIZARD_STEPS[target - 1]! as Step)
+ }
+ />
)}
{/* The agent arc's progress strip. Numbered 1–3 over the wizard's
@@ -1909,7 +2131,14 @@ function OnboardingWizardInner({
{/* mb-6 continues the prototype's single rhythm past this
block: it groups the hero and heading, and the step's own
controls sit a step below on the same spacing. */}
-
+ {/* The gap under the agent — its name to the step's title —
+ is tighter than the step's other rows on purpose. The name
+ labels the character directly above it, so the two read as
+ one object; at the full row rhythm the name floated between
+ the character and the title and belonged to neither. 24px
+ against the 36px used elsewhere, a little over a third
+ less. `mb-9` still holds the block off the step content. */}
+
+ {/* `relative` is load-bearing: the sleep marks anchor
+ to this box and travel out past its top-right
+ corner. */}
+
+
+ {/* Only while it is actually asleep. A still grey
+ silhouette reads as a placeholder that failed to
+ load rather than as something waiting its turn. */}
+ {step < 5 && }
+
@@ -2037,18 +2275,29 @@ function OnboardingWizardInner({
)}
- {/* Step 1: name the organization (both paths). One question, one
- design: this mirrors the funnel's naming screen — same
- question, same sub, same left-aligned heading in a centered
- column — so a customer creating their second organization
- in-app is asked exactly what their first one asked them. */}
+ {/* Step 1: name the organization (both paths).
+ Dressed as the arc steps that follow it — centred heading, no
+ lede, and the same footer pair — because a customer walks
+ straight from here into them, and one screen reading as a
+ different product is more jarring than this one no longer
+ matching the funnel's naming screen exactly. The question
+ itself is still the funnel's, so the ask has not changed.
+
+ The lede went because it said what the field already says: a
+ labelled "Name" under "What is the name of your organization?"
+ does not need a sentence explaining that it names the
+ organization. */}
{step === 1 && (
-
+
-
+ {/* The field takes the agent step's measure rather than the
+ column's, so the two questions the wizard asks — name the
+ organization, name the agent — present the same target.
+ The heading stays full width above it, as it does there. */}
+
@@ -2281,12 +2524,12 @@ function OnboardingWizardInner({
`general` role; a specific one can be set later, where there
is context to choose it in. */}
{step === 3 && (
-
+
setAgentName(e.target.value)}
autoFocus
@@ -2297,132 +2540,130 @@ function OnboardingWizardInner({
{/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
{step === 4 && (
-
+
{/* The two cards are self-describing; an "Adapter type"
eyebrow above them named the mechanism rather than the
choice. */}
-
- {recommendedAdapters.map((opt) => (
-
- ))}
-
+ {/* The row is `ModelSourceTiles`, the same component the
+ connect-step prototype is drawn with, so the shipped step
+ and the design under review cannot drift apart.
-
-
- {showMoreAdapters && (
-
- {moreAdapters.map((opt) => (
-
- ))}
-
- )}
-
-
- {/* Shows as soon as the cheap auth signal reports no ready
- credential, well before any adapter environment test
- runs. Reuses the same panel the agent configuration form
- shows after a test — see AdapterLoginPanel in
- AgentConfigForm.tsx. No "Use saved login" control: the
- hire step already applies a stored login on its own. */}
- {showAdapterLoginPanel && createdCompanyId && resolvedLoginEnvironmentId && (
- {
- queryClient.invalidateQueries({
- queryKey: queryKeys.agents.authSignal(
- createdCompanyId,
- adapterType,
- resolvedLoginEnvironmentId,
- ),
- });
+ Sources come from `recommendedAdapters`, not a list
+ written here. That filter is `recommended` in the display
+ registry, which today means Claude Code and Codex and
+ nothing else — so the row stays two tiles because the
+ registry says so, and a third would appear here the day
+ someone marks one rather than the day someone remembers
+ to edit this file. */}
+ ({
+ id: opt.type,
+ label: opt.label,
+ icon: ,
+ }))}
+ mode={credentialMode}
+ selectedId={
+ recommendedAdapters.some((opt) => opt.type === adapterType)
+ ? adapterType
+ : null
+ }
+ onSelect={(id) => {
+ setAdapterType(id);
+ if (id === "codex_local") return;
+ if (id === "opencode_local") {
+ setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
+ return;
+ }
+ setModel("");
}}
/>
- )}
+
+ {/* The credential switch stands where the adapter
+ disclosure used to. That disclosure existed to reach the
+ adapters this step does not offer, and with the row down
+ to the two that are supported it was a control whose
+ whole contents were out of scope. The question actually
+ left on this step is how the two are authenticated, so
+ that is what the line asks.
+
+ It names the destination rather than the state, which is
+ what a sentence has to do where a checkbox does not —
+ and it is only readable because the tiles' own tags,
+ directly above, say where you are. */}
+
+
+
+
+
+
+ {/* One canvas under the tiles, holding whatever the current
+ choice needs: a browser-code login for Claude, a
+ displayed-code login for Codex, or a key field for either
+ when the mode is keys. Four inputs, one place — so the
+ Connect button below does not move every time the answer
+ changes.
+
+ Closed until a source is picked. `contentKey` is the
+ source and the mode together, because either one changing
+ means a different input, and that is what the canvas
+ swaps on. */}
+
+ {credentialMode === "api" ? (
+
+ ) : showAdapterLoginPanel &&
+ createdCompanyId &&
+ resolvedLoginEnvironmentId ? (
+ /* Shows as soon as the cheap auth signal reports no ready
+ credential, well before any adapter environment test
+ runs. Reuses the same panel the agent configuration
+ form shows after a test — see AdapterLoginPanel in
+ AgentConfigForm.tsx. No "Use saved login" control: the
+ hire step already applies a stored login on its own. */
+ {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.agents.authSignal(
+ createdCompanyId,
+ adapterType,
+ resolvedLoginEnvironmentId,
+ ),
+ });
+ }}
+ />
+ ) : (
+ /* No panel to show, and the two reasons for that are not
+ the same news. Saying either is better than an empty
+ card — the canvas is open because a source is selected,
+ and a blank one reads as something that failed to load —
+ but they must not be conflated: telling someone with no
+ sandbox that they are "already signed in" on it is
+ false, and it hides the one thing actually blocking
+ them. */
+
+ {authSignalUndecided
+ ? "Checking this source's credentials…"
+ : canShowAdapterLogin
+ ? "This source is already signed in on the managed sandbox."
+ : "No managed sandbox is available to sign in against yet."}
+
+ )}
+
{/* Conditional adapter fields */}
{/* No model picker. Every adapter this step offers resolves
@@ -2590,37 +2831,67 @@ function OnboardingWizardInner({
)}
- {isAgentArcStep && (
+ {/* Step 1 shares the arc's footer so the pair keeps its shape and
+ position from the first screen onward. Its Back is the only one
+ that leaves the wizard's steps rather than walking them: step 1
+ is where a company is named, and behind it is the path chooser,
+ so `canGoBackFromOnboardingStep` — which bounds a run to the
+ steps it entered on — does not decide this one. */}
+ {(isAgentArcStep || step === 1) && (
setStep(backStepFrom(step))
- : undefined
+ step === 1
+ ? () => {
+ setOnboardingPath(null);
+ setStep(0);
+ }
+ : canGoBackFromOnboardingStep({ currentStep: step, entryStep })
+ ? () => setStep(backStepFrom(step))
+ : undefined
}
// The prototype's cloud flow hires on this step and calls the
// action "Create". Here the model step sits between, so this
// one advances — which is exactly the distinction the
// prototype's own local flow draws with "Next".
- primaryLabel={step === 3 ? "Next" : step === 4 ? "Connect" : "Get started"}
- loadingLabel={step === 4 ? "Connecting..." : "Launching..."}
+ primaryLabel={
+ step === 1
+ ? "Continue"
+ : step === 3
+ ? "Next"
+ : step === 4
+ ? "Connect"
+ : "Get started"
+ }
+ loadingLabel={
+ step === 1
+ ? "Creating..."
+ : step === 4
+ ? "Connecting..."
+ : "Launching..."
+ }
loading={step === 3 ? false : loading}
primaryDisabled={
- step === 3
- ? !agentName.trim()
- : step === 4
- ? loading || adapterEnvLoading || missionUnresolvedForHire
- : loading || launchStateIncomplete
+ step === 1
+ ? !companyName.trim() || loading
+ : step === 3
+ ? !agentName.trim()
+ : step === 4
+ ? loading || adapterEnvLoading || missionUnresolvedForHire
+ : loading || launchStateIncomplete
}
onPrimary={() => {
- if (step === 3) setStep(4);
+ if (step === 1) {
+ if (skipsMissionStep) void handleCreateCompany();
+ else setStep(2);
+ } else if (step === 3) setStep(4);
else if (step === 4) handleGiveHeartbeat();
else handleLaunchToDashboard();
}}
/>
)}
- {/* Footer navigation */}
- {!isAgentArcStep && (
+ {/* Footer navigation for the steps that still use the old pair. */}
+ {!isAgentArcStep && step !== 1 && (
+ );
+}
+
+/**
+ * The API key field, for when the credential mode is keys rather than a
+ * subscription.
+ *
+ * Built to the login panel's shape on purpose: same card, same padding, same
+ * label-left / control-right row, same 28px control height. These two are
+ * alternatives to each other — one canvas shows one or the other, and the
+ * credential switch above trades between them — so they should read as two
+ * answers to one question rather than as two different kinds of thing. Before
+ * this the key field was a stacked label over a full-width input with no card
+ * at all, and flipping the mode changed the shape of the step rather than its
+ * content.
+ *
+ * The variable name is the label rather than a sentence about it. Someone
+ * pasting a key knows which one they are holding; what they cannot know is where
+ * this step will put it, and the name answers that in the place it is asked —
+ * while staying short enough to sit opposite the field the way "Sign in to the
+ * environment" sits opposite its button.
+ */
+export function ApiKeyField({
+ envKey,
+ value,
+ onChange,
+}: {
+ envKey: string;
+ value: string;
+ onChange: (next: string) => void;
+}) {
+ const inputRef = useRef(null);
+
+ // Focus on mount, because the canvas only opens when this is the thing that
+ // was asked for. Layout effect so it happens before paint rather than as a
+ // visible jump after it.
+ useLayoutEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/ConnectModelPreview.tsx b/ui/src/components/onboarding/ConnectModelPreview.tsx
new file mode 100644
index 0000000000..fdd6f2acb2
--- /dev/null
+++ b/ui/src/components/onboarding/ConnectModelPreview.tsx
@@ -0,0 +1,152 @@
+import { useState } from "react";
+import { MotionConfig } from "motion/react";
+
+import { Checkbox } from "../ui/checkbox";
+import { AgentPreview } from "./AgentPreview";
+import { CredentialModeLink } from "./CredentialModeLink";
+import { FooterNav } from "./FooterNav";
+import {
+ ModelSourceTiles,
+ type CredentialMode,
+ type ModelSource,
+} from "./ModelSourceTiles";
+import { OnboardingHeading } from "./OnboardingPrimitives";
+import { PillGuy } from "./PillGuy";
+import { SleepingZs } from "./SleepingZs";
+import { Stepper } from "./Stepper";
+
+/**
+ * A prototype of the connect step, from the PCLP-Onboarding file (nodes
+ * 2941:8291 and 2933:4592).
+ *
+ * A mock, not the shipped step. The wizard's real step 4 puts two adapter cards
+ * over an advanced-settings disclosure and probes the environment before
+ * hiring; none of that is wired up here. What is here is the part the design is
+ * actually asking a question about — how the row of sources reads as you point
+ * at it, pick one, and flip the whole row between subscription and API
+ * credentials — so it can be judged before any of that machinery is moved.
+ *
+ * It lives in `components/` rather than beside a story because two surfaces
+ * render it: the Storybook stories, and the standalone
+ * `connect-model-preview.html` entry that gets deployed for review. A copy in
+ * each would have drifted the moment one was tweaked.
+ *
+ * Nothing here reaches a backend, and it needs none of the app's providers —
+ * every piece it composes is presentational.
+ */
+
+/**
+ * The two sources the step offers, matching the shipped step's own list.
+ *
+ * Claude Code and Codex are the only adapters the display registry marks
+ * `recommended`, and the real step builds its row from exactly that filter — so
+ * a third tile here would be a design the wizard could never render. OpenCode
+ * was drawn at one point and is deliberately gone.
+ */
+const MODEL_SOURCES: ModelSource[] = [
+ {
+ id: "claude_local",
+ label: "Claude Code",
+ icon: ,
+ },
+ {
+ id: "codex_local",
+ label: "Codex",
+ icon: ,
+ },
+];
+
+/**
+ * Which control flips the credential mode. Two alternates of the same
+ * behaviour, kept side by side so they can be compared rather than argued
+ * about:
+ *
+ * `checkbox` is the Figma frames — a ticked box reading "Use API keys instead",
+ * which shows the current state plainly and costs a row of chrome.
+ *
+ * `link` is a line of text that renames itself on press. Lighter, and it turns
+ * the row into a single sentence, but it can only ever name the destination —
+ * so where you are now is left entirely to the tiles' tags.
+ */
+export type CredentialControl = "checkbox" | "link";
+
+export function ConnectModelPreview({
+ initialSourceId = null,
+ initialUseApiKeys = false,
+ control = "checkbox",
+}: {
+ initialSourceId?: string | null;
+ initialUseApiKeys?: boolean;
+ control?: CredentialControl;
+}) {
+ const [selectedId, setSelectedId] = useState(initialSourceId);
+ const [useApiKeys, setUseApiKeys] = useState(initialUseApiKeys);
+ const mode: CredentialMode = useApiKeys ? "api" : "subscription";
+
+ return (
+ // The arc's own convention: OS-level reduced motion neutralises the
+ // movement, and every piece below still arrives in its final state.
+
+
+ {/* Connect is the arc's second step. `Stepper` carries its own bottom
+ margin, which is the gap the frame wants under the dots. */}
+
+
+
+ {/* `relative` is load-bearing: the sleep marks anchor to this box and
+ travel out past its top-right corner. */}
+
+
+ {/* The CTA has nothing to connect until a source is picked, so it stays
+ disabled rather than failing on press. */}
+ {}}
+ primaryLabel="Connect"
+ primaryDisabled={selectedId === null}
+ onPrimary={() => {}}
+ />
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/CredentialModeLink.tsx b/ui/src/components/onboarding/CredentialModeLink.tsx
new file mode 100644
index 0000000000..cfd85d7d15
--- /dev/null
+++ b/ui/src/components/onboarding/CredentialModeLink.tsx
@@ -0,0 +1,89 @@
+import { AnimatePresence, motion } from "motion/react";
+
+import { cn } from "../../lib/utils";
+import type { CredentialMode } from "./ModelSourceTiles";
+import { LINK_LABEL_FADE_IN, LINK_LABEL_FADE_OUT } from "./onboarding-motion";
+
+/**
+ * The credential-mode switch as a line of text instead of a checkbox — an
+ * alternate for the connect step, not a replacement.
+ *
+ * The label names the destination rather than the state: "Use API keys
+ * instead" while on the subscription, "Use subscription instead" once on API
+ * keys. That is what makes a link work where a checkbox does not — a checkbox
+ * can be ticked or not and reads the same either way, whereas a bare sentence
+ * has to say what pressing it does. The consequence is that this control never
+ * shows you where you are; the tiles' tags do that, and this alternate only
+ * holds up because they are right above it.
+ */
+
+const LINK_LABEL: Record = {
+ subscription: "Use API keys instead",
+ api: "Use subscription instead",
+};
+
+const OTHER_MODE: Record = {
+ subscription: "api",
+ api: "subscription",
+};
+
+export function CredentialModeLink({
+ mode,
+ onChange,
+}: {
+ mode: CredentialMode;
+ onChange: (next: CredentialMode) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/ui/src/components/onboarding/FooterNav.tsx b/ui/src/components/onboarding/FooterNav.tsx
index 2636d714e3..5159d6a838 100644
--- a/ui/src/components/onboarding/FooterNav.tsx
+++ b/ui/src/components/onboarding/FooterNav.tsx
@@ -25,14 +25,19 @@ export function FooterNav({
onPrimary: () => void;
}) {
return (
-
+
{onBack ? (
- // has-[>svg]:pr-4 gives "Back" room from the pill's right edge,
- // overriding size="sm"'s symmetric padding on that side only.
+ // Same size as the primary, not a tier down. Back is ghost until you
+ // point at it, and a shorter pill made the hover surface read as a
+ // different kind of control sitting slightly low in the row rather than
+ // the other half of a pair.
+ //
+ // The padding stays asymmetric against size="lg"'s symmetric px-4: the
+ // arrow needs less room on its side than the word does on its own.