feat(onboarding): the connect step signs in from its own button (#12801)

Connect starts the sign-in; the card that appears is the sign-in rather
than an offer of one; success advances to Review rather than reporting
itself. The two logins end in different places and the button says which:
Claude submits a code back here, so it spins on "Connecting"; OpenAI
finishes in another tab, so it stays a still, disabled Next until the
poll lands.

AdapterLoginPanel grows autoStart / onCancel / onConnected / chrome
rather than a second implementation — the session start, both polls, the
server deadline, the one-shot completion read and the unmount release are
the parts onboarding needs unchanged. Every prop is off by default, so
the agent form and the new-agent page render what they did before.

Claude's code auto-submits on the paste, not on every change:
isValidBrowserCode accepts any printable ASCII from one character up, so
a value-driven submit fired on the first keystroke of anyone who typed.

Also orders the OpenAI card and the settings displayed-code panel
code-above-link, with the instruction worded to match, and releases the
displayed-code session on unmount so an abandoned login stops holding the
one-per-owner reservation.
This commit is contained in:
Tonio 2026-09-03 21:45:45 -07:00 committed by GitHub
parent 9ef3b087c1
commit 1a74719309
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1534 additions and 124 deletions

View File

@ -92,10 +92,14 @@ async function runOnboardingWizard(page: Page, companyName: string) {
await source.waitFor({ timeout: 30_000 });
await source.click();
// The forward button reads "Next" here too, so wait for it to enable rather
// than for it to appear — it is already on screen, disabled, and clicking a
// disabled button raises nothing and does nothing.
const connectNext = page.getByRole("button", { name: /^Next$/ });
// "Connect", not "Next": this step's button starts the sign-in where there
// is one to start, so it is named for what it does. Here there is none —
// this instance has no sandbox environment, so the step has no login to
// offer and Connect goes straight to the hire.
//
// Waited on for enabled rather than for visible: it is already on screen,
// disabled, and clicking a disabled button raises nothing and does nothing.
const connectNext = page.getByRole("button", { name: /^Connect$/ });
await expect(connectNext).toBeEnabled({ timeout: 30_000 });
await connectNext.click();

View File

@ -117,7 +117,7 @@ test.describe("Onboarding wizard", () => {
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
test("adapter step shows the login panel from the cheap auth signal, and blocks the hire on a failed test", async ({
test("connect step starts the sign-in on Connect, rather than hiring, when the signal reports no credential", async ({
page,
}) => {
const pageErrors: string[] = [];
@ -191,6 +191,39 @@ test.describe("Onboarding wizard", () => {
}),
);
// The sign-in Connect now starts. Stubbed so the card is deterministic:
// the session start answers, and the guarded prompt read hands back an
// authorization URL for the card's link row.
await page.route("**/setup-token-login-sessions", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sessionId: "e2e-setup-token-session",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
}),
}),
);
await page.route("**/setup-token-login-sessions/*/prompt", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
authorizationUrl: "https://claude.ai/oauth/authorize?code=true&client=e2e",
transportAdvisory: null,
}),
}),
);
await page.route("**/setup-token-login-sessions/e2e-setup-token-session", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sessionId: "e2e-setup-token-session",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
}),
}),
);
// Fail the adapter test the "Connect" button runs, so the hire gate
// blocks the create and this test can prove no agent is hired.
await page.route("**/test-environment", (route) =>
@ -240,35 +273,107 @@ test.describe("Onboarding wizard", () => {
await page.locator("#onboarding-agent-name").fill("Ada");
await page.getByRole("button", { name: "Next" }).click();
// Step 4 (Connect a model): pick a source. The step arrives with nothing
// selected — the tile row is a question, not a confirmation — and the login
// panel is what the answer opens, so there is nothing to assert until one
// is pressed. By role rather than by label, because which adapters the row
// offers depends on the registry this environment reports.
// Step 4 (Connect a model): pick a source. Nothing to assert yet — the card
// *is* the sign-in now, so it does not exist until Connect is pressed. By
// role rather than by label, because which adapters the row offers depends
// on the registry this environment reports.
const source = page.getByRole("radio").first();
await source.waitFor({ timeout: 30_000 });
await source.click();
// The signal above reports no ready credential, so the login panel must now
// show, with no button to reuse a saved login.
//
// 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,
});
const cardInstruction = page.getByText("Open Claude link then come back and enter code");
await expect(cardInstruction).toHaveCount(0);
// Connect starts the sign-in instead of hiring, which is the ordering the
// step exists to enforce: a hire here would file an agent with no
// credential to run on. Waited on for *enabled* rather than for visible —
// it is already on screen, and clicking a disabled button does nothing.
const connect = page.getByRole("button", { name: "Connect", exact: true });
await expect(connect).toBeEnabled({ timeout: 30_000 });
await connect.click();
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
await expect(
page.getByRole("link", { name: /claude\.ai\/oauth\/authorize/ }),
).toBeVisible({ timeout: 15_000 });
// No "Use saved login" here: the hire step applies a stored login itself.
await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0);
expect(hireCalled).toBe(false);
// The CTA reads "Next" on this step as on the one before it. Waited on for
// *enabled* rather than for visible: it is already on screen and disabled
// until the environment probe settles, and clicking a disabled button
// raises nothing and does nothing.
const connectNext = page.getByRole("button", { name: "Next", exact: true });
await expect(connectNext).toBeEnabled({ timeout: 30_000 });
await connectNext.click();
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
test("connect step blocks the hire when the environment probe fails and no sign-in is needed", async ({
page,
}) => {
// The other half of what the test above used to cover. The two claims are
// different situations now: there, an absent credential makes Connect start
// a sign-in; here there is no sandbox to sign in against — this throwaway
// instance only auto-creates the local environment, and nothing below adds
// one — so Connect goes straight to the probe, and the probe is the gate.
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
// The failed test blocks the hire and shows its own checks.
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
expect(flagRes.ok()).toBe(true);
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
adapterType: "claude_local",
status: "fail",
checks: [
{
code: "claude_cli_not_found",
level: "fail",
message: "The claude CLI was not found on this host.",
},
],
testedAt: new Date().toISOString(),
}),
}),
);
let hireCalled = false;
await page.route("**/agent-hires", (route) => {
hireCalled = true;
return route.continue();
});
await page.goto("/onboarding");
const startBtn = page.getByRole("button", {
name: /Start Onboarding|New Organization|Add Agent/,
});
if (await startBtn.count()) {
await startBtn.first().click();
}
const createCard = page.getByRole("button", { name: /Build a new organization/ });
if (await createCard.count()) {
await createCard.first().click();
}
await expect(
page.getByRole("heading", { name: "What is the name of your organization?" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("e.g. Northwind Labs").fill(`${COMPANY_NAME}-probe-gate`);
await page.getByRole("button", { name: /^Continue/ }).click();
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
await page.locator("#onboarding-agent-name").fill("Ada");
await page.getByRole("button", { name: "Next" }).click();
const source = page.getByRole("radio").first();
await source.waitFor({ timeout: 30_000 });
await source.click();
const connect = page.getByRole("button", { name: "Connect", exact: true });
await expect(connect).toBeEnabled({ timeout: 30_000 });
await connect.click();
// The failed probe blocks the hire and shows its own checks.
await expect(page.getByText("The claude CLI was not found on this host.")).toBeVisible({
timeout: 15_000,
});

View File

@ -69,14 +69,17 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await page.getByRole("button", { name: /^Next$/ }).click();
// The connect step arrives with no source selected — the tile row is a
// question, not a confirmation — so its CTA, which reads "Next" here too,
// stays disabled until one is pressed. Waited on for enabled rather than
// visible: it is already on screen, and clicking a disabled button raises
// nothing and does nothing.
// question, not a confirmation — so its CTA stays disabled until one is
// pressed. It reads "Connect", not "Next": the button starts the sign-in
// where there is one to start. This instance has no sandbox environment, so
// there is none, and Connect goes straight to the hire.
//
// Waited on for enabled rather than visible: it is already on screen, and
// clicking a disabled button raises nothing and does nothing.
const source = page.getByRole("radio").first();
await source.waitFor({ timeout: 30_000 });
await source.click();
const connectNext = page.getByRole("button", { name: /^Next$/ });
const connectNext = page.getByRole("button", { name: /^Connect$/ });
await expect(connectNext).toBeEnabled({ timeout: 30_000 });
await connectNext.click();

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en" class="dark" style="color-scheme: dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#18181b" />
<title>Connect flow — Paperclip onboarding</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>
/*
Unlayered, for the same reason `connect-model-preview.html` carries the
same rule: index.css pins `html, body` to viewport height with
`overflow: hidden` inside @layer base, which is right for the app shell
and clips this page's footer out of reach on a short window.
*/
html,
body {
height: auto;
min-height: 100%;
overflow-y: auto;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/connect-flow-preview-main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,251 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Copy, Check } from "lucide-react";
import { Button } from "./ui/button";
import { copyTextToClipboard } from "../lib/clipboard";
/**
* Which shell a login panel draws itself in.
*
* `panel` is the settings-side chrome the login panels have always had: a
* titled card with its own "Sign in" button, sitting under an environment test
* in the agent configuration form. It stays the default, because two surfaces
* (the agent form and the new-agent page) render the panel that way and neither
* is being redesigned here.
*
* `onboarding` is the connect step's card. The difference is not skin-deep: the
* step's own footer button starts the login, so the panel has no "Sign in"
* control of its own, and success is not something the card reports the step
* moves on. What is left is the part the customer acts on, which is the
* instruction, the link, and the code.
*/
export type AdapterLoginChrome = "panel" | "onboarding";
/**
* The connect step's login card: an instruction with a Cancel beside it, then
* the rows the customer works through.
*
* The rows are the caller's, because the two login modes genuinely differ in
* the last one Claude takes a code back, OpenAI hands one out while
* everything above it is the same card. Passing children rather than a variant
* flag keeps that difference where it actually lives.
*/
export function OnboardingLoginCard({
instruction,
onCancel,
children,
}: {
instruction: string;
onCancel?: () => void;
children: ReactNode;
}) {
return (
<div className="rounded-xl bg-muted/40 px-4 py-3.5 flex flex-col gap-4">
{/* No `gap`: `justify-between` already holds the two apart, and the eight
pixels a gap reserves are eight the instruction does not have. The
longest of these strings needs the full width between the inset and
Cancel to stay on one line, which is how the design draws it a gap
here wrapped it onto a second.
It is still allowed to wrap rather than being pinned to one line: a
translation longer than the English will not fit however the row is
divided, and two lines is a better failure than an overflow. */}
{/* The instruction is a step down from Cancel, which is the hierarchy the
design draws the label describes, the button acts.
It is also what keeps the longest of these strings on one line. The
frame's own label measures 281px, and this string at 12px Inter
measures 280px, where at 14px it needs 327px in a row that has 327px
to give and wraps on the rounding. Matching the width the design
actually renders is the closer reading of it than matching a nominal
size in a font it was not drawn in. */}
<div className="flex items-center justify-between pl-2">
<span className="text-xs text-muted-foreground">{instruction}</span>
{onCancel && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2.5 text-sm font-medium"
onClick={onCancel}
>
Cancel
</Button>
)}
</div>
{children}
</div>
);
}
/**
* One row inside the card: a 44px surface a shade lighter than the card itself.
*
* The lift is what makes the rows read as things to act on rather than lines of
* the paragraph above them, and it is the same step the step's own name field
* uses, so the two screens agree about what an input looks like.
*/
function LoginCardRow({ children }: { children: ReactNode }) {
return (
<div className="flex h-(--sz-44px) items-center gap-2 rounded-lg bg-muted pl-5 pr-2.5">
{children}
</div>
);
}
/**
* The copy control at the right edge of a row.
*
* It swaps to a check for a moment after a copy, which is the only feedback a
* clipboard write can honestly give the write either happened or it did not,
* and there is nothing to show for it on screen otherwise. `onCopied` lets a
* row that wants a word as well as a mark hear about it.
*/
function LoginCardCopyButton({
value,
label,
onCopied,
}: {
value: string;
label: string;
onCopied?: () => void;
}) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
title={label}
className="size-6 shrink-0 text-muted-foreground hover:text-foreground [&_svg]:size-4"
onClick={async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
onCopied?.();
} catch {
setCopied(false);
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? <Check /> : <Copy />}
</Button>
);
}
/**
* The authentication link.
*
* Underlined and an actual anchor, because the instruction above it says to
* open it the copy button beside it is for the case where the login is being
* finished in another browser, not the primary path. It truncates rather than
* wrapping: these URLs carry a query string long enough to push the row to
* three lines, and none of that tail tells the reader anything.
*/
export function OnboardingLoginUrlRow({ url }: { url: string }) {
return (
<LoginCardRow>
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="min-w-0 flex-1 truncate font-mono text-xs text-foreground underline underline-offset-4"
>
{url}
</a>
<LoginCardCopyButton value={url} label="Copy the authentication link" />
</LoginCardRow>
);
}
/**
* The one-time code, for the login that hands one out.
*
* The word beside the button rather than only the mark on it: this code is
* carried to another device, so the confirmation has to survive being read from
* a step away.
*/
export function OnboardingLoginCodeRow({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
return (
<LoginCardRow>
<span className="min-w-0 flex-1 truncate font-mono text-sm tracking-wide text-foreground">
{code}
</span>
{copied && <span className="shrink-0 text-xs text-muted-foreground">copied!</span>}
<LoginCardCopyButton
value={code}
label="Copy the code"
onCopied={() => {
setCopied(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
}}
/>
</LoginCardRow>
);
}
/**
* The field the browser code is pasted back into.
*
* No Submit button beside it: the code arrives in one piece, off the clipboard,
* so the paste is the answer and a press after it confirms nothing the paste
* did not already say.
*
* `onPaste` is what the caller submits on, and it is separate from `onChange`
* on purpose. There is no shape that says "this code is complete"
* `isValidBrowserCode` accepts any run of printable ASCII from one character up,
* deliberately, because the provider's exact format is not pinned down so a
* submit driven by the value alone fires on the first keystroke of anyone who
* types instead of pasting. Enter stays for them.
*/
export function OnboardingLoginCodeInput({
value,
onChange,
onSubmit,
onPaste,
disabled,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onPaste?: () => void;
disabled?: boolean;
}) {
return (
<input
aria-label="Authorization code"
type="text"
autoComplete="off"
spellCheck={false}
placeholder="Paste authorization code here"
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onPaste={() => onPaste?.()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSubmit();
}
}}
className="h-(--sz-44px) w-full rounded-lg bg-muted px-5 font-mono text-xs text-foreground placeholder:font-sans placeholder:text-sm placeholder:text-muted-foreground outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)"
/>
);
}

View File

@ -1441,6 +1441,19 @@ describe("AgentConfigForm environment selector", () => {
expect(mockClipboard.copyTextToClipboard).toHaveBeenCalledWith("WXYZ-1234");
expect(mockClipboard.copyTextToClipboard).toHaveBeenCalledWith("https://auth.example.test/device");
// Code above URL, and the numbering agrees. Opening the page is what leaves
// this screen for a form that wants the code from it, so the code is read
// while it is still in front of you. The panel used to run the other way.
const labels = [...result.container.querySelectorAll("div")]
.map((el) => el.textContent?.trim())
.filter((t) => t === "1. Code" || t === "2. Authentication URL");
expect(labels).toEqual(["1. Code", "2. Authentication URL"]);
const codeIndex = result.container.textContent!.indexOf("WXYZ-1234");
const urlIndex = result.container.textContent!.indexOf("https://auth.example.test/device");
expect(codeIndex).toBeGreaterThan(-1);
expect(urlIndex).toBeGreaterThan(codeIndex);
});
it("keeps the code and URL visible after a later poll returns no prompt", async () => {
@ -1511,6 +1524,36 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.textContent).not.toContain("WXYZ-1234");
});
it("releases an active login session when the panel unmounts", async () => {
// The server holds a one-per-owner reservation until the session reaches a
// terminal state, so a panel that disappears mid-login would leave the owner
// unable to start another until it expires.
//
// Reachable in the settings form only by navigating away, which is why this
// went unnoticed. The connect step unmounts the panel routinely — Cancel
// closes the canvas, switching source remounts it under a new key, closing
// the wizard drops it — so the cleanup is what keeps an immediate retry
// possible. Deliberately not pushed to `roots`: this test does the unmount
// itself, and that unmount is the thing under test.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
await runTest(result.container);
await startLogin(result.container);
expect(findButton(result.container, "Cancel"), "the login should be active").toBeTruthy();
mockAgentsApi.cancelAdapterAuthLogin.mockClear();
await act(async () => {
result.root.unmount();
});
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalledWith(
"company-1",
"codex_local",
"session-1",
);
});
it("announces the login state through a polite live region", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();

View File

@ -35,6 +35,13 @@ import { Button } from "@/components/ui/button";
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import {
OnboardingLoginCard,
OnboardingLoginCodeInput,
OnboardingLoginCodeRow,
OnboardingLoginUrlRow,
type AdapterLoginChrome,
} from "./AdapterLoginChrome";
import {
resolveAdapterTestEnvironmentId,
resolveLocalDefaultEnvironmentId,
@ -1999,9 +2006,32 @@ export type AdapterLoginDescriptor = {
// `onApplyStored` binds the fixed reference to an existing stored login with no
// new login round trip. The panel shows the apply-existing affordance only when
// the status route reports a stored value.
// `autoStart`, `onCancel`, `onConnected` and `chrome` are what the onboarding
// connect step needs, and each is off or absent by default so the two settings
// surfaces that render this panel keep the behaviour they have.
//
// They are props on the existing panels rather than a second implementation
// because the part onboarding needs unchanged is the whole of it: the session
// start, the two polls, the server deadline, the one-shot completion read, the
// unmount release. A copy drawn to the new design would have had to reproduce
// all of that correctly, and the first thing to rot would have been the
// timeout and cleanup paths, which are the ones nobody exercises by hand.
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
onStored?: (storedSessionId: string) => void;
onApplyStored?: () => void;
// Start the login on mount instead of waiting for a press. The connect step's
// footer button is the press — by the time the panel is rendered there, the
// customer has already asked for this.
autoStart?: boolean;
// The customer abandoned the login from inside the card. The panel has
// already cancelled the server session by the time this fires; the caller
// uses it to put its own control back to the state it started in.
onCancel?: () => void;
// The login reached its success state. Onboarding advances on this, which is
// why the `onboarding` chrome draws no success state of its own — the screen
// it would appear on is already gone.
onConnected?: () => void;
chrome?: AdapterLoginChrome;
};
// The login panel dispatcher. It picks the panel from the projected panel mode,
@ -2040,6 +2070,10 @@ function DisplayedCodeLoginPanel({
companyId,
adapterType,
environmentId,
autoStart,
onCancel,
onConnected,
chrome = "panel",
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [startError, setStartError] = useState<string | null>(null);
@ -2100,6 +2134,128 @@ function DisplayedCodeLoginPanel({
const isActive = Boolean(sessionId) && !isTerminal;
const startDisabled = startLogin.isPending || isActive;
// Release the server session at once, without a change to the panel state, the
// way the submitted-browser-code panel does. The server holds a per-owner
// reservation until the session reaches a terminal state, so an abandoned
// session locks the owner out until the server deadline. Fire-and-forget: a
// 404 means the server already removed a terminal session, and a cleanup path
// cannot surface any other error either, so it drops them all. The manual
// Cancel button keeps using the `cancelLogin` mutation, because that path also
// returns the panel to its idle start state.
const releaseServerSession = useCallback(
(id: string) => {
void agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id).catch(() => {
// Drop the error, as above.
});
},
[companyId, adapterType],
);
// Hold the active session id for the unmount cleanup. Onboarding removes this
// panel as soon as Cancel is pressed — `handleCancel` fires the request and
// calls `onCancel` without waiting for it — so the panel can be gone before
// the cancel resolves. Without this, a failed cancel, or any other unmount
// (navigating away, the step advancing), would leave the reservation held
// until the server deadline and an immediate retry unable to start. The ref is
// null once the session leaves the active state, so the cleanup never cancels
// a session the server already removed.
const activeSessionRef = useRef<string | null>(null);
activeSessionRef.current = isActive ? sessionId : null;
useEffect(() => {
return () => {
const id = activeSessionRef.current;
if (id) releaseServerSession(id);
};
}, [releaseServerSession]);
// Start once, on mount, when the caller has already taken the press. The ref
// is the guard rather than the mutation's own pending flag: `startLogin`
// settles, and without a latch a re-render after it settles would read "not
// pending, no session yet" during the gap before the session id lands and
// start a second login the server would count against the per-owner cap.
const autoStartedRef = useRef(false);
const startLoginRef = useRef(startLogin.mutate);
startLoginRef.current = startLogin.mutate;
useEffect(() => {
if (!autoStart || autoStartedRef.current) return;
autoStartedRef.current = true;
startLoginRef.current();
}, [autoStart]);
// Report success upward once. `authenticated` is this panel's terminal
// success: unlike the Claude login there is no completion read after it, so
// the status is the whole of the news.
const connectedRef = useRef(false);
const onConnectedRef = useRef(onConnected);
onConnectedRef.current = onConnected;
useEffect(() => {
if (status !== "authenticated" || connectedRef.current) return;
connectedRef.current = true;
onConnectedRef.current?.();
}, [status]);
const handleCancel = () => {
cancelLogin.mutate();
onCancel?.();
};
if (chrome === "onboarding") {
return (
<OnboardingLoginCard
// Reads in the order the rows sit in, and in the order they are used:
// the code first, because the link is what leaves this screen. The
// sibling card's "Open Claude link then come back and enter code" has
// the same shape — one sentence, "then" for the hand-off — because
// there the returning is the part worth saying.
instruction={
prompt ? "Copy this code then open the authentication link" : "Starting the sign-in…"
}
onCancel={isActive ? handleCancel : undefined}
>
{/* The two rows are the whole card once the prompt lands. Before it
does there is nothing to show but the wait, and after a failure
there is nothing to act on so both of those are a line of text,
not a row. */}
{startError && (
<p role="alert" className="pl-2 text-xs text-destructive">
{startError}
</p>
)}
{isActive && !prompt && !startError && (
<p className="flex items-center gap-2 pl-2 text-xs text-muted-foreground">
<Loader2 className="size-3 shrink-0 animate-spin" />
Preparing
</p>
)}
{/* Code above link, which is the reverse of the settings panel below.
The panel numbers its two rows and its instruction reads "open the
page and enter the code", so there the order follows the sentence.
Here the order follows the hands. The link is the last thing touched
and the first thing that takes attention away press it and the
next screen is a device-code form in another tab, wanting the code
that was on this one. Putting the code above it means it has already
been read, and copied, before the link is there to be pressed. */}
{prompt && (
<>
<OnboardingLoginCodeRow code={prompt.code} />
<OnboardingLoginUrlRow url={prompt.url} />
</>
)}
{isTerminal && status && status !== "authenticated" && (
<p role="alert" className="pl-2 text-xs text-destructive">
{status === "timed_out"
? "The login timed out. Start it again."
: status === "cancelled"
? "The login was cancelled."
: "The login did not finish. Start it again."}
</p>
)}
</OnboardingLoginCard>
);
}
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 flex flex-col gap-2">
{/* `gap`, not `space-y`: the live region below collapses to
@ -2154,16 +2310,36 @@ function DisplayedCodeLoginPanel({
{isActive && prompt && (
<div className="space-y-2">
<div className="text-(length:--text-micro) text-muted-foreground">
Open the authentication page and enter the code.
Copy the code, then open the authentication page.
</div>
{/* URL first, then the code. The instruction above says to open the
page and *then* enter the code, and the numbering now says the
same so the order the two rows appear in has to agree with both,
rather than handing over the code before the page it belongs to. */}
{/* Code first, then the URL, and the sentence and the numbering both
say so.
This used to run the other way, on the reasoning that handing over
a code before the page it belongs to was getting ahead of the
customer. What that missed is where the two rows are used: opening
the page is what leaves this screen, and the form waiting on the
other side wants the code that was on this one. Reaching back for
it is the step worth removing, so the code is read and copied
while it is still in front of you.
The onboarding card is ordered the same way and for the same
reason. The Claude panel below is not, and should not be its
second row is a field to type *into*, so there the page genuinely
does come first. */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
1. Authentication URL
1. Code
</div>
<span className="font-mono text-xs text-foreground break-all">{prompt.code}</span>
</div>
<AdapterLoginCopyButton value={prompt.code} label="Copy code" />
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
2. Authentication URL
</div>
<span className="font-mono text-xs text-foreground break-all">{prompt.url}</span>
</div>
@ -2184,15 +2360,6 @@ function DisplayedCodeLoginPanel({
</Button>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
2. Code
</div>
<span className="font-mono text-xs text-foreground break-all">{prompt.code}</span>
</div>
<AdapterLoginCopyButton value={prompt.code} label="Copy code" />
</div>
</div>
)}
@ -2245,6 +2412,10 @@ function SubmittedBrowserCodeLoginPanel({
environmentId,
onStored,
onApplyStored,
autoStart,
onCancel,
onConnected,
chrome = "panel",
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [startError, setStartError] = useState<string | null>(null);
@ -2558,6 +2729,128 @@ function SubmittedBrowserCodeLoginPanel({
setBrowserCode("");
};
// Start once, on mount, when the caller has already taken the press. Latched
// for the same reason as the displayed-code panel: a second start would burn
// an owner reservation, and here it would also rotate the stored token twice.
const autoStartedRef = useRef(false);
const startLoginRef = useRef(startLogin.mutate);
startLoginRef.current = startLogin.mutate;
useEffect(() => {
if (!autoStart || autoStartedRef.current) return;
autoStartedRef.current = true;
startLoginRef.current();
}, [autoStart]);
/**
* Submit the pasted code without a press.
*
* Only in the onboarding chrome, and only here: this is the login where the
* code comes *back* off the clipboard, so the paste is the answer and a
* Submit button after it adds a step that can be missed. The displayed-code
* login has no field to watch.
*
* Driven by the paste rather than by the value, which is the part that is
* easy to get wrong. `isValidBrowserCode` looks like a completeness check and
* is not one: it accepts any run of printable ASCII from a single character
* up, deliberately, because the provider's exact format has never been
* pinned down. Keying the submit off the value therefore fires on the first
* keystroke of anyone who types the code instead of pasting it submitting
* one character, failing, and clearing the field they were typing into.
*
* So the paste arms it and the shape check still gates it, which leaves
* typing to Enter. A paste that is not usable simply sits in the field.
*
* `submitCode.isPending` is inside `canSubmit` and `handleSubmit` clears the
* field, so one paste can only submit once.
*/
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const autoSubmit = chrome === "onboarding";
const pastedRef = useRef(false);
useEffect(() => {
if (!autoSubmit || !pastedRef.current) return;
pastedRef.current = false;
if (!canSubmit) return;
handleSubmitRef.current();
}, [autoSubmit, canSubmit, browserCode]);
// Report success upward once. The `stored` state is the only success state,
// which is why this watches `isStored` and not the `authenticated` status the
// completion read still has to follow.
const connectedRef = useRef(false);
const onConnectedRef = useRef(onConnected);
onConnectedRef.current = onConnected;
useEffect(() => {
if (!isStored || connectedRef.current) return;
connectedRef.current = true;
onConnectedRef.current?.();
}, [isStored]);
const handleCancel = () => {
cancelLogin.mutate();
onCancel?.();
};
if (chrome === "onboarding") {
return (
<OnboardingLoginCard
instruction={
authorizationUrl
? "Open Claude link then come back and enter code"
: "Starting the sign-in…"
}
onCancel={isActive ? handleCancel : undefined}
>
{startError && (
<p role="alert" className="pl-2 text-xs text-destructive">
{startError}
</p>
)}
{/* The plain-HTTP advisory survives the redesign. It is the one thing
on this card that is not about getting the login done, and dropping
it to keep the card tidy would remove a warning about a code
travelling in clear text. */}
{transportInsecure && (
<p className="flex items-start gap-2 pl-2 text-xs text-amber-700 dark:text-amber-200">
<TriangleAlert className="mt-0.5 size-3 shrink-0" />
This connection is not encrypted. The login code travels in clear text on this
network. Continue only on a network you trust.
</p>
)}
{isActive && !authorizationUrl && !startError && (
<p className="flex items-center gap-2 pl-2 text-xs text-muted-foreground">
<Loader2 className="size-3 shrink-0 animate-spin" />
Preparing
</p>
)}
{authorizationUrl && (
<>
<OnboardingLoginUrlRow url={authorizationUrl} />
{/* Disabled while the submitted code is in flight and while the
completion read runs, so a second paste cannot land on top of a
login that is already finishing. */}
<OnboardingLoginCodeInput
value={browserCode}
onChange={setBrowserCode}
onSubmit={handleSubmit}
onPaste={() => {
pastedRef.current = true;
}}
disabled={submitCode.isPending || isCompleting}
/>
</>
)}
{(isFailure || timedOut) && (
<p role="alert" className="pl-2 text-xs text-destructive">
{timedOut && !isFailure
? CLAUDE_LOGIN_TIMED_OUT_MESSAGE
: CLAUDE_LOGIN_FAILED_MESSAGE}
</p>
)}
</OnboardingLoginCard>
);
}
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 flex flex-col gap-2">
{/* `gap`, not `space-y`: the live region below collapses to

View File

@ -898,14 +898,19 @@ describe("OnboardingWizard — which step it lands on", () => {
}
/**
* The step's own CTA. By exact text, because "Back" sits beside it and both
* steps of the arc label their forward button the same way.
* The step's own CTA. By exact text, because "Back" sits beside it.
*
* Two labels rather than one: the connect step calls its forward button
* "Connect", since there the press starts a sign-in rather than simply
* advancing. The rest of the arc still says "Next". These tests are about
* where a press lands, so either will do.
*/
function stepCta(): HTMLButtonElement {
const cta = [...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
);
expect(cta, "the step should render its Next button").toBeTruthy();
const cta = [...document.body.querySelectorAll("button")].find((b) => {
const text = b.textContent?.trim();
return text === "Next" || text === "Connect";
});
expect(cta, "the step should render its forward button").toBeTruthy();
return cta as HTMLButtonElement;
}

View File

@ -67,6 +67,37 @@ const mockAgentsApi = vi.hoisted(() => ({
status: "present",
}),
),
// The sign-in routes. The connect step's Connect button starts a login rather
// than hiring when the signal says the source has no credential, so the two
// login shapes need enough of a server to reach the state the step is about:
// a session that is running, and a prompt to show for it.
startClaudeSetupTokenLogin: vi.fn(async () => ({
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
})),
getClaudeSetupTokenLoginStatus: vi.fn(async () => ({
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
})),
getClaudeSetupTokenLoginPrompt: vi.fn(async () => ({
authorizationUrl: "https://claude.ai/oauth/authorize?code=true",
transportAdvisory: null,
})),
cancelClaudeSetupTokenLogin: vi.fn(async () => ({})),
submitClaudeSetupTokenBrowserCode: vi.fn(async () => ({})),
completeClaudeSetupTokenLogin: vi.fn(async () => ({ storedSessionId: "stored-1" })),
startAdapterAuthLogin: vi.fn(async () => ({
sessionId: "codex-session-1",
status: "pending",
})),
getAdapterAuthLoginStatus: vi.fn(async () => ({
sessionId: "codex-session-1",
status: "pending",
prompt: { url: "https://auth.openai.com/codex/device", code: "Q2RJ-E1YIF" },
})),
cancelAdapterAuthLogin: vi.fn(async () => ({})),
}));
// The adapter registry mock below always returns this function, so a test
// can shape the built adapter config (e.g. a configured ANTHROPIC_API_KEY)
@ -162,19 +193,29 @@ vi.mock("../adapters/use-disabled-adapters", () => ({
// makes it undefined and the call throws.
useAdapterRegistryLoaded: () => true,
}));
// Adapters with a declared login capability, mirroring the real registry
// closely enough for the login-panel gate: `claude_local` and `codex_local`
// both support a sandbox login. Every other type has none, matching the
// real `useAdapterCapabilities` fallback for an unlisted type.
const ADAPTERS_WITH_LOGIN = new Set(["claude_local", "codex_local"]);
// Adapters with a declared login capability, mirroring the real registry:
// `claude_local` and `codex_local` both support a sandbox login, and every
// other type has none, matching the real `useAdapterCapabilities` fallback for
// an unlisted type.
//
// The panel modes are the real ones rather than one mode for both. They used to
// be, back when nothing outside the panel dispatcher read them. The connect
// step reads them now — the two logins end in different places, so its button
// waits differently for each — and a mock that called Claude's login
// `displayed_code` would have the step testing the wrong half of that.
// Reconcile with KNOWN_DEFAULTS in `adapters/use-adapter-capabilities.ts`.
const ADAPTER_LOGIN_MODES: Record<string, "displayed_code" | "submitted_browser_code"> = {
claude_local: "submitted_browser_code",
codex_local: "displayed_code",
};
vi.mock("../adapters/use-adapter-capabilities", () => ({
useAdapterCapabilities: () => (type: string) => ({
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
login: ADAPTERS_WITH_LOGIN.has(type)
? { panelMode: "displayed_code" as const, timeoutPolicy: "fixed" as const }
login: ADAPTER_LOGIN_MODES[type]
? { panelMode: ADAPTER_LOGIN_MODES[type]!, timeoutPolicy: "fixed" as const }
: undefined,
}),
}));
@ -245,6 +286,20 @@ async function pickFirstSource(
await click((text) => text === label);
}
/**
* The arc footer's primary button, whatever this step calls it.
*
* Step 4 calls it "Connect", because there it starts a sign-in rather than
* simply advancing; every other arc step calls it "Next". These tests are about
* what the press does, not what it reads, so they match either restating the
* label at twenty call sites would make a copy change look like a behaviour
* regression. The label itself is pinned once, in the step test that is about
* the label.
*/
function isArcPrimary(text: string): boolean {
return text.startsWith("Next") || text.startsWith("Connect");
}
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
beforeEach(() => {
mockAuthApi.getSession.mockResolvedValue({
@ -388,7 +443,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(document.body.textContent).toContain("Connect a model");
await pickFirstSource(clickByText);
@ -398,7 +453,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// Through Connect to Review, so the Mission-row assertion runs against
// the checklist that actually renders it — stopping at the model step
// would let a Mission regression pass unseen.
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
// The review step is the heading and the woken agent, nothing else: the
// checklist that restated the walk in three rows is gone, and with it
// the Mission row that could only render unchecked.
@ -460,9 +515,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickText((t) => t.startsWith("Next"));
await clickText((t) => isArcPrimary(t));
await pickFirstSource(clickText);
await clickText((t) => t.startsWith("Next"));
await clickText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
// The mock is declared with no parameters, so index the call rather than
@ -495,12 +550,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(document.body.textContent).toContain("Connect a model");
await pickFirstSource(clickByText);
const connect = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Next"),
isArcPrimary(b.textContent?.trim() ?? ""),
)!;
await act(async () => {
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -639,7 +694,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(document.body.textContent).toContain("Connect a model");
// Pick a source. The step arrives with nothing chosen — `adapterType`
@ -667,7 +722,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
@ -692,7 +747,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
@ -738,7 +793,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
setControlledValue(field, KEY);
});
await flushReact();
await handles.clickByText((t) => t.startsWith("Next"));
await handles.clickByText((t) => isArcPrimary(t));
return handles;
}
@ -821,7 +876,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
const { root, clickByText } = await connectWithApiKey();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockSecretsApi.createMyUserSecret).toHaveBeenCalledTimes(1);
@ -841,12 +896,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
// Switch to API keys, which changes the configuration the hire will send.
await clickByText((t) => t.startsWith("Use API key"));
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
@ -868,13 +923,13 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
// A second Connect must not treat the first (cached) blocking result as
// reusable — it re-probes, and the create path stays closed.
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
@ -889,7 +944,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
@ -914,7 +969,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// scenario this test is named for.
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
@ -939,7 +994,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
// The status route must not even be asked — the conflict is decided
@ -964,7 +1019,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
@ -989,7 +1044,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalled();
const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[];
@ -1008,7 +1063,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// The default `beforeEach` mock already rejects with a 404 `ApiError`.
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalled();
const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[];
@ -1062,7 +1117,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
);
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
@ -1085,7 +1140,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
@ -1106,10 +1161,10 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAgentsApi.hire.mockRejectedValue(new Error("hire failed"));
const { root, clickByText } = await openConnectStep();
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1);
await clickByText((t) => t.startsWith("Next"));
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(2);
await act(async () => root.unmount());
@ -1895,12 +1950,12 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
).toBe(false);
const cta = [...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
(b) => isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(cta, "the step should render its Next button").toBeTruthy();
expect(cta, "the step should render its forward button").toBeTruthy();
expect(
cta!.hasAttribute("disabled"),
"Next must not advance a question the step has not visibly asked",
"Connect must not advance a question the step has not visibly asked",
).toBe(true);
// And it opens again the moment the customer answers it themselves.
@ -1909,7 +1964,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
for (let i = 0; i < 5; i++) await flushReact();
const ctaAfter = [...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
(b) => isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(ctaAfter!.hasAttribute("disabled")).toBe(false);
@ -1936,11 +1991,11 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
).toBe(false);
const cta = [...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
(b) => isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(
cta!.hasAttribute("disabled"),
"Next must not hire an adapter the row never offered",
"Connect must not hire an adapter the row never offered",
).toBe(true);
await act(async () => root.unmount());
@ -2006,24 +2061,122 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
it("shows the login panel for claude_local when the signal reports no ready credential", async () => {
/** Press the step's forward button and let the sign-in queries settle. */
async function pressArcPrimary() {
const cta = [...document.body.querySelectorAll("button")].find((b) =>
isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(cta, "the step should render its forward button").toBeTruthy();
await act(async () => {
cta!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 8; i++) await flushReact();
}
it("starts the claude_local sign-in on Connect 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 Anthropic");
// Nothing before the press. The card *is* the sign-in now, so it does not
// exist until the step has been asked to start one — which is the whole
// difference between this step and the one it replaced.
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
await pressArcPrimary();
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled();
// Connect started a sign-in rather than hiring. The ordering is the point:
// a hire here would create an agent with no credential to run on.
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"Open Claude link then come back and enter code",
);
await act(async () => root.unmount());
});
it("shows the login panel for codex_local when the signal cannot decide", async () => {
it("submits the browser code on the paste, not on the first keystroke", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pressArcPrimary();
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement | null;
expect(field, "the Claude card should offer a code field").toBeTruthy();
// The trap this pins. `isValidBrowserCode` reads like a completeness
// check and is not one — it accepts any printable ASCII from a single
// character up — so an auto-submit keyed off the value fires here, on the
// first character of anyone typing the code rather than pasting it, and
// clears the field they are typing into.
await act(async () => {
setControlledValue(field!, "Q");
});
for (let i = 0; i < 4; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled();
// The paste is the answer, so it goes without a press. Order matches a
// real paste: the event lands before the value changes.
await act(async () => {
field!.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field!, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledWith(
"company-new",
"claude-session-1",
"Q2RJ-E1YIF-authorization-code",
);
await act(async () => root.unmount());
});
it("starts the codex_local sign-in on Connect 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 OpenAI");
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
await pressArcPrimary();
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
// The displayed-code login hands a code over rather than taking one back,
// so both halves of it have to reach the screen.
expect(document.body.textContent).toContain("Copy this code then open the authentication link");
expect(document.body.textContent).toContain("Q2RJ-E1YIF");
// Code above link, deliberately. Pressing the link is what takes the
// customer to another tab, and it wants the code that was on this one —
// so the code has to have been read before the link is there to press.
const code = [...document.body.querySelectorAll("span")].find(
(el) => el.textContent?.trim() === "Q2RJ-E1YIF",
);
const link = document.body.querySelector('a[href*="auth.openai.com"]');
expect(code, "the code row should render").toBeTruthy();
expect(link, "the link row should render").toBeTruthy();
expect(
code!.compareDocumentPosition(link!) & Node.DOCUMENT_POSITION_FOLLOWING,
"the code should come before the link",
).toBeTruthy();
await act(async () => root.unmount());
});
it("hides the login panel when the signal reports a ready credential", async () => {
it("hires on Connect, with no sign-in, 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 Anthropic");
await pressArcPrimary();
// The positive half is what makes this a test: a source that is already
// signed in goes straight to the hire, so Connect keeps its old meaning
// wherever there is no sign-in to do. Asserting only the absence of a
// card would pass just as well if the button had stopped working.
expect(mockAgentsApi.hire).toHaveBeenCalled();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
await act(async () => root.unmount());
});

View File

@ -670,6 +670,27 @@ function OnboardingWizardInner({
const [credentialMode, setCredentialMode] = useState<CredentialMode>(
(saved?.credentialMode as CredentialMode) ?? "subscription",
);
/**
* Whether Connect has been pressed for the current source.
*
* The step's footer button is what starts the sign-in now, so this is the
* whole of the difference between the card being absent and the card running:
* the panel is mounted with `autoStart` the moment this is true, and it
* cancels back to false. Deliberately not in the draft a login is a live
* server session with a deadline on it, and restoring a wizard an hour later
* into "connecting" would be describing a session that is long gone.
*/
const [loginStarted, setLoginStarted] = useState(false);
/**
* Whether that sign-in reached its success state.
*
* Only the displayed-code login ever sits here to be read: the browser-code
* login advances the step from its own success, so nothing gets the chance
* to render this. It exists because OpenAI's login finishes in another tab,
* with nothing to type back so the step has to wait, and the footer button
* is where the waiting shows.
*/
const [loginConnected, setLoginConnected] = useState(false);
/**
* 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
@ -1136,6 +1157,56 @@ function OnboardingWizardInner({
const connectStepReady =
sourceSelected && !adapterEnvLoading && !missionUnresolvedForHire;
/**
* Whether this step has a sign-in to do before it can hire.
*
* The same four conditions the card itself renders on, named once so the
* footer button and the card cannot disagree about whether a login is
* happening. When it is false an API key, a source already signed in on the
* sandbox, no sandbox to sign in against Connect goes straight to the hire,
* exactly as it did before.
*/
const connectStepNeedsLogin = Boolean(
credentialMode !== "api" &&
showAdapterLoginPanel &&
createdCompanyId &&
resolvedLoginEnvironmentId,
);
/**
* Whether the source's login ends by taking a code back from the customer.
*
* This is what splits the two waits, and it is a real difference rather than
* a cosmetic one. The browser-code login finishes here, in the field on the
* card, so the button is busy and says so. The displayed-code login finishes
* somewhere else entirely another tab, possibly another device so the
* button is not busy, it is waiting, and a spinner would be claiming work
* this screen is not doing.
*/
const loginSubmitsBrowserCode =
adapterCaps.login?.panelMode === "submitted_browser_code";
// Connect is pressed, the login is running, and it has not succeeded yet.
const connectStepLoggingIn =
connectStepNeedsLogin && loginStarted && !loginConnected;
/**
* What the step's primary action does, for both the button and Cmd+Enter.
*
* One function rather than the condition written twice. The keyboard path
* has drifted from the button here before the comment on its `step === 4`
* branch is about exactly that and the gap it left was a hire that skipped
* a check. This one would be worse: Cmd+Enter would hire before the sign-in
* it is meant to start, against a source with no credential.
*/
function handleConnectStepPrimary() {
if (connectStepNeedsLogin && !loginStarted) {
setLoginStarted(true);
return;
}
void handleGiveHeartbeat();
}
/**
* When the input canvas is open: exactly when a source has been chosen.
*
@ -1152,7 +1223,32 @@ function OnboardingWizardInner({
* unanswered, and the visible tiles are the thing to press. `showAdapterLoginPanel`
* still decides what goes *inside* the canvas only not whether it exists.
*/
const canvasOpen = sourceSelected;
/**
* The one thing that can be wrong here before anything is pressed: there is
* no sandbox to sign in against, so Connect cannot get anywhere. Worth saying
* on arrival rather than after a press that goes nowhere.
*
* Its two neighbours in the old canvas are not worth the same. "Checking this
* source's credentials" narrated a request nothing was waiting on, and "this
* source is already signed in" answered a question the customer had not asked
* yet both were written for a canvas that opened on selection, and the
* press is what opens it now.
*/
const connectStepHasNoSandbox =
credentialMode !== "api" && !canShowAdapterLogin && !authSignalUndecided;
/**
* Open once there is something in it: a key field, a sign-in that has been
* started, or the news that no sign-in is possible.
*
* It no longer opens on selection alone. The card is the sign-in itself now,
* and a sign-in starts when Connect is pressed so between picking a tile
* and pressing the button there is nothing to put here, and the step is the
* question and the button, which is what the design draws.
*/
const canvasOpen =
sourceSelected &&
(credentialMode === "api" || loginStarted || connectStepHasNoSandbox);
// 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
@ -1224,6 +1320,16 @@ function OnboardingWizardInner({
setAdapterEnvError(null);
}, [step, adapterType, model, command, args, url, credentialMode, apiKey]);
// A login belongs to one source in one credential mode. Switching either
// means the card on screen is answering a question nobody asked any more, so
// the step goes back to offering Connect. The panel is keyed on the adapter
// as well, so it unmounts on the same change and releases its server session
// on the way out.
useEffect(() => {
setLoginStarted(false);
setLoginConnected(false);
}, [adapterType, credentialMode]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
adapterEnvResult?.checks.some(
@ -2101,8 +2207,16 @@ function OnboardingWizardInner({
// the condition out here again is what let this path hire against a
// source the tile row had never shown, after the button was gated and
// this was not.
else if (step === 4 && agentName.trim() && connectStepReady)
handleGiveHeartbeat();
// Also gated on `connectStepLoggingIn`, the way the button is: a sign-in
// that is already running has nothing for this to do, and re-entering it
// would start a second server session.
else if (
step === 4 &&
agentName.trim() &&
connectStepReady &&
!connectStepLoggingIn
)
handleConnectStepPrimary();
else if (step === 5) handleLaunchToDashboard();
}
}
@ -2797,17 +2911,36 @@ function OnboardingWizardInner({
) : 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. */
/* The same panel the agent configuration form shows after
a test see AdapterLoginPanel in AgentConfigForm.tsx
in the connect step's chrome and driven by the step's
own footer button. `autoStart` is that button: mounting
only happens once Connect is pressed, so the press has
already been taken by the time the panel exists.
No "Use saved login" control: the hire step already
applies a stored login on its own. */
<AdapterLoginPanel
key={`${adapterType}:${resolvedLoginEnvironmentId}`}
companyId={createdCompanyId}
adapterType={adapterType}
environmentId={resolvedLoginEnvironmentId}
chrome="onboarding"
autoStart
onCancel={() => setLoginStarted(false)}
onConnected={() => {
setLoginConnected(true);
// The browser-code login ends here, on this screen,
// so the step moves on by itself — there is no
// success state to sit on, and one would be a screen
// whose only content is that you may continue.
//
// The displayed-code login does not: it is still
// running in another tab when this fires, and the
// customer's attention is there. It waits for the
// press, which is what the enabled Next is for.
if (loginSubmitsBrowserCode) void handleGiveHeartbeat();
}}
onStored={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.agents.authSignal(
@ -2819,20 +2952,18 @@ function OnboardingWizardInner({
}}
/>
) : (
/* 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. */
/* The canvas only opens without a panel for one reason
now `connectStepHasNoSandbox` and it is the reason
worth saying out loud, because it is the one that makes
Connect a dead press.
Its two former neighbours are gone with the canvas that
opened on selection: "already signed in" reassured
against a question nobody had asked, and "checking…"
narrated a request the customer was not waiting on.
Neither survives a canvas that opens on a press. */
<p className="text-xs text-muted-foreground">
{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."}
No managed sandbox is available to sign in against yet.
</p>
)}
</ConnectInputCanvas>
@ -3030,16 +3161,37 @@ function OnboardingWizardInner({
? "Continue"
: step === 5
? "Get started"
: "Next"
: step === 4
? // "Connect" is the step's own verb, and it is what
// starts the sign-in rather than what follows it.
//
// It becomes "Next" for the displayed-code login
// once that is running: at that point the sign-in
// is happening somewhere else, the button is not
// the thing doing it, and offering to "Connect" a
// second time would read as a retry.
connectStepLoggingIn && !loginSubmitsBrowserCode
? "Next"
: "Connect"
: "Next"
}
loadingLabel={
step === 1
? "Creating..."
: step === 4
? "Connecting..."
? "Connecting"
: "Launching..."
}
loading={step === 3 ? false : loading}
// The browser-code login is finished on this screen, so the
// button is genuinely busy for its duration and shows it. The
// displayed-code login is not — see `loginSubmitsBrowserCode`
// — so it stays a still, disabled Next instead of spinning
// against work happening in another tab.
loading={
step === 3
? false
: loading || (connectStepLoggingIn && loginSubmitsBrowserCode)
}
primaryDisabled={
step === 1
? !companyName.trim() || loading
@ -3052,7 +3204,11 @@ function OnboardingWizardInner({
// it, and be hired against whatever the draft
// happened to carry. See `connectStepReady`, which
// Cmd+Enter asks as well.
!connectStepReady || loading
!connectStepReady ||
loading ||
// A sign-in is running and has not landed. Nothing
// to press until it does.
connectStepLoggingIn
: loading || launchStateIncomplete
}
onPrimary={() => {
@ -3060,7 +3216,10 @@ function OnboardingWizardInner({
if (skipsMissionStep) void handleCreateCompany();
else setStep(2);
} else if (step === 3) setStep(4);
else if (step === 4) handleGiveHeartbeat();
// One button, two jobs — start the sign-in, or hire — and
// Cmd+Enter has to do the same thing. See
// `handleConnectStepPrimary`.
else if (step === 4) handleConnectStepPrimary();
else handleLaunchToDashboard();
}}
/>

View File

@ -0,0 +1,295 @@
import { StrictMode, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { MotionConfig } from "motion/react";
import { isValidBrowserCode } from "@paperclipai/shared";
import {
OnboardingLoginCard,
OnboardingLoginCodeInput,
OnboardingLoginCodeRow,
OnboardingLoginUrlRow,
} from "./components/AdapterLoginChrome";
import { AgentPreview } from "./components/onboarding/AgentPreview";
import { ConnectInputCanvas } from "./components/onboarding/ConnectInputCanvas";
import { CredentialModeLink } from "./components/onboarding/CredentialModeLink";
import { FooterNav } from "./components/onboarding/FooterNav";
import {
ModelSourceTiles,
type CredentialMode,
type ModelSource,
} from "./components/onboarding/ModelSourceTiles";
import { OnboardingHeading } from "./components/onboarding/OnboardingPrimitives";
import { PillGuy } from "./components/onboarding/PillGuy";
import { SleepingZs } from "./components/onboarding/SleepingZs";
import { Stepper } from "./components/onboarding/Stepper";
import "./index.css";
/**
* Backend-free walkthrough of the connect step's sign-in, deployed so the flow
* can be reviewed from a link rather than a checkout.
*
* The sibling of `connect-model-preview-main.tsx`, and the difference between
* them is the point of this one. That page renders `ConnectModelPreview`, a
* mock built to ask a question about the tile row. This one imports the
* *shipped* login card, rows and field from `components/AdapterLoginChrome`
* the same components the wizard renders so what is on screen is the
* implementation rather than a drawing of it. The step's furniture around them
* (stepper, avatar, heading, tiles, credential link, footer) is the real
* presentational set too.
*
* What is faked is only the server. The three delays below stand in for a
* session start, a prompt round trip, and the poll that lands while the
* customer is finishing a login somewhere else. The wizard itself is not here:
* it needs a query client, a router and a company.
*/
const PROMPT_DELAY_MS = 1200;
const SUBMIT_DELAY_MS = 900;
const POLL_DELAY_MS = 3200;
/**
* OpenAI's blossom, inlined the shipped step inlines it for the same reason.
* The supplied asset is a white fill that disappears on a light tile, and only
* an inline path can take `currentColor`. Keep in step with the copy in
* `OnboardingWizard.tsx`.
*/
function OpenAiBlossom({ className }: { className?: string }) {
return (
<svg viewBox="0 0 716 716" className={className} fill="none" aria-hidden>
<path
fill="currentColor"
d="M508.749 317.399C516.777 287.314 508.991 253.884 485.389 230.282C461.788 206.681 428.36 198.895 398.273 206.923C376.231 184.928 343.39 174.956 311.148 183.596C278.906 192.234 255.45 217.292 247.36 247.361C217.291 255.451 192.233 278.91 183.595 311.149C174.957 343.391 184.927 376.232 206.924 398.274C198.896 428.359 206.683 461.789 230.284 485.391C253.885 508.992 287.313 516.779 317.401 508.75C339.442 530.745 372.286 540.717 404.525 532.079C436.767 523.441 460.223 498.384 468.313 468.315C498.383 460.224 523.44 436.766 532.078 404.526C540.716 372.285 530.747 339.443 508.749 317.402V317.399ZM470.899 244.776C486.892 260.77 493.488 282.601 490.687 303.412L415.577 260.046C412.411 258.218 408.509 258.218 405.345 260.046L317.401 310.82V277.526C317.401 275.191 318.652 273.005 320.676 271.837L387.644 233.174C414.178 218.353 448.346 222.223 470.901 244.776H470.899ZM357.837 311.144L398.275 334.491V381.185L357.837 404.532L317.398 381.185V334.491L357.837 311.144ZM264.776 269.693C265.207 239.305 285.644 211.649 316.453 203.393C338.3 197.54 360.505 202.744 377.127 215.573L302.014 258.937C298.848 260.764 296.898 264.144 296.898 267.798V369.346L268.065 352.699C266.043 351.531 264.776 349.353 264.776 347.017V269.691V269.693ZM203.391 316.454C209.244 294.608 224.854 277.978 244.276 269.999V356.73C244.276 360.384 246.226 363.763 249.392 365.591L337.337 416.365L308.503 433.013C306.481 434.181 303.961 434.188 301.939 433.02L234.971 394.357C208.868 378.789 195.138 347.261 203.391 316.454ZM244.775 470.9C228.781 454.906 222.186 433.075 224.986 412.264L300.096 455.63C303.263 457.457 307.164 457.457 310.328 455.63L398.273 404.856V438.149C398.273 440.485 397.022 442.671 394.997 443.839L328.029 482.502C301.495 497.322 267.327 493.452 244.772 470.9H244.775ZM450.897 445.982C450.466 476.371 430.029 504.027 399.22 512.283C377.373 518.136 355.168 512.932 338.547 500.102L413.659 456.738C416.826 454.911 418.775 451.532 418.775 447.877V346.329L447.609 362.977C449.631 364.145 450.897 366.323 450.897 368.659V445.985V445.982ZM512.282 399.221C506.429 421.068 490.819 437.697 471.397 445.676V358.946C471.397 355.292 469.448 351.912 466.281 350.085L378.336 299.311L407.17 282.663C409.192 281.495 411.712 281.487 413.734 282.655L480.702 321.318C506.805 336.887 520.536 368.415 512.282 399.221Z"
/>
</svg>
);
}
const MODEL_SOURCES: ModelSource[] = [
{
id: "claude_local",
label: "Claude",
icon: <img src="/brands/claude-color.svg" alt="" className="size-full" />,
},
{ id: "codex_local", label: "OpenAI", icon: <OpenAiBlossom className="size-full" /> },
];
/** Where the flow is. `auth` splits by source, exactly as the step does. */
type Phase = "idle" | "connecting" | "auth" | "submitting" | "done";
function ConnectFlowPreview({
initialSourceId,
initialPhase,
}: {
initialSourceId: string | null;
initialPhase: Phase;
}) {
const [selectedId, setSelectedId] = useState<string | null>(initialSourceId);
const [useApiKeys, setUseApiKeys] = useState(false);
const [phase, setPhase] = useState<Phase>(initialPhase);
const [code, setCode] = useState("");
const [polled, setPolled] = useState(false);
const timers = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const submitsBrowserCode = selectedId === "claude_local";
const mode: CredentialMode = useApiKeys ? "api" : "subscription";
const after = (ms: number, fn: () => void) => {
timers.current.push(setTimeout(fn, ms));
};
useEffect(() => () => timers.current.forEach(clearTimeout), []);
// The prompt round trip.
useEffect(() => {
if (phase !== "connecting") return;
after(PROMPT_DELAY_MS, () => setPhase("auth"));
}, [phase]);
// The poll that lands while the customer is away in another tab, for the
// displayed-code login only. Armed on *reaching* `auth` rather than on
// leaving `connecting`, so a `?state=openai` deep link arms it too — hung off
// the transition, that link opened on a Next that never enabled.
useEffect(() => {
if (phase !== "auth" || submitsBrowserCode || polled) return;
after(POLL_DELAY_MS, () => setPolled(true));
}, [phase, submitsBrowserCode, polled]);
const finishSubmit = () => {
setPhase("submitting");
after(SUBMIT_DELAY_MS, () => setPhase("done"));
};
// The same two-part rule the shipped panel uses: the paste arms the submit,
// and the value gates it. Both halves matter here. Running it from the paste
// handler alone would submit whatever was in the field *before* the paste —
// the handler fires first — and skipping the check would advance the flow on
// an empty or malformed paste, which is a worse lie than not previewing it,
// since demonstrating this interaction is what the page is for.
const pastedRef = useRef(false);
useEffect(() => {
if (!pastedRef.current) return;
pastedRef.current = false;
if (!isValidBrowserCode(code.trim())) return;
finishSubmit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code]);
const reset = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
setPhase("idle");
setCode("");
setPolled(false);
};
const loggingIn = phase === "connecting" || phase === "auth" || phase === "submitting";
const done = phase === "done";
return (
<MotionConfig reducedMotion="user">
<div className="w-(--sz-560px) max-w-full p-10">
<Stepper step={done ? 3 : 2} />
<div className="flex flex-col items-center">
<div className="relative size-(--sz-72px)">
<PillGuy state={done ? "alive" : "dormant"} className="size-full" />
{!done && <SleepingZs />}
</div>
<AgentPreview agentName="Ron" agentRole="" />
</div>
<div className="pt-6">
<OnboardingHeading
center
title={done ? "Connected" : "Connect a model"}
lede={
done
? "The step advances straight to Review — there is no success screen."
: "Paperclip works with your existing subscription or API keys."
}
/>
</div>
{!done && (
<>
<div className="space-y-2 pt-12">
<ModelSourceTiles
label="Model source"
sources={MODEL_SOURCES}
mode={mode}
selectedId={selectedId}
onSelect={(id) => {
if (loggingIn) return;
setSelectedId(id);
}}
/>
<CredentialModeLink
mode={mode}
onChange={(next) => {
setUseApiKeys(next === "api");
reset();
}}
/>
</div>
{/* Opens on the press, not on the selection the card is the
sign-in, so there is nothing to hold before one starts. */}
<ConnectInputCanvas
open={phase === "auth" || phase === "submitting"}
contentKey={`${selectedId}:${mode}`}
>
{submitsBrowserCode ? (
<OnboardingLoginCard
instruction="Open Claude link then come back and enter code"
onCancel={reset}
>
<OnboardingLoginUrlRow url="https://claude.ai/oauth/authorize?code=true&client=paperclip&scope=all" />
<OnboardingLoginCodeInput
value={code}
onChange={setCode}
disabled={phase === "submitting"}
onSubmit={() => {
if (code.trim()) finishSubmit();
}}
onPaste={() => {
pastedRef.current = true;
}}
/>
</OnboardingLoginCard>
) : (
<OnboardingLoginCard
instruction="Copy this code then open the authentication link"
onCancel={reset}
>
{/* Code above link see the same order in the shipped panel.
Pressing the link leaves for another tab that wants this
code, so it is read before the link is there to press. */}
<OnboardingLoginCodeRow code="Q2RJ-E1YIF" />
<OnboardingLoginUrlRow url="https://auth.openai.com/codex/device" />
</OnboardingLoginCard>
)}
</ConnectInputCanvas>
</>
)}
<FooterNav
onBack={phase === "idle" ? () => {} : undefined}
primaryLabel={
// "Start over" is preview-only: the real step has left for Review
// by now, and this page has nowhere to send you but back.
done ? "Start over" : loggingIn && !submitsBrowserCode ? "Next" : "Connect"
}
loadingLabel="Connecting"
// Busy only where the work is happening on this screen. The
// displayed-code login finishes in another tab, so its button is
// waiting rather than working, and stays still.
loading={loggingIn && submitsBrowserCode}
primaryDisabled={
done ? false : selectedId === null || (loggingIn && !(polled && !submitsBrowserCode))
}
onPrimary={() => {
if (done) reset();
else if (phase === "idle") setPhase("connecting");
else if (polled && !submitsBrowserCode) setPhase("done");
}}
/>
{/* Preview-only. A real paste carries the code; here anything will do. */}
{phase === "auth" && submitsBrowserCode && (
<p className="pt-4 text-center text-xs text-muted-foreground/70">
Preview: paste any text into the field to see the auto-submit.
</p>
)}
{phase === "auth" && !submitsBrowserCode && !polled && (
<p className="pt-4 text-center text-xs text-muted-foreground/70">
Preview: Next enables when the poll lands, a few seconds from now.
</p>
)}
</div>
</MotionConfig>
);
}
/** `?state=` opens on a frame; everything stays clickable afterwards. */
const STATES: Record<string, { initialSourceId: string | null; initialPhase: Phase }> = {
default: { initialSourceId: null, initialPhase: "idle" },
selected: { initialSourceId: "claude_local", initialPhase: "idle" },
connecting: { initialSourceId: "claude_local", initialPhase: "connecting" },
claude: { initialSourceId: "claude_local", initialPhase: "auth" },
openai: { initialSourceId: "codex_local", initialPhase: "auth" },
};
const requested = new URLSearchParams(window.location.search).get("state") ?? "default";
const initial = STATES[requested] ?? STATES.default!;
createRoot(document.getElementById("root")!).render(
<StrictMode>
{/* Centred against the viewport with `min-h-dvh` and `my-auto`, the way the
sibling preview is: align-items would put the top of a too-tall step out
of scroll reach, and auto margins collapse instead. */}
<div className="flex min-h-dvh justify-center">
<div className="my-auto">
<ConnectFlowPreview {...initial} />
</div>
</div>
</StrictMode>,
);

View File

@ -0,0 +1,71 @@
import fs from "node:fs";
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Builds the standalone connect-flow preview (`connect-flow-preview.html`) on its
* own, for deploying as a static page that a reviewer can open from a link.
*
* A separate config rather than a second rollup input on the app's build, and
* the reason is what ends up on the host rather than tidiness: a shared build
* emits the app's ~6MB `main` chunk into the same `dist/assets`, and every file
* in the deployed directory is publicly fetchable whether or not anything links
* to it. Building alone means the deployed bundle is this screen and the pieces
* it composes, full stop. It also leaves the app's own build untouched.
*
* cd ui && npx vite build --config vite.flow-preview.config.mjs
*
* Invoked directly rather than through a `build:flow-preview` script, and the
* reason is governance rather than taste: CODEOWNERS owns `package.json` by
* bare name, which matches at any depth, so adding one line to `ui/package.json`
* puts a preview-only convenience behind a code-owner review. The command is
* the same either way.
*/
const OUT_DIR = "dist-flow-preview";
/**
* Land the entry as `index.html` so the mock is the site root and the output
* directory deploys as-is no rename step to forget on a redeploy.
*
* Renamed on disk in `closeBundle` rather than rekeyed in `generateBundle`:
* Vite's own HTML plugin emits the document after user plugins have had their
* `generateBundle` turn, so a bundle-level rename finds nothing to rename. The
* document's asset links are absolute (`/assets/...`), so moving the file
* itself breaks nothing.
*/
const previewAsIndex = {
name: "flow-preview-html-as-index",
closeBundle() {
const built = path.resolve(__dirname, OUT_DIR, "connect-flow-preview.html");
if (!fs.existsSync(built)) return;
fs.renameSync(built, path.resolve(__dirname, OUT_DIR, "index.html"));
},
};
export default defineConfig({
plugins: [react(), tailwindcss(), previewAsIndex],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
lexical: path.resolve(__dirname, "./node_modules/lexical/dist/Lexical.mjs"),
},
},
build: {
outDir: OUT_DIR,
emptyOutDir: true,
minify: "esbuild",
rollupOptions: {
input: path.resolve(__dirname, "connect-flow-preview.html"),
},
},
esbuild: {
drop: ["console", "debugger"],
legalComments: "none",
},
});