From 539c9212f4b98e37643f5a8e3b603f1b5845b5d7 Mon Sep 17 00:00:00 2001 From: Tonio Date: Sun, 6 Sep 2026 09:02:21 -0700 Subject: [PATCH] fix(onboarding): keep the pasted code on screen, and make the auto-copy real (#12921) The Claude card cleared its field the instant the paste was submitted, so the code appeared and vanished in the same breath and the seconds that followed had no feedback at all - it read as the paste having been dropped. The code stays now; the field is disabled and the step's button carries the status. Scoped to the onboarding chrome: the panel in the agent form stays open long after its login and has its own status area, so clearing is still right there. The OpenAI card's auto-copy took its latch before the attempt rather than on success, so the first refusal was permanent - and the first attempt is the one most likely to be refused, since it fires while the card is still arriving. It also ran with an empty code on the paths where the row renders before the server's one-time prompt has a value, which wrote nothing and then said Copied! over whatever the clipboard already held. It now waits for the document to have focus, retries when focus returns, guards against a second attempt while one is in flight, and only latches once a write has actually landed. The arc's question is a quarter larger, 36px to 45px, as a token with its own leading. paperclipai/paperclip-cloud#400 moves the signup wizard's matching heading by the same quarter - the two flows are pinned to each other on purpose. --- ui/src/components/AdapterLoginChrome.test.tsx | 112 +++++++++++++++++- ui/src/components/AdapterLoginChrome.tsx | 62 ++++++++-- ui/src/components/AgentConfigForm.tsx | 18 ++- ui/src/components/OnboardingWizard.test.tsx | 5 + .../onboarding/OnboardingPrimitives.tsx | 4 +- ui/src/index.css | 8 ++ 6 files changed, 191 insertions(+), 18 deletions(-) diff --git a/ui/src/components/AdapterLoginChrome.test.tsx b/ui/src/components/AdapterLoginChrome.test.tsx index 00147d52bc..df572a6040 100644 --- a/ui/src/components/AdapterLoginChrome.test.tsx +++ b/ui/src/components/AdapterLoginChrome.test.tsx @@ -2,12 +2,13 @@ import { createRoot, type Root } from "react-dom/client"; import { flushSync } from "react-dom"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type React from "react"; import { OnboardingLoginCard, OnboardingCardField, + OnboardingLoginCodeRow, onboardingCardInputClass, } from "./AdapterLoginChrome"; @@ -141,3 +142,112 @@ describe("the connect step's cards", () => { expect(ready).toContain("min-h-(--sz-108px)"); }); }); + +/** + * The displayed-code card puts the code on the clipboard the moment it is + * readable, so the customer can paste it wherever they are being asked for it + * without reaching for the button. That convenience is only worth anything if + * it actually happened — a card claiming "Copied!" over an empty clipboard is + * worse than one that never claimed it, because the customer stops checking. + */ +describe("the displayed code's auto-copy", () => { + function stubClipboard() { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + return writeText; + } + + function stubFocus(focused: boolean) { + const original = document.hasFocus; + document.hasFocus = () => focused; + return () => { + document.hasFocus = original; + }; + } + + afterEach(() => { + Reflect.deleteProperty(navigator, "clipboard"); + }); + + it("writes nothing when there is no code yet", async () => { + // The row renders before the server's one-time prompt carries a value on + // some paths. The latch used to be taken on that first run, so the copy + // that mattered never ran and the clipboard kept whatever it already had. + const writeText = stubClipboard(); + const restore = stubFocus(true); + try { + await render(); + expect(writeText).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("copies the code once it arrives", async () => { + const writeText = stubClipboard(); + const restore = stubFocus(true); + try { + await render(); + expect(writeText).toHaveBeenCalledWith("WFK7-4GA3U"); + } finally { + restore(); + } + }); + + it("asks once while a write is still in flight", async () => { + // The success latch is only taken when the write resolves, so it cannot + // also mean "already running" — without a separate guard a focus event + // arriving mid-write started a second attempt. + let settle: () => void = () => {}; + const writeText = vi.fn( + () => + new Promise((resolve) => { + settle = resolve; + }), + ); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const restore = stubFocus(true); + try { + await render(); + expect(writeText).toHaveBeenCalledTimes(1); + + await act(async () => { + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("focus")); + }); + expect(writeText).toHaveBeenCalledTimes(1); + + await act(async () => { + settle(); + }); + } finally { + restore(); + } + }); + + it("waits for the document rather than spending its one attempt unfocused", async () => { + // A write from an unfocused document is refused, and the first attempt is + // the most likely to be refused, since it fires while the card is still + // arriving. Latching before the attempt made that refusal permanent. + const writeText = stubClipboard(); + const restore = stubFocus(false); + try { + await render(); + expect(writeText).not.toHaveBeenCalled(); + + document.hasFocus = () => true; + await act(async () => { + window.dispatchEvent(new Event("focus")); + }); + expect(writeText).toHaveBeenCalledWith("WFK7-4GA3U"); + } finally { + restore(); + } + }); +}); diff --git a/ui/src/components/AdapterLoginChrome.tsx b/ui/src/components/AdapterLoginChrome.tsx index f4dbc472f5..cf7a89cf64 100644 --- a/ui/src/components/AdapterLoginChrome.tsx +++ b/ui/src/components/AdapterLoginChrome.tsx @@ -247,19 +247,55 @@ export function OnboardingLoginCodeRow({ }, []); 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. - }); + // An empty code is not a code. The row renders before the server's one-time + // prompt has a value on some paths, and the previous version latched on + // that first run — so the copy that mattered never ran, and the card said + // "Copied!" over an empty clipboard. + if (!autoCopy || autoCopiedRef.current || !code) return; + + let cancelled = false; + // The success latch is taken when the write resolves, so it cannot also + // stand in for "a write is already running" — a focus event landing while + // one was in flight started a second. Same text either way, but it doubles + // the reveal timers and there is no reason to ask twice. + let inFlight = false; + + const attempt = () => { + if (cancelled || autoCopiedRef.current || inFlight) return; + // A write from an unfocused document is refused, and worse, some engines + // resolve it without writing. Wait for focus rather than spend the one + // attempt on it. + if (typeof document !== "undefined" && !document.hasFocus()) return; + inFlight = true; + void copyTextToClipboard(code) + .then(() => { + if (cancelled) return; + // Latched on success, not before it. Latching up front made the first + // refusal permanent — and the first attempt is the one most likely to + // be refused, since it fires while the card is still arriving. + autoCopiedRef.current = true; + window.removeEventListener("focus", attempt); + // 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. The listener gives it another go when the document comes + // back, and the button is there the whole time regardless. + }) + .finally(() => { + inFlight = false; + }); + }; + + attempt(); + window.addEventListener("focus", attempt); + return () => { + cancelled = true; + window.removeEventListener("focus", attempt); + }; }, [autoCopy, code]); return ( diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2497cfdac0..cedc57ad55 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -2862,9 +2862,21 @@ function SubmittedBrowserCodeLoginPanel({ const handleSubmit = () => { if (!canSubmit) return; submitCode.mutate(trimmedCode); - // Clear the browser code right after submit, so the secret never lingers in - // the input. - setBrowserCode(""); + // Onboarding keeps the code on screen; the panel still clears it. + // + // Clearing emptied the input in the same frame the paste landed, so on the + // connect step the only feedback for the seconds that followed was a field + // that had just gone blank — reported from staging as the paste looking + // dropped, or the step looking stuck. There the field is disabled from here + // on and the step's own button carries the status, so the code can stay: + // `resetLocalState` clears it whenever a session starts, resumes or is + // cleared, the value dies with the panel moments later, and the code is + // single-use and already spent. + // + // The panel is not that. It sits in a form that stays open long after the + // login, with its own status area doing the reporting — so there the code + // does have somewhere to linger, and clearing it remains right. + if (chrome !== "onboarding") setBrowserCode(""); }; // Start once, on mount, when the caller has already taken the press, and diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 562b14318f..8be485f16b 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -2228,6 +2228,11 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( "claude-session-1", "Q2RJ-E1YIF-authorization-code", ); + // And it stays on screen. Clearing the field on submit emptied it in the + // same frame the paste landed, so the only feedback for the seconds that + // followed was an input that had just gone blank — reported from staging + // as the paste looking dropped, or the step looking stuck. + expect(field!.value).toBe("Q2RJ-E1YIF-authorization-code"); await act(async () => root.unmount()); }); diff --git a/ui/src/components/onboarding/OnboardingPrimitives.tsx b/ui/src/components/onboarding/OnboardingPrimitives.tsx index da3b997c19..e28af821a8 100644 --- a/ui/src/components/onboarding/OnboardingPrimitives.tsx +++ b/ui/src/components/onboarding/OnboardingPrimitives.tsx @@ -36,7 +36,9 @@ export function OnboardingHeading({ }) { return (
-

{title}

+

+ {title} +

{lede ? (

{lede}

) : null} diff --git a/ui/src/index.css b/ui/src/index.css index a5234a7720..f66d4de01e 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -2332,6 +2332,14 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --text-nano: 10px; --text-micro: 11px; --text-compact: 13px; + /* The onboarding arc's question. A quarter larger than the 36px it was, so + the step's one question carries the screen it is alone on. Off Tailwind's + scale (text-4xl is 36, text-5xl 48), hence a token rather than a step. */ + --text-onboarding-title: 45px; + /* Its own leading, because `text-(length:…)` sets only the size: the 36px + step carried 40px of line height with it, and inheriting the body's would + have loosened a question that wraps onto two lines. */ + --leading-onboarding-title: 1.1; --tracking-label: 0.08em; --tracking-eyebrow: 0.14em; --tracking-caps: 0.2em;