diff --git a/doc/connections/GITHUB.md b/doc/connections/GITHUB.md
index f79febf07f..eb894ce016 100644
--- a/doc/connections/GITHUB.md
+++ b/doc/connections/GITHUB.md
@@ -7,6 +7,10 @@ only in its existing encrypted secret system.
## Self-hosted setup
+The Access step uses **Continue** to open the local setup screen.
+**Continue to GitHub** on that screen starts the provider handoff. The first
+button does not imply that the browser is leaving Paperclip yet.
+
A self-hosted instance needs one Paperclip Cloud approval before its first
managed connection. After approval, setup returns to step 2 and continues to
GitHub without another instance approval or a service restart.
diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx
index 266dd58039..1886358b70 100644
--- a/ui/src/features/connections/ConnectionSetupFlow.tsx
+++ b/ui/src/features/connections/ConnectionSetupFlow.tsx
@@ -1868,14 +1868,12 @@ export function ConnectionSetupFlow({
: linkAuthMode === "oauth"
? "oauth"
: "api_key";
- // The primary label names the next effect, so an OAuth handoff never arrives
- // unannounced.
- const accessMethodIsKnown = !entry
- || Boolean(connectionMethodKey)
- || credentialSourceMethods.length === 1;
- const accessSubmitLabel = accessStepAuthKind === "oauth" && accessMethodIsKnown
+ // Name the actual next effect: multi-method apps and enrollment still have
+ // a local setup screen, even when OAuth is already the selected method.
+ const accessContinuesToProvider = Boolean(directOAuthEntry);
+ const accessSubmitLabel = accessContinuesToProvider
? `Continue to ${entry?.name ?? "sign-in"}`
- : "Save and continue";
+ : accessStepAuthKind === "oauth" ? "Continue" : "Save and continue";
const stepIndex = (zapierSource || entry) && step !== "gallery" && step !== "success"
? SELECTED_APP_STEP_INDEX[step]
@@ -2132,6 +2130,7 @@ export function ConnectionSetupFlow({
capabilities={galleryQuery.data?.capabilities}
githubIdentity={entry?.slug === "github"}
submitLabel={accessSubmitLabel}
+ continuesToProvider={accessContinuesToProvider}
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
pending={connectMutation.isPending || oauthStartMutation.isPending}
@@ -3581,6 +3580,7 @@ export function AccessStep({
capabilities,
githubIdentity = false,
submitLabel,
+ continuesToProvider = false,
identityLoading = false,
preserveAgentAccess = false,
pending = false,
@@ -3605,6 +3605,8 @@ export function AccessStep({
} | null;
githubIdentity?: boolean;
submitLabel: string;
+ /** Only show an external-handoff cue when this action starts provider OAuth. */
+ continuesToProvider?: boolean;
/** Wait for a durable OAuth connection before showing a reconnect identity. */
identityLoading?: boolean;
/** Reconnect changes credentials only; existing install reach stays intact. */
@@ -3842,7 +3844,7 @@ export function AccessStep({
>
{pending ? : null}
{submitLabel}
- {!pending && authKind === "oauth" ? : null}
+ {!pending && continuesToProvider ? : null}
diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx
index 5cd3d6a28d..fc20c65fe4 100644
--- a/ui/src/pages/apps/AppsConnect.test.tsx
+++ b/ui/src/pages/apps/AppsConnect.test.tsx
@@ -157,6 +157,7 @@ async function passAccessStep() {
}
const submit = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Save and continue"
+ || b.textContent?.trim() === "Continue"
|| b.textContent?.trim().startsWith("Continue to"),
);
await act(async () => {
@@ -774,6 +775,38 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
},
);
+ it("labels GitHub's local setup transition without promising a provider handoff", async () => {
+ mockSearch.value = "source=github";
+ listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
+
+ await render();
+
+ const continueButton = buttonByText("Continue");
+ expect(continueButton).toBeDefined();
+ expect(continueButton?.disabled).toBe(false);
+ expect(continueButton?.querySelector(".lucide-arrow-up-right")).toBeNull();
+ expect(buttonByText("Continue to GitHub")).toBeUndefined();
+
+ await passAccessStep();
+
+ expect(container.textContent).toContain("Step 2 of 2");
+ expect(container.textContent).toContain("How do you want to connect?");
+ expect(buttonByText("Continue to GitHub")).toBeDefined();
+ expect(startOAuthMock).not.toHaveBeenCalled();
+ expect(connectAppMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps provider-specific wording and the handoff cue for direct OAuth", async () => {
+ mockSearch.value = "source=notion";
+ listGalleryMock.mockResolvedValue({ apps: [NOTION] });
+
+ await render();
+
+ const continueButton = buttonByText("Continue to Notion");
+ expect(continueButton).toBeDefined();
+ expect(continueButton?.querySelector(".lucide-arrow-up-right")).not.toBeNull();
+ });
+
it("keeps GitHub's personal identity defaults while its managed method awaits enrollment", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({
@@ -798,6 +831,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(radioContaining("My GitHub account")?.getAttribute("aria-checked")).toBe("true");
expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true");
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
+ expect(buttonByText("Continue")).toBeDefined();
+ expect(buttonByText("Continue to GitHub")).toBeUndefined();
await passAccessStep();
@@ -916,7 +951,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- const accessContinue = buttonByText("Save and continue") ?? buttonByText("Continue to GitHub");
+ const accessContinue = buttonByText("Continue");
expect(accessContinue?.disabled).toBe(false);
await act(async () => {
accessContinue?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -1116,7 +1151,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
).find((r) => r.textContent?.includes("Just agents I pick"));
expect(pick?.disabled).toBe(false);
// Continue refuses the forbidden choice even though it is the current one.
- expect(buttonByText("Save and continue")?.disabled).toBe(true);
+ expect(buttonByText("Continue")?.disabled).toBe(true);
});
it("opens the selected app directly on its setup route", async () => {
@@ -1329,7 +1364,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
));
expect(container.textContent).toContain("This task grants access only to Ada");
const continueButton = Array.from(container.querySelectorAll("button")).find(
- (button) => button.textContent?.trim() === "Save and continue",
+ (button) => button.textContent?.trim() === "Continue",
);
expect(continueButton).toBeTruthy();
expect(continueButton?.disabled).toBe(false);
diff --git a/ui/storybook/stories/permitted-vs-installed.stories.tsx b/ui/storybook/stories/permitted-vs-installed.stories.tsx
index dc2372993b..6d5874bc63 100644
--- a/ui/storybook/stories/permitted-vs-installed.stories.tsx
+++ b/ui/storybook/stories/permitted-vs-installed.stories.tsx
@@ -307,6 +307,7 @@ function SeededAccessStep({
setInstallAgentIds={setIds}
capabilities={capabilities}
submitLabel={authKind === "oauth" ? "Continue to Gmail" : "Save and continue"}
+ continuesToProvider={authKind === "oauth"}
onBack={() => {}}
onContinue={() => {}}
/>