diff --git a/apps/cloud/src/auth/route-paths.ts b/apps/cloud/src/auth/route-paths.ts index 967a74dc18..1e19b3a098 100644 --- a/apps/cloud/src/auth/route-paths.ts +++ b/apps/cloud/src/auth/route-paths.ts @@ -7,4 +7,4 @@ export const PUBLIC_PATHS = new Set(["/login"]); /** Pages an authenticated-but-org-less user is FOR (everything else redirects to onboarding). */ -export const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]); +export const ONBOARDING_PATHS = new Set(["/create-org", "/setup-app", "/setup-mcp"]); diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e0ca33dbdf..b7cdb74804 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as CreateOrgRouteImport } from './routes/bare/create-org' import { Route as LoginRouteImport } from './routes/bare/login' +import { Route as SetupAppRouteImport } from './routes/bare/setup-app' import { Route as SetupMcpRouteImport } from './routes/bare/setup-mcp' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' @@ -41,6 +42,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const SetupAppRoute = SetupAppRouteImport.update({ + id: '/setup-app', + path: '/setup-app', + getParentRoute: () => rootRouteImport, +} as any) const SetupMcpRoute = SetupMcpRouteImport.update({ id: '/setup-mcp', path: '/setup-mcp', @@ -166,6 +172,7 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK export interface FileRoutesByFullPath { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute + '/setup-app': typeof SetupAppRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -189,6 +196,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute + '/setup-app': typeof SetupAppRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -213,6 +221,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/create-org': typeof CreateOrgRoute '/login': typeof LoginRoute + '/setup-app': typeof SetupAppRoute '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -238,6 +247,7 @@ export interface FileRouteTypes { fullPaths: | '/create-org' | '/login' + | '/setup-app' | '/setup-mcp' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -261,6 +271,7 @@ export interface FileRouteTypes { to: | '/create-org' | '/login' + | '/setup-app' | '/setup-mcp' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -284,6 +295,7 @@ export interface FileRouteTypes { | '__root__' | '/create-org' | '/login' + | '/setup-app' | '/setup-mcp' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' @@ -308,6 +320,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { CreateOrgRoute: typeof CreateOrgRoute LoginRoute: typeof LoginRoute + SetupAppRoute: typeof SetupAppRoute SetupMcpRoute: typeof SetupMcpRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren @@ -343,6 +356,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/setup-app': { + id: '/setup-app' + path: '/setup-app' + fullPath: '/setup-app' + preLoaderRoute: typeof SetupAppRouteImport + parentRoute: typeof rootRouteImport + } '/setup-mcp': { id: '/setup-mcp' path: '/setup-mcp' @@ -512,6 +532,7 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = const rootRouteChildren: RootRouteChildren = { CreateOrgRoute: CreateOrgRoute, LoginRoute: LoginRoute, + SetupAppRoute: SetupAppRoute, SetupMcpRoute: SetupMcpRoute, ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: diff --git a/apps/cloud/src/routes/bare/setup-app.tsx b/apps/cloud/src/routes/bare/setup-app.tsx new file mode 100644 index 0000000000..13df292d59 --- /dev/null +++ b/apps/cloud/src/routes/bare/setup-app.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SetupAppPage } from "../../web/pages/setup-app"; + +export const Route = createFileRoute("/setup-app")({ + component: SetupAppPage, +}); diff --git a/apps/cloud/src/web/onboarding-progress.test.ts b/apps/cloud/src/web/onboarding-progress.test.ts new file mode 100644 index 0000000000..2cf77e0162 --- /dev/null +++ b/apps/cloud/src/web/onboarding-progress.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + readOnboardingPracticeProgress, + writeOnboardingPracticeProgress, +} from "./onboarding-progress"; + +const makeStorage = () => { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + values, + }; +}; + +describe("onboarding practice progress", () => { + it("persists recognized steps per organization", () => { + const storage = makeStorage(); + writeOnboardingPracticeProgress(storage, "acme", new Set(["build_app", "store_notes"])); + + expect([...readOnboardingPracticeProgress(storage, "acme")]).toEqual([ + "build_app", + "store_notes", + ]); + expect([...readOnboardingPracticeProgress(storage, "other")]).toEqual([]); + }); + + it("ignores malformed or unknown stored values", () => { + const storage = makeStorage(); + storage.values.set("executor.onboarding.progress.v1.acme", '["build_app", "unknown", 2]'); + + expect([...readOnboardingPracticeProgress(storage, "acme")]).toEqual([]); + }); +}); diff --git a/apps/cloud/src/web/onboarding-progress.ts b/apps/cloud/src/web/onboarding-progress.ts new file mode 100644 index 0000000000..6d8c88f982 --- /dev/null +++ b/apps/cloud/src/web/onboarding-progress.ts @@ -0,0 +1,47 @@ +export const ONBOARDING_PRACTICE_STEPS = [ + "build_app", + "create_workflow", + "create_skill", + "store_notes", +] as const; + +export type OnboardingPracticeStep = (typeof ONBOARDING_PRACTICE_STEPS)[number]; + +type OnboardingStorage = Pick; + +const storageKey = (organizationSlug: string | null): string => + `executor.onboarding.progress.v1.${organizationSlug ?? "local"}`; + +const ProgressSchema = Schema.Array(Schema.Literals(ONBOARDING_PRACTICE_STEPS)); +const decodeProgress = Schema.decodeUnknownOption(Schema.fromJsonString(ProgressSchema)); + +export const readOnboardingPracticeProgress = ( + storage: OnboardingStorage | null | undefined, + organizationSlug: string | null, +): ReadonlySet => { + if (!storage) return new Set(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser storage can throw when disabled + try { + const raw = storage.getItem(storageKey(organizationSlug)); + return raw + ? new Set(Option.getOrElse(decodeProgress(raw), () => [] as OnboardingPracticeStep[])) + : new Set(); + } catch { + return new Set(); + } +}; + +export const writeOnboardingPracticeProgress = ( + storage: OnboardingStorage | null | undefined, + organizationSlug: string | null, + progress: ReadonlySet, +): void => { + if (!storage) return; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser storage can throw when disabled + try { + storage.setItem(storageKey(organizationSlug), JSON.stringify([...progress])); + } catch { + // Storage is optional. The visible state remains useful for this session. + } +}; +import { Option, Schema } from "effect"; diff --git a/apps/cloud/src/web/pages/create-org.tsx b/apps/cloud/src/web/pages/create-org.tsx index 446ec7b586..c241bcd3d4 100644 --- a/apps/cloud/src/web/pages/create-org.tsx +++ b/apps/cloud/src/web/pages/create-org.tsx @@ -79,7 +79,7 @@ export const CreateOrgPage = () => { const form = useCreateOrganizationForm({ defaultName: suggestedName, onSuccess: () => { - void navigate({ to: "/setup-mcp" }); + void navigate({ to: "/setup-app" }); }, }); @@ -102,7 +102,7 @@ export const CreateOrgPage = () => {

- Step 1 of 2 + Step 1 of 3

{count > 0 ? "You've been invited" : "Create your organization"} diff --git a/apps/cloud/src/web/pages/setup-app.tsx b/apps/cloud/src/web/pages/setup-app.tsx new file mode 100644 index 0000000000..42ec0a8e7f --- /dev/null +++ b/apps/cloud/src/web/pages/setup-app.tsx @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import { useAuth } from "../auth"; + +/** Waits for the organization write after creation before entering the scoped app picker. */ +export const SetupAppPage = () => { + const auth = useAuth(); + const navigate = useNavigate(); + const organizationSlug = + auth.status === "authenticated" ? (auth.organization?.slug ?? null) : null; + + useEffect(() => { + if (!organizationSlug) return; + void navigate({ + to: "/{-$orgSlug}/integrations/browse", + params: { orgSlug: organizationSlug }, + search: { onboarding: 1 }, + replace: true, + }); + }, [navigate, organizationSlug]); + + return ( +
+
+

+ Step 2 of 3 +

+

Choose your first app

+

Preparing your workspace...

+
+
+ ); +}; diff --git a/apps/cloud/src/web/pages/setup-mcp.tsx b/apps/cloud/src/web/pages/setup-mcp.tsx index a9e7bd2660..3758a20e0a 100644 --- a/apps/cloud/src/web/pages/setup-mcp.tsx +++ b/apps/cloud/src/web/pages/setup-mcp.tsx @@ -17,6 +17,47 @@ import { import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; import { useAuth } from "../auth"; +import { + readOnboardingPracticeProgress, + writeOnboardingPracticeProgress, + type OnboardingPracticeStep, +} from "../onboarding-progress"; + +const ONBOARDING_PRACTICE = [ + { + key: "build_app", + title: "Build a small app", + description: "Ask your agent to make a useful interface around your first integration.", + prompt: + "Using my connected app, build a small interface that helps me complete one everyday task.", + }, + { + key: "create_workflow", + title: "Create a workflow", + description: "Turn a repeated task into something your agent can run on a schedule or trigger.", + prompt: + "Create a workflow that checks my connected app every five minutes and notifies me when it finds something that needs my attention.", + }, + { + key: "create_skill", + title: "Teach your agent a skill", + description: "Save reusable instructions so future tasks start with the right context.", + prompt: + "Create a reusable skill for working with my connected app. Include the conventions and checks you should follow every time.", + }, + { + key: "store_notes", + title: "Save useful context", + description: "Give your agent durable notes it can use in future work.", + prompt: + "Add notes to emails from my connected app and store the important context so you can use it in future tasks.", + }, +] as const satisfies readonly { + readonly key: OnboardingPracticeStep; + readonly title: string; + readonly description: string; + readonly prompt: string; +}[]; export const SetupMcpPage = () => { const navigate = useNavigate(); @@ -31,12 +72,31 @@ export const SetupMcpPage = () => { navigate({ to: "/{-$orgSlug}", params: { orgSlug: organizationSlug ?? undefined } }); const [origin, setOrigin] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); + const [agentConnected, setAgentConnected] = useState(false); const [elicitationMode, setElicitationMode] = useState("model"); + const [practiceProgress, setPracticeProgress] = useState>( + () => readOnboardingPracticeProgress(globalThis.localStorage, organizationSlug), + ); useEffect(() => { setOrigin(window.location.origin); }, []); + useEffect(() => { + setPracticeProgress(readOnboardingPracticeProgress(globalThis.localStorage, organizationSlug)); + }, [organizationSlug]); + + const completePracticeStep = (step: OnboardingPracticeStep) => { + setPracticeProgress((previous) => { + if (previous.has(step)) return previous; + const next = new Set(previous); + next.add(step); + writeOnboardingPracticeProgress(globalThis.localStorage, organizationSlug, next); + trackEvent("onboarding_practice_prompt_copied", { step }); + return next; + }); + }; + const endpoint = origin ? buildMcpHttpEndpoint({ origin, @@ -60,7 +120,7 @@ export const SetupMcpPage = () => {

- Step 2 of 2 + Step 3 of 3

Connect your MCP client

@@ -150,28 +210,76 @@ export const SetupMcpPage = () => {

Adds the server to a supported agent.

-
- {/* oxlint-disable-next-line react/forbid-elements */} - - + +
+ ) : ( +
- Continue to app - -
+
+

Try it with your agent

+

+ Copy one of these prompts into your agent to see what Executor can do with the app + you just added. +

+
+
+ {ONBOARDING_PRACTICE.map((step) => { + const complete = practiceProgress.has(step.key); + return ( +
+
+

{step.title}

+

+ {step.description} +

+
+
+ + {complete ? "Prompt copied" : "Ready to try"} + + completePracticeStep(step.key)} + /> +
+
+ ); + })} +
+
+ +
+ + )}

); diff --git a/e2e/cloud/auth-routing-flow.test.ts b/e2e/cloud/auth-routing-flow.test.ts index 30efb17cdd..4eeec6363d 100644 --- a/e2e/cloud/auth-routing-flow.test.ts +++ b/e2e/cloud/auth-routing-flow.test.ts @@ -69,8 +69,8 @@ scenario( orgName, ); await page.getByRole("button", { name: "Create organization" }).click(); - await page.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); - await page.getByRole("button", { name: "Continue to app" }).click(); + await page.getByText("Choose your first app").waitFor({ timeout: 30_000 }); + await page.getByRole("button", { name: "Skip to workspace" }).click(); // The bare landing canonicalizes onto the new org's slug. await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 }); await page.getByText("Integrations").first().waitFor({ timeout: 30_000 }); diff --git a/e2e/cloud/onboarding-mcp-url.test.ts b/e2e/cloud/onboarding-mcp-url.test.ts index 84de5e0a70..0c82750006 100644 --- a/e2e/cloud/onboarding-mcp-url.test.ts +++ b/e2e/cloud/onboarding-mcp-url.test.ts @@ -26,10 +26,11 @@ scenario( }, ); - await step("Create an organization to advance to the MCP setup step", async () => { + await step("Create an organization, then open the MCP setup step", async () => { await page.getByPlaceholder("Northwind Labs").fill("Test Org"); await page.getByRole("button", { name: "Create organization" }).click(); - // Successful creation navigates to the 'Connect your MCP client' step. + await page.getByText("Choose your first app").waitFor(); + await visit(page, "/setup-mcp"); await page.getByText("Connect your MCP client").waitFor(); }); diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index c096c2ca5b..926882dabd 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -27,8 +27,8 @@ scenario( await visit(page, "/"); await page.getByPlaceholder("Northwind Labs").fill(ORG); await page.getByRole("button", { name: "Create organization" }).click(); - await page.getByText("Connect your MCP client").waitFor(); - await page.getByRole("button", { name: "Continue to app" }).click(); + await page.getByText("Choose your first app").waitFor(); + await page.getByRole("button", { name: "Skip to workspace" }).click(); await page.getByText("Integrations").first().waitFor(); // The console canonicalizes onto the org's URL slug (/doomed-org). await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { diff --git a/e2e/cloud/org-limit.test.ts b/e2e/cloud/org-limit.test.ts index dcdb570936..4aca45222c 100644 --- a/e2e/cloud/org-limit.test.ts +++ b/e2e/cloud/org-limit.test.ts @@ -32,11 +32,11 @@ scenario( await page.getByPlaceholder("Northwind Labs").fill("Acme 1"); await page.getByRole("button", { name: "Create organization" }).click(); // Onboarding step 2 — proves the first org was created. - await page.getByText("Connect your MCP client").waitFor(); + await page.getByText("Choose your first app").waitFor(); }); - await step("Continue into the app", async () => { - await page.getByRole("button", { name: "Continue to app" }).click(); + await step("Skip app setup and open the app", async () => { + await page.getByRole("button", { name: "Skip to workspace" }).click(); await page.getByText("Integrations").first().waitFor(); // Let the router navigation fully settle (slow on a cold dev server) // before opening menus — a late remount closes them mid-interaction. diff --git a/e2e/cloud/org-multitab-cookie.test.ts b/e2e/cloud/org-multitab-cookie.test.ts index 75125828ba..a5819d35ce 100644 --- a/e2e/cloud/org-multitab-cookie.test.ts +++ b/e2e/cloud/org-multitab-cookie.test.ts @@ -57,8 +57,8 @@ scenario( await visit(tab1, "/"); await tab1.getByPlaceholder("Northwind Labs").fill("Multitab A"); await tab1.getByRole("button", { name: "Create organization" }).click(); - await tab1.getByText("Connect your MCP client").waitFor({ timeout: 30_000 }); - await tab1.getByRole("button", { name: "Continue to app" }).click(); + await tab1.getByText("Choose your first app").waitFor({ timeout: 30_000 }); + await tab1.getByRole("button", { name: "Skip to workspace" }).click(); await tab1.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 }); await tab1.getByText("Integrations").first().waitFor({ timeout: 30_000 }); slugA = slugOf(tab1); diff --git a/e2e/cloud/org-switcher.test.ts b/e2e/cloud/org-switcher.test.ts index 62f77bd4ed..2f2d8326c2 100644 --- a/e2e/cloud/org-switcher.test.ts +++ b/e2e/cloud/org-switcher.test.ts @@ -32,11 +32,11 @@ scenario( await page.getByPlaceholder("Northwind Labs").fill(ORG_1); await page.getByRole("button", { name: "Create organization" }).click(); // Onboarding step 2 — proves the first org was created. - await page.getByText("Connect your MCP client").waitFor(); + await page.getByText("Choose your first app").waitFor(); }); - await step("Continue into the app", async () => { - await page.getByRole("button", { name: "Continue to app" }).click(); + await step("Skip app setup and open the app", async () => { + await page.getByRole("button", { name: "Skip to workspace" }).click(); await page.getByText("Integrations").first().waitFor(); // Let the router navigation fully settle before opening menus — a late // remount closes them mid-interaction. The console canonicalizes onto diff --git a/packages/react/src/api/analytics.test.ts b/packages/react/src/api/analytics.test.ts index c301d0c486..abdbb2a35c 100644 --- a/packages/react/src/api/analytics.test.ts +++ b/packages/react/src/api/analytics.test.ts @@ -16,6 +16,10 @@ describe("analytics seam", () => { expect(() => trackEvent("integration_browse_opened", { via: "header" })).not.toThrow(); }); + it("accepts the onboarding integration entry point", () => { + expect(() => trackEvent("integration_browse_opened", { via: "onboarding" })).not.toThrow(); + }); + it("forwards name and properties to the mounted client", () => { const seen: Array<{ name: AnalyticsEventName; properties: unknown }> = []; setAnalyticsClient((name, properties) => { diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 1161bbb5fc..e23d5639a2 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -34,7 +34,7 @@ export interface AnalyticsEvents { /** The full-page picker was opened. Replaces the connect dialog, whose * `integration_connect_dialog_opened` this supersedes — keep both readable * in dashboards spanning the change. */ - integration_browse_opened: { via: "header" | "empty-state" | "sidebar" }; + integration_browse_opened: { via: "header" | "empty-state" | "sidebar" | "onboarding" }; integration_detect_submitted: { success: boolean; detected_kind?: string; @@ -189,6 +189,9 @@ export interface AnalyticsEvents { org_invitation_accepted: { success: boolean }; setup_mcp_completed: {}; setup_mcp_skipped: {}; + onboarding_practice_prompt_copied: { + step: "build_app" | "create_workflow" | "create_skill" | "store_notes"; + }; // ── Cloud: billing & support ───────────────────────────────────────────── billing_plan_selected: { diff --git a/packages/react/src/pages/integration-add.tsx b/packages/react/src/pages/integration-add.tsx index f6b1f32a72..9c9067c842 100644 --- a/packages/react/src/pages/integration-add.tsx +++ b/packages/react/src/pages/integration-add.tsx @@ -29,10 +29,20 @@ function AddIntegrationContent(props: { authHeader?: string; authNote?: string; authKind?: string; + onboarding?: boolean; specOverrides?: string; }) { - const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } = - props; + const { + pluginKey, + url, + preset, + namespace, + authHeader, + authNote, + authKind, + onboarding, + specOverrides, + } = props; const navigate = useNavigate(); const integrationPlugins = useIntegrationPlugins(); const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); @@ -82,6 +92,13 @@ function AddIntegrationContent(props: { ...(slug ? { integration_slug: slug } : {}), }); refreshIntegrations(); + if (onboarding) { + void navigate({ + to: "/{-$orgSlug}/integrations/browse", + search: { onboarding: 1 }, + }); + return; + } void navigate( slug ? { @@ -94,7 +111,11 @@ function AddIntegrationContent(props: { }} onCancel={() => { trackEvent("integration_add_cancelled", { plugin_key: pluginKey }); - void navigate({ to: "/{-$orgSlug}" }); + void navigate( + onboarding + ? { to: "/{-$orgSlug}/integrations/browse", search: { onboarding: 1 } } + : { to: "/{-$orgSlug}" }, + ); }} /> diff --git a/packages/react/src/pages/integration-browse.tsx b/packages/react/src/pages/integration-browse.tsx index c63789d3ef..ee3c784582 100644 --- a/packages/react/src/pages/integration-browse.tsx +++ b/packages/react/src/pages/integration-browse.tsx @@ -365,8 +365,8 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { // --------------------------------------------------------------------------- /** Let members browse integrations while reserving creation for workspace admins. */ -export function IntegrationBrowsePage() { - useExecutorDocumentTitle("Add an integration"); +export function IntegrationBrowsePage({ onboarding = false }: { readonly onboarding?: boolean }) { + useExecutorDocumentTitle(onboarding ? "Choose your first app" : "Add an integration"); const canCreate = useCanCreateWorkspaceConnections(); const navigate = useNavigate(); const integrationPlugins = useIntegrationPlugins(); @@ -389,6 +389,7 @@ export function IntegrationBrowsePage() { // Slugs of integrations added from this page, keyed the same way, so the // card can flip to View without waiting for the catalog refetch. const [quickAddedSlugs, setQuickAddedSlugs] = useState>(new Map()); + const reportedOnboardingView = useRef(false); const quickAdders = useRef(new Map()); const registerQuickAdd = useCallback((key: string, fn: QuickAddFn | null) => { if (fn) quickAdders.current.set(key, fn); @@ -454,6 +455,25 @@ export function IntegrationBrowsePage() { ); }, [installed]); + const hasFirstIntegration = + quickAddedSlugs.size > 0 || + (AsyncResult.isSuccess(installed) && + installed.value.some((integration) => integration.canRemove)); + + useEffect(() => { + if (!onboarding || reportedOnboardingView.current) return; + reportedOnboardingView.current = true; + trackEvent("integration_browse_opened", { via: "onboarding" }); + }, [onboarding]); + + const continueOnboarding = () => { + window.location.assign("/setup-mcp"); + }; + + const skipOnboarding = () => { + void navigate({ to: "/{-$orgSlug}" }); + }; + const isAdded = useCallback( (kind: string, ...candidates: readonly (string | undefined)[]): boolean => candidates.some((candidate) => { @@ -527,9 +547,9 @@ export function IntegrationBrowsePage() { void navigate({ to: "/{-$orgSlug}/integrations/add/$pluginKey", params: { pluginKey }, - search: { url: trimmed, namespace: detected.slug }, + search: { url: trimmed, namespace: detected.slug, ...(onboarding ? { onboarding: 1 } : {}) }, }); - }, [query, doDetect, navigate, integrationPlugins]); + }, [query, doDetect, navigate, integrationPlugins, onboarding]); const goToAdd = useCallback( (input: { @@ -554,11 +574,12 @@ export function IntegrationBrowsePage() { ...(input.auth?.header ? { authHeader: input.auth.header } : {}), ...(input.auth?.note ? { authNote: input.auth.note } : {}), ...(input.auth?.kind ? { authKind: input.auth.kind } : {}), + ...(onboarding ? { onboarding: 1 } : {}), ...(input.specOverrides ? { specOverrides: JSON.stringify(input.specOverrides) } : {}), }, }); }, - [navigate], + [navigate, onboarding], ); /** One-click add, in place. The registry knew the URL and the auth @@ -693,10 +714,10 @@ export function IntegrationBrowsePage() { void navigate({ to: "/{-$orgSlug}/integrations/add/$pluginKey", params: { pluginKey: entry.pluginKey }, - search: { preset: entry.preset.id }, + search: { preset: entry.preset.id, ...(onboarding ? { onboarding: 1 } : {}) }, }); }, - [navigate], + [navigate, onboarding], ); // --- Preset rows --------------------------------------------------------- @@ -923,10 +944,35 @@ export function IntegrationBrowsePage() { ))}
+ {onboarding ? ( +
+
+

Step 2 of 3

+

+ {hasFirstIntegration + ? "Your first app is ready. Continue to connect your agent." + : "Add an app to continue with the guided setup."} +

+
+
+ + +
+
+ ) : null} + {!canCreate && (

Requires a workspace admin to add integrations. diff --git a/packages/react/src/routes/integrations.add.$pluginKey.tsx b/packages/react/src/routes/integrations.add.$pluginKey.tsx index aade1e0736..a6156b2144 100644 --- a/packages/react/src/routes/integrations.add.$pluginKey.tsx +++ b/packages/react/src/routes/integrations.add.$pluginKey.tsx @@ -14,6 +14,7 @@ const SearchParams = Schema.toStandardSchemaV1( authHeader: Schema.optional(Schema.String), authNote: Schema.optional(Schema.String), authKind: Schema.optional(Schema.String), + onboarding: Schema.optional(Schema.Unknown), // JSON-encoded RFC 6902 patch the registry says to apply to the spec. specOverrides: Schema.optional(Schema.String), }), @@ -23,7 +24,7 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") validateSearch: SearchParams, component: () => { const { pluginKey } = Route.useParams(); - const { url, preset, namespace, authHeader, authNote, authKind, specOverrides } = + const { url, preset, namespace, authHeader, authNote, authKind, onboarding, specOverrides } = Route.useSearch(); return ( ); diff --git a/packages/react/src/routes/integrations.browse.tsx b/packages/react/src/routes/integrations.browse.tsx index 416a07d14d..54ea1cc611 100644 --- a/packages/react/src/routes/integrations.browse.tsx +++ b/packages/react/src/routes/integrations.browse.tsx @@ -1,7 +1,19 @@ +import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { IntegrationBrowsePage } from "../pages/integration-browse"; +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ onboarding: Schema.optional(Schema.Unknown) }), +); + +const isOnboardingRequested = (value: unknown): boolean => + value === 1 || value === "1" || value === true; + export const Route = createFileRoute("/{-$orgSlug}/integrations/browse")({ - component: IntegrationBrowsePage, + validateSearch: SearchParams, + component: () => { + const { onboarding } = Route.useSearch(); + return ; + }, });