History

Add Google service fan-out picker

Rhys Sullivan committed 4 months ago

Commit a1aaefa1 parent 9980d732 files · +415 −69Browse files

Unified · Split

2 files
  1. packages/plugins/google/src/react/AddGoogleSource.tsx +405 −56
  2. packages/plugins/google/src/react/GoogleProductPicker.tsx +10 −13
packages/plugins/google/src/react/AddGoogleSource.tsx+405−56
@@ -1,6 +1,8 @@
1−import { useCallback, useMemo, useState } from "react";
1+import { useCallback, useMemo, useState, type ChangeEvent } from "react";
22 import { useAtomSet } from "@effect/atom-react";
3+import { Link } from "@tanstack/react-router";
34 import * as Exit from "effect/Exit";
5+import { CheckIcon, CircleIcon, TriangleAlert } from "lucide-react";
46
57 import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
68 import {
@@ -8,23 +10,32 @@ import { · 1 unmodified line
810 useIntegrationIdentity,
911 } from "@executor-js/react/plugins/integration-identity";
1012 import { Button } from "@executor-js/react/components/button";
13+import { FieldLabel } from "@executor-js/react/components/field";
1114 import { FloatActions } from "@executor-js/react/components/float-actions";
15+import { Input } from "@executor-js/react/components/input";
1216 import {
1317 addIntegrationErrorMessage,
18+ errorMessageFromExit,
1419 FormErrorAlert,
1520 SlugCollisionAlert,
1621 useSlugAlreadyExists,
1722 } from "@executor-js/react/lib/integration-add";
1823 import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
1924
20−import { addGoogleBundle } from "./atoms";
25+import { addGoogleBundle, addGoogleServices } from "./atoms";
2126 import { GoogleProductPicker } from "./GoogleProductPicker";
2227 import {
2328 GOOGLE_PHOTOS_PRESET_ID,
2429 googleOpenApiPresets,
2530 googlePhotosPresetIds,
31+ googleServiceSlug,
2632 type GoogleOpenApiPreset,
2733 } from "../sdk/presets";
34+import type {
35+ GoogleAddServicesInput,
36+ GoogleAddServicesResult,
37+ GoogleBundleConfig,
38+} from "../sdk/plugin";
2839
2940 const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
3041
@@ -34,14 +45,249 @@ const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set( · 3 unmodified lines
3445 .map((preset: GoogleOpenApiPreset) => preset.id),
3546 );
3647
37−const googleBundleUrls = (
38− selectedPresetIds: ReadonlySet<string>,
39− customUrls: readonly string[],
40−): readonly string[] => {
41− const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) =>
42− preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [],
48+const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map(
49+ googleOpenApiPresets.map((preset: GoogleOpenApiPreset) => [preset.id, preset]),
50+);
51+
52+export type GoogleServiceIdentityOverride = {
53+ readonly slug: string;
54+ readonly name: string;
55+};
56+
57+export type AddGoogleServicesMutation = (input: {
58+ readonly payload: GoogleAddServicesInput;
59+ readonly reactivityKeys: typeof integrationWriteKeys;
60+}) => Promise<Exit.Exit<GoogleAddServicesResult, unknown>>;
61+
62+export const googleAddServicesPayload = (input: {
63+ readonly presetIds: readonly string[];
64+ readonly identityOverride?: GoogleServiceIdentityOverride;
65+ readonly baseUrl?: string;
66+}): GoogleAddServicesInput => {
67+ const identityOverride = input.presetIds.length === 1 ? input.identityOverride : undefined;
68+ const services = input.presetIds.map((presetId: string) => ({
69+ presetId,
70+ ...(identityOverride?.slug.trim() ? { slug: identityOverride.slug.trim() } : {}),
71+ ...(identityOverride?.name.trim() ? { name: identityOverride.name.trim() } : {}),
72+ }));
73+ const baseUrl = input.baseUrl?.trim() ?? "";
74+ return baseUrl.length > 0 ? { services, baseUrl } : { services };
75+};
76+
77+export const submitGoogleServicesSelection = (
78+ doAddServices: AddGoogleServicesMutation,
79+ input: {
80+ readonly presetIds: readonly string[];
81+ readonly identityOverride?: GoogleServiceIdentityOverride;
82+ readonly baseUrl?: string;
83+ },
84+): Promise<Exit.Exit<GoogleAddServicesResult, unknown>> =>
85+ doAddServices({
86+ payload: googleAddServicesPayload(input),
87+ reactivityKeys: integrationWriteKeys,
88+ });
89+
90+export type GoogleServiceResultRow =
91+ | {
92+ readonly status: "added";
93+ readonly presetId: string;
94+ readonly slug: string;
95+ readonly toolCount: number;
96+ }
97+ | {
98+ readonly status: "skipped";
99+ readonly presetId: string;
100+ readonly slug: string;
101+ readonly reason: "already_exists";
102+ }
103+ | {
104+ readonly status: "failed";
105+ readonly presetId: string;
106+ readonly slug: string;
107+ readonly error: string;
108+ };
109+
110+export const googleAddServicesResultRows = (
111+ result: GoogleAddServicesResult,
112+): readonly GoogleServiceResultRow[] => [
113+ ...result.added.map((entry) => ({
114+ status: "added" as const,
115+ presetId: entry.presetId,
116+ slug: String(entry.slug),
117+ toolCount: entry.toolCount,
118+ })),
119+ ...result.skipped.map((entry) => ({
120+ status: "skipped" as const,
121+ presetId: entry.presetId,
122+ slug: String(entry.slug),
123+ reason: entry.reason,
124+ })),
125+ ...result.failed.map((entry) => ({
126+ status: "failed" as const,
127+ presetId: entry.presetId,
128+ slug: String(entry.slug),
129+ error: entry.error,
130+ })),
131+];
132+
133+export const mergeGoogleAddServicesResult = (
134+ previous: GoogleAddServicesResult,
135+ next: GoogleAddServicesResult,
136+): GoogleAddServicesResult => {
137+ const nextPresetIds = new Set(googleAddServicesResultRows(next).map((row) => row.presetId));
138+ return {
139+ added: [...previous.added.filter((entry) => !nextPresetIds.has(entry.presetId)), ...next.added],
140+ skipped: [
141+ ...previous.skipped.filter((entry) => !nextPresetIds.has(entry.presetId)),
142+ ...next.skipped,
143+ ],
144+ failed: [
145+ ...previous.failed.filter((entry) => !nextPresetIds.has(entry.presetId)),
146+ ...next.failed,
147+ ],
148+ };
149+};
150+
151+const googlePresetName = (presetId: string): string =>
152+ googleOpenApiPresetById.get(presetId)?.name ?? presetId;
153+
154+export function GoogleServiceResultPanel(props: {
155+ readonly result: GoogleAddServicesResult;
156+ readonly retryingPresetId: string | null;
157+ readonly onRetry: (presetId: string) => void | Promise<void>;
158+}) {
159+ const rows = googleAddServicesResultRows(props.result);
160+ if (rows.length === 0) return null;
161+
162+ return (
163+ <section className="space-y-3 rounded-lg border border-border bg-muted/10 px-3 py-3">
164+ <div>
165+ <h2 className="text-sm font-medium text-foreground">Google products</h2>
166+ <p className="text-[11px] text-muted-foreground">
167+ Each selected product is added as its own integration.
168+ </p>
169+ </div>
170+ <ul className="space-y-2">
171+ {rows.map((row: GoogleServiceResultRow) => {
172+ const presetName = googlePresetName(row.presetId);
173+ return (
174+ <li
175+ key={`${row.status}:${row.presetId}`}
176+ className="flex items-start gap-2 rounded-md border border-border bg-background px-2.5 py-2"
177+ >
178+ <span
179+ className={
180+ row.status === "added"
181+ ? "mt-0.5 text-emerald-600"
182+ : row.status === "skipped"
183+ ? "mt-0.5 text-muted-foreground"
184+ : "mt-0.5 text-destructive"
185+ }
186+ >
187+ {row.status === "added" ? (
188+ <CheckIcon className="size-3.5" />
189+ ) : row.status === "skipped" ? (
190+ <CircleIcon className="size-3.5" />
191+ ) : (
192+ <TriangleAlert className="size-3.5" />
193+ )}
194+ </span>
195+ <div className="min-w-0 flex-1 space-y-0.5">
196+ <div className="flex min-w-0 items-center gap-2">
197+ <span className="truncate text-sm font-medium text-foreground">{presetName}</span>
198+ <span className="shrink-0 text-[11px] text-muted-foreground">
199+ {row.status === "added"
200+ ? "Added"
201+ : row.status === "skipped"
202+ ? "Already exists"
203+ : "Failed"}
204+ </span>
205+ </div>
206+ {row.status === "added" ? (
207+ <p className="text-[11px] text-muted-foreground">
208+ {row.toolCount} tool{row.toolCount === 1 ? "" : "s"} added.
209+ </p>
210+ ) : row.status === "skipped" ? (
211+ <p className="text-[11px] text-muted-foreground">
212+ This integration already exists.
213+ </p>
214+ ) : (
215+ <p className="text-[11px] text-destructive">{row.error}</p>
216+ )}
217+ </div>
218+ {row.status === "failed" ? (
219+ <Button
220+ type="button"
221+ variant="outline"
222+ size="xs"
223+ loading={props.retryingPresetId === row.presetId}
224+ onClick={() => void props.onRetry(row.presetId)}
225+ >
226+ Retry
227+ </Button>
228+ ) : (
229+ <Button variant="ghost" size="xs" asChild>
230+ <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: row.slug }}>
231+ Open
232+ </Link>
233+ </Button>
234+ )}
235+ </li>
236+ );
237+ })}
238+ </ul>
239+ </section>
43240 );
44− return [...new Set([...fromPresets, ...customUrls])];
241+}
242+
243+const BaseUrlSettings = (props: {
244+ readonly baseUrl: string;
245+ readonly onBaseUrlChange: (value: string) => void;
246+}) => (
247+ <section className="space-y-2 rounded-lg border border-border bg-muted/10 px-3 py-3">
248+ <div className="space-y-1">
249+ <FieldLabel>Google product settings</FieldLabel>
250+ <p className="text-[11px] text-muted-foreground">
251+ Selected products keep their preset names and namespaces.
252+ </p>
253+ </div>
254+ <Input
255+ value={props.baseUrl}
256+ onChange={(event: ChangeEvent<HTMLInputElement>) => props.onBaseUrlChange(event.target.value)}
257+ placeholder="Base URL override (optional)"
258+ className="font-mono text-sm"
259+ />
260+ </section>
261+);
262+
263+const CustomGoogleBundleResult = (props: { readonly slug: string }) => (
264+ <section className="rounded-lg border border-border bg-muted/10 px-3 py-3">
265+ <div className="flex items-center justify-between gap-3">
266+ <div className="min-w-0">
267+ <h2 className="text-sm font-medium text-foreground">Custom Discovery URLs</h2>
268+ <p className="text-[11px] text-muted-foreground">
269+ Custom URLs were added through the legacy Google bundle path.
270+ </p>
271+ </div>
272+ <Button variant="ghost" size="xs" asChild>
273+ <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: props.slug }}>
274+ Open
275+ </Link>
276+ </Button>
277+ </div>
278+ </section>
279+);
280+
281+const googleBundlePayload = (config: GoogleBundleConfig): GoogleBundleConfig => {
282+ const baseUrl = config.baseUrl?.trim() ?? "";
283+ const description = config.description?.trim() ?? "";
284+ return {
285+ urls: config.urls,
286+ ...(config.slug !== undefined ? { slug: config.slug } : {}),
287+ ...(config.name !== undefined ? { name: config.name } : {}),
288+ ...(description.length > 0 ? { description } : {}),
289+ ...(baseUrl.length > 0 ? { baseUrl } : {}),
290+ };
45291 };
46292
47293 export default function AddGoogleSource(props: {
@@ -58,19 +304,31 @@ export default function AddGoogleSource(props: { · 10 unmodified lines
58304 const [baseUrl, setBaseUrl] = useState("");
59305 const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
60306 const [adding, setAdding] = useState(false);
307+ const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null);
61308 const [addError, setAddError] = useState<string | null>(null);
309+ const [servicesResult, setServicesResult] = useState<GoogleAddServicesResult | null>(null);
310+ const [customBundleSlug, setCustomBundleSlug] = useState<string | null>(null);
311+
312+ const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]);
313+ const singleSelectedPreset =
314+ selectedIds.length === 1 ? googleOpenApiPresetById.get(selectedIds[0]!) : undefined;
315+ const usesCustomBundleFallback = customDiscoveryUrls.length > 0;
62316
63317 const identity = useIntegrationIdentity({
64− fallbackName: isGooglePhotosPreset ? "Google Photos" : "Google",
318+ fallbackName: usesCustomBundleFallback
319+ ? "Custom Google APIs"
320+ : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google")),
65321 fallbackNamespace:
66− props.initialNamespace ?? (isGooglePhotosPreset ? "google_photos" : "google"),
322+ props.initialNamespace ??
323+ (usesCustomBundleFallback
324+ ? "google_custom"
325+ : singleSelectedPreset
326+ ? googleServiceSlug(singleSelectedPreset.id)
327+ : isGooglePhotosPreset
328+ ? "google_photos"
329+ : "google"),
67330 });
68331
69− const bundleDiscoveryUrls = useMemo(
70− () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls),
71− [selectedPresetIds, customDiscoveryUrls],
72− );
73−
74332 const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => {
75333 setSelectedPresetIds((current: ReadonlySet<string>) => {
76334 const next = new Set(current);
@@ -92,41 +350,119 @@ export default function AddGoogleSource(props: { · 15 unmodified lines
92350 );
93351 }, []);
94352
95− const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" });
353+ const doAddServices = useAtomSet(addGoogleServices, { mode: "promiseExit" });
354+ const doAddBundle = useAtomSet(addGoogleBundle, { mode: "promiseExit" });
96355
97− const resolvedSourceId =
98− slugifyNamespace(identity.namespace) || (isGooglePhotosPreset ? "google_photos" : "google");
356+ const resolvedSourceId = slugifyNamespace(identity.namespace) || "google_custom";
99357 const resolvedDisplayName =
100− identity.name.trim() || (isGooglePhotosPreset ? "Google Photos" : "Google");
358+ identity.name.trim() ||
359+ (usesCustomBundleFallback
360+ ? "Custom Google APIs"
361+ : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google")));
101362 const resolvedDescription =
102363 descriptionDraft ??
103− (isGooglePhotosPreset
104− ? "Google Photos albums, uploads, app-created media, and selected picker media."
105− : "Google APIs");
106− const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId);
107− const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists;
364+ (usesCustomBundleFallback
365+ ? "Custom Google APIs."
366+ : isGooglePhotosPreset
367+ ? "Google Photos albums, uploads, app-created media, and selected picker media."
368+ : "Google APIs");
369+ const customSlugAlreadyExists = useSlugAlreadyExists(
370+ usesCustomBundleFallback ? resolvedSourceId : "",
371+ );
372+ const identityOverride =
373+ selectedIds.length === 1 && !usesCustomBundleFallback
374+ ? { slug: resolvedSourceId, name: resolvedDisplayName }
375+ : undefined;
376+ const canAdd =
377+ (selectedIds.length > 0 || customDiscoveryUrls.length > 0) &&
378+ !customSlugAlreadyExists &&
379+ !adding;
108380
109− const handleAdd = async () => {
110− setAdding(true);
111− setAddError(null);
112− const exit = await doAdd({
113− payload: {
114− urls: [...bundleDiscoveryUrls],
381+ const addCustomDiscoveryBundle = async (): Promise<boolean> => {
382+ if (customDiscoveryUrls.length === 0) return true;
383+ const exit = await doAddBundle({
384+ payload: googleBundlePayload({
385+ urls: [...customDiscoveryUrls],
115386 slug: resolvedSourceId,
116387 name: resolvedDisplayName,
117− ...(resolvedDescription.trim().length > 0
118− ? { description: resolvedDescription.trim() }
119− : {}),
120− ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}),
121− },
388+ description: resolvedDescription,
389+ baseUrl,
390+ }),
122391 reactivityKeys: integrationWriteKeys,
123392 });
124393 if (Exit.isFailure(exit)) {
125394 setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google"));
126− setAdding(false);
395+ return false;
396+ }
397+ setCustomBundleSlug(String(exit.value.slug));
398+ return true;
399+ };
400+
401+ const handleAdd = async () => {
402+ setAdding(true);
403+ setAddError(null);
404+ setServicesResult(null);
405+ setCustomBundleSlug(null);
406+ if (selectedIds.length > 0) {
407+ const exit = await submitGoogleServicesSelection(doAddServices, {
408+ presetIds: selectedIds,
409+ ...(identityOverride ? { identityOverride } : {}),
410+ baseUrl,
411+ });
412+ if (Exit.isFailure(exit)) {
413+ setAddError(errorMessageFromExit(exit, "Failed to add Google services"));
414+ setAdding(false);
415+ return;
416+ }
417+ setServicesResult(exit.value);
418+ }
419+ await addCustomDiscoveryBundle();
420+ setAdding(false);
421+ };
422+
423+ const handleRetry = async (presetId: string) => {
424+ setRetryingPresetId(presetId);
425+ setAddError(null);
426+ const retryIdentityOverride =
427+ identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId
428+ ? identityOverride
429+ : undefined;
430+ const exit = await submitGoogleServicesSelection(doAddServices, {
431+ presetIds: [presetId],
432+ ...(retryIdentityOverride ? { identityOverride: retryIdentityOverride } : {}),
433+ baseUrl,
434+ });
435+ if (Exit.isFailure(exit)) {
436+ setAddError(errorMessageFromExit(exit, "Failed to add Google service"));
437+ setRetryingPresetId(null);
127438 return;
128439 }
129− props.onComplete(String(exit.value.slug));
440+ setServicesResult((current) =>
441+ current ? mergeGoogleAddServicesResult(current, exit.value) : exit.value,
442+ );
443+ setRetryingPresetId(null);
444+ };
445+
446+ const showIdentityDetails = selectedIds.length === 1 || usesCustomBundleFallback;
447+ const detailTitle = usesCustomBundleFallback
448+ ? "Custom Google Discovery URLs"
449+ : (singleSelectedPreset?.name ?? "Google");
450+ const detailSubtitle = usesCustomBundleFallback
451+ ? selectedIds.length > 0
452+ ? `${customDiscoveryUrls.length} custom URL${
453+ customDiscoveryUrls.length === 1 ? "" : "s"
454+ } added through the legacy bundle path. Selected products keep preset names.`
455+ : `${customDiscoveryUrls.length} custom URL${
456+ customDiscoveryUrls.length === 1 ? "" : "s"
457+ } added through the legacy bundle path.`
458+ : "This product is added as its own integration.";
459+
460+ const dismiss = () => {
461+ if (servicesResult || customBundleSlug) {
462+ props.onComplete();
463+ return;
464+ }
465+ props.onCancel();
130466 };
131467
132468 return (
@@ -134,7 +470,7 @@ export default function AddGoogleSource(props: { · 1 unmodified line
134470 <div>
135471 <h1 className="text-xl font-semibold text-foreground">Add Google integration</h1>
136472 <p className="mt-1 text-[13px] text-muted-foreground">
137− Bundle Google APIs into one integration with a shared OAuth consent.
473+ Each selected product is added as its own integration.
138474 </p>
139475 </div>
140476
@@ -146,28 +482,41 @@ export default function AddGoogleSource(props: { · 5 unmodified lines
146482 onRemoveCustomUrl={removeCustomDiscoveryUrl}
147483 />
148484
149− <OpenApiSourceDetailsFields
150− title="Google"
151− subtitle={`${bundleDiscoveryUrls.length} Google API${
152− bundleDiscoveryUrls.length !== 1 ? "s" : ""
153− } · one shared OAuth consent`}
154− identity={identity}
155− description={resolvedDescription}
156− onDescriptionChange={setDescriptionDraft}
157− baseUrl={baseUrl}
158− onBaseUrlChange={setBaseUrl}
159− baseUrlLabel="Base URL override (optional)"
160− faviconIcon={GOOGLE_BUNDLE_FAVICON}
161− faviconUrl={baseUrl}
162− />
485+ {showIdentityDetails ? (
486+ <OpenApiSourceDetailsFields
487+ title={detailTitle}
488+ subtitle={detailSubtitle}
489+ identity={identity}
490+ {...(usesCustomBundleFallback
491+ ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft }
492+ : {})}
493+ baseUrl={baseUrl}
494+ onBaseUrlChange={setBaseUrl}
495+ baseUrlLabel="Base URL override (optional)"
496+ faviconIcon={GOOGLE_BUNDLE_FAVICON}
497+ faviconUrl={baseUrl}
498+ />
499+ ) : (
500+ <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} />
501+ )}
163502
164− {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
503+ {customSlugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
165504
166505 {addError && <FormErrorAlert message={addError} />}
167506
507+ {servicesResult && (
508+ <GoogleServiceResultPanel
509+ result={servicesResult}
510+ retryingPresetId={retryingPresetId}
511+ onRetry={handleRetry}
512+ />
513+ )}
514+
515+ {customBundleSlug && <CustomGoogleBundleResult slug={customBundleSlug} />}
516+
168517 <FloatActions>
169− <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
170− Cancel
518+ <Button variant="ghost" onClick={dismiss} disabled={adding || retryingPresetId !== null}>
519+ {servicesResult || customBundleSlug ? "Done" : "Cancel"}
171520 </Button>
172521 <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}>
173522 Connect Google
packages/plugins/google/src/react/GoogleProductPicker.tsx+10−13
@@ -27,12 +27,10 @@ import { isGoogleDiscoveryUrl } from "../sdk/discovery"; · 26 unmodified lines
2727 // GoogleProductPicker - the "customize your Google connection" surface.
2828 //
2929 // A checkable card grid over `googleOpenApiPresets`, grouped/annotated by
30−// `oauthAudience`. The user picks which Google APIs to bundle into the single
31−// `google` integration; the parent turns the selected discovery URLs into a
32−// `{ kind: "googleDiscoveryBundle", urls }` add. A "View scopes" panel previews
33−// the unioned OAuth consent (via `googleOAuthConsentBatches`) BEFORE connecting,
34−// and a custom-URL escape hatch lets advanced users paste any Google Discovery
35−// document the preset list doesn't cover.
30+// `oauthAudience`. The user picks which Google APIs to add as separate
31+// integrations. A "View scopes" panel previews the selected OAuth scopes, and a
32+// custom-URL escape hatch lets advanced users paste any Google Discovery document
33+// the preset list doesn't cover.
3634 // ---------------------------------------------------------------------------
3735
3836 // Audience groups, ordered from least- to most-privileged. The warning tiers
@@ -219,9 +217,9 @@ export function GoogleProductPicker({ · 180 unmodified lines
219217 [],
220218 );
221219
222− // The "View scopes" preview unions the selected presets' representative
223− // consent scopes through `googleOAuthConsentBatches`, mirroring the unioned
224− // scopes the bundle converter ultimately stores on the integration.
220+ // The "View scopes" preview groups the selected presets' representative
221+ // consent scopes so the user can review the requested OAuth surface before
222+ // adding separate integrations.
225223 const consentBatches = useMemo(
226224 () =>
227225 googleOAuthConsentBatches(
@@ -244,8 +242,7 @@ export function GoogleProductPicker({ · 16 unmodified lines
244242 <div className="space-y-1">
245243 <FieldLabel>Customize your Google connection</FieldLabel>
246244 <p className="text-[11px] text-muted-foreground">
247− Pick the Google APIs to bundle into one connection. They share a single OAuth consent and
248− appear as merged tools under one Google integration.
245+ Pick Google APIs to add. Each selected product becomes its own integration.
249246 </p>
250247 </div>
251248
@@ -311,8 +308,8 @@ export function GoogleProductPicker({ · 59 unmodified lines
311308 <Badge variant="secondary">OAuth</Badge>
312309 </div>
313310 <p className="text-[11px] text-muted-foreground">
314− The selected Google APIs share one OAuth consent. Review the scopes below, then connect a
315− Google account from the integration page after adding.
311+ Each selected Google product gets its own OAuth setup. Connect accounts from each
312+ integration page after adding.
316313 </p>
317314 </div>
318315