From 5ac05871b54fc2194b2ff7ae0230b381f96dc64c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 5 Nov 2026 15:10:50 -0800 Subject: [PATCH] fix(onboarding): unify sign-in actions and recover failed logins (#19336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip lets people create agents and connect their AI accounts. > - Onboarding uses the shared login panel to connect Claude and OpenAI accounts. > - The sign-in link did not update the footer, so the footer could open sign-in again. > - A failed or expired sign-in also left onboarding without a retry action. > - This pull request gives both sign-in controls one action and makes failed attempts recoverable. > - Users can return with a code or start a fresh attempt while keeping their setup. ## Linked Issues or Issue Description **What happened?** The onboarding instruction link opens Claude but leaves the footer offering Sign in to Claude. If a code is entered without a paste event, the footer opens provider sign-in again. A failed code, expired session, or failed sandbox request can also leave an error with no retry control. Component tests reproduce these failures. This PR addresses that explicit footer action. It does not establish a fix for unsolicited navigation of the original Paperclip tab to Claude before code entry. That reported hosted-Cloud behavior remains under investigation and needs browser-level evidence. **Expected behavior** The link and footer should use the same sign-in action. Both should open one provider tab and put the footer into a disabled Waiting for code… state. The code field should stay available. Failed or expired attempts should show a clear message and an enabled Retry sign-in button. **Steps to reproduce** 1. Choose Claude during onboarding with no saved subscription login. 2. Open Sign in to Claude from the instruction link. 3. Return and enter the authorization code without a paste event, such as by typing it. 4. Press the footer action. Before this fix, it opens Claude again. **Paperclip version or commit** Reproduced in component tests against master at 31bf6e44828. **Deployment mode** The fixed footer defect is reproduced in the hosted onboarding component path. The regression tests use the real wizard and login panel with simulated API responses. No live Claude authorization was performed. Related merged work: #13193 added immediate paste feedback; #12921 kept the pasted code visible. Neither handles this footer path. The duplicate search found no open PR for this defect. ## What Changed - Use one handler for the sign-in link and footer. Opening either enters Waiting for code… and disables the footer. The code field stays available. The inline link can reopen the same provider URL if the provider tab was closed. - Submit an entered Claude code from the footer or Enter. Preserve automatic submission on paste and block duplicate submissions. - Replace raw red onboarding errors with a short failure or expiry message. Hide the stale sign-in link and old code field. - Add Retry sign-in in the onboarding footer. The login panel owns recovery: release the old session, recheck the owner’s active session, then resume it or start a fresh attempt. Clear the old code and keep the onboarding choices. - Pause polling during recovery. Treat an already-removed session as released, but keep other cancellation failures retryable. Keep missing resumed sessions recoverable. - Give shared-card hosts without a retry footer an inline Retry sign-in action. - Add coverage for both sign-in controls, reopening the current provider URL, failed codes, startup, lookup, prompt, status, expiry, missing sessions, completion, cancellation, resume, and hosts without a retry footer. - Add failed and expired Claude sign-in stories to the production onboarding suite. Their scoped fixtures support draft-agent saves, cancellation, and a fresh second attempt. The login panel owns code validation, session lifecycle, and retry. The wizard owns the footer and opens the provider tab. Optional action callbacks let each host own its controls; existing panel hosts retain their session behavior. ## Verification - The original four link and footer regressions failed before the initial fix. - `pnpm -r typecheck`: passed across the workspace. UI typecheck was repeated after the final UI changes. - From `ui/`, `pnpm exec vitest run --config vitest.config.ts src/components/OnboardingWizard.test.tsx src/components/AgentConfigForm.render.test.tsx src/components/AdapterLoginChrome.test.tsx`: 405 tests passed. - Component checks confirm that both controls open the same URL once, disable the waiting footer, and leave the code field usable. Retry waits for cancellation, clears the old code, and submits the next code to the new session. Repeated clicks do not create duplicate attempts. Back still prevents a late failure or success from reopening the card or hiring. - `pnpm check:token-gates` and `git diff --check`: passed. - `pnpm build`: passed across the workspace before the final shared-card fallback. The UI build was repeated after that change. - Full local `pnpm test:run` was not repeated for this revision. The earlier local run on `11fca74` passed all six chat integration shards (1,931 tests), then was stopped after that head’s complete CI plan passed. It did not finish locally. - CI is pending on the updated head. Greptile reviewed `876142d3877cdfe63e8438d00b605f4b66aded05` and scored it 5/5 with no new actionable findings. All review threads are resolved. - The earlier review of `4283b1c` scored 4/5 and found that the link could not reopen a closed provider tab. That finding was fixed in `876142d`. The handler accepts the waiting state while the footer stays disabled. Regression assertions cover reopening the same URL without starting another session. - Storybook browser inspection used the real wizard with simulated APIs in dark mode at 760 × 900. Both new story play functions reached their completed state. Clicking Retry in the expired story restored an empty code field and the second attempt’s authorization URL. No provider page was opened. - New story IDs: `onboarding-agent-arc--claude-sign-in-failed` and `onboarding-agent-arc--claude-sign-in-expired`. Run `pnpm storybook` and select Agent arc → Claude Sign In Failed or Claude Sign In Expired. - Storybook catalog validation and its 30 node tests passed. This proves the story structure and the local simulated interaction; it does not prove a live provider exchange or Linux visual-baseline parity. - No live provider sign-in or deployed browser acceptance was run. The original unsolicited main-tab navigation report remains unverified. Earlier CI evidence remains recorded: on `11fca74`, server shard 2/12 failed in the unchanged `remote-workspace-migration.test.ts` orphan-index fixture. Creating its Git worktree lock file returned `ENOENT` before the behavior under test ran. The test and migration implementation have no diff in this PR. The focused local test passed (1 passed, 124 skipped). The same-head CI rerun and aggregate verification passed on attempt 2. The cause of that initial failure was not established. Current-head CI failure retained: on `876142d`, server shard 11/12 failed in the unchanged `status-card-evaluation-work.test.ts` test “coalesces one hundred committed source writes without delaying another company's saved deadline.” The clock assertion at line 384 finished 610 ms after the saved deadline, against a limit of less than 500 ms. There are no server changes in this PR. The focused local test passed (1 passed, 35 skipped). The aggregate verification job failed because `general_tests` failed; it reported no separate test defect. The first shard rerun request was rejected while the workflow was still running. After the workflow completed, GitHub accepted a rerun of server shard 11/12 and its dependent verification job on the same head. That rerun is pending. The cause of the initial timing overrun is not established; a passing local test does not explain it. [Initial failed job](https://github.com/paperclipai/paperclip/actions/runs/42123655875/job/127583449952). ## Risks - The onboarding footer now tracks sign-in, code submission, and retry. The ordinary paste flow stays automatic; typed codes still need an explicit action. - Retry cancels the current attempt before starting another one. A cancellation failure keeps Retry available and prevents a second start. - The failure card uses short, fixed text instead of raw provider errors. Standalone configuration panels retain their existing error presentation. - No API, credential storage, authorization, schema, or deployment changes. ## Model Used OpenAI Codex, `gpt-6.1-sol`, xhigh reasoning. Used repository inspection, code editing, and local command execution. Context window size is not exposed by the harness. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/AI-CONNECTIONS.md | 8 + ui/src/components/AdapterLoginChrome.tsx | 21 +- .../AgentConfigForm.render.test.tsx | 19 ++ ui/src/components/AgentConfigForm.tsx | 207 +++++++++---- ui/src/components/OnboardingWizard.test.tsx | 284 +++++++++++++++++- ui/src/components/OnboardingWizard.tsx | 41 ++- .../onboarding-agent-arc.stories.tsx | 80 ++++- 7 files changed, 591 insertions(+), 69 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 50457cee566..0886efe59dd 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -9,6 +9,14 @@ Onboarding and new-agent setup retain upstream's `SavedProviderKeySelect` and homes. Managed default/shared accounts are additional choices in that same selector. Selecting “Sign in to another account” survives background refreshes; Claude authorization paste keeps upstream's immediate Connecting feedback. +The onboarding sign-in link and footer use the same action. Either opens the +provider in a new tab and disables the footer with Waiting for code…. The code +field stays available. A Claude code entered without a paste event can be sent +with Submit code in the footer or Enter in the field. These actions submit to +the current login session and do not open another provider window. +Failed or expired sign-ins show a short message and a Retry sign-in footer +button. Retry releases the old session before starting a fresh sign-in. It keeps +the onboarding setup and clears the old code. New-agent Connect offers three persistent tiles: the provider's subscription, the provider's API key, and Advanced. All three stay visible while the selected diff --git a/ui/src/components/AdapterLoginChrome.tsx b/ui/src/components/AdapterLoginChrome.tsx index df6cb0d5c95..e0e76cc7e83 100644 --- a/ui/src/components/AdapterLoginChrome.tsx +++ b/ui/src/components/AdapterLoginChrome.tsx @@ -418,12 +418,18 @@ export function OnboardingCardField({ export function ProviderSubscriptionCard({ providerName, authorizationUrl, + onOpenAuthorization, mode, loading, + failure, + onRetry, children, }: { providerName: string; authorizationUrl?: string; + onOpenAuthorization?: () => void; + failure?: "expired" | "failed"; + onRetry?: () => void; mode: "submitted_code" | "displayed_code" | "browser_redirect"; loading?: boolean; children: ReactNode; @@ -431,10 +437,15 @@ export function ProviderSubscriptionCard({ return ( { + if (!onOpenAuthorization) return; + event.preventDefault(); + onOpenAuthorization(); + }} target="_blank" rel="noreferrer noopener" className="underline underline-offset-2 hover:text-foreground" @@ -446,8 +457,14 @@ export function ProviderSubscriptionCard({ ? " then come back and enter authorization code" : " by providing the authorization code below"} - } + )} > + {failure && ( +
+

Retry sign-in to get a fresh code. Your setup is saved.

+ {onRetry && } +
+ )} {children}
); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 5f00880a2cd..d6fca41489c 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -3449,6 +3449,25 @@ describe("AgentConfigForm environment selector", () => { expect(findButton(container, "Cancel")).toBeFalsy(); expect(mockAgentsApi.cancelClaudeSetupTokenLogin).not.toHaveBeenCalled(); }); + it.each(["claude_local", "codex_local"])("offers inline retry for %s when its host has no retry footer", async adapterType => { + const claude = adapterType === "claude_local"; + const statusApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginStatus : mockAgentsApi.getAdapterAuthLoginStatus; + statusApi.mockResolvedValueOnce({ sessionId: claude ? "claude-session-1" : "session-1", status: "failed", expiresAt: null }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + await act(async () => renderFormView(root, createFormQueryClient(), + )); + await flushUntil(() => Boolean(findButton(container, "Retry sign-in"))); + expect(container.textContent).toContain("Sign-in didn’t finish"); + const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin; + startApi.mockResolvedValueOnce({ sessionId: "retry-session", status: "starting", expiresAt: null }); + await clickByText(container, "Retry sign-in"); + await flushUntil(() => (container.textContent ?? "").includes("Sign in to")); + expect(startApi).toHaveBeenCalledTimes(2); + expect(findButton(container, "Retry sign-in")).toBeUndefined(); + }); it("offers an apply-existing affordance when the status route reports a stored value", async () => { mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "secret-1", diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2126e5708ea..6ede0845965 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -2544,17 +2544,12 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & { // the completion failed, or the session failed or ran out of time. The pair // of `onCodeSubmitted`, so a caller that showed work can stop showing it. onSubmitFailed?: () => void; + onOpenAuthorization?: () => void; + onSubmitReady?: (submit: (() => void) | null) => void; + onRetryReady?: (retry: (() => void) | null) => 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, and the submit and how it - * ended are reported through `onCodeSubmitted`, `onSubmitFailed` and - * `onConnected` — so this stays a single value rather than a whole session - * handed upward. */ onPromptReady?: (authorizationUrl: string | null) => void; }; @@ -2607,8 +2602,11 @@ function DisplayedCodeLoginPanel({ chrome = "panel", aiConnection, onPromptReady, + onOpenAuthorization, + onRetryReady, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); + const [retrying, setRetrying] = useState(false); const [startError, setStartError] = useState(null); // The server delivers the one-time prompt on the first owner read only. Latch // it so a later poll that returns a null prompt does not hide the code and the @@ -2696,7 +2694,7 @@ function DisplayedCodeLoginPanel({ const statusQuery = useQuery({ queryKey: ["adapter-login-status", companyId, adapterType, sessionId], queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!), - enabled: Boolean(sessionId) && !releasingResumedSession, + enabled: Boolean(sessionId) && !releasingResumedSession && !retrying, // A status 404 is unrecoverable: the server removed the row, so a retry // cannot bring it back. The shared retry policy never retries a 4xx, so // it stops at once and fails loudly; only transient errors are retried. @@ -2712,14 +2710,14 @@ function DisplayedCodeLoginPanel({ // returns a null prompt after the one-time delivery, so keep the latched value. useEffect(() => { const next = statusQuery.data?.prompt ?? null; - if (next) setLatchedPrompt(next); - }, [statusQuery.data]); + if (next && !retrying) setLatchedPrompt(next); + }, [statusQuery.data, retrying]); const session = statusQuery.data ?? startLogin.data ?? null; const status = session?.status ?? null; const prompt = latchedPrompt; const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false; - const isActive = Boolean(sessionId) && !isTerminal; + const isActive = Boolean(sessionId) && !isTerminal && !retrying; // A saving bind also blocks a new Sign in: the bind is an agent-update save, // and a second login started while it is in flight could finish its own // save first — the older save would then land last and silently revert the @@ -2752,7 +2750,7 @@ function DisplayedCodeLoginPanel({ useEffect(() => { const error = statusQuery.error; if (!(error instanceof ApiError && error.status === 404)) return; - if (!resumedRef.current || releasingResumedSession) return; + if (chrome === "onboarding" || !resumedRef.current || releasingResumedSession) return; setReleasingResumedSession(true); const id = sessionId; void (async () => { @@ -2764,7 +2762,7 @@ function DisplayedCodeLoginPanel({ setReleasingResumedSession(false); clearActiveSession(); })(); - }, [statusQuery.error, releasingResumedSession, sessionId, companyId, adapterType, clearActiveSession]); + }, [statusQuery.error, releasingResumedSession, sessionId, companyId, adapterType, clearActiveSession, chrome]); // Start once, on mount, when the caller has already taken the press, and // only once the resume read has answered: a resumed session takes over @@ -2870,36 +2868,74 @@ function DisplayedCodeLoginPanel({ onPromptReadyRef.current?.(prompt?.url ?? null); }, [prompt]); + const failed = Boolean(isTerminal && status !== "authenticated") || statusQuery.isError; + const retryingRef = useRef(false); + const retryLogin = useMutation({ + mutationFn: async () => { + if (sessionId) { + await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId).catch(error => { + if (!(error instanceof ApiError && error.status === 404)) throw error; + }); + } + const active = await activeSessionQuery.refetch({ throwOnError: true }); + clearActiveSession(); + startLogin.reset(); + if (active.data) { + resumedRef.current = true; + setLatchedPrompt(active.data.prompt ?? null); + setSessionId(active.data.sessionId); + } else { + await startLogin.mutateAsync(); + } + }, + onMutate: () => { + setStartError(null); + setLatchedPrompt(null); + }, + onError: error => setStartError(describeError(error).body), + onSettled: () => { + retryingRef.current = false; + setRetrying(false); + }, + }); + const retryAction = () => { + if (retryingRef.current) return; + retryingRef.current = true; + setRetrying(true); + retryLogin.mutate(); + }; + const retryActionRef = useRef(retryAction); + retryActionRef.current = retryAction; + const onRetryReadyRef = useRef(onRetryReady); + onRetryReadyRef.current = onRetryReady; + const retryReady = Boolean(startError || failed) && !retryLogin.isPending && !startLogin.isPending; + useEffect(() => { + onRetryReadyRef.current?.(retryReady ? () => retryActionRef.current() : null); + return () => onRetryReadyRef.current?.(null); + }, [retryReady]); + if (chrome === "onboarding") { - const failed = isTerminal && status && status !== "authenticated"; + const failure = !retryLogin.isPending && (startError || failed) + ? status === "timed_out" ? "expired" : "failed" + : undefined; return ( - {startError ? ( -
-

{startError}

- {activeSessionQuery.error instanceof AdapterLoginConflictError && ( - - )} -
- ) : failed ? ( -

- {status === "timed_out" - ? "The login timed out. Start it again." - : status === "cancelled" - ? "The login was cancelled." - : "The login did not finish. Start it again."} -

- ) : ( - - )} + {failure ? ( + activeSessionQuery.error instanceof AdapterLoginConflictError && ( + + ) + ) : !retryLogin.isPending && }
); } @@ -3102,8 +3138,12 @@ function SubmittedBrowserCodeLoginPanel({ chrome = "panel", aiConnection, onPromptReady, + onOpenAuthorization, + onSubmitReady, + onRetryReady, }: AdapterLoginPanelProps) { const [sessionId, setSessionId] = useState(null); + const [retrying, setRetrying] = useState(false); const [startError, setStartError] = useState(null); // The server delivers the authorization URL on the guarded prompt read only. // Latch it so a later poll does not hide the URL. @@ -3127,6 +3167,7 @@ function SubmittedBrowserCodeLoginPanel({ // or a failure. The field is locked for that stretch: the step's button is // saying "Connecting" above it, and a second paste would submit again. const [codeSubmitted, setCodeSubmitted] = useState(false); + const codeSubmittedRef = useRef(false); // True after the status poll returns 404. The server removes the row and the // in-memory session at once on any non-stored terminal state, so a status 404 // means the login failed and the server cleaned up. The panel stops both @@ -3156,6 +3197,7 @@ function SubmittedBrowserCodeLoginPanel({ setTimedOut(false); setStatusGone(false); setCodeSubmitted(false); + codeSubmittedRef.current = false; completionStartedRef.current = false; }; @@ -3291,7 +3333,7 @@ function SubmittedBrowserCodeLoginPanel({ // status 404 also stops the polls: the server cleaned up the session, so the // panel enters a terminal failure state instead. const pollingEnabled = - Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession; + Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession && !retrying; const statusQuery = useQuery({ queryKey: ["claude-setup-token-status", companyId, sessionId], @@ -3324,7 +3366,7 @@ function SubmittedBrowserCodeLoginPanel({ useEffect(() => { const error = statusQuery.error; if (!(error instanceof ApiError && error.status === 404)) return; - if (resumedRef.current) { + if (resumedRef.current && chrome !== "onboarding") { if (releasingResumedSession) return; setReleasingResumedSession(true); const id = sessionId; @@ -3340,7 +3382,7 @@ function SubmittedBrowserCodeLoginPanel({ return; } setStatusGone(true); - }, [statusQuery.error, releasingResumedSession, sessionId, companyId]); + }, [statusQuery.error, releasingResumedSession, sessionId, companyId, chrome]); // Adopt the caller's active session once, on mount. This is what makes a // page reload keep the session: with no local state at all, the panel would @@ -3381,11 +3423,11 @@ function SubmittedBrowserCodeLoginPanel({ useEffect(() => { const url = promptQuery.data?.authorizationUrl ?? null; - if (url) setAuthorizationUrl(url); + if (url && !retrying) setAuthorizationUrl(url); // The advisory rides the same guarded prompt read as the URL. Latch it, so a // later poll does not clear the disclaimer. if (promptQuery.data?.transportAdvisory) setTransportInsecure(true); - }, [promptQuery.data]); + }, [promptQuery.data, retrying]); const submitCode = useMutation({ mutationFn: (code: string) => @@ -3431,7 +3473,7 @@ function SubmittedBrowserCodeLoginPanel({ statusGone || Boolean(status && CLAUDE_LOGIN_FAILURE_STATUSES.has(status)); const isCompleting = status === "authenticated" && !isStored && !completionFailed; - const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut; + const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut && !retrying; const startDisabled = startLogin.isPending || isActive; // Cap the active login at the server deadline. The timer arms when the login @@ -3478,7 +3520,8 @@ function SubmittedBrowserCodeLoginPanel({ onSubmitFailedRef.current = onSubmitFailed; const handleSubmit = () => { - if (!canSubmit) return; + if (!canSubmit || codeSubmittedRef.current) return; + codeSubmittedRef.current = true; // A new attempt supersedes the last attempt's error, and has to: the // failure report below watches for an error after a submit, and one left // over from before it would end this attempt the moment it began. @@ -3560,6 +3603,13 @@ function SubmittedBrowserCodeLoginPanel({ */ const handleSubmitRef = useRef(handleSubmit); handleSubmitRef.current = handleSubmit; + const onSubmitReadyRef = useRef(onSubmitReady); + onSubmitReadyRef.current = onSubmitReady; + const submitReady = canSubmit && !startError; + useEffect(() => { + onSubmitReadyRef.current?.(submitReady ? () => handleSubmitRef.current() : null); + return () => onSubmitReadyRef.current?.(null); + }, [submitReady]); const autoSubmit = chrome === "onboarding"; const pastedRef = useRef(false); useEffect(() => { @@ -3590,6 +3640,7 @@ function SubmittedBrowserCodeLoginPanel({ if (!codeSubmitted) return; if (!startError && !isFailure && !timedOut) return; setCodeSubmitted(false); + codeSubmittedRef.current = false; onSubmitFailedRef.current?.(); }, [codeSubmitted, startError, isFailure, timedOut]); @@ -3599,13 +3650,67 @@ function SubmittedBrowserCodeLoginPanel({ onPromptReadyRef.current?.(authorizationUrl); }, [authorizationUrl]); + const retryingRef = useRef(false); + const retryLogin = useMutation({ + mutationFn: async () => { + if (sessionId) { + await agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId).catch(error => { + if (!(error instanceof ApiError && error.status === 404)) throw error; + }); + } + const active = await activeSessionQuery.refetch({ throwOnError: true }); + clearActiveSession(); + startLogin.reset(); + submitCode.reset(); + completeLogin.reset(); + if (active.data) { + resumedRef.current = true; + setSessionId(active.data.sessionId); + setAuthorizationUrl(active.data.prompt?.authorizationUrl ?? null); + setTransportInsecure(Boolean(active.data.prompt?.transportAdvisory)); + } else { + await startLogin.mutateAsync(); + } + }, + onMutate: () => { + setStartError(null); + setAuthorizationUrl(null); + }, + onError: error => setStartError(describeError(error).body), + onSettled: () => { + retryingRef.current = false; + setRetrying(false); + }, + }); + const retryAction = () => { + if (retryingRef.current) return; + retryingRef.current = true; + setRetrying(true); + retryLogin.mutate(); + }; + const retryActionRef = useRef(retryAction); + retryActionRef.current = retryAction; + const onRetryReadyRef = useRef(onRetryReady); + onRetryReadyRef.current = onRetryReady; + const onboardingFailed = Boolean(startError || isFailure || timedOut || statusQuery.isError || promptQuery.isError); + const retryReady = onboardingFailed && !retryLogin.isPending && !startLogin.isPending; + useEffect(() => { + onRetryReadyRef.current?.(retryReady ? () => retryActionRef.current() : null); + return () => onRetryReadyRef.current?.(null); + }, [retryReady]); + if (chrome === "onboarding") { - const failedNow = isFailure || timedOut; + const failure = !retryLogin.isPending && onboardingFailed + ? timedOut || status === "timed_out" ? "expired" : "failed" + : undefined; return ( {/* The plain-HTTP advisory survives the redesign. It is the one thing on @@ -3619,15 +3724,7 @@ function SubmittedBrowserCodeLoginPanel({ network. Continue only on a network you trust.

)} - {startError ? ( -

- {startError} -

- ) : failedNow ? ( -

- {timedOut && !isFailure ? CLAUDE_LOGIN_TIMED_OUT_MESSAGE : CLAUDE_LOGIN_FAILED_MESSAGE} -

- ) : ( + {!failure && !retryLogin.isPending && ( root.unmount()); }); + it.each([ + ["claude_local", /Claude/, "claude.ai", "link"], + ["claude_local", /Claude/, "claude.ai", "button"], + ["codex_local", /OpenAI/, "auth.openai.com", "link"], + ["codex_local", /OpenAI/, "auth.openai.com", "button"], + ] as const)("waits for the code after the %s %s %s %s opens sign-in", async (adapterType, source, host, control) => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType }); + await pickSource(source); + const open = vi.spyOn(window, "open").mockImplementation(() => null); + try { + const link = document.body.querySelector(`a[href*="${host}"]`) as HTMLAnchorElement; + expect(link.target).toBe("_blank"); + await act(async () => { + if (control === "link") link.click(); + else [...document.body.querySelectorAll("button")].at(-1)!.click(); + }); + expect(open).toHaveBeenCalledExactlyOnceWith(link.href, "_blank", "noreferrer,noopener"); + const primary = [...document.body.querySelectorAll("button")].at(-1)!; + expect(primary.textContent?.trim()).toBe("Waiting for code…"); + expect(primary.disabled).toBe(true); + await act(async () => primary.click()); + await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true, cancelable: true }))); + expect(open).toHaveBeenCalledTimes(1); + await act(async () => link.click()); + expect(open).toHaveBeenNthCalledWith(2, link.href, "_blank", "noreferrer,noopener"); + expect(primary.textContent?.trim()).toBe("Waiting for code…"); + expect(primary.disabled).toBe(true); + if (adapterType === "claude_local") expect((document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement).disabled).toBe(false); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(adapterType === "claude_local" ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1); + } finally { + open.mockRestore(); + await act(async () => root.unmount()); + } + }); + + it.each([false, true])("submits a code inserted without a paste event from the footer (opened link: %s)", async openedLink => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + await pickSource(/Claude/); + const open = vi.spyOn(window, "open").mockImplementation(() => null); + try { + if (openedLink) await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click()); + open.mockClear(); + const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement; + await act(async () => setControlledValue(field, "authorization-code")); + expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled(); + const primary = [...document.body.querySelectorAll("button")].at(-1)!; + expect(primary.textContent?.trim()).toBe("Submit code"); + expect(primary.disabled).toBe(false); + await act(async () => { primary.click(); primary.click(); }); + expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code"); + expect(open).not.toHaveBeenCalled(); + expect([...document.body.querySelectorAll("button")].at(-1)!.textContent?.trim()).toBe("Connecting"); + expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + open.mockRestore(); + await act(async () => root.unmount()); + } + }); + + it.each([{}, { metaKey: true }, { ctrlKey: true }])("submits the code once with Enter from its field (%j)", async modifier => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + await pickSource(/Claude/); + const open = vi.spyOn(window, "open").mockImplementation(() => null); + try { + open.mockClear(); + const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement; + await act(async () => setControlledValue(field, "authorization-code")); + await act(async () => field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...modifier }))); + expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code"); + expect(open).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + open.mockRestore(); + await act(async () => root.unmount()); + } + }); + + it.each([{ metaKey: true }, { ctrlKey: true }])("submits the code with the wizard shortcut while waiting (%j)", async modifier => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + await pickSource(/Claude/); + const open = vi.spyOn(window, "open").mockImplementation(() => null); + try { + await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click()); + open.mockClear(); + const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement; + await act(async () => setControlledValue(field, "authorization-code")); + const wizard = document.querySelector('[data-testid="onboarding-wizard"]')!; + await act(async () => wizard.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...modifier }))); + expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code"); + expect(open).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + open.mockRestore(); + await act(async () => root.unmount()); + } + }); + + it("withdraws the footer submit action when the code is cleared or the card is closed", async () => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType: "claude_local" }); + await pickSource(/Claude/); + const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement; + await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click()); + await act(async () => setControlledValue(field, "authorization-code")); + const primary = () => [...document.body.querySelectorAll("button")].at(-1)!; + expect(primary().textContent?.trim()).toBe("Submit code"); + await act(async () => setControlledValue(field, "")); + expect(primary().textContent?.trim()).toBe("Waiting for code…"); + expect(primary().disabled).toBe(true); + await act(async () => setControlledValue(field, "authorization-code")); + const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!; + await act(async () => back.click()); + for (let i = 0; i < 10; i++) await flushReact(); + expect(primary().textContent?.trim()).toBe("Next"); + expect(primary().disabled).toBe(true); + expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled(); + 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" }); @@ -2986,13 +3117,157 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( for (let i = 0; i < 8; i++) await flushReact(); expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1); - expect(document.body.textContent).toContain("That authorization code was not accepted."); - expect(cta()).toBe("Sign in to Claude"); + expect(document.body.textContent).toContain("Sign-in didn’t finish"); + expect(document.body.textContent).not.toContain("That authorization code was not accepted."); + expect(cta()).toBe("Retry sign-in"); expect(mockAgentsApi.hire).not.toHaveBeenCalled(); await act(async () => root.unmount()); }); + it.each(["code", "start", "lookup", "prompt", "status", "failed", "timed_out", "gone", "completion"] as const)("retries Claude sign-in after a %s failure", async failure => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + if (failure === "code") mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockRejectedValueOnce(new Error("Private provider detail")); + if (failure === "start") mockAgentsApi.startClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Sandbox unavailable")); + if (failure === "lookup") mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(new Error("Lookup unavailable")); + if (failure === "gone" || failure === "status") mockAgentsApi.getClaudeSetupTokenLoginStatus.mockRejectedValueOnce(new ApiError("Session unavailable", failure === "gone" ? 404 : 503, {})); + if (failure === "prompt") mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockRejectedValueOnce(new ApiError("Sandbox unavailable", 503, {})); + if (failure === "failed" || failure === "timed_out" || failure === "completion") { + mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValueOnce({ + sessionId: "claude-session-1", status: failure === "completion" ? "authenticated" : failure, + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }); + } + if (failure === "completion") mockAgentsApi.completeClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Completion unavailable")); + const { root } = await openStep4({ adapterType: "claude_local" }); + try { + await pickSource(/Claude/); + if (failure === "code") { + const field = document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement; + await act(async () => { + field.dispatchEvent(new Event("paste", { bubbles: true })); + setControlledValue(field, "expired-code"); + }); + } + for (let i = 0; i < 8; i++) await flushReact(); + const primary = () => [...document.body.querySelectorAll("button")].at(-1)!; + expect(document.body.textContent).toContain(failure === "timed_out" ? "This sign-in expired" : "Sign-in didn’t finish"); + expect(document.querySelector('[role="alert"]')?.className).not.toContain("text-destructive"); + expect(document.body.textContent).toContain("Retry sign-in to get a fresh code. Your setup is saved."); + expect(document.body.textContent).not.toContain("Private provider detail"); + expect(document.querySelector('a[href*="claude.ai"]')).toBeNull(); + expect(document.querySelector('input[aria-label="Authorization code"]')).toBeNull(); + expect(primary().textContent?.trim()).toBe("Retry sign-in"); + expect(primary().disabled).toBe(false); + const startsBeforeRetry = mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length; + let release: () => void = () => {}; + if (failure !== "start" && failure !== "lookup") mockAgentsApi.cancelClaudeSetupTokenLogin.mockImplementationOnce(() => new Promise(resolve => { release = () => resolve({}); })); + mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(new ApiError("Not found", 404, null)); + mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValueOnce({ + sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), + }); + mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({ + sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), + }); + mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({ authorizationUrl: "https://claude.ai/oauth/authorize?attempt=2", transportAdvisory: null }); + await act(async () => { primary().click(); primary().click(); }); + if (failure !== "start" && failure !== "lookup") { + expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1"); + expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(startsBeforeRetry); + expect(primary().disabled).toBe(true); + await act(async () => release()); + } + for (let i = 0; i < 12; i++) await flushReact(); + expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(startsBeforeRetry + 1); + expect(primary().textContent?.trim()).toBe("Sign in to Claude"); + const field = document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement; + expect(field.value).toBe(""); + expect(document.querySelector('[role="alert"]')).toBeNull(); + await act(async () => setControlledValue(field, "fresh-code")); + await act(async () => primary().click()); + expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenLastCalledWith("company-new", "claude-session-2", "fresh-code"); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + } + }); + + it.each(["claude_local", "codex_local"] as const)("keeps a missing resumed %s session retryable", async adapterType => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const claude = adapterType === "claude_local"; + const active = { + sessionId: claude ? "claude-session-1" : "codex-session-1", environmentId: "env-sandbox-1", + status: "waiting_for_user", expiresAt: null, failure: null, + aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true }, + panelMode: claude ? "submitted_browser_code" : "displayed_code", + prompt: claude ? { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" } : { url: "https://auth.openai.com/codex/device", code: "OLD-CODE" }, + }; + const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession; + activeApi.mockResolvedValue(active); + const statusApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginStatus : mockAgentsApi.getAdapterAuthLoginStatus; + statusApi.mockRejectedValueOnce(new ApiError("Session gone", 404, {})); + const { root } = await openStep4({ adapterType }); + try { + for (let i = 0; i < 10; i++) await flushReact(); + const primary = () => [...document.body.querySelectorAll("button")].at(-1)!; + expect(primary().textContent?.trim()).toBe("Retry sign-in"); + expect(document.body.textContent).toContain("Sign-in didn’t finish"); + activeApi.mockRejectedValue(new ApiError("Not found", 404, null)); + await act(async () => primary().click()); + for (let i = 0; i < 12; i++) await flushReact(); + expect(primary().textContent?.trim()).toBe(claude ? "Sign in to Claude" : "Sign in to OpenAI"); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + } + }); + + it("keeps retry available after cancellation fails, and accepts an already-removed session", async () => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValueOnce({ sessionId: "claude-session-1", status: "failed", expiresAt: new Date(Date.now() + 600_000).toISOString() }); + mockAgentsApi.cancelClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Temporary error")) + .mockRejectedValueOnce(new ApiError("Session gone", 404, {})); + const { root } = await openStep4({ adapterType: "claude_local" }); + try { + await pickSource(/Claude/); + const primary = () => [...document.body.querySelectorAll("button")].at(-1)!; + await act(async () => primary().click()); + for (let i = 0; i < 8; i++) await flushReact(); + expect(primary().textContent?.trim()).toBe("Retry sign-in"); + expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1); + mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValueOnce({ sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString() }); + await act(async () => primary().click()); + for (let i = 0; i < 12; i++) await flushReact(); + expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(2); + expect(primary().textContent?.trim()).toBe("Sign in to Claude"); + } finally { + await act(async () => root.unmount()); + } + }); + + it.each(["timed_out", "gone"] as const)("retries OpenAI sign-in after the session is %s", async failure => { + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + if (failure === "gone") mockAgentsApi.getAdapterAuthLoginStatus.mockRejectedValueOnce(new ApiError("Session gone", 404, {})); + else mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValueOnce({ sessionId: "codex-session-1", status: "timed_out", prompt: { url: "https://auth.openai.com/codex/device", code: "OLD-CODE" } }); + const { root } = await openStep4({ adapterType: "codex_local" }); + try { + await pickSource(/OpenAI/); + const primary = () => [...document.body.querySelectorAll("button")].at(-1)!; + expect(primary().textContent?.trim()).toBe("Retry sign-in"); + mockAgentsApi.startAdapterAuthLogin.mockResolvedValueOnce({ sessionId: "codex-session-2", status: "pending" }); + mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({ sessionId: "codex-session-2", status: "pending", prompt: { url: "https://auth.openai.com/codex/device", code: "FRESH-CODE" } }); + await act(async () => { primary().click(); primary().click(); }); + for (let i = 0; i < 12; i++) await flushReact(); + expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", "codex-session-1"); + expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(2); + expect(primary().textContent?.trim()).toBe("Sign in to OpenAI"); + expect(document.body.textContent).toContain("FRESH-CODE"); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + } + }); + it("does not reopen the card when a pasted code fails after Back", async () => { // The panel stays mounted through Back's exit, so its report of a failed // submit can land mid-exit. Restoring the button there reopened the card @@ -3451,7 +3726,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await pickSource(/OpenAI/); expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); - expect(document.body.textContent).toContain("Paperclip is temporarily unavailable"); + expect(document.body.textContent).toContain("Sign-in didn’t finish"); await act(async () => root.unmount()); }); @@ -3467,7 +3742,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await pickSource(/Claude/); expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled(); - expect(document.body.textContent).toContain("Paperclip is temporarily unavailable"); + expect(document.body.textContent).toContain("Sign-in didn’t finish"); await act(async () => root.unmount()); }); @@ -4281,6 +4556,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( expiresAt: null, failure: null, panelMode: "submitted_browser_code", + aiConnection: { provider: "anthropic", method: "subscription", ownership: "personal", agentIds: [], allAgents: true }, prompt: { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" }, }); const { root } = await openStep4({ adapterType: "claude_local" }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index fe5c1fca4d2..7b7297f7a31 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -688,6 +688,8 @@ function OnboardingWizardInner({ * browser. Its arrival is also what moves the step off its waiting beat. */ const [connectAuthUrl, setConnectAuthUrl] = useState(null); + const [retryLogin, setRetryLogin] = useState<(() => void) | null>(null); + const [submitLoginCode, setSubmitLoginCode] = useState<(() => void) | 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 @@ -1558,10 +1560,13 @@ function OnboardingWizardInner({ */ const connectSourceLabel = CONNECT_SOURCE_NAMES[adapterType] ?? adapterType; const connectCta: { label: string; icon: FooterPrimaryIcon; disabled: boolean } = - setupError ? { label: "Retry", icon: "arrow", disabled: false } : connectProgress + setupError ? { label: "Retry", icon: "arrow", disabled: false } + : retryLogin ? { label: "Retry sign-in", icon: "arrow", disabled: false } : connectProgress ? { label: adapterEnvLoading ? "Testing…" : connectProgress, icon: "spinner", disabled: true } + : (connectPhase === "ready" || connectPhase === "waiting") && submitLoginCode + ? { label: "Submit code", icon: "arrow", disabled: false } : connectPhase === "waiting" - ? { label: "Waiting for code", icon: "spinner", disabled: true } + ? { label: "Waiting for code…", icon: "spinner", disabled: true } : connectPhase === "connecting" ? { label: "Connecting", icon: "spinner", disabled: true } : connectPhase === "ready" @@ -1601,6 +1606,8 @@ function OnboardingWizardInner({ setLoading(false); setAdapterEnvLoading(false); setConnectAuthUrl(null); + setSubmitLoginCode(null); + setRetryLogin(null); connectingSinceRef.current = null; setConnectCredentialStored(false); // Where the reverse starts depends on how far the sequence got. Backing out @@ -1618,12 +1625,31 @@ function OnboardingWizardInner({ * a check. This one would be worse: Cmd+Enter would hire before the sign-in * it is meant to start, against a source with no credential. */ + function openConnectAuthorization() { + if ((connectPhase !== "ready" && connectPhase !== "waiting") || !connectAuthUrl || retryLogin) return; + window.open(connectAuthUrl, "_blank", "noreferrer,noopener"); + setConnectPhase("waiting"); + } + function handleConnectStepPrimary() { if (setupError) { setSetupError(false); setSetupAttempt(value => value + 1); setError(null); return; } + if (retryLogin) { + setConnectAuthUrl(null); + setSubmitLoginCode(null); + setRetryLogin(null); + connectingSinceRef.current = null; + setConnectCredentialStored(false); + setConnectPhase("loading"); + retryLogin(); + return; + } + if ((connectPhase === "ready" || connectPhase === "waiting") && submitLoginCode) { + submitLoginCode(); + return; + } // 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"); + openConnectAuthorization(); return; } // The hold owns the hire once "Connecting" is showing. That now starts at @@ -2647,7 +2673,7 @@ function OnboardingWizardInner({ step === 4 && agentName.trim() && connectStepReady && - !connectStepLoggingIn + (retryLogin || submitLoginCode || !connectStepLoggingIn) ) handleConnectStepPrimary(); else if (step === 5) handleLaunchToDashboard(); @@ -3174,6 +3200,11 @@ function OnboardingWizardInner({ // The prompt arriving is what ends the waiting beat. if (url) setConnectPhase((p) => (p === "loading" ? "ready" : p)); }} + onOpenAuthorization={openConnectAuthorization} + onRetryReady={retry => { + if (connectCardLive) setRetryLogin(() => retry); + }} + onSubmitReady={submit => setSubmitLoginCode(() => submit)} onCodeSubmitted={() => { // The button reacts to the paste, not to the server. // Waiting for the login to be stored left about a diff --git a/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx b/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx index 1d7d197b35b..a70e7dccf89 100644 --- a/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx +++ b/ui/storybook/stories/onboarding/onboarding-agent-arc.stories.tsx @@ -43,7 +43,7 @@ const meta = { ], "implementation": "production", "boundary": "fixtures", - "notes": "Production wizard with simulated accounts, immutable-target discovery, native/Legacy setup and refused/unconfirmed cleanup. Fixtures do not prove isolation, lease cleanup, runtime limits, draft reload persistence or model execution." + "notes": "Production wizard with simulated accounts, failed/expired sign-in and retry, immutable-target discovery, native/Legacy setup and refused/unconfirmed cleanup. Fixtures do not prove isolation, lease cleanup, runtime limits, draft reload persistence or model execution." }, }, } satisfies Meta; @@ -382,14 +382,16 @@ function signedOutConnectionFixture() { return () => { clearOnboardingDraft(); resetOnboardingFixtureState(); }; } -async function openProviderConnection(provider: "Claude" | "OpenAI", mode: "subscription" | "api") { +async function openProviderConnection(provider: "Claude" | "OpenAI", mode: "subscription" | "api", loginState: "ready" | "failed" = "ready") { await advance("Connect a model"); await userEvent.click(await screen.findByRole("button", { name: "Use API key instead" }, { timeout: STEP_TIMEOUT_MS })); if (mode === "subscription") { await userEvent.click(await screen.findByRole("button", { name: "Use subscription instead" }, { timeout: STEP_TIMEOUT_MS })); } await userEvent.click(await screen.findByRole("radio", { name: new RegExp(`^${provider} `) }, { timeout: STEP_TIMEOUT_MS })); - if (mode === "api") { + if (loginState === "failed") { + await screen.findByRole("button", { name: "Retry sign-in" }, { timeout: STEP_TIMEOUT_MS }); + } else if (mode === "api") { await screen.findByLabelText("API key", {}, { timeout: STEP_TIMEOUT_MS }); } else if (provider === "Claude") { await screen.findByLabelText("Authorization code", {}, { timeout: STEP_TIMEOUT_MS }); @@ -404,6 +406,78 @@ export const ClaudeSubscription: StoryObj = { render: () => , play: () => openProviderConnection("Claude", "subscription"), }; +function claudeLoginFailureStory(failure: "failed" | "timed_out"): StoryObj { + return { + name: failure === "timed_out" ? "Connect · Claude sign-in expired" : "Connect · Claude sign-in failed", + beforeEach: () => { + const reset = signedOutConnectionFixture(); + const previous = window.fetch; + const base = `/api/companies/${STORYBOOK_COMPANY_ID}/setup-token-login-sessions`; + let attempts = 0; + let setupAgent: Record | null = null; + const released = new Set(); + const fixtureFetch: typeof fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const session = (id: string, status: string) => ({ + sessionId: id, environmentId: "environment-storybook-sandbox", status, + expiresAt: new Date(Date.now() + 600_000).toISOString(), failure: null, + }); + if (url.pathname === `/api/companies/${STORYBOOK_COMPANY_ID}/agents`) { + if (method === "GET") return Response.json(setupAgent ? [setupAgent] : []); + if (method === "POST") { + const body = JSON.parse(typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : "{}"); + setupAgent = { ...storybookHiredAgent, ...body, id: body.setupId, status: "paused", pauseReason: "setup" }; + return Response.json(setupAgent, { status: 201 }); + } + } + if (setupAgent && url.pathname === `/api/agents/${setupAgent.id}` && url.searchParams.get("companyId") === STORYBOOK_COMPANY_ID) { + if (method === "PATCH") { + const body = JSON.parse(typeof init?.body === "string" ? init.body : input instanceof Request ? await input.clone().text() : "{}"); + setupAgent = { ...setupAgent, ...body }; + } + return Response.json(setupAgent); + } + if (url.pathname === base && method === "POST") { + if (attempts > 0 && !released.has(`claude-storybook-${attempts}`)) { + return Response.json({ error: "Release the previous attempt first." }, { status: 409 }); + } + attempts += 1; + return Response.json(session(`claude-storybook-${attempts}`, "awaiting_browser_code"), { status: 201 }); + } + if (url.pathname === `${base}/active`) return new Response(null, { status: 404 }); + const match = url.pathname.slice(base.length).match(/^\/(claude-storybook-\d+)(?:\/(prompt|cancel))?$/); + if (url.pathname.startsWith(`${base}/`) && match) { + const id = match[1]!; + if (match[2] === "cancel" && method === "POST") { + released.add(id); + return Response.json(session(id, "cancelled")); + } + if (match[2] === "prompt") return Response.json({ authorizationUrl: `https://claude.example.test/authorize?attempt=${attempts}`, transportAdvisory: null }); + return Response.json(session(id, id === "claude-storybook-1" ? failure : "awaiting_browser_code")); + } + return previous(input, init); + }; + window.fetch = fixtureFetch; + return () => { + if (window.fetch === fixtureFetch) window.fetch = previous; + reset(); + }; + }, + render: () => , + play: async () => { + await openProviderConnection("Claude", "subscription", "failed"); + await expect(screen.getByRole("button", { name: "Retry sign-in" })).toBeEnabled(); + await expect(screen.getByRole("alert")).toHaveTextContent("Retry sign-in to get a fresh code. Your setup is saved."); + await expect(screen.queryByRole("link", { name: "Sign in to Claude" })).not.toBeInTheDocument(); + await expect(screen.queryByLabelText("Authorization code")).not.toBeInTheDocument(); + }, + }; +} + +export const ClaudeSignInFailed = claudeLoginFailureStory("failed"); +export const ClaudeSignInExpired = claudeLoginFailureStory("timed_out"); + export const CodexSubscription: StoryObj = { name: "Connect · Codex / OpenAI subscription", beforeEach: signedOutConnectionFixture,