From c2205a88b4e7b1639bf759edde46e4ae9a67456a Mon Sep 17 00:00:00 2001
From: scotttong
Date: Mon, 28 Sep 2026 20:58:37 -0700
Subject: [PATCH] feat(connections): one connect screen per connector with a
stated default
PAP-659 step 1 (C0 + bucket A) and step 2 (C1 + C2) of the approved plan.
- Delete the Access step. Identity and agent reach resolve to a default that
is printed above the primary action and repeated on the completion screen,
with the same controls one click away in an Advanced disclosure.
- A selected connector is a single screen, so its stepper is gone rather than
showing one lonely dot.
- Always have a default connection method: the ranking in
getRecommendedConnectionMethod is now used instead of discarded for
multi-method apps, and the alternates plus the capability choice move into
the same Advanced disclosure.
- The one primary action carries the external-handoff cue.
Co-Authored-By: Claude Opus 5
---
pnpm-lock.yaml | 9 +-
.../connections/ConnectionSetupFlow.tsx | 429 ++++++++++++------
.../remote-mcp/RemoteMcpConnectionSetup.tsx | 2 +-
ui/src/pages/apps/AppsConnect.test.tsx | 188 +++++---
4 files changed, 400 insertions(+), 228 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4544b619c3b..ff5c230ba95 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -43,7 +43,7 @@ patchedDependencies:
hash: gtvuasrdnibltgd6jvdqufvsdy
path: patches/acpx@0.12.0.patch
acpx@0.13.1:
- hash: ikfco27aeue26z63kex2z5d5ce
+ hash: jxpmkytjzjh3vghha3pybg7k54
path: patches/acpx@0.13.1.patch
embedded-postgres@18.1.0-beta.16:
hash: 55uhvnotpqyiy37rn3pqpukhei
@@ -508,7 +508,7 @@ importers:
version: 0.156.0
acpx:
specifier: 0.13.1
- version: 0.13.1(patch_hash=ikfco27aeue26z63kex2z5d5ce)
+ version: 0.13.1(patch_hash=jxpmkytjzjh3vghha3pybg7k54)
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -960,7 +960,7 @@ importers:
version: 8.18.0
acpx:
specifier: 0.13.1
- version: 0.13.1(patch_hash=ikfco27aeue26z63kex2z5d5ce)
+ version: 0.13.1(patch_hash=jxpmkytjzjh3vghha3pybg7k54)
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -7656,7 +7656,6 @@ packages:
opencode-ai@1.18.32:
resolution: {integrity: sha512-SCrZWdq44y/EoH2+fE4HLcXS+DzpVqHPzmXk3p2RrufYy8LWvpfhRhKtijb5ktvx8r94ScqToqwHxR0BwS65OQ==}
- cpu: [arm64, x64]
os: [darwin, linux, win32]
hasBin: true
@@ -13348,7 +13347,7 @@ snapshots:
- bare-buffer
- react-native-b4a
- acpx@0.13.1(patch_hash=ikfco27aeue26z63kex2z5d5ce):
+ acpx@0.13.1(patch_hash=jxpmkytjzjh3vghha3pybg7k54):
dependencies:
'@agentclientprotocol/sdk': 1.4.0(zod@4.4.3)
commander: 15.0.0
diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx
index e1907963871..386ac193510 100644
--- a/ui/src/features/connections/ConnectionSetupFlow.tsx
+++ b/ui/src/features/connections/ConnectionSetupFlow.tsx
@@ -102,7 +102,7 @@ import {
} from "@/pages/apps/generic-mcp-connect";
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
-type Step = "gallery" | "access" | "key" | "success";
+type Step = "gallery" | "key" | "success";
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
type EnrollmentAccessState = {
@@ -184,7 +184,6 @@ function oauthCallbackErrorMessage(outcome: string | null, code: string | null):
}
const ROUTE_STAGE_BY_STEP: Partial> = {
- access: "access",
key: "setup",
success: "complete",
};
@@ -196,10 +195,8 @@ export function requestedConnectionInitialStep(input: {
hasPrefilledLink: boolean;
zapierSource: boolean;
}): Step {
- if (input.requestedAppKey) {
- return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access";
- }
- return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery";
+ if (input.requestedAppKey) return "key";
+ return input.hasPrefilledLink || input.zapierSource ? "key" : "gallery";
}
export function requestedConnectionEntry(input: {
@@ -305,24 +302,23 @@ function withConnectionIntent(href: string, interactionId?: string | null): stri
type AppAccessSelection = "all_agents" | { agentIds: string[] };
-// Access comes before credentials so the reader knows what identity and reach
-// the secret is about to get before they share it (PAP-17835).
-const STEP_LABELS = ["Pick app", "Access", "Add your key"];
+// PAP-659: identity and agent reach are no longer a step. They are resolved to
+// a default, stated in one line above the primary action, and changed either in
+// the Advanced disclosure on this screen or on the Permissions tab afterwards.
+const STEP_LABELS = ["Pick app", "Add your key"];
const STEP_INDEX: Record, number> = {
gallery: 0,
- access: 1,
- key: 2,
-};
-const SELECTED_APP_STEP_INDEX: Record, number> = {
- access: 0,
key: 1,
};
-const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"];
+const SELECTED_APP_STEP_INDEX: Record, number> = {
+ key: 0,
+};
+const ZAPIER_STEP_LABELS = ["Add MCP URL"];
// Waiting for browser sign-in happens *inside* the flow's last step, so the
// waiting screen reuses the step model of the flow that opened it. It must not
-// append a trailing step the flow never lands on: a stepper that grows from two
-// dots to three the moment you press Connect reads as a step you missed.
-const OAUTH_SIGN_IN_STEP_LABELS = ["Access", "Sign in"];
+// append a trailing step the flow never lands on: a stepper that grows from one
+// dot to two the moment you press Connect reads as a step you missed.
+const OAUTH_SIGN_IN_STEP_LABELS = ["Sign in"];
/**
* Which identity a fresh connection should default to (PAP-17835).
@@ -394,16 +390,13 @@ function availableToolConnectionMethod(
function recommendedSetupConnectionMethod(
methods: readonly ConnectionMethodDef[],
): ConnectionMethodDef | null {
- const recommended = getRecommendedConnectionMethod(methods);
- // Capability choices (for example Google Workspace read versus write) have
- // an intentional default. Unrelated region/authentication variants should
- // still ask the operator to choose unless only one is available. A method
- // that supports an agent-owned identity must also be selected before the
- // Access step: that ownership decision cannot be represented by a legacy
- // compatibility method such as GitHub's advanced PAT option.
- return methods.length === 1 || recommended?.capabilityProfile || recommended?.grantKinds?.includes("agent")
- ? recommended
- : null;
+ // PAP-659 C1: every connector arrives with a method already chosen.
+ // `getRecommendedConnectionMethod` already ranks managed/one-click OAuth over
+ // customer-owned OAuth over API keys, and write/draft capability over read;
+ // this used to throw that ranking away for multi-method apps and ask the
+ // operator instead. The alternates are still reachable, in the Advanced
+ // disclosure, so nothing became unavailable — it just stopped blocking.
+ return getRecommendedConnectionMethod(methods);
}
function recommendedManagedConnectorMethod(
@@ -901,7 +894,7 @@ function StandardConnectionSetupFlow({
resetGenericAuthState();
setCredentials({});
setConnectResult(null);
- setStep("access");
+ setStep("key");
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
return;
}
@@ -930,7 +923,7 @@ function StandardConnectionSetupFlow({
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod, Boolean(requestedAgentId)));
- setStep("access");
+ setStep("key");
navigate(
credentialSource === "vercel_connect"
? withConnectionIntent(vercelConnectSourceHref(picked.slug), connectionIntentId)
@@ -1598,10 +1591,6 @@ function StandardConnectionSetupFlow({
zapierSource,
]);
- useEffect(() => {
- if (reconnectConnection?.connectionPurpose === "ai" && step === "access") setStep("key");
- }, [reconnectConnection?.connectionPurpose, step]);
-
// Resume the exact method and non-secret provider configuration that the
// interrupted draft already chose. Secrets are intentionally never read back
// into the browser; credential-based methods ask for a replacement value.
@@ -1723,9 +1712,9 @@ function StandardConnectionSetupFlow({
},
onError: (error) => {
// Creation must feel transactional: a failed commit returns the operator
- // to Access with their identity and agent selections intact rather than
- // stranding them on a half-made connection.
- setAppStep("access");
+ // to the connect screen with their identity and agent selections intact
+ // rather than stranding them on a half-made connection.
+ setAppStep("key");
pushToast({
title: "Couldn’t finish setup",
body: error instanceof Error ? error.message : "Please try again.",
@@ -1908,6 +1897,66 @@ function StandardConnectionSetupFlow({
);
}
+ const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
+ const setupCredentialSourceMethods = preEnrollmentManagedMethod
+ ? [preEnrollmentManagedMethod]
+ : credentialSourceMethods;
+
+ // The stated default's identity line only makes sense when there *is* a
+ // credential, so it reads the selected method's auth kind.
+ const accessStepMethod = entry
+ ? (connectionMethodKey
+ ? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
+ : setupCredentialSourceMethods[0] ?? null)
+ : null;
+ const accessStepAuthKind: ToolConnectionAuthKind = entry
+ ? accessStepMethod?.auth ?? "none"
+ : linkAuthMode === "none"
+ ? "none"
+ : linkAuthMode === "oauth"
+ ? "oauth"
+ : "api_key";
+ // PAP-659 C0: the resolved default is stated, not asked. `Change` opens the
+ // same controls the deleted Access step owned, inline and never blocking.
+ const connectionDefaults = step === "key" ? (
+
+ ) : null;
+
const showCuratedOAuthState = Boolean(
automaticOAuthEntry
&& step === "key"
@@ -1947,6 +1996,15 @@ function StandardConnectionSetupFlow({
} : undefined}
authorizationHost={authorizationHost}
authorizationUrl={authorizationFallbackUrl}
+ guidance={entry?.slug === "railway" && accessStepMethod ? (
+
+
{accessStepMethod.guidanceMd}
+
+ {accessStepMethod.warnings?.map((warning) =>
{warning}
)}
+
+
+ ) : null}
+ defaults={connectionDefaults}
onOpenAuthorization={openAuthorizationTab}
onRetry={async () => {
setOAuthError(null);
@@ -1985,20 +2043,16 @@ function StandardConnectionSetupFlow({
? { applicationId: prefill.applicationId, draftOnly: true }
: {},
);
- if (!directOAuthAccessConfirmedRef.current && !resumeConnectionId) {
- if (refreshedConnection) {
- setGrantKind(
- refreshedConnection.credentialPolicy === "per_user"
- ? "user"
- : refreshedConnection.credentialPolicy === "per_agent"
- ? "agent"
- : "organization",
- );
- }
- setOAuthPhase("entry");
- setOAuthError(null);
- setStep("access");
- return;
+ if (refreshedConnection && !resumeConnectionId) {
+ // Adopt the durable draft's identity before resuming it, so the
+ // stated default on screen matches what is about to be authorized.
+ setGrantKind(
+ refreshedConnection.credentialPolicy === "per_user"
+ ? "user"
+ : refreshedConnection.credentialPolicy === "per_agent"
+ ? "agent"
+ : "organization",
+ );
}
if (refreshedConnection) {
startOAuth(refreshedConnection);
@@ -2013,7 +2067,7 @@ function StandardConnectionSetupFlow({
oauthHandoffAbortRef.current?.abort();
setOAuthPhase("entry");
setOAuthError(null);
- setAppStep("access");
+ backToGallery();
}}
onCancel={() => {
oauthHandoffAbortRef.current?.abort();
@@ -2039,6 +2093,7 @@ function StandardConnectionSetupFlow({
error={oauthError}
authorizationHost={authorizationHost}
authorizationUrl={authorizationFallbackUrl}
+ defaults={connectionDefaults}
onOpenAuthorization={openAuthorizationTab}
onRetry={() => {
setOAuthError(null);
@@ -2071,10 +2126,6 @@ function StandardConnectionSetupFlow({
connectResult?.application.name ??
entry?.name ??
(linkName.trim() || defaultGenericMcpName(linkUrl) || "this app");
- const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource);
- const setupCredentialSourceMethods = preEnrollmentManagedMethod
- ? [preEnrollmentManagedMethod]
- : credentialSourceMethods;
const credentialSourceApps = vercelConnectMode
? visibleGalleryApps.filter(
(app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0,
@@ -2088,7 +2139,7 @@ function StandardConnectionSetupFlow({
: undefined;
const aiMethod = reconnectAiMethod ?? entry?.methods.find(method => method.key === connectionMethodKey)?.ai
?? (!connectionMethodKey && entry?.methods.every(method => method.ai) ? entry.methods[0]?.ai : undefined);
- const credentialStep = entry ? renderCredentialStep?.({ app: entry, name: galleryName || entry.name, grantKind: effectiveGrantKind, agentIds: [...installAgentIds], allAgents: installChoice === "all", onBack: () => setAppStep("access") }) ?? (aiMethod && selectedCompanyId ? <> backToGallery() }) ?? (aiMethod && selectedCompanyId ? <> onCancel ? onCancel() : navigate("/apps")}
onComplete={result => { onComplete?.({ connectionId: result.connectionId }); if (!onComplete) navigate(`/apps/${result.connectionId}/permissions`); }}
/>> : undefined) : undefined;
+ // One screen per connector (PAP-659): a selected app has a single step, so
+ // its stepper collapses to nothing rather than showing one lonely dot.
const stepLabels = reconnectConnection?.connectionPurpose === "ai" ? ["Reconnect account"] : credentialStep !== undefined
- ? ["Access", "Connect account"]
+ ? ["Connect account"]
: zapierSource
? ZAPIER_STEP_LABELS
: entry && setupCredentialSourceMethods.length > 1
- ? ["Access", "Choose connection"]
+ ? ["Choose connection"]
: entry && setupCredentialSourceMethods[0]?.auth === "oauth"
- ? ["Access", "Sign in"]
+ ? ["Sign in"]
: isGoogleSheetsRobotMethod(entry, connectionMethodKey)
- ? ["Access", "Share sheet"]
+ ? ["Share sheet"]
: entry
- ? ["Access", "Add your key"]
+ ? ["Add your key"]
: STEP_LABELS;
- // The Access step's identity question only makes sense when there *is* a
- // credential, so it reads the selected method's auth kind.
- const accessStepMethod = entry
- ? (connectionMethodKey
- ? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null
- : setupCredentialSourceMethods[0] ?? null)
- : null;
- const accessStepAuthKind: ToolConnectionAuthKind = entry
- ? accessStepMethod?.auth ?? "none"
- : linkAuthMode === "none"
- ? "none"
- : linkAuthMode === "oauth"
- ? "oauth"
- : "api_key";
- // 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"}`
- : accessStepAuthKind === "oauth" ? "Continue" : "Save and continue";
-
const stepIndex = reconnectConnection?.connectionPurpose === "ai" ? 0 : (zapierSource || entry) && step !== "gallery" && step !== "success"
? SELECTED_APP_STEP_INDEX[step]
: step === "success"
@@ -2147,7 +2179,9 @@ function StandardConnectionSetupFlow({
? vercelConnectMode
? "Choose a reviewed app to connect through Vercel."
: "Pick the app you want your agents to use."
- : `Step ${stepIndex + 1} of ${stepLabels.length}`
+ : stepLabels.length <= 1
+ ? "Connect now — permissions and access are yours to change afterwards."
+ : `Step ${stepIndex + 1} of ${stepLabels.length}`
}
step={step}
activeIndex={stepIndex}
@@ -2190,7 +2224,7 @@ function StandardConnectionSetupFlow({
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
setGrantKind(reconnectGrantKind ?? (requestedAgentId ? "user" : "organization"));
- setStep("access");
+ setStep("key");
}}
/>
)}
@@ -2202,7 +2236,7 @@ function StandardConnectionSetupFlow({
This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.
@@ -3516,6 +3537,9 @@ function KeyStep({
{usingCustomGoogleOAuth ? "Use Paperclip instead" : "Use your own Google OAuth app"}
) : capabilityMethods.length > 1 ? (
+ // PAP-659 C1: the ranked default is already selected. This stays as the
+ // way to pick something else, one disclosure away, rather than a question
+ // the screen opens with.
Choose a connection method to continue.}
) : null;
+ const advancedMethodSelection = authenticationSelection;
+ const hasAdvancedSettings = advancedConfigFields.length > 0
+ || optionalCustomerOAuthClient
+ || hasAlternateMethods;
+ // Keep the disclosure open when what is inside it is load-bearing right now:
+ // a non-default method in use, or a selection the connector still needs.
+ const forceAdvancedOpen = usingCustomGoogleOAuth || !hasMethodSelection;
if (isGoogleSheetsRobotMethod(entry, method)) {
const parsed = parseGoogleSheetIds(googleSheetsLinks);
@@ -3582,6 +3613,8 @@ function KeyStep({
+ {defaults}
+
Back
@@ -3614,9 +3647,6 @@ function KeyStep({
) : null}
Back
@@ -3959,6 +3993,10 @@ export function AccessStepContent({
identityLoading = false,
preserveAgentAccess = false,
pending = false,
+ /** Embedded in the Advanced disclosure: no wizard footer to press. */
+ hideFooter = false,
+ /** Embedded: drop the page-level width cap so it fits its container. */
+ bare = false,
onBack,
onContinue,
}: {
@@ -3988,6 +4026,8 @@ export function AccessStepContent({
/** Reconnect changes credentials only; existing install reach stays intact. */
preserveAgentAccess?: boolean;
pending?: boolean;
+ hideFooter?: boolean;
+ bare?: boolean;
onBack: () => void;
onContinue: () => void;
}) {
@@ -4032,8 +4072,8 @@ export function AccessStepContent({
const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?";
return (
-
-
+
+
{identityHeading}
@@ -4207,7 +4247,7 @@ export function AccessStepContent({
{/* Mobile stacks actions full-width with the primary action first in
reading order; desktop keeps Back on the left. */}
-
+ {hideFooter ? null :
Back
@@ -4220,7 +4260,93 @@ export function AccessStepContent({
{submitLabel}
{!pending && continuesToProvider ? : null}
+
}
+
+ );
+}
+
+/**
+ * The stated default (PAP-659 C0).
+ *
+ * The Access step used to ask two questions whose answers were already correct
+ * on arrival. Instead of asking, the resolved answer is printed in one line
+ * above the primary action and repeated on the completion screen, so the reach
+ * being granted is said twice on the happy path without costing a click.
+ */
+export function connectionDefaultSummarySentence(input: {
+ grantKind: ConnectionGrantKind;
+ authKind: ToolConnectionAuthKind;
+ installChoice: "specific" | "all";
+ installCount: number;
+ lockedAgentId?: string | null;
+ preserveAgentAccess?: boolean;
+}): string {
+ const identity = input.authKind === "none"
+ ? "No sign-in needed"
+ : input.grantKind === "user"
+ ? "Connects as you"
+ : input.grantKind === "agent"
+ ? "Connects as a dedicated agent account"
+ : "Connects for everyone in your organization";
+ const reach = input.preserveAgentAccess
+ ? "agent access stays as it is"
+ : input.lockedAgentId
+ ? "available to the agent that asked for it"
+ : input.installChoice === "all"
+ ? "available to all agents"
+ : input.installCount === 0
+ ? "no agents selected yet"
+ : `available to ${input.installCount} selected ${input.installCount === 1 ? "agent" : "agents"}`;
+ return `${identity}, ${reach}.`;
+}
+
+/**
+ * The stated default plus the collapsed Advanced disclosure that replaced the
+ * Access step. It sits in the same place on every connector and never blocks
+ * the primary action: opening it is optional, and everything inside it can
+ * also be changed on the Permissions tab after connecting.
+ */
+export function ConnectionAccessDefaults({
+ companyId,
+ sentence,
+ notice,
+ disabled = false,
+ ...accessProps
+}: Omit[0], "agents" | "agentsLoading" | "submitLabel" | "onBack" | "onContinue" | "pending" | "continuesToProvider" | "hideFooter" | "bare"> & {
+ companyId: string;
+ sentence: string;
+ /** Shown when the resolved default genuinely cannot apply. Never a step. */
+ notice?: string | null;
+ disabled?: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
);
}
@@ -4283,6 +4409,7 @@ export function ConnectionSetupCompletionScreen({
logoUrl,
darkLogoUrl,
summary,
+ statedDefault,
onDone,
}: {
appName: string;
@@ -4290,6 +4417,11 @@ export function ConnectionSetupCompletionScreen({
darkLogoUrl?: string | null;
/** Identity / Available to / Actions, as three lines rather than badges. */
summary: Array<{ label: string; value: string }>;
+ /**
+ * The same sentence the connect screen printed above its primary action, so
+ * the reach that was granted is stated twice on the happy path (PAP-659 C0).
+ */
+ statedDefault?: string;
onDone: () => void;
}) {
return (
@@ -4301,6 +4433,7 @@ export function ConnectionSetupCompletionScreen({
{appName} is ready.
+ {statedDefault ?
{statedDefault}
: null}
{summary.map((line) => (
diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx
index 6fca08e095e..d68e05bd2c9 100644
--- a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx
+++ b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx
@@ -63,7 +63,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
= 0 && !s.setupComplete ? `Step ${currentStep + 1} of 2` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`}
- step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} />
+ step={currentStep >= 0 && !s.setupComplete ? "key" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} />
{upstreamServiceName && {provider.name} is an external service that handles the connection and requests to {upstreamServiceName}. After connecting, the agent will verify the app and guide you through any additional authorization.}
{s.notice &&
{s.notice}
}
diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx
index 29bcf5c236f..2a75ee56c4d 100644
--- a/ui/src/pages/apps/AppsConnect.test.tsx
+++ b/ui/src/pages/apps/AppsConnect.test.tsx
@@ -171,11 +171,55 @@ function stepLabelsOnScreen(): string[] {
}
/**
- * Advance past the Access step (PAP-17835), which now sits between picking a
- * curated app and entering its credential. Picks "Any agent" so Continue is
- * enabled without depending on the agent list.
+ * Open the Advanced disclosure that now holds the identity and agent-reach
+ * controls the Access step used to own (PAP-659 C0). Closed by default and
+ * unmounted while closed, so a test that asserts on those controls has to open
+ * it first — which is itself the assertion that they are one click away.
+ */
+async function openAccessAdvanced() {
+ const change = Array.from(document.body.querySelectorAll("button")).find(
+ (b) => b.textContent?.trim() === "Change" && b.getAttribute("aria-expanded") === "false",
+ );
+ if (!change) return;
+ await act(async () => {
+ change.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+}
+
+/**
+ * PAP-659 deleted the Access step: picking a connector now opens its connect
+ * screen directly, and identity/agent reach are a stated default with an
+ * Advanced disclosure rather than a screen to pass.
+ *
+ * What remains is the one-click handoff the Access step used to perform for
+ * automatic-OAuth definitions, which is now the primary action of the sign-in
+ * screen itself. Everywhere else this is a no-op, so the call sites keep
+ * reading as "get to the part this test is about".
*/
async function passAccessStep() {
+ const onOAuthEntryScreen = Boolean(
+ document.body.textContent?.includes("Paperclip will open")
+ || document.body.textContent?.includes("Your connection is saved."),
+ );
+ if (onOAuthEntryScreen) {
+ const handoff = Array.from(document.body.querySelectorAll("button")).find((b) => {
+ const label = b.textContent?.trim() ?? "";
+ return label.startsWith("Continue to") || label.startsWith("Finish with");
+ });
+ await act(async () => {
+ handoff?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+ return;
+ }
+ // The remote-MCP aggregator setup (arcade, composio, executor, zapier) keeps
+ // its own Access step until PAP-659's no-auth pass replaces it. Recognise it
+ // by its own stepper rather than by any control the shared flow also has.
+ if (!stepLabelsOnScreen().includes("Access")) {
+ await flushReact();
+ return;
+ }
const anyAgent = Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((option) => option.textContent?.includes("Any agent"));
if (anyAgent) {
@@ -184,11 +228,10 @@ async function passAccessStep() {
});
await flushReact();
}
- 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"),
- );
+ const submit = Array.from(document.body.querySelectorAll("button")).find((b) => {
+ const label = b.textContent?.trim() ?? "";
+ return label === "Save and continue" || label === "Continue";
+ });
await act(async () => {
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -579,6 +622,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
+ await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
@@ -678,12 +722,19 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).not.toContain("Connect for tool access instead");
});
- it("asks for a GitHub identity and defaults to the current user and every agent", async () => {
+ it("states the GitHub identity default and keeps its controls one click away", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
await render();
- expect(container.textContent).toContain("Access");
+ // PAP-659 C0: the default is printed, not asked. Nothing about identity or
+ // reach blocks the primary action.
+ expect(container.textContent).toContain("Connects as you, available to all agents.");
+ expect(container.textContent).not.toContain("Connect GitHub as");
+ expect(container.querySelector('[role="radio"]')).toBeNull();
+
+ await openAccessAdvanced();
+
expect(container.textContent).toContain("Connect GitHub as");
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
expect(container.textContent).not.toContain("Choose access before adding credentials");
@@ -715,11 +766,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(anyAgent?.getAttribute("aria-checked")).toBe("true");
});
- it("keeps provider guidance and card styling out of the shared access step", async () => {
+ it("keeps provider guidance and card styling out of the access controls", async () => {
mockParams.appKey = "pagerduty";
listGalleryMock.mockResolvedValue({ apps: [PAGERDUTY] });
await render();
+ await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
@@ -736,11 +788,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(accessCard?.classList.contains("shadow-sm")).toBe(false);
});
- it("defaults to every agent and only blocks an empty explicit selection", async () => {
+ it("defaults to every agent and states an empty explicit selection", async () => {
mockParams.appKey = "github";
await render();
+ await openAccessAdvanced();
- expect(buttonByText("Save and continue")?.disabled).toBe(false);
+ // PAP-659 C0: narrowing reach never blocks Connect. An empty selection is
+ // reported in the stated default instead of disabling the primary action.
+ expect(container.textContent).toContain("available to all agents.");
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
@@ -749,43 +804,28 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await flushReact();
- expect(buttonByText("Save and continue")?.disabled).toBe(true);
+ expect(container.textContent).toContain("no agents selected yet.");
});
- it("keeps the access selections when the wizard moves backward", async () => {
+ it("reflects a changed access selection in the stated default", async () => {
mockParams.appKey = "github";
await render();
+ await openAccessAdvanced();
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
- .find((r) => r.textContent?.includes("My GitHub account"))
- ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
- await act(async () => {
- Array.from(document.body.querySelectorAll('[role="radio"]'))
- .find((r) => r.textContent?.includes("Any agent"))
+ .find((r) => r.textContent?.includes("A dedicated account for an agent"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- await act(async () => {
- buttonByText("Save and continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
- expect(container.textContent).toContain("Connect GitHub");
+ // The sentence above the primary action is the only place this is said, so
+ // it has to track the controls rather than print a fixed default.
+ expect(container.textContent).toContain("Connects as a dedicated agent account");
- await act(async () => {
- buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
-
- // Moving backward must not silently reset the identity the operator chose.
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
- expect(radios.find((r) => r.textContent?.includes("My GitHub account"))?.getAttribute("aria-checked"))
- .toBe("true");
- expect(radios.find((r) => r.textContent?.includes("Any agent"))?.getAttribute("aria-checked"))
- .toBe("true");
+ expect(radios.find((r) => r.textContent?.includes("A dedicated account for an agent"))
+ ?.getAttribute("aria-checked")).toBe("true");
});
it("uses Cancel to exit while the bottom Back button stays in the wizard", async () => {
@@ -824,6 +864,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await flushReact();
+ await openAccessAdvanced();
+
const github = identityChoices();
expect(github.wholeOrg?.getAttribute("aria-checked")).toBe("true");
expect(github.justMe?.getAttribute("aria-checked")).toBe("false");
@@ -844,10 +886,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- await act(async () => {
- buttonByText("Save and continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
const keyField = container.querySelector("input[type=password]");
await act(async () => setInputValue(keyField!, "posthog-personal-token"));
@@ -986,6 +1024,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockSearch.value = "source=asana";
await render();
+ await openAccessAdvanced();
expect(document.body.textContent).toContain("Which humans can use this credential?");
expect(document.body.textContent).toContain("Which agents can use this connection?");
@@ -1011,9 +1050,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
- expect(document.body.textContent).toContain("Step 1 of 2");
- expect(document.body.textContent).toContain("Access · Choose connection");
+ // PAP-659: a selected app is one screen, so there is no stepper and no
+ // Access stage to deep-link into; a legacy stage=access URL lands here.
+ expect(document.body.textContent).not.toContain("Step 1 of 2");
expect(document.body.textContent).not.toContain("Pick app ·");
+ expect(document.body.textContent).toContain("Connects for everyone in your organization");
+ await openAccessAdvanced();
expect(document.body.textContent).toContain("Which humans can use this credential?");
expect(document.body.textContent).toContain("Just me");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
@@ -1647,9 +1689,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockParams.appKey = "posthog";
listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });
await render();
- // Access comes first for a curated app; the method chooser shares a screen
- // with the credential fields, so it sits behind it (PAP-17835).
- expect(container.textContent).toContain("Which humans can use this credential?");
+ // PAP-659: identity is stated, not asked, and the method chooser moved
+ // into the same Advanced disclosure.
+ expect(container.textContent).toContain("Connects for everyone in your organization");
await passAccessStep();
expect(container.textContent).toContain("How do you want to connect?");
@@ -1838,7 +1880,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
requestedAgentId="agent-1"
/>
));
- expect(container.textContent).toContain("This task grants access only to Ada");
+ expect(container.textContent).toContain("available to the agent that asked for it");
const continueButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Continue",
);
@@ -1995,6 +2037,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(navigateTopLevelMock).not.toHaveBeenCalled();
+ expect(container.textContent).toContain("Connects for everyone in your organization");
+ await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).not.toContain("Choose access before sign-in");
const identityRadios = Array.from(document.body.querySelectorAll('[role="radio"]'));
@@ -2041,19 +2085,20 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
- const stepsOnAccess = stepLabelsOnScreen();
- expect(stepsOnAccess).toEqual(["Access", "Sign in"]);
+ // PAP-659: a curated connector is a single screen, so it shows no stepper
+ // at all. Connecting must not conjure one.
+ expect(stepLabelsOnScreen()).toEqual([]);
+ expect(stepDotCount()).toBe(0);
await passAccessStep();
await submitCuratedOAuthSetup();
expect(container.textContent).toContain("Preparing secure sign-in");
- // Connecting must not grow the stepper a step the flow never lands on.
- expect(stepLabelsOnScreen()).toEqual(stepsOnAccess);
- expect(stepDotCount()).toBe(stepsOnAccess.length);
+ expect(stepLabelsOnScreen()).toEqual([]);
+ expect(stepDotCount()).toBe(0);
});
- it("backs from the sign-in checkpoint to Access without exiting the wizard", async () => {
+ it("backs from the sign-in checkpoint out to the connector gallery", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
@@ -2067,9 +2112,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await flushReact();
- expect(container.textContent).toContain("Which humans can use this credential?");
- expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=notion&stage=access");
- expect(mockNavigate).not.toHaveBeenCalledWith("/apps");
+ expect(mockNavigate).toHaveBeenCalledWith("/apps");
});
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
@@ -2103,7 +2146,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
- expect(container.textContent).toContain("Existing agent access stays the same");
+ expect(container.textContent).toContain("agent access stays as it is");
expect(startOAuthMock).not.toHaveBeenCalled();
await passAccessStep();
await submitCuratedOAuthSetup();
@@ -2269,9 +2312,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).toContain(
- credentialPolicy === "per_user" ? "Just me" : "Any human in the organization",
+ credentialPolicy === "per_user" ? "Connects as you" : "Connects for everyone in your organization",
);
- expect(container.textContent).toContain("Existing agent access stays the same");
+ expect(container.textContent).toContain("agent access stays as it is");
await passAccessStep();
await submitCuratedOAuthSetup();
@@ -2529,7 +2572,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
- expect(container.textContent).toContain("Existing agent access stays the same");
+ expect(container.textContent).toContain("agent access stays as it is");
await passAccessStep();
await submitCuratedOAuthSetup();
expect(startOAuthMock).toHaveBeenCalledWith("conn-refreshed", {
@@ -2575,7 +2618,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
- expect(container.textContent).toContain("Existing agent access stays the same");
+ expect(container.textContent).toContain("agent access stays as it is");
await passAccessStep();
await submitCuratedOAuthSetup();
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
@@ -2708,6 +2751,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(connectAppMock).not.toHaveBeenCalled();
+ await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
});
@@ -2798,6 +2842,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
+ await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
@@ -2941,6 +2986,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
mockSearch.value =
"link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77";
await render();
+ await openAccessAdvanced();
expect(container.textContent).toContain("Which humans can use this credential?");
await passAccessStep();
@@ -3051,21 +3097,15 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
- it("steps back from the key step to Access, and from Access to Connectors", async () => {
+ it("steps back from the single connect screen straight to Connectors", async () => {
mockSearch.value = "";
mockParams.appKey = "github";
await render();
await passAccessStep();
expect(container.textContent).toContain("Connect GitHub");
- // Back from the credential goes to Access, not all the way out: the
- // selections made there have to survive (PAP-17835).
- await act(async () => {
- buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushReact();
- expect(container.textContent).toContain("Connect GitHub as");
-
+ // PAP-659: there is no Access step to return to, so Back is a single hop
+ // out of the connector rather than a two-hop wizard retreat.
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -3510,10 +3550,10 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
});
// The curated flow has its own regression test for this. The generic flow is
- // a separate three-step model with a separate override, so it needs its own —
+ // a separate two-step model with a separate override, so it needs its own —
// otherwise the waiting screen could quietly drop back to the curated
- // two-step labels here and nothing would catch it.
- it("keeps the generic three-step model when a pasted endpoint hands off to sign-in", async () => {
+ // single-step label here and nothing would catch it.
+ it("keeps the generic two-step model when a pasted endpoint hands off to sign-in", async () => {
connectAppMock.mockResolvedValue({
connectionId: "conn-1",
application: { id: "app-1", name: "mcp.example.test" },
@@ -3526,7 +3566,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
await gotoLinkFrame(container, "https://mcp.example.test/mcp");
const stepsBeforeHandoff = stepLabelsOnScreen();
- expect(stepsBeforeHandoff).toEqual(["Pick app", "Access", "Add your key"]);
+ expect(stepsBeforeHandoff).toEqual(["Pick app", "Add your key"]);
await act(async () => {
buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));