feat(onboarding): the connect step's sign-in as one continuous sequence (#12863)

Picking a source starts the sign-in: the row collapses to the answer, the card opens where the credential link was, and the footer button walks Sign in -> Waiting for code -> Connecting before the step advances. Back unwinds it a beat at a time.

Nothing mounts to change layout - the card and the link are always rendered and their heights animate, with inert holding the a11y line - because a mount changes the page in one frame and no easing can smooth a step already taken.

Review fixes in the same branch: the displayed-code panel now reports its prompt upward (the OpenAI path could not leave the loading beat without it), the two-second hold is a cancellable beat rather than a dropped timer, unwinding a sequence that never opened a card no longer starts a login to cancel it, and the key field regains focus-on-open.
This commit is contained in:
Tonio 2026-09-04 17:48:16 -07:00 committed by GitHub
parent 4b0e324c63
commit f2349990cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1939 additions and 723 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ node_modules/
**/node_modules/
dist/
dist-preview/
dist-flow-preview/
packages/paperclip-runner/runner/target/
ui/storybook-static/
.env

View File

@ -117,7 +117,7 @@ test.describe("Onboarding wizard", () => {
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
test("connect step starts the sign-in on Connect, rather than hiring, when the signal reports no credential", async ({
test("connect step starts the sign-in when the source is chosen, rather than hiring, when the signal reports no credential", async ({
page,
}) => {
const pageErrors: string[] = [];
@ -273,29 +273,32 @@ 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. 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.
// Step 4 (Connect a model). 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 });
// Nothing before the row is answered: the card is the answer to the tile,
// and the button has nothing to do until there is a source to do it with.
const cardInstruction = page.getByText("then come back and enter authorization code");
await expect(cardInstruction).toHaveCount(0);
await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled();
// Answering the row is what starts the sign-in — this is the ordering the
// step exists to enforce, since a hire here would file an agent with no
// credential to run on. It is also the whole of the interaction: there is
// no second press between choosing a source and being signed in.
await source.click();
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(cardInstruction).toBeVisible({ timeout: 30_000 });
// One destination, two ways to it: the card's own link for anyone
// finishing in another browser, and the step's button for the flow.
const cardLink = page.getByRole("link", { name: /^Sign in to / });
await expect(cardLink).toBeVisible({ timeout: 15_000 });
await expect(cardLink).toHaveAttribute("href", /claude\.ai\/oauth\/authorize/);
await expect(
page.getByRole("link", { name: /claude\.ai\/oauth\/authorize/ }),
).toBeVisible({ timeout: 15_000 });
page.getByRole("button", { name: /^Sign in to / }),
).toBeEnabled({ 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);

View File

@ -4,12 +4,12 @@ import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it } from "vitest";
import type React from "react";
import {
OnboardingLoginCard,
OnboardingLoginCodeInput,
OnboardingCardField,
onboardingCardInputClass,
} from "./AdapterLoginChrome";
import { ApiKeyField } from "./onboarding/ConnectInputCanvas";
/**
* The connect step's canvas holds one of two cards, and the credential switch
@ -52,53 +52,92 @@ async function render(node: React.ReactNode): Promise<HTMLElement> {
return container;
}
describe("the connect step's two cards", () => {
it("gives the key field and the sign-in field the same input, from one declaration", async () => {
// The assertion that would have caught the drift this file exists for: not
// "both look like X", which passes right up until one of them is restyled,
// but that the two carry the byte-identical class string.
const signIn = await render(
<OnboardingLoginCard instruction="Open Claude link then come back and enter code">
<OnboardingLoginCodeInput value="" onChange={() => {}} onSubmit={() => {}} />
</OnboardingLoginCard>,
);
const keyCard = await render(
<ApiKeyField envKey="ANTHROPIC_API_KEY" value="" onChange={() => {}} />,
);
describe("the connect step's cards", () => {
let container: HTMLDivElement;
let root: Root | null = null;
const codeInput = signIn.querySelector("input")!;
const keyInput = keyCard.querySelector("input")!;
expect(codeInput.className).toBe(onboardingCardInputClass);
expect(keyInput.className).toBe(codeInput.className);
afterEach(() => {
if (root) flushSync(() => root!.unmount());
root = null;
document.body.innerHTML = "";
});
it("wraps the key field in the same card shell as the sign-in", async () => {
// The shell, not just the input. The key field used to draw its own
// bordered box, so flipping the credential switch changed the shape of the
// step rather than its content.
const signIn = await render(
<OnboardingLoginCard instruction="Open Claude link then come back and enter code">
<OnboardingLoginCodeInput value="" onChange={() => {}} onSubmit={() => {}} />
</OnboardingLoginCard>,
);
const keyCard = await render(
<ApiKeyField envKey="ANTHROPIC_API_KEY" value="" onChange={() => {}} />,
function render(node: React.ReactNode) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
flushSync(() => root!.render(node));
}
it("gives every card field the same input, from one declaration", () => {
// The step asks for three different things in this row — a browser code, a
// key — and they sit one toggle apart in the same canvas, so a divergence
// between them is visible by flipping a switch. Sharing the declaration is
// what stops that; this is the assertion that the sharing is real.
render(
<>
<OnboardingCardField value="" onChange={() => {}} onSubmit={() => {}} />
<OnboardingCardField
label="API key"
placeholder="Enter API key here"
masked
value=""
onChange={() => {}}
onSubmit={() => {}}
/>
</>,
);
expect(keyCard.firstElementChild!.className).toBe(signIn.firstElementChild!.className);
const [code, key] = [...container.querySelectorAll("input")];
expect(code!.className).toBe(onboardingCardInputClass);
expect(key!.className).toBe(code!.className);
});
it("labels the key field with the variable it will be written to", async () => {
// The name answers what a paster cannot answer for themselves — where this
// step puts the key — so it is the label rather than a sentence about it,
// and it reaches assistive tech as the field's name too.
const keyCard = await render(
<ApiKeyField envKey="ANTHROPIC_API_KEY" value="" onChange={() => {}} />,
it("masks a key and does not mask a one-time code", () => {
// A provider key is a credential that goes on living; a browser code is
// single-use and about to be pasted somewhere the customer can see.
render(
<>
<OnboardingCardField value="" onChange={() => {}} onSubmit={() => {}} />
<OnboardingCardField
label="API key"
masked
value=""
onChange={() => {}}
onSubmit={() => {}}
/>
</>,
);
expect(keyCard.textContent).toContain("ANTHROPIC_API_KEY");
expect(keyCard.querySelector("input")!.getAttribute("aria-label")).toBe("ANTHROPIC_API_KEY");
// Still a password field: the key is a secret even while being pasted.
expect(keyCard.querySelector("input")!.getAttribute("type")).toBe("password");
const [code, key] = [...container.querySelectorAll("input")];
expect(code!.getAttribute("type")).toBe("text");
expect(code!.getAttribute("aria-label")).toBe("Authorization code");
expect(key!.getAttribute("type")).toBe("password");
expect(key!.getAttribute("aria-label")).toBe("API key");
});
it("holds one height across the card's waiting and ready states", () => {
// The card opens on a spinner and then fills. Both states share a floor, so
// the footer below is pushed down once for one event rather than twice —
// the loaded card growing into place would be a second shove.
render(
<OnboardingLoginCard loading instruction="Starting…">
<div />
</OnboardingLoginCard>,
);
const waiting = container.firstElementChild!.className;
flushSync(() => root!.unmount());
root = null;
document.body.innerHTML = "";
render(
<OnboardingLoginCard instruction="Ready">
<OnboardingCardField value="" onChange={() => {}} onSubmit={() => {}} />
</OnboardingLoginCard>,
);
const ready = container.firstElementChild!.className;
expect(waiting).toContain("min-h-(--sz-108px)");
expect(ready).toContain("min-h-(--sz-108px)");
});
});

View File

@ -1,8 +1,17 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Copy, Check } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { Copy, Check, Loader2 } from "lucide-react";
import { Button } from "./ui/button";
import { copyTextToClipboard } from "../lib/clipboard";
import {
CARD_REVEAL_FIELD,
CARD_REVEAL_INSTRUCTION,
CARD_REVEAL_TRAVEL,
COPIED_REVEAL,
COPIED_REVEAL_DELAY_MS,
COPIED_REVEAL_TRAVEL,
} from "./onboarding/onboarding-motion";
/**
* Which shell a login panel draws itself in.
@ -21,6 +30,32 @@ import { copyTextToClipboard } from "../lib/clipboard";
*/
export type AdapterLoginChrome = "panel" | "onboarding";
/**
* What the connect step calls each source.
*
* One map, read by the tile row and by the sign-in card's sentence, so the row
* and the card cannot end up calling the same provider different things.
*
* Deliberately not the display registry's label, which ten other surfaces read
* and which names the tool that runs ("Codex CLI was not found on this host").
* Also not `ADAPTER_LOGIN_PROVIDER`, which names the account being signed in to
* "Anthropic" is right in a settings panel listing credentials and wrong in a
* sentence that reads "Sign in to Claude".
*
* Three names for two adapters is a tension worth stating rather than hiding.
* The concepts differ vendor, tool, account but if the product decides
* otherwise, this is the one to delete.
*/
export const CONNECT_SOURCE_NAMES: Record<string, string> = {
claude_local: "Claude",
codex_local: "OpenAI",
};
/** The provider name for a source, falling back to the type when unlisted. */
export function connectSourceName(adapterType: string): string {
return CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
}
/**
* The connect step's login card: an instruction with a Cancel beside it, then
* the rows the customer works through.
@ -33,6 +68,7 @@ export type AdapterLoginChrome = "panel" | "onboarding";
export function OnboardingLoginCard({
instruction,
onCancel,
loading = false,
children,
}: {
/**
@ -42,10 +78,32 @@ export function OnboardingLoginCard({
*/
instruction: ReactNode;
onCancel?: () => void;
/**
* Show a spinner instead of the contents, at the same height.
*
* The card opens before the sign-in has anything to put in it. Rendering it
* empty and growing later would push the footer twice for one event, so both
* states share a floor see `--sz-108px`, which is exactly the height of an
* instruction over one row. A card holding more than that (the settings
* chrome's link *and* field) still grows past it.
*/
loading?: boolean;
children: ReactNode;
}) {
if (loading) {
return (
<div
className="flex min-h-(--sz-108px) items-center justify-center rounded-xl bg-muted/40"
role="status"
aria-label="Preparing the sign-in"
>
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="rounded-xl bg-muted/40 px-4 py-3.5 flex flex-col gap-4">
<div className="flex min-h-(--sz-108px) flex-col gap-4 rounded-xl bg-muted/40 p-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
@ -64,7 +122,11 @@ export function OnboardingLoginCard({
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">
<motion.div
className="flex items-center justify-between pl-2"
initial={{ opacity: 0, y: CARD_REVEAL_TRAVEL }}
animate={{ opacity: 1, y: 0, transition: CARD_REVEAL_INSTRUCTION }}
>
<span className="text-xs text-muted-foreground">{instruction}</span>
{onCancel && (
<Button
@ -77,8 +139,16 @@ export function OnboardingLoginCard({
Cancel
</Button>
)}
</div>
{children}
</motion.div>
{/* A beat behind the sentence above it, so the card reads as one thing
unfolding and the instruction has been read by the time the field is
ready to be pasted into. */}
<motion.div
initial={{ opacity: 0, y: CARD_REVEAL_TRAVEL }}
animate={{ opacity: 1, y: 0, transition: CARD_REVEAL_FIELD }}
>
{children}
</motion.div>
</div>
);
}
@ -129,7 +199,7 @@ function LoginCardCopyButton({
size="icon-xs"
aria-label={label}
title={label}
className="size-6 shrink-0 text-muted-foreground hover:text-foreground [&_svg]:size-4"
className="size-6 shrink-0 text-muted-foreground hover:text-foreground [&_svg]:size-3"
onClick={async () => {
try {
await copyTextToClipboard(value);
@ -147,62 +217,78 @@ function LoginCardCopyButton({
);
}
/**
* 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.
* `autoCopy` puts it on the clipboard as the card lands and says so. The code
* is going to be pasted somewhere else that is its whole purpose so making
* the customer press a button first is a step that exists only to be completed.
*
* The claim is made only when the write actually succeeded. A clipboard write
* needs transient user activation, and this one happens a beat after the press
* that started the sign-in, so a browser may well refuse it; "Copied!" over an
* empty clipboard would send someone to paste nothing. The button beside it is
* the path that always works, and is why the failure is quiet rather than an
* error.
*/
export function OnboardingLoginCodeRow({ code }: { code: string }) {
export function OnboardingLoginCodeRow({
code,
autoCopy = false,
}: {
code: string;
autoCopy?: boolean;
}) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoCopiedRef = useRef(false);
useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
useEffect(() => {
if (!autoCopy || autoCopiedRef.current) return;
autoCopiedRef.current = true;
void copyTextToClipboard(code)
.then(() => {
// Written now, said later: the clipboard should be ready the instant
// the code is readable, but the claim waits for the rest of the card to
// stop moving — see COPIED_REVEAL_DELAY_MS.
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(true), COPIED_REVEAL_DELAY_MS);
})
.catch(() => {
// Refused, most likely for want of user activation. The button stays.
});
}, [autoCopy, code]);
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>}
<div className="flex h-(--sz-44px) items-center gap-2 rounded-lg bg-muted px-4">
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{code}</span>
<AnimatePresence initial={false}>
{copied && (
<motion.span
key="copied"
className="shrink-0 text-sm text-muted-foreground/40"
initial={{ opacity: 0, y: COPIED_REVEAL_TRAVEL }}
animate={{ opacity: 1, y: 0, transition: COPIED_REVEAL }}
exit={{ opacity: 0, transition: COPIED_REVEAL }}
>
Copied!
</motion.span>
)}
</AnimatePresence>
<LoginCardCopyButton
value={code}
label="Copy the code"
onCopied={() => {
setCopied(true);
// No wait here. A press is a direct action, and delaying its
// acknowledgement would read as the button having missed.
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
setCopied(true);
}}
/>
</LoginCardRow>
</div>
);
}
@ -220,39 +306,63 @@ export const onboardingCardInputClass =
"outline-none focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)";
/**
* The field the browser code is pasted back into.
* The card's single-line field, whatever the card is asking for.
*
* 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.
* Three cards use it and they want different things: a browser code pasted
* back, and an API key typed or pasted in. Same row, same measurements what
* changes is the label, the placeholder, and whether the value should be masked.
*
* `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.
* No Submit button beside it in the code case: 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 that case 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. A key field simply omits it:
* a key is not submitted by arriving, it is submitted by the step's own button.
*/
export function OnboardingLoginCodeInput({
export function OnboardingCardField({
value,
onChange,
onSubmit,
onPaste,
disabled,
label = "Authorization code",
placeholder = "Paste authorization code here",
masked = false,
autoFocus = false,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onPaste?: () => void;
disabled?: boolean;
label?: string;
placeholder?: string;
/** A provider key is a credential; a one-time browser code is not. */
masked?: boolean;
/**
* Take focus when the card opens.
*
* For the key card, where the field is the only thing being asked for and the
* card only opens because it was asked for. The code cards do not: their
* customer is on their way to another tab, and a caret waiting behind them is
* not where the next action is.
*/
autoFocus?: boolean;
}) {
return (
<input
aria-label="Authorization code"
type="text"
// eslint-disable-next-line jsx-a11y/no-autofocus -- see the prop's note
autoFocus={autoFocus}
aria-label={label}
type={masked ? "password" : "text"}
autoComplete="off"
spellCheck={false}
placeholder="Paste authorization code here"
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}

View File

@ -36,10 +36,10 @@ import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2,
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import {
connectSourceName,
OnboardingLoginCard,
OnboardingLoginCodeInput,
OnboardingCardField,
OnboardingLoginCodeRow,
OnboardingLoginUrlRow,
type AdapterLoginChrome,
} from "./AdapterLoginChrome";
import {
@ -2040,6 +2040,17 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
// it would appear on is already gone.
onConnected?: () => void;
chrome?: AdapterLoginChrome;
/**
* The address the customer has to open, once the server has produced one.
*
* The one fact about a running login that the step needs outside the card:
* its own button is what sends the customer there, and a prompt arriving is
* what moves the step from waiting to ready. Everything else it needs the
* panel already does the paste submits itself, success is reported through
* `onConnected`, and an unmount releases the session so this stays a single
* value rather than a whole session handed upward.
*/
onPromptReady?: (authorizationUrl: string | null) => void;
};
// The login panel dispatcher. It picks the panel from the projected panel mode,
@ -2082,6 +2093,7 @@ function DisplayedCodeLoginPanel({
onCancel,
onConnected,
chrome = "panel",
onPromptReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [startError, setStartError] = useState<string | null>(null);
@ -2203,55 +2215,49 @@ function DisplayedCodeLoginPanel({
onConnectedRef.current?.();
}, [status]);
// Report the prompt's URL upward, the way the submitted-browser-code panel
// does. The caller's loading beat ends when this arrives, so without it the
// onboarding step waits on a card that has already opened: the code is on
// screen and the button stays disabled. Fires with null on mount, before the
// one-time prompt lands, which is the same null the caller starts from.
const onPromptReadyRef = useRef(onPromptReady);
onPromptReadyRef.current = onPromptReady;
useEffect(() => {
onPromptReadyRef.current?.(prompt?.url ?? null);
}, [prompt]);
const handleCancel = () => {
cancelLogin.mutate();
onCancel?.();
};
if (chrome === "onboarding") {
const failed = isTerminal && status && status !== "authenticated";
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.
loading={!prompt && !startError && !failed}
instruction={
prompt ? "Copy this code then open the authentication link" : "Starting the sign-in…"
<>
{/* The same destination as the step's own button. Two ways to one
link: the button for the customer following the flow, the anchor
for anyone finishing in another browser. */}
<a
href={prompt?.url}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Sign in to {connectSourceName(adapterType)}
</a>
{" by providing the authorization code below"}
</>
}
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 && (
{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" && (
) : failed ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{status === "timed_out"
? "The login timed out. Start it again."
@ -2259,6 +2265,8 @@ function DisplayedCodeLoginPanel({
? "The login was cancelled."
: "The login did not finish. Start it again."}
</p>
) : (
<OnboardingLoginCodeRow code={prompt?.code ?? ""} autoCopy />
)}
</OnboardingLoginCard>
);
@ -2424,6 +2432,7 @@ function SubmittedBrowserCodeLoginPanel({
onCancel,
onConnected,
chrome = "panel",
onPromptReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [startError, setStartError] = useState<string | null>(null);
@ -2799,25 +2808,35 @@ function SubmittedBrowserCodeLoginPanel({
onCancel?.();
};
const onPromptReadyRef = useRef(onPromptReady);
onPromptReadyRef.current = onPromptReady;
useEffect(() => {
onPromptReadyRef.current?.(authorizationUrl);
}, [authorizationUrl]);
if (chrome === "onboarding") {
const failedNow = isFailure || timedOut;
return (
<OnboardingLoginCard
loading={!authorizationUrl && !startError && !failedNow}
instruction={
authorizationUrl
? "Open Claude link then come back and enter code"
: "Starting the sign-in…"
<>
<a
href={authorizationUrl ?? undefined}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Sign in to {connectSourceName(adapterType)}
</a>
{" then come back and enter authorization code"}
</>
}
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. */}
{/* The plain-HTTP advisory survives the redesign. It is the one thing on
this card 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" />
@ -2825,35 +2844,24 @@ function SubmittedBrowserCodeLoginPanel({
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) && (
{startError ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{timedOut && !isFailure
? CLAUDE_LOGIN_TIMED_OUT_MESSAGE
: CLAUDE_LOGIN_FAILED_MESSAGE}
{startError}
</p>
) : failedNow ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{timedOut && !isFailure ? CLAUDE_LOGIN_TIMED_OUT_MESSAGE : CLAUDE_LOGIN_FAILED_MESSAGE}
</p>
) : (
<OnboardingCardField
value={browserCode}
onChange={setBrowserCode}
onSubmit={handleSubmit}
onPaste={() => {
pastedRef.current = true;
}}
disabled={submitCode.isPending || isCompleting}
/>
)}
</OnboardingLoginCard>
);

View File

@ -229,6 +229,7 @@ import { queryKeys } from "../lib/queryKeys";
import { ADAPTER_AUTH_MISSING_CHECK_CODE, getEnvironmentCapabilities } from "@paperclipai/shared";
import { CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model";
import { ONBOARDING_STORAGE_KEY, OnboardingWizard } from "./OnboardingWizard";
import { CONNECTED_HOLD_MS } from "./onboarding/onboarding-motion";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@ -650,7 +651,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
describe("hire gate: adapter authentication (claude_local, the default onboarding adapter)", () => {
/** Drives the wizard to the Connect step, agent name already filled in. */
async function openConnectStep() {
async function openConnectStep({ useApiKeys = false } = {}) {
// The tile row is built from this registry, and the suite's default is
// empty. That was survivable while the step preselected a source; now that
// nothing is chosen until a tile is pressed, a step with no tiles is a step
@ -697,6 +698,14 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await clickByText((t) => isArcPrimary(t));
expect(document.body.textContent).toContain("Connect a model");
// The credential mode is chosen *before* a source, because picking a
// source starts the sequence and the mode link fades out with the row —
// after that it is inert, and switching would mean changing the card out
// from under a running sign-in.
if (useApiKeys) {
await clickByText((t) => t.startsWith("Use API key"));
}
// Pick a source. The step arrives with nothing chosen — `adapterType`
// carries a value for the hire, but that is not the same as the customer
// having answered — so the input surface stays closed and the step will
@ -784,8 +793,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
});
async function connectWithApiKey() {
const handles = await openConnectStep();
await handles.clickByText((t) => t.startsWith("Use API key"));
const handles = await openConnectStep({ useApiKeys: true });
const field = document.body.querySelector(
'input[type="password"]',
) as HTMLInputElement;
@ -899,10 +907,25 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
// Switch to API keys, which changes the configuration the hire will send.
// Switching the credential mode means backing out first: the mode link
// fades away with the row once a source is chosen, and is inert after
// that, so it cannot be used to change the card out from under a running
// sign-in. Back unwinds to the question, and the answer is given again.
await clickByText((t) => t.startsWith("Back"));
await clickByText((t) => t.startsWith("Use API key"));
await pickFirstSource(clickByText);
const field = document.body.querySelector(
'input[type="password"]',
) as HTMLInputElement;
await act(async () => {
setControlledValue(field, "sk-ant-rekey");
});
await flushReact();
await clickByText((t) => isArcPrimary(t));
// A different configuration, so the passing probe from before it cannot
// stand in for one against this one.
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
await act(async () => root.unmount());
@ -2061,6 +2084,22 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
/**
* Press a source tile. This is what starts the sign-in the row is the
* question, and answering it is the whole of the trigger. Nothing is
* selected on arrival, including from a draft that names an adapter.
*/
async function pickSource(match: RegExp) {
const tile = [...document.body.querySelectorAll('[role="radio"]')].find((t) =>
match.test(t.textContent ?? ""),
);
expect(tile, "the row should offer that source").toBeTruthy();
await act(async () => {
tile!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
}
/** Press the step's forward button and let the sign-in queries settle. */
async function pressArcPrimary() {
const cta = [...document.body.querySelectorAll("button")].find((b) =>
@ -2082,23 +2121,70 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
// difference between this step and the one it replaced.
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
await pressArcPrimary();
await pickSource(/Claude/);
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",
"Sign in to Claude then come back and enter authorization code",
);
await act(async () => root.unmount());
});
it("collapses the row to the chosen source and walks the button through the sign-in", async () => {
// The sequence, end to end, as the step actually runs it: the row is the
// question, answering it starts the sign-in, and the button reports where
// that has got to rather than offering an action it cannot perform.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
// Collapse is read off the row's own layout, not off how many tiles are
// in the DOM: the leaving tile exits through AnimatePresence, and in
// jsdom an exit never completes, so it stays mounted either way.
const rowCentred = () =>
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center");
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
// Nothing chosen yet on a fresh arrival: the row is a question.
expect(rowCentred()).toBe(false);
await pickSource(/Claude/);
// The row has been answered, so it now shows only the answer — leaving
// the alternative up would invite a press that has to cancel a live
// session to honour.
expect(rowCentred()).toBe(true);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled();
// And the button has become the sign-in rather than a step advance.
expect(cta()).toBe("Sign in to Claude");
// Back unwinds rather than leaving the step: the row is a question again.
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
expect(rowCentred()).toBe(false);
expect(cta()).toBe("Next");
expect(document.body.textContent).toContain("Connect a model");
await act(async () => root.unmount());
});
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();
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
@ -2132,33 +2218,201 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
await act(async () => root.unmount());
});
it("starts the sign-in on the first press, even when it changes the adapter", async () => {
// The regression this is here for. Picking a source sets the phase *and*
// the adapter, and a reset keyed on the adapter then put the phase
// straight back — so the first press did nothing and only a second one,
// which changed no adapter and so woke no effect, was allowed to stand.
//
// It only showed when the chosen source differed from the one the draft
// carried: picking the adapter already in state is a no-op React bails
// out of, so every existing case here happened to miss it.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/OpenAI/);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
"one press should have answered the row",
).toBe(true);
await act(async () => root.unmount());
});
it("opens the OpenAI card's sign-in once the prompt lands", async () => {
// The panel that shows a code owns its session and the wizard learns the
// prompt's URL only by being told. When it was not told, the step waited
// on a card that had already opened: the code sat on screen under a
// button still reading "Next", disabled, with nothing left to wait for.
//
// The sibling test above stops at the login having started, which is why
// this went unseen — and the harness could not see it either, since it
// supplies the prompt itself rather than going through the panel.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/OpenAI/);
for (let i = 0; i < 6; i++) await flushReact();
// The displayed-code panel is the one under test: if the row picked a
// source the other panel serves, the rest of this proves nothing.
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalled();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
const cta = [...document.body.querySelectorAll("button")].pop()!;
expect(cta.textContent?.trim()).toBe("Sign in to OpenAI");
// The code is the card's own content, and it arrives with the URL.
expect(document.body.textContent).toContain("Q2RJ-E1YIF");
// The button is the whole point: its destination is the URL the panel
// reports, so an unreported prompt leaves it reading like an offer and
// refusing the press. The label alone does not catch that — the phase can
// reach `ready` before the signal resolves, which names the button
// without enabling it.
expect(cta.hasAttribute("disabled"), "the sign-in should be pressable").toBe(
false,
);
await act(async () => root.unmount());
});
it("does not hire after Back interrupts the hold before step 5", async () => {
// "Connecting" is held for two seconds so it reads as a state rather than
// a flicker, and Back stays live throughout. The hire behind that hold
// knows nothing about the phase, so a timer left running took a customer
// who had just backed out to Review with an agent hired anyway.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
// A session the server has already authenticated, so the panel completes
// and reports success on its own. The paste that normally gets it there
// is the sibling test's subject; this one is about what the success does.
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "authenticated",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
for (let i = 0; i < 8; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta(), "success should have taken the button to the hold").toBe(
"Connecting",
);
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
// Past the hold: whatever it was going to do, it has had its chance.
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400));
});
for (let i = 0; i < 6; i++) await flushReact();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Connect a model");
await act(async () => root.unmount());
});
it("starts no login when Back interrupts the collapse", async () => {
// Backing out before the card has opened has nothing to close. Unwinding
// through the card beat regardless mounted the panel — which starts a
// server login on mount — only for the unmount to cancel it, and a cancel
// that fails holds the per-owner reservation until the server deadline,
// so the retry the customer is about to make cannot start.
//
// The beats collapse to zero without a `matchMedia` to ask, which is why
// this stubs one: the collapse has to still be running when Back lands.
const realMatchMedia = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
try {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
// Still collapsing: the flushes above are microtasks and 0ms timers,
// far inside the collapse's own duration.
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
for (let i = 0; i < 6; i++) await flushReact();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Connect a model");
await act(async () => root.unmount());
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
}
});
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(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
await pressArcPrimary();
await pickSource(/OpenAI/);
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(
"Sign in to OpenAI by providing the authorization code below",
);
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.
// The link lives inside the sentence now rather than in a row of its own,
// and points at the same address the step's button opens — it is the
// path for anyone finishing the sign-in in another browser.
const link = document.body.querySelector('a[href*="auth.openai.com"]');
expect(link, "the sign-in link should be in the instruction").toBeTruthy();
expect(link!.textContent).toBe("Sign in to OpenAI");
// And the code sits below the sentence carrying it.
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",
link!.compareDocumentPosition(code!) & Node.DOCUMENT_POSITION_FOLLOWING,
"the code should follow the sentence that links to the sign-in",
).toBeTruthy();
await act(async () => root.unmount());
@ -2168,6 +2422,9 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
const { root } = await openStep4({ adapterType: "claude_local" });
// Answer the row, then press: with a credential already in place there is
// no sign-in to run, so the button goes straight to the hire.
await pickSource(/Claude/);
await pressArcPrimary();
// The positive half is what makes this a test: a source that is already

View File

@ -11,6 +11,38 @@ import type {
} from "@paperclipai/shared";
import { AGENT_ROLES, AGENT_ROLE_LABELS, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import { AdapterLoginPanel } from "./AgentConfigForm";
import {
CONNECT_SOURCE_NAMES,
OnboardingCardField,
OnboardingLoginCard,
} from "./AdapterLoginChrome";
import {
beatDelay,
CARD_ENTER,
CARD_EXIT,
CARD_EXIT_MS,
CONNECTED_HOLD_MS,
MAKE_ROOM,
MAKE_ROOM_MS,
SOURCE_COLLAPSE_MS,
SOURCE_LINK_EXIT,
} from "./onboarding/onboarding-motion";
/**
* Where the connect step's sign-in sequence is. Space and visibility land on
* different beats, which is why there are more of these than there are things
* on screen see the derived state in the step itself.
*/
type ConnectPhase =
| "idle"
| "collapsing"
| "loading"
| "ready"
| "waiting"
| "connecting"
| "unwindCard"
| "unwindRoom"
| "unwindRow";
import { secretsApi } from "../api/secrets";
import { Label } from "./ui/label";
import { Input } from "./ui/input";
@ -96,8 +128,7 @@ import {
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 { FooterNav, type FooterPrimaryIcon } from "./onboarding/FooterNav";
import { OnboardingHeading } from "./onboarding/OnboardingPrimitives";
import { DEFAULT_AGENT_ROLE } from "../lib/onboarding-agent-role";
import { capsuleHeroMotion } from "./onboarding/onboarding-motion";
@ -203,25 +234,6 @@ const MODEL_SOURCE_BRAND_MARKS: Record<string, string> = {
claude_local: "/brands/claude-color.svg",
};
/**
* What the connect step calls each source.
*
* Deliberately not the display registry's label, which ten other surfaces read.
* This step asks which *provider* you are signing in with the panel under the
* row says "Sign in to Anthropic" and "Sign in to OpenAI" while the agent
* config screens name the tool that runs ("Codex CLI was not found on this
* host"). One rename in the registry would make that message say OpenAI, which
* is vaguer, not clearer.
*
* It is a tension worth naming rather than hiding: DESIGN.md asks for one name
* per concept, and this is two names for one adapter. The concepts are
* different vendor here, tool there but if the product decides otherwise,
* this map is the thing to delete.
*/
const MODEL_SOURCE_NAMES: Record<string, string> = {
claude_local: "Claude",
codex_local: "OpenAI",
};
/**
* OpenAI's blossom, inline rather than served from `/brands`.
@ -629,12 +641,19 @@ function OnboardingWizardInner({
* defaulted. A customer who never touched the row could reach the end of the
* step having chosen nothing.
*
* Restored true when the draft names a source. Someone returning here has
* already answered, and asking again would throw that answer away.
* Always false on arrival, including from a draft that names a source.
*
* It used to restore, on the reasoning that someone returning had already
* answered and asking again threw that answer away. Picking a source is what
* starts the sign-in now, so a restored selection is not an answer the step
* can act on it is a lit tile with nothing behind it, and the sequence has
* no way to begin from there without either starting a server session
* unbidden or leaving the button to do a job the row is supposed to do.
*
* `adapterType` still restores; it is what the hire needs. This is only about
* whether the row has been *answered* on this visit.
*/
const [sourcePicked, setSourcePicked] = useState<boolean>(
() => typeof saved?.adapterType === "string" && saved.adapterType.length > 0,
);
const [sourcePicked, setSourcePicked] = useState(false);
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner";
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
// Native drafts may carry provider-specific configuration that is invalid
@ -671,26 +690,27 @@ function OnboardingWizardInner({
(saved?.credentialMode as CredentialMode) ?? "subscription",
);
/**
* Whether Connect has been pressed for the current source.
* Where the connect step's sign-in sequence is.
*
* 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.
* Picking a source starts it now, rather than a press of the footer button:
* the row collapses, the card opens, and the button becomes the sign-in. The
* beats are ordered rather than concurrent, and each waits for the animation
* before it see `onboarding-motion`, where the durations these timers use
* live beside the transitions they mirror.
*
* 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 "waiting for a
* code" would describe a session that is long gone.
*/
const [loginStarted, setLoginStarted] = useState(false);
const [connectPhase, setConnectPhase] = useState<ConnectPhase>("idle");
/**
* Whether that sign-in reached its success state.
* The address the running login wants the customer to open.
*
* 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.
* Reported up by the panel, because the step's own button is what opens it
* the card shows the same link inline for anyone finishing in another
* browser. Its arrival is also what moves the step off its waiting beat.
*/
const [loginConnected, setLoginConnected] = useState(false);
const [connectAuthUrl, setConnectAuthUrl] = useState<string | null>(null);
/**
* 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
@ -1186,9 +1206,169 @@ function OnboardingWizardInner({
const loginSubmitsBrowserCode =
adapterCaps.login?.panelMode === "submitted_browser_code";
// Connect is pressed, the login is running, and it has not succeeded yet.
/**
* 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;
/*
The sequence's derived state. Space and visibility are separate throughout:
the credential link fades on the first beat but keeps its space until the
second, and the card takes its space on the second but only appears on the
third so the column slides once, when there is a reason for it to.
*/
const connectCollapsed =
connectPhase !== "idle" && connectPhase !== "unwindRow" && sourceSelected;
const connectHasCard = credentialMode === "api" || connectStepNeedsLogin || connectStepHasNoSandbox;
const connectCardLive =
connectHasCard &&
(connectPhase === "loading" ||
connectPhase === "ready" ||
connectPhase === "waiting" ||
connectPhase === "connecting");
const connectCardSpace = connectCardLive || (connectHasCard && connectPhase === "unwindCard");
/**
* Whether the card's contents are rendered at all.
*
* A beat longer than its space, and that beat matters. The height animates
* away over `unwindRoom`, and an element with nothing in it has no height to
* animate *from* unmounting the contents when the space starts closing
* collapsed the column in a single frame instead, a 54px jump measured right
* after the fade. They stay until the room has finished closing.
*
* It cannot simply be "always", either: the panel starts a server session on
* mount, so rendering it at idle would open an OAuth session merely because
* the step was visited.
*/
const connectCardMounted = connectCardSpace || connectPhase === "unwindRoom";
const connectLinkSpace =
connectPhase === "idle" ||
connectPhase === "collapsing" ||
connectPhase === "unwindRoom" ||
connectPhase === "unwindRow";
const connectLinkVisible = connectPhase === "idle" || connectPhase === "unwindRow";
/** A sign-in is running and has not succeeded. */
const connectStepLoggingIn =
connectStepNeedsLogin && loginStarted && !loginConnected;
connectStepNeedsLogin && connectPhase !== "idle" && connectPhase !== "connecting";
/**
* The beats, each waiting for the animation before it.
*
* `loading` is the exception: it ends when the server produces a prompt, not
* on a timer, so the card waits exactly as long as the login actually takes.
*/
useEffect(() => {
if (step !== 4) return;
if (connectPhase === "collapsing") {
const t = setTimeout(
// A key has nothing to fetch — the field exists the moment the source
// is chosen — and a source already signed in has nothing to fetch
// either. Only a live sign-in spends a beat waiting for its prompt;
// sending the others through it would stall them on a card that never
// opens.
() =>
setConnectPhase(
credentialMode === "api" || !connectStepNeedsLogin ? "ready" : "loading",
),
beatDelay(SOURCE_COLLAPSE_MS),
);
return () => clearTimeout(t);
}
if (connectPhase === "connecting") {
// No success state: the step advances. The hold is so "Connecting" is
// legible as a state rather than a flicker on the way out — a step that
// left the instant a paste landed would read as the paste having gone
// wrong.
//
// A beat rather than a bare timer because Back stays live through it. A
// dropped handle hired two seconds after the customer had backed out,
// landing them on Review having asked for the opposite; `handleGiveHeartbeat`
// has no notion of the phase and could not refuse it. Leaving the phase —
// Back, the step changing, unmount — now cancels the hire with it.
const t = setTimeout(() => void handleGiveHeartbeat(), CONNECTED_HOLD_MS);
return () => clearTimeout(t);
}
if (connectPhase === "unwindCard") {
const t = setTimeout(() => setConnectPhase("unwindRoom"), beatDelay(CARD_EXIT_MS));
return () => clearTimeout(t);
}
if (connectPhase === "unwindRoom") {
const t = setTimeout(() => setConnectPhase("unwindRow"), beatDelay(MAKE_ROOM_MS));
return () => clearTimeout(t);
}
if (connectPhase === "unwindRow") {
// Let the selection go as the row starts back, not once it has arrived.
// Held to the end, the tile changed colour with nothing else moving —
// a cut rather than a release. Released here it fades across the travel
// and settles into its default instead of snapping to it. The tiles take
// the slower duration while `settling`, so the fade lasts the journey.
setSourcePicked(false);
const t = setTimeout(() => setConnectPhase("idle"), beatDelay(SOURCE_COLLAPSE_MS));
return () => clearTimeout(t);
}
return;
}, [step, connectPhase, credentialMode, connectStepNeedsLogin]);
/**
* The button's four faces, and which of them can be pressed.
*
* It is only live where there is something for it to do: a sign-in to open, a
* key to submit, or a hire to run. Through the waits it is the step reporting
* rather than offering see `FooterNav`, where the label cross-fades over an
* easing width so those changes read as one control rather than four.
*/
const connectSourceLabel = CONNECT_SOURCE_NAMES[adapterType] ?? adapterType;
const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } =
connectPhase === "waiting"
? { label: "Waiting for code", icon: "spinner", disabled: true }
: connectPhase === "connecting"
? { label: "Connecting", icon: "spinner", disabled: true }
: connectPhase === "ready"
? connectStepNeedsLogin
? {
label: `Sign in to ${connectSourceLabel}`,
icon: "none",
disabled: !connectAuthUrl,
}
: {
label: "Connect",
icon: "arrow",
disabled:
!connectStepReady || (credentialMode === "api" && !apiKey.trim()),
}
: // Nothing is chosen on arrival, and the row is what chooses. Until
// it has been answered the button has nothing to do.
{ label: "Next", icon: "arrow", disabled: true };
/**
* Back, on the connect step, unwinds the sign-in before it leaves the step.
*
* With no Cancel on the card this is the only way out, and what it undoes
* depends on how far in you are. Unmounting the panel is what releases the
* server session see the release-on-unmount effect in `AdapterLoginPanel`
* so the card leaving is the cancel, not a separate call.
*/
function unwindConnectStep() {
setConnectAuthUrl(null);
// Where the reverse starts depends on how far the sequence got. Backing out
// during the collapse has no card to close and no room to give back, and
// entering `unwindCard` regardless mounted the panel — which starts a
// server login on mount — purely so the unmount could cancel it. Should
// that cancel fail, the reservation is held to the server deadline and an
// immediate retry cannot start. With no card open, the row is the whole of
// the unwind.
setConnectPhase(connectCardLive ? "unwindCard" : "unwindRow");
}
/**
* What the step's primary action does, for both the button and Cmd+Enter.
@ -1200,10 +1380,13 @@ function OnboardingWizardInner({
* it is meant to start, against a source with no credential.
*/
function handleConnectStepPrimary() {
if (connectStepNeedsLogin && !loginStarted) {
setLoginStarted(true);
// Mid-sequence the button belongs to the sign-in, not to the step.
if (connectPhase === "ready" && connectStepNeedsLogin) {
if (connectAuthUrl) window.open(connectAuthUrl, "_blank", "noreferrer,noopener");
setConnectPhase("waiting");
return;
}
if (connectStepLoggingIn) return;
void handleGiveHeartbeat();
}
@ -1223,19 +1406,6 @@ 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.
*/
/**
* 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
@ -1248,7 +1418,7 @@ function OnboardingWizardInner({
*/
const canvasOpen =
sourceSelected &&
(credentialMode === "api" || loginStarted || connectStepHasNoSandbox);
(credentialMode === "api" || connectCardSpace || 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
@ -1320,15 +1490,26 @@ 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.
/**
* Leaving the step puts the row back to a question.
*
* Deliberately keyed on the step and nothing else. It used to reset on
* `adapterType` too, which made picking a source take two clicks: the first
* set the phase *and* the adapter, this effect saw the adapter change and put
* the phase straight back to idle, and only a second click which changed no
* adapter, so woke no effect was allowed to stand.
*
* Nothing else needs to reset it. A source can only change by being picked,
* and picking sets the phase itself; the credential mode can only change
* before the sequence starts, because its control is inert once the row has
* collapsed.
*/
useEffect(() => {
setLoginStarted(false);
setLoginConnected(false);
}, [adapterType, credentialMode]);
if (step === 4) return;
setConnectPhase("idle");
setConnectAuthUrl(null);
setSourcePicked(false);
}, [step]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
@ -2333,7 +2514,11 @@ function OnboardingWizardInner({
// tiles stretch and the name field sits under a question far
// narrower than itself.
isAgentArcStep || step === 1
? "w-(--sz-560px) max-w-full px-8 py-10 sm:px-(--sz-64px) sm:py-11"
? // 40px inset, not 64: the connect sequence is drawn against
// a 480px column and the arc's other steps share the shell,
// so they widen with it rather than sitting narrower than
// the step between them.
"w-(--sz-560px) max-w-full px-8 py-10 sm:px-10 sm:py-11"
: "w-full max-w-md px-8 py-12",
)}
>
@ -2814,37 +2999,20 @@ function OnboardingWizardInner({
{/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
{step === 4 && (
<div className="space-y-8">
{/* The two cards are self-describing; an "Adapter type"
eyebrow above them named the mechanism rather than the
choice. */}
<div>
{/* 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.
{/* Sources come from `recommendedAdapters`, not a list
written here that filter is `recommended` in the
display registry, so a third tile appears the day
someone marks one rather than the day someone
remembers to edit this file.
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. */}
Picking one starts the sign-in now. The row is the
question, and answering it is what opens the card. */}
<ModelSourceTiles
label="Model source"
sources={recommendedAdapters.map((opt) => ({
id: opt.type,
// The vendor name where this step has one, the registry's
// tool name where it does not. `MODEL_SOURCE_NAMES` was
// added with the reasoning above it and then never read,
// so the row went on showing "Claude Code" and "Codex"
// — the tool names — under a heading asking which
// provider you are signing in to.
//
// The fallback is what keeps the row rendering if the
// registry ever marks a third adapter `recommended`:
// an unnamed source gets its tool name rather than
// nothing.
label: MODEL_SOURCE_NAMES[opt.type] ?? opt.label,
label: CONNECT_SOURCE_NAMES[opt.type] ?? opt.label,
icon: <ModelSourceMark type={opt.type} Fallback={opt.icon} />,
}))}
mode={credentialMode}
@ -2854,69 +3022,112 @@ function OnboardingWizardInner({
? adapterType
: null
}
collapsed={connectCollapsed}
settling={connectPhase === "unwindRow"}
onSelect={(id) => {
if (connectPhase !== "idle") return;
setSourcePicked(true);
setAdapterType(id);
if (id === "codex_local") return;
if (id === "opencode_local") {
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
return;
}
setModel("");
if (id === "opencode_local") setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
else if (id !== "codex_local") setModel("");
setConnectPhase("collapsing");
}}
/>
{/* 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. */}
<div className="-ml-3 mt-1">
<CredentialModeLink
mode={credentialMode}
onChange={setCredentialMode}
/>
</div>
{/* Fades on the first beat but keeps its space until the
second, so pressing a tile moves nothing vertically.
Once a sign-in is running there is no switching to keys
without abandoning it, so it goes rather than sitting
there inviting a press that cannot be honoured. */}
<motion.div
className="overflow-hidden"
/*
Inert once it has faded. It is clipped to nothing rather
than unmounted, so without this it stays clickable and
focusable a control that has stopped applying, still
answering to a keyboard and still able to change the
credential mode out from under a running sign-in.
*/
inert={!connectLinkVisible}
initial={false}
animate={{
opacity: connectLinkVisible ? 1 : 0,
height: connectLinkSpace ? "auto" : 0,
}}
transition={{ opacity: SOURCE_LINK_EXIT, height: MAKE_ROOM }}
>
<div className="-ml-3 mt-1">
<CredentialModeLink mode={credentialMode} onChange={setCredentialMode} />
</div>
</motion.div>
</div>
{/* 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.
{/*
Room first, card second. `height` opens the space which the
link's collapse shares, so the column slides once and the
opacity only starts once that has finished. Reversed on the
way out: fade, then give the room back.
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. */}
<ConnectInputCanvas
open={canvasOpen}
contentKey={`${adapterType}:${credentialMode}`}
Nothing mounts to make that happen. A mount changes layout in
one frame, and no easing can smooth a step that has already
happened. Which means the card is always in the DOM, so it is
`inert` while closed: a clipped element is still focusable and
still announced, and the authorization field must not be
reachable inside a card nobody can see.
*/}
<motion.div
className="overflow-hidden"
inert={!connectCardLive}
initial={false}
animate={{
height: connectCardSpace ? "auto" : 0,
marginTop: connectCardSpace ? 20 : 0,
opacity: connectCardLive ? 1 : 0,
}}
transition={{
height: MAKE_ROOM,
marginTop: MAKE_ROOM,
opacity: connectCardLive
? { ...CARD_ENTER, delay: MAKE_ROOM.duration }
: CARD_EXIT,
}}
>
{credentialMode === "api" ? (
<ApiKeyField
envKey={apiKeyEnvKeyFor(adapterType)}
value={apiKey}
onChange={setApiKey}
/>
) : showAdapterLoginPanel &&
{/*
The wrapper is always rendered that is what lets its
height animate rather than jump but its contents are
not, and they outlive the space by a beat. See
`connectCardMounted`.
*/}
{!connectCardMounted ? null : credentialMode === "api" ? (
<OnboardingLoginCard
instruction={`Provide your ${
CONNECT_SOURCE_NAMES[adapterType] ?? adapterType
} API key to connect`}
>
<OnboardingCardField
label="API key"
placeholder="Enter API key here"
masked
// The card is the answer to the tile just pressed, so
// the field is unambiguously the next thing. Carried
// over from the key field this card replaced.
autoFocus
value={apiKey}
onChange={setApiKey}
onSubmit={() => handleConnectStepPrimary()}
/>
</OnboardingLoginCard>
) : connectStepNeedsLogin &&
createdCompanyId &&
resolvedLoginEnvironmentId ? (
/* 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.
in the connect step's chrome. It owns the session; the
step owns the sequence around it.
Unmounting it is the cancel: the panel releases its
server session on unmount, so Back closing the card is
what frees the owner's reservation.
No "Use saved login" control: the hire step already
applies a stored login on its own. */
@ -2927,19 +3138,16 @@ function OnboardingWizardInner({
environmentId={resolvedLoginEnvironmentId}
chrome="onboarding"
autoStart
onCancel={() => setLoginStarted(false)}
onPromptReady={(url) => {
setConnectAuthUrl(url);
// The prompt arriving is what ends the waiting beat.
if (url) setConnectPhase((p) => (p === "loading" ? "ready" : p));
}}
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();
// The hold before the step advances is the phase's own
// beat, above, so that backing out during it cancels
// the hire.
setConnectPhase("connecting");
}}
onStored={() => {
queryClient.invalidateQueries({
@ -2951,22 +3159,15 @@ function OnboardingWizardInner({
});
}}
/>
) : (
/* 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. */
) : connectStepHasNoSandbox ? (
/* The one thing that can be wrong here before anything is
pressed, and the one worth saying out loud: without a
sandbox there is nothing to sign in against. */
<p className="text-xs text-muted-foreground">
No managed sandbox is available to sign in against yet.
</p>
)}
</ConnectInputCanvas>
) : null}
</motion.div>
{/* Conditional adapter fields */}
{/* No model picker. Every adapter this step offers resolves
@ -3143,7 +3344,11 @@ function OnboardingWizardInner({
{(isAgentArcStep || step === 1) && (
<FooterNav
onBack={
step === 1
// On the connect step Back unwinds the sign-in first, and
// only means "the previous step" once nothing is running.
step === 4 && connectPhase !== "idle"
? unwindConnectStep
: step === 1
? () => {
setOnboardingPath(null);
setStep(0);
@ -3162,19 +3367,10 @@ function OnboardingWizardInner({
: step === 5
? "Get started"
: 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"
? connectCta.label
: "Next"
}
primaryIcon={step === 4 ? connectCta.icon : undefined}
loadingLabel={
step === 1
? "Creating..."
@ -3187,28 +3383,16 @@ function OnboardingWizardInner({
// 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)
}
// Step 4 says what it is doing through `connectCta` instead:
// it has four faces and only two of them are the step working.
loading={step === 3 || step === 4 ? false : loading}
primaryDisabled={
step === 1
? !companyName.trim() || loading
: step === 3
? !agentName.trim()
: step === 4
? // Nothing is chosen on arrival, so the step cannot
// advance until something is. Without this a customer
// could pass the model step having touched none of
// it, and be hired against whatever the draft
// happened to carry. See `connectStepReady`, which
// Cmd+Enter asks as well.
!connectStepReady ||
loading ||
// A sign-in is running and has not landed. Nothing
// to press until it does.
connectStepLoggingIn
? connectCta.disabled || loading
: loading || launchStateIncomplete
}
onPrimary={() => {

View File

@ -1,165 +0,0 @@
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import {
OnboardingLoginCard,
onboardingCardInputClass,
} from "../AdapterLoginChrome";
import {
CANVAS_CONTENT_ENTER,
CANVAS_ENTER_TRAVEL,
CANVAS_CONTENT_EXIT,
CANVAS_CONTENT_TRAVEL,
} from "./onboarding-motion";
/**
* The connect step's input surface: one card that holds whatever the current
* choice needs, rather than a different control appearing in a different place
* for each combination.
*
* There are four things it can hold a browser-code login for Claude, a
* displayed-code login for Codex, and an API key field for either and they are
* not the same shape or the same height. Giving each its own slot would move the
* Connect button every time the choice changed. One canvas that resizes keeps
* the step's furniture still and makes the card read as the answer to the tile
* above it.
*
* It is closed until a source is picked. An empty card under an untouched row of
* tiles is a box asking to be filled with nothing.
*/
/** Three lines of body text, so a short prompt and a long one open the same card. */
const MIN_CONTENT_HEIGHT = 66;
export function ConnectInputCanvas({
open,
contentKey,
children,
}: {
open: boolean;
/**
* Identity of what is inside, and what the swap animates between. The source
* and the credential mode together, because either one changing means a
* different input is needed.
*/
contentKey: string;
children: ReactNode;
}) {
if (!open) return null;
/*
No edge and no fill of its own. Everything this holds already draws its own
surface the login panel is a bordered, filled card, the key field a
bordered input so a frame here was the same treatment twice, one nested a
few pixels inside the other. The canvas is a place for the input to be, not
a thing to look at.
Which leaves the padding to the contents as well: theirs is already sized
for what they hold, and a second inset would push it off the step's measure.
The wrapper animates its arrival and nothing else. Picking a source is what
brings this into being, so it descends into place rather than appearing
already there the movement is what ties it to the tile just pressed.
Opacity and transform only. An earlier version animated *height* here with
`overflow: hidden`, and stalled three separate times once leaving the login
card rendered inside a two-pixel box, once at four percent opacity while
`open` was true throughout. The casualty each time was the OAuth URL a
customer has to click. A height that is measured once cannot hold a panel
that grows when a login starts; these two properties can, because neither
clips and neither is measured.
*/
return (
<motion.div
className="mt-5 flex items-center"
style={{ minHeight: MIN_CONTENT_HEIGHT }}
initial={{ opacity: 0, y: -CANVAS_ENTER_TRAVEL }}
animate={{ opacity: 1, y: 0, transition: CANVAS_CONTENT_ENTER }}
>
{/*
`popLayout`, so the leaving input is taken out of flow while it animates
and the arriving one decides the card's height on its own. The default
mode would stack them and jump the card to the sum of both mid-swap.
Not `mode="wait"`: it will not mount the next child until the previous
reports its exit finished, that report never came here, and the swap
stalled into an instant change with no transition at all.
*/}
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={contentKey}
className="w-full"
initial={{ opacity: 0, y: CANVAS_CONTENT_TRAVEL }}
animate={{ opacity: 1, y: 0, transition: CANVAS_CONTENT_ENTER }}
exit={{
opacity: 0,
y: CANVAS_CONTENT_TRAVEL,
transition: CANVAS_CONTENT_EXIT,
}}
>
{children}
</motion.div>
</AnimatePresence>
</motion.div>
);
}
/**
* The API key field, for when the credential mode is keys rather than a
* subscription.
*
* Built to the sign-in card's shape on purpose, and that reasoning is
* unchanged from when it was written only its target moved. These two are
* alternatives to each other: one canvas shows one or the other and the
* credential switch above trades between them, so they have to read as two
* answers to one question rather than as two different kinds of thing. It was
* matched to the old bordered panel; the connect step's sign-in became a
* borderless card with 44px rows, and this stayed behind, so flipping the
* toggle changed the shape of the step rather than its content the exact
* failure the original note was written to prevent.
*
* It now composes the same primitives rather than restating their measurements,
* which is what keeps that from happening again.
*
* 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. It takes the instruction slot the sign-in cards use for their
* sentence, in mono, because it is a name and not prose.
*/
export function ApiKeyField({
envKey,
value,
onChange,
}: {
envKey: string;
value: string;
onChange: (next: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(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 (
<OnboardingLoginCard
instruction={<span className="font-mono">{envKey}</span>}
>
<input
ref={inputRef}
aria-label={envKey}
type="password"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Paste your key"
className={onboardingCardInputClass}
/>
</OnboardingLoginCard>
);
}

View File

@ -1,5 +1,11 @@
import { motion } from "motion/react";
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "../ui/button";
import { CTA_LABEL_IN, CTA_WIDTH } from "./onboarding-motion";
/** What sits after the primary label. */
export type FooterPrimaryIcon = "arrow" | "spinner" | "none";
/**
* Shared footer for the arc's step cards: a ghost pill "Back" and a primary
@ -8,6 +14,12 @@ import { Button } from "../ui/button";
* `onBack` is optional a run that entered on this step has nowhere behind it
* to return to, and an inert Back button reads as a dead control rather than a
* boundary.
*
* The primary button animates between labels rather than swapping them. The
* connect step walks it through four ("Next" "Sign in to Claude" "Waiting
* for code" → "Connecting"), which are very different widths, and a control
* that changes size instantly reads as a different control appearing. See
* `CTA_WIDTH` and `CTA_LABEL_*` for why the two halves are timed apart.
*/
export function FooterNav({
onBack,
@ -15,6 +27,7 @@ export function FooterNav({
primaryDisabled,
loading,
loadingLabel,
primaryIcon,
onPrimary,
}: {
onBack?: () => void;
@ -22,8 +35,19 @@ export function FooterNav({
primaryDisabled?: boolean;
loading?: boolean;
loadingLabel?: string;
/**
* Defaults to the loading state's own reading spinner while loading, arrow
* otherwise so callers that predate this prop are unchanged. The connect
* step sets it directly, because "Sign in to Claude" carries no icon while
* "Waiting for code" carries a spinner without the step being `loading`: it
* is waiting on another tab, not working.
*/
primaryIcon?: FooterPrimaryIcon;
onPrimary: () => void;
}) {
const label = loading && loadingLabel ? loadingLabel : primaryLabel;
const icon: FooterPrimaryIcon = primaryIcon ?? (loading ? "spinner" : "arrow");
return (
<div className="flex items-center justify-between pt-9">
{onBack ? (
@ -47,16 +71,50 @@ export function FooterNav({
) : (
<span />
)}
<Button
size="lg"
className="rounded-full px-6"
onClick={onPrimary}
disabled={primaryDisabled || loading}
>
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : null}
{loading && loadingLabel ? loadingLabel : primaryLabel}
{!loading ? <ArrowRight className="ml-1 size-3.5" /> : null}
</Button>
{/*
`layout` on the button and `popLayout` on its contents are what make the
width ease rather than jump: the outgoing label leaves the flow at once,
so the button's target width becomes the incoming label's, and the
layout animation carries it there while the words cross-fade in place.
Without `popLayout` the two labels would briefly sit side by side and
the button would widen to hold both before shrinking back.
*/}
<motion.div layout transition={CTA_WIDTH} className="min-w-0">
<Button
size="lg"
className="w-full rounded-full px-6"
onClick={onPrimary}
disabled={primaryDisabled || loading}
>
{/*
One label in the DOM at a time, keyed so a change remounts it and it
fades in over the width easing underneath.
Deliberately not a cross-fade through `AnimatePresence`. That keeps
the outgoing label mounted while it leaves, which puts two words
inside one button: the accessible name becomes "NextConnect", and
anything reading the button's text — including this repo's own step
tests sees both. It is also fragile, since an exit that never
resolves never unmounts.
The width carries the elegance here. The word arriving over a shape
that is still easing reads as one control changing rather than two
labels trading places, which is what the cross-fade was for.
*/}
<motion.span
key={`${label}:${icon}`}
className="flex items-center whitespace-nowrap"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={CTA_LABEL_IN}
>
{icon === "spinner" ? <Loader2 className="mr-1 size-4 animate-spin" /> : null}
{label}
{icon === "arrow" ? <ArrowRight className="ml-1 size-3.5" /> : null}
</motion.span>
</Button>
</motion.div>
</div>
);
}

View File

@ -2,7 +2,13 @@ import { useRef, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "../../lib/utils";
import { TAG_SWAP_ENTER, TAG_SWAP_EXIT, TAG_SWAP_TRAVEL } from "./onboarding-motion";
import {
SOURCE_EXIT_FADE,
SOURCE_COLLAPSE_MOVE,
TAG_SWAP_ENTER,
TAG_SWAP_EXIT,
TAG_SWAP_TRAVEL,
} from "./onboarding-motion";
/**
* The connect step's row of model sources, and the tag under each one saying
@ -61,12 +67,14 @@ function ModelSourceTile({
selected,
onSelect,
buttonRef,
settling,
}: {
source: ModelSource;
mode: CredentialMode;
selected: boolean;
onSelect: () => void;
buttonRef: (node: HTMLButtonElement | null) => void;
settling: boolean;
}) {
return (
<button
@ -77,7 +85,13 @@ function ModelSourceTile({
onClick={onSelect}
className={cn(
"flex min-w-0 flex-1 cursor-pointer flex-col items-center gap-1.5 self-stretch rounded-md border p-3",
"transition-(--tp-border-color-background-color) duration-(--motion-duration-fast) ease-(--motion-ease-standard)",
// Longer while the row is settling back to its default. Dropping the
// selection is the last thing that happens on the way out, and at the
// interaction duration it landed as a colour swap after everything else
// had stopped — a cut rather than a release. Across the tile's travel
// it reads as the choice being let go.
"transition-(--tp-border-color-background-color) ease-(--motion-ease-standard)",
settling ? "duration-(--motion-duration-slow)" : "duration-(--motion-duration-fast)",
// Focus is a ring, never a border. The stroke has exactly one job here
// and lending it to focus as well would mean tabbing across the row
// looked like picking every tile in turn.
@ -120,6 +134,8 @@ export function ModelSourceTiles({
selectedId,
onSelect,
label,
collapsed = false,
settling = false,
}: {
sources: ModelSource[];
mode: CredentialMode;
@ -127,6 +143,20 @@ export function ModelSourceTiles({
selectedId: string | null;
onSelect: (id: string) => void;
label: string;
/**
* Show only the chosen source, centred.
*
* The row is a question, and once a sign-in is running it has been answered
* leaving the alternative on screen invites a press that would have to cancel
* a live server session to honour. Collapsing says the choice is made without
* disabling anything, which reads better than a greyed-out tile.
*/
collapsed?: boolean;
/**
* The row is returning to its default. Only changes how long the selected
* styling takes to leave see the tile's own note.
*/
settling?: boolean;
}) {
const tiles = useRef(new Map<string, HTMLButtonElement>());
@ -148,12 +178,17 @@ export function ModelSourceTiles({
tiles.current.get(target.id)?.focus();
};
const shown = collapsed ? sources.filter((source) => source.id === selectedId) : sources;
return (
<div
role="radiogroup"
aria-label={label}
className="flex items-start gap-3"
className={cn("flex items-start gap-3", collapsed && "justify-center")}
onKeyDown={(event) => {
// Collapsed, the row is a statement rather than a choice; arrow keys
// would move a selection that is no longer being asked for.
if (collapsed) return;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
moveSelection(1);
@ -163,19 +198,40 @@ export function ModelSourceTiles({
}
}}
>
{sources.map((source) => (
<ModelSourceTile
key={source.id}
source={source}
mode={mode}
selected={source.id === selectedId}
onSelect={() => onSelect(source.id)}
buttonRef={(node) => {
if (node) tiles.current.set(source.id, node);
else tiles.current.delete(source.id);
}}
/>
))}
{/*
`popLayout` takes the leaving tile out of flow at once, so the survivor's
`layout` animation targets its final centred position rather than
chasing a gap that is still closing.
The wrapper carries the width, not the tile: held at the width it had
with two in the row, so the kept tile travels without also growing.
*/}
<AnimatePresence initial={false} mode="popLayout">
{shown.map((source) => (
<motion.div
key={source.id}
layout
transition={SOURCE_COLLAPSE_MOVE}
exit={{ opacity: 0, transition: SOURCE_EXIT_FADE }}
className={cn(
"flex min-w-0",
collapsed ? "w-(--sz-source-tile-two-up)" : "flex-1",
)}
>
<ModelSourceTile
source={source}
mode={mode}
selected={source.id === selectedId}
onSelect={() => onSelect(source.id)}
settling={settling}
buttonRef={(node) => {
if (node) tiles.current.set(source.id, node);
else tiles.current.delete(source.id);
}}
/>
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View File

@ -161,3 +161,185 @@ export const CANVAS_ENTER_TRAVEL = 10;
export const CANVAS_CONTENT_ENTER = TAG_SWAP_ENTER;
export const CANVAS_CONTENT_EXIT = TAG_SWAP_EXIT;
export const CANVAS_CONTENT_TRAVEL = TAG_SWAP_TRAVEL;
/**
* The connect step's sign-in sequence: picking a source, the card opening on a
* wait, and the primary button walking through four labels.
*
* All of it is built from the vocabulary above rather than a second one. The
* sequence is one gesture that starts at the tile row and ends at the button,
* so a new curve partway through would break it into separate events the
* same reasoning the canvas tokens are written with.
*/
/**
* The row collapsing to the chosen source.
*
* The unpicked tile fades where it stands while the picked one travels to the
* centre, and the two are deliberately not symmetrical: one is leaving and one
* is being kept, so animating both the same way would read as the row
* reshuffling rather than as a choice being made. The exit is the tag's, short
* enough to be gone before the survivor arrives.
*
* The travel is a layout animation, not a fixed offset the distance depends
* on which tile was picked, and hard-coding it would send the right-hand tile
* the wrong way.
*/
export const SOURCE_COLLAPSE_MOVE = { duration: 0.42, ease: TAG_SWAP_EASE } as const;
export const SOURCE_COLLAPSE_FADE = TAG_SWAP_EXIT;
/**
* The credential-mode link leaving as the row collapses.
*
* Faster than the collapse it accompanies. It is not part of the choice, it is
* a control that has stopped applying once a sign-in is running there is no
* switching to keys without cancelling so it should be gone before the eye
* follows the tile, rather than travelling alongside it and inviting a press.
*/
export const SOURCE_LINK_EXIT = { duration: 0.16, ease: TAG_SWAP_EASE } as const;
/**
* The card's staged reveal once the sign-in has something to show.
*
* The instruction first, the field a beat later. The order is the reading
* order, and the gap is what makes it read as one thing unfolding rather than
* two arriving together it also means the sentence has been read by the time
* the field is ready to be pasted into, which is the point of staging it at all.
*
* Both rise slightly, on the canvas's own travel, so the reveal belongs to the
* surface that opened rather than being a separate entrance inside it.
*/
export const CARD_REVEAL_TRAVEL = 6;
export const CARD_REVEAL_INSTRUCTION = { duration: 0.3, ease: STEP_EASE } as const;
export const CARD_REVEAL_FIELD = { duration: 0.3, delay: 0.12, ease: STEP_EASE } as const;
/**
* The primary button changing label.
*
* Two animations at once, and they are separate on purpose. The text
* cross-fades on the link label's timing the outgoing word mostly gone before
* the incoming one starts, so two labels are never legible at once. The button's
* *width* eases in and out underneath it, because "Next" and "Waiting for code"
* are very different sizes and snapping between them would make a settled
* control look like it was replaced.
*
* The width is the slower of the two, so the shape finishes arriving after the
* word does. Reversing that reads as the button resizing and then, separately,
* changing its mind about what it says.
*/
export const CTA_WIDTH = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
export const CTA_LABEL_OUT = LINK_LABEL_FADE_OUT;
export const CTA_LABEL_IN = LINK_LABEL_FADE_IN;
/**
* The deliberate pause between a pasted code being accepted and the step
* advancing.
*
* Not a fetch the work is already done by the time this starts. It exists so
* "Connecting" is legible as a state rather than a flicker on the way out: the
* step advancing the instant a paste lands reads as the paste having gone
* wrong, because nothing acknowledged it. Two seconds is long enough to be read
* and short enough not to feel stalled.
*/
export const CONNECTED_HOLD_MS = 2000;
/**
* The sign-in card arriving and leaving, and the footer moving because of it.
*
* These are sequenced rather than concurrent, and the ordering is the whole
* point. Running the collapse and the card's arrival together read as two
* unrelated things happening at once; run in order, the row answering the
* question is what *causes* the card to open.
*
* The footer is not animated directly. The card holds its own space while it
* fades `AnimatePresence` keeps it mounted through its exit so the footer
* only moves once the card is genuinely gone, and a `layout` animation carries
* it. That is why the exit is quick and the settle that follows is separate:
* "card goes, then the bar comes back up" is two beats, not one.
*/
export const CARD_ENTER = { duration: 0.3, ease: STEP_EASE } as const;
export const CARD_EXIT = { duration: 0.18, ease: TAG_SWAP_EASE } as const;
export const FOOTER_SETTLE = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
/**
* Milliseconds, for the timers that drive the sequence from one beat to the
* next. Kept beside the transitions they mirror so the two cannot drift a
* timer that fires early would start the next beat over the top of the one
* still running, which is the exact fault this sequencing exists to fix.
*/
export const SOURCE_COLLAPSE_MS = SOURCE_COLLAPSE_MOVE.duration * 1000;
export const CARD_EXIT_MS = CARD_EXIT.duration * 1000;
export const FOOTER_SETTLE_MS = FOOTER_SETTLE.duration * 1000;
/**
* Making room for the card, and giving it back.
*
* Its own beat, before the card is visible at all. The card used to arrive by
* mounting, which meant its space appeared in a single frame: everything above
* jumped to its new position instead of travelling there, and the credential
* link's space vanished at the same instant, compounding it.
*
* Nothing mounts or unmounts to make this happen now. The card and the link are
* both always rendered, and their *heights* animate so every frame is a real
* layout the column can settle into, and the whole step slides. The card only
* fades in once the room exists.
*/
export const MAKE_ROOM = { duration: 0.34, ease: TAG_SWAP_EASE } as const;
export const MAKE_ROOM_MS = MAKE_ROOM.duration * 1000;
/**
* The unpicked tile's fade, shortened from the tag's exit.
*
* It leaves the flow at once and travels nowhere, so while it is still legible
* it sits on top of the tile moving underneath it. At the tag's 260ms that
* overlap was long enough to read as two tiles briefly occupying one another;
* at 180 the survivor is clear before it arrives.
*/
export const SOURCE_EXIT_FADE = { duration: 0.18, ease: TAG_SWAP_EASE } as const;
/**
* "Copied!" arriving beside a code that was put on the clipboard for you.
*
* It rises as it fades in, which is the difference between a label that was
* always there and one that just happened the code did not change, so
* something has to say that an action occurred. Short, and it stays: this is a
* statement about the clipboard's contents, and those are still true a second
* later.
*/
export const COPIED_REVEAL_TRAVEL = 6;
export const COPIED_REVEAL = { duration: 0.26, ease: STEP_EASE } as const;
/**
* How long "Copied!" waits before it appears.
*
* The clipboard is written the moment the card is live, but saying so while the
* instruction and the code are themselves still fading in buries the one part
* of the card that is reporting an event rather than presenting a fact it
* arrives inside the reveal and reads as another thing that was always there.
*
* Timed off the card's own reveal, so it lands after the last of it settles
* rather than at a number picked to look right. The extra beat is deliberate
* separation: this is the only thing moving by then, which is what makes it
* noticeable at all.
*/
export const COPIED_REVEAL_DELAY_MS =
(CARD_REVEAL_FIELD.delay + CARD_REVEAL_FIELD.duration) * 1000 + 120;
/**
* How long to wait before the next beat of the connect sequence.
*
* The waits exist to let an animation finish. Where nothing is animating they
* are just a slower screen, so reduced motion collapses them to nothing the
* same thing `index.css` does to the duration tokens under that media query,
* applied to the timers that mirror them.
*
* No `matchMedia` at all is treated as reduced rather than as full motion. The
* honest reading of "cannot ask" is "do not animate", and it means the sequence
* still advances anywhere the query is unavailable a server render, an older
* embedder, or a test environment instead of stalling on a beat that will
* never elapse.
*/
export function beatDelay(ms: number): number {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return 0;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms;
}

View File

@ -1,16 +1,24 @@
import { StrictMode, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { MotionConfig } from "motion/react";
import { MotionConfig, motion } from "motion/react";
import { isValidBrowserCode } from "@paperclipai/shared";
import {
CARD_ENTER,
CARD_EXIT,
CARD_EXIT_MS,
CONNECTED_HOLD_MS,
MAKE_ROOM,
MAKE_ROOM_MS,
SOURCE_COLLAPSE_MS,
SOURCE_LINK_EXIT,
} from "./components/onboarding/onboarding-motion";
import {
OnboardingLoginCard,
OnboardingLoginCodeInput,
OnboardingCardField,
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 {
@ -43,9 +51,6 @@ import "./index.css";
* 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.
@ -73,8 +78,41 @@ const MODEL_SOURCES: ModelSource[] = [
{ 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";
/**
* Where the sequence is.
*
* `loading` and `ready` are the same card at the same height only its
* contents differ so the footer is pushed down once, when the card arrives,
* and never again.
*/
type Phase =
| "idle"
/** The row answering: chosen tile travelling to centre, the other leaving. */
| "collapsing"
/** The card arriving on a spinner, pushing the footer down as it does. */
| "loading"
| "ready"
| "waiting"
| "connecting"
/** Back 1: the card fades while still holding its space. */
| "unwindCard"
/** Back 2: the room closes — everything slides back, the link's space returns. */
| "unwindRoom"
/** Back 3: the row reopens, the link fades back, the button reverts. */
| "unwindRow"
| "done";
/** Stand-ins for the round trips a real sign-in makes. */
const PROMPT_DELAY_MS = 1400;
/** The displayed-code login's wait, standing in for the authorisation poll. */
const POLL_DELAY_MS = 3000;
/** The code OpenAI's login hands over. */
const DISPLAYED_CODE = "Q2RJ-E1YIF";
const OAUTH_URL: Record<string, string> = {
claude_local: "https://claude.ai/oauth/authorize?code=true&client=paperclip",
codex_local: "https://auth.openai.com/codex/device",
};
function ConnectFlowPreview({
initialSourceId,
@ -87,66 +125,176 @@ function ConnectFlowPreview({
const [useApiKeys, setUseApiKeys] = useState(false);
const [phase, setPhase] = useState<Phase>(initialPhase);
const [code, setCode] = useState("");
const [polled, setPolled] = useState(false);
const [apiKey, setApiKey] = useState("");
const timers = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const submitsBrowserCode = selectedId === "claude_local";
const mode: CredentialMode = useApiKeys ? "api" : "subscription";
const providerName = selectedId === "codex_local" ? "OpenAI" : "Claude";
const signInLabel = `Sign in to ${providerName}`;
/*
Claude takes a code back from the customer; OpenAI hands one over. That is
the only difference between the two cards the sentence and the last row
and everything around them, every transition included, is shared.
*/
const apiMode = mode === "api";
const handsOverCode = !apiMode && selectedId === "codex_local";
const instructionTail = handsOverCode
? " by providing the authorization code below"
: " then come back and enter authorization code";
const authUrl = OAUTH_URL[selectedId ?? "claude_local"]!;
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 sequence, one beat handing to the next.
// 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.
Each wait is the duration of the animation before it, so a beat never
starts over the top of the one still running which is what made the
collapse and the card's arrival read as two unrelated things happening at
once rather than one causing the other.
*/
useEffect(() => {
if (phase !== "auth" || submitsBrowserCode || polled) return;
after(POLL_DELAY_MS, () => setPolled(true));
}, [phase, submitsBrowserCode, polled]);
if (phase === "collapsing") {
// The row finishes answering before the card starts arriving.
//
// A key goes straight to `ready`. There is no prompt to fetch for one —
// the field is available the moment the source is chosen — and the
// canvas's own notes are explicit that a spinner standing in for no work
// is a slower screen that also says something untrue.
after(SOURCE_COLLAPSE_MS, () => setPhase(apiMode ? "ready" : "loading"));
} else if (phase === "loading") {
after(PROMPT_DELAY_MS, () => setPhase("ready"));
} else if (phase === "waiting" && handsOverCode) {
// Nothing comes back to this screen for the displayed-code login: the
// customer authorises in the other tab and the server says so. Stood in
// for here so the sequence can be walked end to end.
after(POLL_DELAY_MS, () => setPhase("connecting"));
} else if (phase === "connecting" && handsOverCode) {
after(CONNECTED_HOLD_MS, () => setPhase("done"));
} else if (phase === "unwindCard") {
// The card's own fade, with its space still held. The fields are emptied
// at the end of it, once nothing is legible, so the reset is never seen.
after(CARD_EXIT_MS, () => {
setCode("");
setApiKey("");
setPhase("unwindRoom");
});
} else if (phase === "unwindRoom") {
// The room closing: the card's height going and the link's coming back,
// together, so the column slides once rather than twice.
after(MAKE_ROOM_MS, () => setPhase("unwindRow"));
} else if (phase === "unwindRow") {
// Let the selection go as the row starts back, not after it has arrived.
// Held until the end, the tile changed colour with nothing else moving,
// which is the hard cut — released here it fades across the travel and
// the tile settles into its default rather than snapping to it.
setSelectedId(null);
after(SOURCE_COLLAPSE_MS, () => setPhase("idle"));
}
}, [phase, handsOverCode, apiMode]);
const finishSubmit = () => {
setPhase("submitting");
after(SUBMIT_DELAY_MS, () => setPhase("done"));
/**
* A key is submitted by the step's button, not by arriving. It goes straight
* to the hold: there is nothing to wait for once it has been handed over.
*/
const submitKey = () => {
if (!apiKey.trim() || phase !== "ready") return;
setPhase("connecting");
after(CONNECTED_HOLD_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.
/** Back: two beats, card first, row second. */
const unwind = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
// What was typed stays until the card has gone. Clearing it here emptied
// the field while it was still on screen, so the placeholder appeared under
// the fade — the card left saying "Enter API key here" over a field the
// customer had just filled in.
setPhase("unwindCard");
};
const reset = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
setCode("");
setApiKey("");
setSelectedId(null);
setPhase("idle");
};
// Picking a source is what starts the sign-in — the row collapses, the link
// goes, and the card opens on a wait, all from the one press.
const pick = (id: string) => {
if (phase !== "idle") return;
setSelectedId(id);
setPhase("collapsing");
};
// The paste is the answer; see the shipped panel for why it is the paste and
// not the value. The hold after it is deliberate — `CONNECTED_HOLD_MS`.
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
setPhase("connecting");
after(CONNECTED_HOLD_MS, () => setPhase("done"));
}, [code]);
const reset = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
setPhase("idle");
setCode("");
setPolled(false);
};
// Collapsed from the moment a tile is pressed until the row is asked to
// reopen — `unwindRow` is where it expands, one beat after the card left.
const collapsed =
phase !== "idle" &&
phase !== "unwindRow" &&
phase !== "done";
const loggingIn = phase === "connecting" || phase === "auth" || phase === "submitting";
/*
Space and visibility are separate throughout, and that separation is the
whole fix. The link fades on the first beat but keeps its space until the
second, so pressing a tile moves nothing vertically; the card takes its
space on the second beat but only becomes visible on the third, so the
column has finished sliding before anything appears in it.
*/
const cardLive =
phase === "loading" || phase === "ready" || phase === "waiting" || phase === "connecting";
const cardSpace = cardLive || phase === "unwindCard";
const linkSpace =
phase === "idle" || phase === "collapsing" || phase === "unwindRoom" || phase === "unwindRow";
const linkVisible = phase === "idle" || phase === "unwindRow";
const done = phase === "done";
// Four labels, three shapes. The button is only ever pressable on `ready`:
// before that there is nothing to sign in to, and after it the sign-in is
// happening somewhere this screen cannot hurry.
const cta =
// `unwindCard` keeps whatever the button said: the first beat of Back is
// the card leaving, and changing the label at the same time would make two
// things happen in a beat meant to carry one.
phase === "unwindCard"
? { label: signInLabel, icon: "none" as const, disabled: true }
: done
? { label: "Start over", icon: "arrow" as const, disabled: false }
: phase === "ready" && apiMode
? // A key is typed here rather than fetched elsewhere, so the button is
// the submit and stays dead until there is something to submit.
{ label: "Connect", icon: "arrow" as const, disabled: !apiKey.trim() }
: phase === "ready"
? { label: signInLabel, icon: "none" as const, disabled: false }
: phase === "waiting"
? { label: "Waiting for code", icon: "spinner" as const, disabled: true }
: phase === "connecting"
? { label: "Connecting", icon: "spinner" as const, disabled: true }
: { label: "Next", icon: "arrow" as const, disabled: true };
return (
<MotionConfig reducedMotion="user">
<div className="w-(--sz-560px) max-w-full p-10">
{/* 40px inset, not 64: the sequence draws a 480px column inside the 560
frame, wider than the arc's other steps. */}
<div className="w-(--sz-560px) max-w-full px-10 py-10">
<Stepper step={done ? 3 : 2} />
<div className="flex flex-col items-center">
@ -177,93 +325,164 @@ function ConnectFlowPreview({
sources={MODEL_SOURCES}
mode={mode}
selectedId={selectedId}
onSelect={(id) => {
if (loggingIn) return;
setSelectedId(id);
}}
/>
<CredentialModeLink
mode={mode}
onChange={(next) => {
setUseApiKeys(next === "api");
reset();
}}
onSelect={pick}
collapsed={collapsed}
settling={phase === "unwindRow"}
/>
{/* Gone as soon as a source is picked, and faster than the row
collapses. Once a sign-in is running there is no switching to
keys without abandoning it, so leaving the control on screen
would invite a press that cannot be honoured. */}
{/*
Always rendered; only its height and opacity move, and they move
on different beats. Fading it and removing its space together is
what produced the jump on the very first press the row above
had not moved yet, so the column shortened for no visible
reason.
*/}
<motion.div
className="overflow-hidden"
initial={false}
animate={{ opacity: linkVisible ? 1 : 0, height: linkSpace ? "auto" : 0 }}
transition={{ opacity: SOURCE_LINK_EXIT, height: MAKE_ROOM }}
>
<CredentialModeLink
mode={mode}
onChange={(next) => setUseApiKeys(next === "api")}
/>
</motion.div>
</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}`}
{/*
Room first, card second. `height` opens the space over MAKE_ROOM
which the link's collapse shares, so the column slides once
and the opacity only starts once that has finished. Reversed on
the way out: fade, then give the room back.
Nothing mounts or unmounts here. A mount changes layout in one
frame, and no easing can smooth a step that has already happened.
*/}
<motion.div
className="overflow-hidden"
/*
Inert whenever the card is not live.
Animating height instead of mounting means the card is always in
the DOM, and a clipped element is still focusable and still
announced at state 1 the authorization field was reachable by
keyboard inside a card nobody could see. `inert` removes the
whole subtree from focus order and the accessibility tree
without giving up the height animation that made the column
slide.
*/
inert={!cardLive}
initial={false}
animate={{
height: cardSpace ? "auto" : 0,
marginTop: cardSpace ? 20 : 0,
opacity: cardLive ? 1 : 0,
}}
transition={{
height: MAKE_ROOM,
marginTop: MAKE_ROOM,
opacity: cardLive
? { ...CARD_ENTER, delay: MAKE_ROOM.duration }
: CARD_EXIT,
}}
>
{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>
<OnboardingLoginCard
loading={phase === "loading"}
instruction={
apiMode ? (
// No link, because there is nowhere to sign in to. The
// key is pasted straight in, so the sentence only has
// to say what is wanted.
`Provide your ${providerName} API key to connect`
) : (
<>
{/* The same destination as the button below. Two ways
to reach one link: the button for the customer
following the flow, the anchor for anyone who
wants to copy it into another browser. */}
<a
href={authUrl}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
{signInLabel}
</a>
{instructionTail}
</>
)
}
>
{/* The one place the three paths differ. */}
{apiMode ? (
<OnboardingCardField
label="API key"
placeholder="Enter API key here"
masked
value={apiKey}
onChange={setApiKey}
disabled={phase === "connecting"}
onSubmit={submitKey}
/>
) : handsOverCode ? (
<OnboardingLoginCodeRow code={DISPLAYED_CODE} autoCopy={cardLive} />
) : (
<OnboardingCardField
value={code}
onChange={setCode}
disabled={phase === "connecting"}
onSubmit={() => {
if (isValidBrowserCode(code.trim())) {
setPhase("connecting");
after(CONNECTED_HOLD_MS, () => setPhase("done"));
}
}}
onPaste={() => {
pastedRef.current = true;
}}
/>
)}
</OnboardingLoginCard>
</motion.div>
</>
)}
{/*
Back unwinds the sign-in before it leaves the step. Once the row has
collapsed there is a live session behind the card, and the nearest
thing to "back" is the state before it started the row open again,
both sources offered. Only from there does Back mean the previous
step.
It is also the only way out now that the card has no Cancel of its
own: one control, and what it undoes depends on how far in you are.
*/}
<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))
}
onBack={() => {
if (phase === "idle" || phase === "unwindCard" || phase === "unwindRow") return;
unwind();
}}
primaryLabel={cta.label}
primaryIcon={cta.icon}
primaryDisabled={cta.disabled}
onPrimary={() => {
if (done) reset();
else if (phase === "idle") setPhase("connecting");
else if (polled && !submitsBrowserCode) setPhase("done");
if (done) {
reset();
return;
}
if (phase !== "ready") return;
if (apiMode) {
submitKey();
return;
}
window.open(authUrl, "_blank", "noreferrer,noopener");
setPhase("waiting");
}}
/>
{/* 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>
);
@ -272,10 +491,10 @@ function ConnectFlowPreview({
/** `?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" },
loading: { initialSourceId: "claude_local", initialPhase: "loading" },
ready: { initialSourceId: "claude_local", initialPhase: "ready" },
waiting: { initialSourceId: "claude_local", initialPhase: "waiting" },
openai: { initialSourceId: "codex_local", initialPhase: "ready" },
};
const requested = new URLSearchParams(window.location.search).get("state") ?? "default";
@ -283,9 +502,6 @@ 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} />

View File

@ -2368,6 +2368,18 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--sz-20rem: 20rem; /* AgentSkillReleasePicker.tsx release menu width cap. */
--sz-18px: 18px; /* Extracted from ui/src/components/ActivityFeed.tsx (p-[18px]). */
--sz-44px: 44px; /* Extracted from ui/src/components/AgentConfigForm.tsx (min-h-[44px]). */
/* The width one source tile has when the connect step's row holds two of
them. The row collapses to the chosen source once a sign-in starts, and the
survivor has to keep the width it already had letting `flex-1` take the
whole row would make the tile grow as it travelled, which reads as a
different tile arriving rather than the one just pressed being kept. */
--sz-source-tile-two-up: calc((100% - 0.75rem) / 2);
/* The connect step's sign-in card, held at one height across its own states.
It opens on a spinner and then fills with an instruction and a field, and
the height is fixed so that arrival is the only movement a card that grew
as its contents landed would push the footer twice for one event. 108px is
16 padding + a 16 line + a 16 gap + a 44 field + 16 padding. */
--sz-108px: 108px;
--sz-88px: 88px; /* Extracted from ui/src/components/AgentConfigForm.tsx (min-h-[88px]). */
--sz-240px: 240px; /* Extracted from ui/src/components/AgentConfigForm.tsx (max-h-[240px]). */
--sz-10_5rem: 10.5rem; /* Extracted from ui/src/components/BlockedInboxView.tsx (w-[10.5rem]). */

View File

@ -0,0 +1,222 @@
import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CompanyProvider } from "./context/CompanyContext";
import { DialogProvider, useDialog } from "./context/DialogContext";
import { OnboardingWizard } from "./components/OnboardingWizard";
import "./index.css";
/**
* The real onboarding wizard, with the network stubbed, deployed so the connect
* step can be walked as the product actually renders it.
*
* The sibling `connect-flow-preview` draws the sequence from the same
* components but drives it with its own state machine. This one renders
* `OnboardingWizard` itself, so what is on screen is the step's real code path:
* its phases, its panel, its session handling and its footer.
*
* Everything below the app is faked and nothing above it is. Every API call in
* this codebase goes through one `fetch` in `api/client.ts`, so intercepting
* that is enough to stand the whole wizard up without a server no module
* mocks, and therefore no chance of previewing something other than the code
* that ships.
*/
const COMPANY = { id: "company-preview", name: "Initech", issuePrefix: "INI" };
const SESSION_ID = "preview-session";
const AUTH_URL = "https://claude.ai/oauth/authorize?code=true&client=paperclip";
const OPENAI_URL = "https://auth.openai.com/codex/device";
/** How long the fake server takes to produce a prompt. */
const PROMPT_LATENCY_MS = 1200;
let sessionStartedAt = 0;
let authenticated = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
/**
* The canned server.
*
* Only the routes the connect step actually reaches. Anything else answers with
* an empty object rather than a 404: an unhandled call should not be the reason
* a preview looks broken, and the console still shows what was asked for.
*/
function respond(pathname: string, method: string): Response {
const has = (p: string) => pathname.includes(p);
/*
Ordered most-specific first, and the registry matched at the *end* of the
path rather than anywhere in it. Several routes live under
`/adapters/:type` the model list and the auth signal among them so a
substring test for the registry answers those with a list of adapters, and
the step then tries to sort model ids that are not there.
*/
if (has("/instance/settings/experimental")) return json({ enableConferenceRoomChat: true });
if (has("/instance/settings")) return json({ defaultEnvironmentId: "env-sandbox" });
// No credential anywhere, which is what makes the step offer a sign-in.
if (has("/auth-signal")) return json({ status: "absent" });
if (has("/claude-oauth-token-status")) return json({}, 404);
if (pathname.endsWith("/models")) return json([]);
if (pathname.endsWith("/environments/capabilities"))
return json({
sandboxProviders: {
daytona: {
status: "supported",
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: false,
supportsInteractiveSetup: false,
interactiveSetupConnectionTypes: [],
supportsTemplateCapture: false,
supportsTemplateDelete: false,
supportsLoginPty: true,
source: "plugin",
},
},
});
if (pathname.endsWith("/environments"))
return json([
{
id: "env-sandbox",
name: "Daytona",
description: null,
driver: "sandbox",
status: "active",
config: { provider: "daytona" },
envVars: {},
metadata: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
if (pathname.endsWith("/adapters"))
return json(
[
["claude_local", "Claude Code", "submitted_browser_code", "fixed"],
["codex_local", "Codex", "displayed_code", "caller_bounded"],
].map(([type, label, panelMode, timeoutPolicy]) => ({
type,
label,
source: "builtin",
modelsCount: 0,
loaded: true,
disabled: false,
capabilities: {
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsAcp: true,
login: { panelMode, timeoutPolicy },
},
})),
);
// The browser-code login: a session, then a prompt once the latency passes.
if (has("/setup-token-login-sessions")) {
if (has("/prompt")) {
const ready = Date.now() - sessionStartedAt > PROMPT_LATENCY_MS;
return ready ? json({ authorizationUrl: AUTH_URL, transportAdvisory: null }) : json({}, 404);
}
if (has("/completion")) return json({ storedSessionId: "stored-preview" });
if (has("/code")) {
// Accepted, and the next status read reports the login authenticated.
authenticated = true;
return json({ sessionId: SESSION_ID, status: "authenticated" });
}
if (has("/cancel")) return json({});
if (method === "POST") {
sessionStartedAt = Date.now();
authenticated = false;
return json({
sessionId: SESSION_ID,
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
}
return json({
sessionId: SESSION_ID,
status: authenticated ? "authenticated" : "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
}
// The displayed-code login: one session that hands a code over.
if (has("/login-sessions")) {
if (has("/cancel")) return json({});
if (method === "POST") {
sessionStartedAt = Date.now();
return json({ sessionId: SESSION_ID, status: "pending" });
}
const ready = Date.now() - sessionStartedAt > PROMPT_LATENCY_MS;
return json({
sessionId: SESSION_ID,
status: "pending",
prompt: ready ? { url: OPENAI_URL, code: "Q2RJ-E1YIF" } : null,
});
}
if (has("/test-environment"))
return json({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date().toISOString(),
});
if (has("/agent-hires")) return json({ agent: { id: "agent-preview" }, approval: null });
if (has("/goals")) return json([]);
if (has("/companies")) return json(method === "POST" ? COMPANY : [COMPANY]);
return json({});
}
const realFetch = window.fetch.bind(window);
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const method = (init?.method ?? "GET").toUpperCase();
// Only the app's own API is stood in for; anything else (fonts, assets) goes
// to the network as normal.
if (!url.includes("/api/")) return realFetch(input as RequestInfo, init);
const { pathname } = new URL(url, window.location.origin);
// eslint-disable-next-line no-console
console.debug("[preview]", method, pathname);
return respond(pathname, method);
}) as typeof window.fetch;
/** Opens the wizard on the connect step, which is what this page is for. */
function OpenOnConnectStep() {
const { openOnboarding } = useDialog();
useEffect(() => {
openOnboarding({ initialStep: 4, companyId: COMPANY.id });
}, [openOnboarding]);
return null;
}
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<DialogProvider>
<OpenOnConnectStep />
<OnboardingWizard />
</DialogProvider>
</CompanyProvider>
</QueryClientProvider>
</MemoryRouter>
</StrictMode>,
);

View File

@ -61,7 +61,12 @@ export default defineConfig({
emptyOutDir: true,
minify: "esbuild",
rollupOptions: {
input: path.resolve(__dirname, "connect-flow-preview.html"),
input: [
path.resolve(__dirname, "connect-flow-preview.html"),
// The same sequence rendered by the real wizard rather than a harness,
// so the shipped code path can be walked from the same deployment.
path.resolve(__dirname, "wizard-preview.html"),
],
},
},
esbuild: {

28
ui/wizard-preview.html Normal file
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 step — the real wizard</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/wizard-preview-main.tsx"></script>
</body>
</html>