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,