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:
parent
4b0e324c63
commit
f2349990cc
|
|
@ -4,6 +4,7 @@ node_modules/
|
|||
**/node_modules/
|
||||
dist/
|
||||
dist-preview/
|
||||
dist-flow-preview/
|
||||
packages/paperclip-runner/runner/target/
|
||||
ui/storybook-static/
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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]). */
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue