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 &&
Retry sign-in }
+
+ )}
{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 && (
-
cancelConflictingLogin.mutate()}>
- Cancel previous sign-in and retry
-
- )}
-
- ) : 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 && (
+ cancelConflictingLogin.mutate()}>
+ Cancel previous sign-in and retry
+
+ )
+ ) : !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,