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;