From 620b4c442cc08687eb7633da4a101f801d20e609 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:01:56 -0700 Subject: [PATCH 01/59] Add standalone reproduction of the current add-integration flow --- bun.lock | 25 ++ package.json | 1 + packages/onboarding-demo/README.md | 39 +++ packages/onboarding-demo/index.html | 18 + packages/onboarding-demo/package.json | 31 ++ packages/onboarding-demo/src/app.tsx | 223 ++++++++++++ packages/onboarding-demo/src/fixtures.ts | 240 +++++++++++++ packages/onboarding-demo/src/flow.ts | 111 ++++++ packages/onboarding-demo/src/main.tsx | 12 + .../src/screens/add-account-modal.tsx | 318 ++++++++++++++++++ .../src/screens/add-openapi-page.tsx | 261 ++++++++++++++ .../src/screens/connect-dialog.tsx | 279 +++++++++++++++ .../src/screens/integration-detail.tsx | 105 ++++++ .../src/screens/integrations-page.tsx | 142 ++++++++ packages/onboarding-demo/src/shell.tsx | 97 ++++++ packages/onboarding-demo/src/step-state.ts | 45 +++ packages/onboarding-demo/src/styles.css | 5 + packages/onboarding-demo/tsconfig.json | 15 + packages/onboarding-demo/vite.config.ts | 12 + 19 files changed, 1979 insertions(+) create mode 100644 packages/onboarding-demo/README.md create mode 100644 packages/onboarding-demo/index.html create mode 100644 packages/onboarding-demo/package.json create mode 100644 packages/onboarding-demo/src/app.tsx create mode 100644 packages/onboarding-demo/src/fixtures.ts create mode 100644 packages/onboarding-demo/src/flow.ts create mode 100644 packages/onboarding-demo/src/main.tsx create mode 100644 packages/onboarding-demo/src/screens/add-account-modal.tsx create mode 100644 packages/onboarding-demo/src/screens/add-openapi-page.tsx create mode 100644 packages/onboarding-demo/src/screens/connect-dialog.tsx create mode 100644 packages/onboarding-demo/src/screens/integration-detail.tsx create mode 100644 packages/onboarding-demo/src/screens/integrations-page.tsx create mode 100644 packages/onboarding-demo/src/shell.tsx create mode 100644 packages/onboarding-demo/src/step-state.ts create mode 100644 packages/onboarding-demo/src/styles.css create mode 100644 packages/onboarding-demo/tsconfig.json create mode 100644 packages/onboarding-demo/vite.config.ts diff --git a/bun.lock b/bun.lock index 4aebc2673..f5df3fd0e 100644 --- a/bun.lock +++ b/bun.lock @@ -860,6 +860,29 @@ "vitest": "catalog:", }, }, + "packages/onboarding-demo": { + "name": "@executor-js/onboarding-demo", + "version": "0.0.0", + "dependencies": { + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/sdk": "workspace:*", + "lucide-react": "^1.7.0", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7", + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + }, + }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", "version": "1.6.0", @@ -1804,6 +1827,8 @@ "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], + "@executor-js/onboarding-demo": ["@executor-js/onboarding-demo@workspace:packages/onboarding-demo"], + "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], diff --git a/package.json b/package.json index 007a4bb01..c284f4562 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "packages/*/*", "packages/react", "packages/app", + "packages/onboarding-demo", "apps/*", "examples/*", "e2e" diff --git a/packages/onboarding-demo/README.md b/packages/onboarding-demo/README.md new file mode 100644 index 000000000..dd4a51344 --- /dev/null +++ b/packages/onboarding-demo/README.md @@ -0,0 +1,39 @@ +# Onboarding demo + +A standalone reproduction of executor's **current** add-integration flow, from +"the CLI is connected" to "the integration has a working connection". No API, no +auth, no tenant — it runs off `src/fixtures.ts` and the public integrations.sh +endpoints. + +``` +bun run --cwd packages/onboarding-demo dev # http://localhost:5199 +``` + +Every screen is addressable: `#integrations-empty`, `#connect-dialog`, +`#add-openapi`, `#detail-accounts`, `#add-account-credential`, +`#add-account-place`, `#oauth-stuck`, `#integrations-populated`. `[` and `]` +step through them. The right-hand panel names the route and source component +each screen was reproduced from, and quotes the first-run reactions recorded +against it. + +## Fidelity + +- The curated preset list is imported from the real plugin modules + (`@executor-js/plugin-openapi/presets`, `@executor-js/plugin-mcp/presets`), + so it is the list the console shows, in the console's order. +- UI primitives and design tokens are imported from `@executor-js/react`, not + copied — the reproduction uses the same buttons, dialogs, card stacks, tabs + and tokens as the console. +- The catalog search calls the real `integrations.sh/api/search` with the same + 250ms debounce and 2-character minimum. +- Screen structure and copy are transcribed from the source components named in + the inspector. + +What is faked: the workspace's own data (integrations, connections, tools) and +every mutation. Adding an integration waits and moves on; nothing persists. + +`fixtures.ts` also exposes two integrations.sh endpoints the console does **not** +use today — `fetchCatalogDomains` (the whole ~3.4k-domain popularity-sorted +registry) and `fetchSurfaceCredentials` (per-domain credential label, the URL +that mints the key, and setup instructions). They are unused by the +reproduction and present as raw material for the rework. diff --git a/packages/onboarding-demo/index.html b/packages/onboarding-demo/index.html new file mode 100644 index 000000000..a9c61987a --- /dev/null +++ b/packages/onboarding-demo/index.html @@ -0,0 +1,18 @@ + + + + + + Executor onboarding — current flow + + + + + +
+ + + diff --git a/packages/onboarding-demo/package.json b/packages/onboarding-demo/package.json new file mode 100644 index 000000000..1b1095c92 --- /dev/null +++ b/packages/onboarding-demo/package.json @@ -0,0 +1,31 @@ +{ + "name": "@executor-js/onboarding-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/react": "workspace:*", + "@executor-js/sdk": "workspace:*", + "lucide-react": "^1.7.0", + "react": "catalog:", + "react-dom": "catalog:", + "sonner": "^2.0.7" + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/onboarding-demo/src/app.tsx b/packages/onboarding-demo/src/app.tsx new file mode 100644 index 000000000..3b1d2ea31 --- /dev/null +++ b/packages/onboarding-demo/src/app.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { PanelRightCloseIcon, PanelRightOpenIcon } from "lucide-react"; +import { cn } from "@executor-js/react/lib/utils"; +import { Shell } from "./shell"; +import { IntegrationsPage } from "./screens/integrations-page"; +import { ConnectDialog } from "./screens/connect-dialog"; +import { AddOpenApiPage } from "./screens/add-openapi-page"; +import { IntegrationDetailPage } from "./screens/integration-detail"; +import { AddAccountModal } from "./screens/add-account-modal"; +import { flowSteps, type FlowStep, type StepId } from "./flow"; +import { stepState, type AccountModal, type ScreenView } from "./step-state"; +import { + gmailAuthMethods, + gmailIntegration, + posthogAuthMethods, + posthogIntegration, + seededIntegrations, + type DemoIntegration, + type DemoPreset, +} from "./fixtures"; + +const posthogPreset: DemoPreset = { + id: "posthog", + name: "PostHog", + summary: "Product analytics, events, feature flags, and insights.", + icon: "https://integrations.sh/logo/posthog.com", + url: "https://raw.githubusercontent.com/PostHog/posthog/master/openapi/openapi.json", + pluginKey: "openapi", + pluginLabel: "OpenAPI", +}; + +function Inspector(props: { + readonly step: FlowStep; + readonly onSelect: (id: StepId) => void; + readonly onClose: () => void; +}) { + return ( + + ); +} + +const stepFromHash = (): StepId => { + const hash = globalThis.location?.hash.replace(/^#/, "") ?? ""; + return flowSteps.some((step) => step.id === hash) ? (hash as StepId) : "integrations-empty"; +}; + +export function App() { + // Each screen is addressable as `#`, so a specific screen can be + // linked to directly rather than clicked toward. + const [stepId, setStepId] = useState(stepFromHash); + const [inspectorOpen, setInspectorOpen] = useState(true); + const [overrides, setOverrides] = useState<{ + readonly integrations?: readonly DemoIntegration[]; + readonly connectOpen?: boolean; + readonly accountModal?: AccountModal; + readonly view?: ScreenView; + }>({}); + + const base = useMemo(() => stepState[stepId], [stepId]); + const state = { ...base, ...overrides }; + const step = flowSteps.find((candidate) => candidate.id === stepId) ?? flowSteps[0]!; + + const goToStep = useCallback((id: StepId) => { + setStepId(id); + setOverrides({}); + if (globalThis.location) globalThis.location.hash = id; + }, []); + + useEffect(() => { + const onHashChange = () => { + setStepId(stepFromHash()); + setOverrides({}); + }; + globalThis.addEventListener("hashchange", onHashChange); + return () => globalThis.removeEventListener("hashchange", onHashChange); + }, []); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "[" && event.key !== "]") return; + const index = flowSteps.findIndex((candidate) => candidate.id === stepId); + const next = event.key === "]" ? index + 1 : index - 1; + const target = flowSteps[next]; + if (target) goToStep(target.id); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [stepId, goToStep]); + + const integrations = state.integrations ?? seededIntegrations; + + return ( +
+
+ + {state.view === "integrations" && ( + setOverrides((prev) => ({ ...prev, connectOpen: true }))} + onOpenIntegration={() => setOverrides((prev) => ({ ...prev, view: "detail" }))} + /> + )} + + {state.view === "add" && ( + goToStep("integrations-empty")} + onComplete={() => goToStep("detail-accounts")} + /> + )} + + {state.view === "detail" && ( + + setOverrides((prev) => ({ ...prev, accountModal: "credential" })) + } + /> + )} + +
+ + {inspectorOpen ? ( + setInspectorOpen(false)} /> + ) : ( + // oxlint-disable-next-line react/forbid-elements + + )} + + setOverrides((prev) => ({ ...prev, connectOpen: open }))} + onPickPreset={() => goToStep("add-openapi")} + onPickPlugin={() => goToStep("add-openapi")} + onPickCatalogEntry={() => goToStep("add-openapi")} + /> + + {state.accountModal !== "none" && ( + + setOverrides((prev) => ({ ...prev, accountModal: open ? prev.accountModal : "none" })) + } + integrationName={state.accountModal === "oauth-stuck" ? "Gmail" : "PostHog"} + methods={state.accountModal === "oauth-stuck" ? gmailAuthMethods : posthogAuthMethods} + initialStep={state.accountModal === "place" ? "place" : "validate"} + stuckConnecting={state.accountModal === "oauth-stuck"} + onAdded={() => goToStep("integrations-populated")} + /> + )} +
+ ); +} diff --git a/packages/onboarding-demo/src/fixtures.ts b/packages/onboarding-demo/src/fixtures.ts new file mode 100644 index 000000000..2f3875696 --- /dev/null +++ b/packages/onboarding-demo/src/fixtures.ts @@ -0,0 +1,240 @@ +// --------------------------------------------------------------------------- +// Everything the reproduction renders from. +// +// Presets come from the REAL plugin modules, so the curated list here is the +// same list the console shows. Connections, tools and auth methods are static +// stand-ins for the API reads the console makes. The integrations.sh calls are +// the genuine public endpoints — that catalog is already client-callable, which +// is exactly why it is the obvious raw material for the rework. +// --------------------------------------------------------------------------- + +import { openApiPresets, type OpenApiPreset } from "@executor-js/plugin-openapi/presets"; +import { mcpPresets, type McpPreset } from "@executor-js/plugin-mcp/presets"; + +export type PluginKey = "openapi" | "mcp" | "graphql" | "google"; + +export interface DemoPreset { + readonly id: string; + readonly name: string; + readonly summary: string; + readonly icon?: string; + readonly url?: string; + readonly pluginKey: PluginKey; + readonly pluginLabel: string; +} + +const fromOpenApi = (preset: OpenApiPreset): DemoPreset => ({ + id: preset.id, + name: preset.name, + summary: preset.summary, + ...(preset.icon ? { icon: preset.icon } : {}), + ...(preset.url ? { url: preset.url } : {}), + pluginKey: "openapi", + pluginLabel: "OpenAPI", +}); + +const fromMcp = (preset: McpPreset): DemoPreset => ({ + id: preset.id, + name: preset.name, + summary: preset.summary, + ...(preset.icon ? { icon: preset.icon } : {}), + ...("url" in preset && preset.url ? { url: preset.url } : {}), + pluginKey: "mcp", + pluginLabel: "MCP", +}); + +/** The console's "Popular integrations" list, in the console's own order: + * every loaded plugin's presets, concatenated plugin by plugin. */ +export const curatedPresets: readonly DemoPreset[] = [ + ...openApiPresets.map(fromOpenApi), + ...mcpPresets.map(fromMcp), +]; + +/** The plugin chips under "Or add manually". */ +export const integrationPlugins: readonly { readonly key: PluginKey; readonly label: string }[] = [ + { key: "openapi", label: "OpenAPI" }, + { key: "mcp", label: "MCP" }, + { key: "graphql", label: "GraphQL" }, + { key: "google", label: "Google" }, +]; + +// --------------------------------------------------------------------------- +// Workspace state — what a brand-new tenant has, which is nothing +// --------------------------------------------------------------------------- + +export interface DemoIntegration { + readonly slug: string; + readonly name: string; + readonly kind: PluginKey; + readonly icon?: string; + readonly toolCount: number; +} + +export const seededIntegrations: readonly DemoIntegration[] = []; + +/** What the workspace looks like after the user adds PostHog — the state Theo + * reached before hunting for the key field. */ +export const posthogIntegration: DemoIntegration = { + slug: "posthog", + name: "PostHog", + kind: "openapi", + icon: "https://integrations.sh/logo/posthog.com", + toolCount: 0, +}; + +/** The OAuth-only integration, for the step where the popup dies. */ +export const gmailIntegration: DemoIntegration = { + slug: "gmail", + name: "Gmail", + kind: "google", + icon: "https://integrations.sh/logo/google.com", + toolCount: 0, +}; + +// --------------------------------------------------------------------------- +// Auth methods for the add-connection modal +// +// Shape mirrors `AuthMethod` in packages/react/src/lib/auth-placements.tsx. +// The labels are the ones a spec-derived integration actually produces: an +// OpenAPI spec whose security schemes carry no human name yields ordinal +// method labels, which is the "Method 1" tab. +// --------------------------------------------------------------------------- + +export interface DemoPlacement { + readonly carrier: "header" | "query" | "env"; + readonly name: string; + readonly prefix: string; +} + +export interface DemoAuthMethod { + readonly id: string; + readonly label: string; + readonly kind: "apikey" | "oauth" | "none"; + readonly placements: readonly DemoPlacement[]; +} + +export const posthogAuthMethods: readonly DemoAuthMethod[] = [ + { + id: "method-1", + label: "Method 1", + kind: "apikey", + placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }], + }, + { + id: "method-2", + label: "Method 2", + kind: "apikey", + placements: [{ carrier: "query", name: "personal_api_key", prefix: "" }], + }, + { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, +]; + +export const gmailAuthMethods: readonly DemoAuthMethod[] = [ + { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] }, +]; + +// --------------------------------------------------------------------------- +// integrations.sh — the live public catalog +// --------------------------------------------------------------------------- + +export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh"; + +export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const; +export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; + +export interface CatalogEntry { + readonly domain: string; + readonly description: string; + readonly kinds: readonly CatalogKind[]; +} + +export const catalogLogoUrl = (domain: string, size: number): string => + `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`; + +const isConnectableKind = (kind: string): kind is CatalogKind => + (CONNECTABLE_KINDS as readonly string[]).includes(kind); + +/** `/api/search` — the same endpoint the console's connect dialog calls, and + * the same 250ms-debounced, ≥2-character contract. */ +export const searchCatalog = async ( + query: string, + limit = 10, +): Promise => { + const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN); + url.searchParams.set("q", query); + url.searchParams.set("limit", String(limit)); + const response = await fetch(url); + if (!response.ok) return []; + const payload = (await response.json()) as { + readonly results?: readonly { + readonly domain: string; + readonly description: string; + readonly kinds: readonly string[]; + }[]; + }; + return (payload.results ?? []) + .map((entry) => ({ + domain: entry.domain, + description: entry.description, + kinds: entry.kinds.filter(isConnectableKind), + })) + .filter((entry) => entry.kinds.length > 0); +}; + +export interface CatalogDomain { + readonly domain: string; + readonly icon: string; + readonly description: string; + readonly formats: Readonly>; + readonly popularity: number; +} + +/** `/api/domains.json` — the whole popularity-sorted registry (~3.4k domains). + * The console never calls this; it is here because it is the list the product + * is missing, and the rework will want it. */ +export const fetchCatalogDomains = async (): Promise => { + const response = await fetch(`${INTEGRATIONS_SH_ORIGIN}/api/domains.json`); + if (!response.ok) return []; + const payload = (await response.json()) as { readonly data?: readonly CatalogDomain[] }; + return payload.data ?? []; +}; + +export interface SurfaceCredential { + readonly id: string; + readonly type: string; + readonly label: string; + readonly generateUrl?: string; + readonly setup?: string; +} + +/** `/api//surface` — the per-domain document. The console reads only + * `surfaces[].url` out of this; the `credentials` block (which key, where to + * mint it, how) is fetched and thrown away. */ +export const fetchSurfaceCredentials = async ( + domain: string, +): Promise => { + const response = await fetch( + `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(domain)}/surface`, + ); + if (!response.ok) return []; + const payload = (await response.json()) as { + readonly credentials?: Readonly< + Record< + string, + { + readonly type?: string; + readonly label?: string; + readonly generateUrl?: string; + readonly setup?: string; + } + > + >; + }; + return Object.entries(payload.credentials ?? {}).map(([id, value]) => ({ + id, + type: value.type ?? "unknown", + label: value.label ?? id, + ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}), + ...(value.setup ? { setup: value.setup } : {}), + })); +}; diff --git a/packages/onboarding-demo/src/flow.ts b/packages/onboarding-demo/src/flow.ts new file mode 100644 index 000000000..2f3b13898 --- /dev/null +++ b/packages/onboarding-demo/src/flow.ts @@ -0,0 +1,111 @@ +// --------------------------------------------------------------------------- +// The flow, as one ordered list of screens. +// +// Each step carries the first-run reaction recorded against that exact screen, +// so a redesign can be checked against the complaint it is meant to answer +// rather than against a general sense that the old screen was bad. +// --------------------------------------------------------------------------- + +export type StepId = + | "integrations-empty" + | "connect-dialog" + | "add-openapi" + | "detail-accounts" + | "add-account-credential" + | "add-account-place" + | "oauth-stuck" + | "integrations-populated"; + +export interface FlowStep { + readonly id: StepId; + readonly label: string; + /** Where this screen lives in the real console. */ + readonly route: string; + /** The component this reproduction is taken from. */ + readonly source: string; + /** Verbatim first-run reactions to this screen. */ + readonly reactions: readonly string[]; +} + +export const flowSteps: readonly FlowStep[] = [ + { + id: "integrations-empty", + label: "Integrations (empty)", + route: "/", + source: "packages/react/src/pages/integrations.tsx", + reactions: [ + "Adding to clis was easy. Not clear what to do next.", + "Next is this, not a great screen to find integrations from. No text search? I just wanted to add email", + ], + }, + { + id: "connect-dialog", + label: "Connect dialog", + route: "/ (dialog)", + source: "packages/react/src/pages/integrations.tsx › ConnectDialog", + reactions: [ + "I'd love a proper like picker grid and search", + "I want to add multiple to set up for experimentation. I clicked the first one I want (posthog) and it brought me here", + "Annoying because now I have to sit and wait for it to do whatever and then add, and I could be spending this time finding other integrations I want", + ], + }, + { + id: "add-openapi", + label: "Add integration form", + route: "/integrations/add/openapi", + source: "packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx", + reactions: [ + "This view is a bit of a mess. There's no clear hierarchy on what exactly I'm supposed to be doing, what matters and doesn't, and what button I should hit. There's the big add integration at the bottom and the weird treatment around it. Just very unclear what the step is", + "Hit “add” and now another loading screen, well spinning wheel button", + ], + }, + { + id: "detail-accounts", + label: "Integration detail · Accounts", + route: "/integrations/posthog?tab=accounts", + source: "packages/react/src/pages/integration-detail.tsx", + reactions: [ + "wtf is this?? I just added it why do I have to add it again??", + "The hierarchy of the integrations and connections is not something I should have to understand or care about as I initially setup", + "I increasingly hate this view. The more that I look at it, there are just so many things demanding attention and none of them are the thing I actually want to do", + ], + }, + { + id: "add-account-credential", + label: "Add connection · credential", + route: "/integrations/posthog?tab=accounts&addAccount=1", + source: "packages/react/src/components/add-account-modal.tsx", + reactions: [ + "Where the fk do I paste the key?? The Method 1 thing is locked", + "I'm not scared of doing anything because I already copied the API key and it's on my clipboard but I can't paste it yet. I just have to not use my computer because I don't have clipboard history management and never will", + "I'm even scared to send a screenshot because I'm going to lose the key. I'm just going to have to recreate it. I think I'll throw it in a note.", + "Okay so what type of key do I need? Obviously doing this for every integration will be annoying but I'm sure you can get some agent to go do it.", + ], + }, + { + id: "add-account-place", + label: "Add connection · placement + owner", + route: "/integrations/posthog?tab=accounts&addAccount=1", + source: "packages/react/src/components/add-account-modal.tsx", + reactions: ["How many times does the word “personal” appear on this page? lol"], + }, + { + id: "oauth-stuck", + label: "OAuth · stuck connecting", + route: "/integrations/gmail?tab=accounts&addAccount=1", + source: "packages/react/src/plugins/oauth-sign-in.tsx", + reactions: [ + "Closed the broken auth page and now app is stuck in “connecting...”", + "On business Gmail, allowed me to click an “Advanced options” (“I trust the developer”) thing, but normal personal accounts, no", + ], + }, + { + id: "integrations-populated", + label: "Integrations (populated)", + route: "/", + source: "packages/react/src/pages/integrations.tsx", + reactions: ["This is filling me with rage for some reason"], + }, +]; + +export const stepIndex = (id: StepId): number => flowSteps.findIndex((step) => step.id === id); diff --git a/packages/onboarding-demo/src/main.tsx b/packages/onboarding-demo/src/main.tsx new file mode 100644 index 000000000..20a192afc --- /dev/null +++ b/packages/onboarding-demo/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./app"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (root) + createRoot(root).render( + + + , + ); diff --git a/packages/onboarding-demo/src/screens/add-account-modal.tsx b/packages/onboarding-demo/src/screens/add-account-modal.tsx new file mode 100644 index 000000000..c4194122a --- /dev/null +++ b/packages/onboarding-demo/src/screens/add-account-modal.tsx @@ -0,0 +1,318 @@ +// Reproduction of packages/react/src/components/add-account-modal.tsx. +// +// The credential path is a 2-step wizard behind a horizontally scrolling strip +// of auth-method tabs. Step 1 is the key plus an optional read-only probe; +// step 2 is the display name and the "Connection saved to" owner picker. The +// OAuth path is not a wizard and shows an app picker instead. +// +// The word "Personal" is reproduced at every site it occurs on the real screen: +// the derived connection name, the callable-name hint, the owner picker's label +// and its selected option. + +import { useState } from "react"; +import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; +import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; +import { cn } from "@executor-js/react/lib/utils"; +import type { DemoAuthMethod } from "../fixtures"; + +function StepHeader(props: { readonly label: string; readonly hint?: string }) { + return ( +
+ + {props.hint ? {props.hint} : null} +
+ ); +} + +function PlacementLine(props: { readonly placement: DemoAuthMethod["placements"][number] }) { + const { placement } = props; + const lead = + placement.carrier === "header" + ? `${placement.name || "Authorization"}: ` + : placement.carrier === "env" + ? `${placement.name || "TOKEN"}=` + : `?${placement.name || "api_key"}=`; + return ( + + {lead} + {placement.prefix ? ( + {placement.prefix} + ) : null} + •••••• + + ); +} + +export function AddAccountModal(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly integrationName: string; + readonly methods: readonly DemoAuthMethod[]; + /** Start on the wizard's second step, which is the screen the owner picker + * and its repeated "Personal" live on. */ + readonly initialStep?: "validate" | "place"; + /** Reproduce the post-OAuth stuck state: the popup was closed, and the + * button never leaves "Connecting…". */ + readonly stuckConnecting?: boolean; + readonly onAdded: () => void; +}) { + const [methodId, setMethodId] = useState(props.methods[0]?.id ?? ""); + const [wizardStep, setWizardStep] = useState<"validate" | "place">( + props.initialStep ?? "validate", + ); + const [secret, setSecret] = useState(""); + const [revealed, setRevealed] = useState(false); + const [label, setLabel] = useState(""); + const [owner, setOwner] = useState<"user" | "org">("user"); + + const method = props.methods.find((candidate) => candidate.id === methodId) ?? props.methods[0]; + const isOAuth = method?.kind === "oauth"; + const wizardActive = !isOAuth; + const showValidateStep = !wizardActive || wizardStep === "validate"; + const showPlaceStep = !wizardActive || wizardStep === "place"; + + const ownerWord = owner === "user" ? "Personal" : "Workspace"; + const derivedName = `${ownerWord} ${props.integrationName}`; + const callableName = (label.trim() || derivedName).toLowerCase().replace(/[^a-z0-9]+/g, "_"); + + const singlePlacement = method?.placements[0]; + const affix = + singlePlacement && singlePlacement.carrier === "header" + ? `${singlePlacement.name}: ${singlePlacement.prefix}` + : null; + + return ( + + + + + Add connection · {props.integrationName} + {wizardActive ? ( + + Step {wizardStep === "validate" ? 1 : 2} of 2 + + ) : null} + + + A connection is a saved way to use this integration, owned by you or the workspace. + + + +
+ {showValidateStep && ( + { + setMethodId(next); + setWizardStep("validate"); + }} + className="w-full min-w-0 max-w-full gap-0" + > + +
+ {props.methods.map((candidate) => ( +
+ + {candidate.label} + +
+ ))} + +
+
+ + + {method && !isOAuth && method.placements.length > 0 && !affix ? ( +
+ {method.placements.map((placement, index) => ( + + ))} +
+ ) : null} + +
+ + + {isOAuth ? ( +
+

No app for {props.integrationName} yet

+

+ None of your registered apps target this integration's OAuth endpoint. + Register one to connect. +

+
+ +
+
+ ) : affix ? ( +
+ + + {affix} + + + {/* oxlint-disable-next-line react/forbid-elements */} + setSecret(event.target.value)} + className="min-w-0 flex-1 bg-transparent px-3 outline-none" + /> + +
+ ) : ( +
+ setSecret((event.target as HTMLInputElement).value)} + className="font-mono" + /> + +
+ )} +
+ + {!isOAuth && ( +
+ +
+ + + Pick an operation… + users_me + + organizations_list + + projects_list + + +
+
+ )} +
+
+ )} + + {showPlaceStep && ( + <> +
+ + setLabel((event.target as HTMLInputElement).value)} + /> +

+ This connection will be callable as{" "} + {callableName}. +

+
+ +
+ + setOwner(event.target.value === "org" ? "org" : "user")} + > + Personal + Workspace + +
+ + )} +
+ + + + {isOAuth ? ( + + ) : wizardStep === "validate" ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/add-openapi-page.tsx b/packages/onboarding-demo/src/screens/add-openapi-page.tsx new file mode 100644 index 000000000..9c52e5bdd --- /dev/null +++ b/packages/onboarding-demo/src/screens/add-openapi-page.tsx @@ -0,0 +1,261 @@ +// Reproduction of packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +// in its post-analyze state, plus the AuthMethodListEditor block from +// packages/react/src/components/auth-method-list-editor.tsx. +// +// The locked "Method 1" panel is reproduced exactly: LockIcon, muted fill, +// `cursor-not-allowed select-none`, and a masked `Authorization: Bearer ••••••` +// placement line. It is a read-only echo of the spec's declared auth scheme — +// it takes no input — but it is the only credential-shaped thing on the page, +// and it appears at the moment the user has just copied an API key. + +import { useState } from "react"; +import { LockIcon, XIcon, ChevronDownIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; +import type { DemoPreset } from "../fixtures"; + +function PlacementLine(props: { + readonly carrier: "header" | "query"; + readonly name: string; + readonly prefix: string; +}) { + const lead = props.carrier === "header" ? `${props.name}: ` : `?${props.name}=`; + return ( + + {lead} + {props.prefix ? {props.prefix} : null} + •••••• + + ); +} + +function FieldLabel(props: { readonly children: React.ReactNode }) { + return ( + + ); +} + +export function AddOpenApiPage(props: { + readonly preset: DemoPreset | null; + readonly onCancel: () => void; + readonly onComplete: () => void; +}) { + const [adding, setAdding] = useState(false); + const [overridesOpen, setOverridesOpen] = useState(false); + const [healthCheckOpen, setHealthCheckOpen] = useState(false); + const name = props.preset?.name ?? "API"; + const slug = props.preset?.id ?? "api"; + + return ( +
+
+
+

Add OpenAPI integration

+
+ + {/* Details — OpenApiIntegrationDetailsFields */} + + +
+ {props.preset?.icon ? ( + + ) : ( + + )} +
+

{name}

+

1.0.0 · 214 operations · 31 tags

+
+
+
+
+ Integration ID + +
+
+ Display name + +
+
+ Description + +
+
+ Base URL override (optional) + +

+ Overrides the spec's servers; leave empty to choose the server (and + variables) per tool call. +

+
+
+ OpenAPI spec + +
+
+
+
+ + {/* SpecOverridesEditor — collapsed */} +
+ {/* oxlint-disable-next-line react/forbid-elements */} + + {overridesOpen && ( +
+ [] +
+ )} +
+ + {/* AuthMethodListEditor */} +
+
+

+ Authentication +

+ +
+ +
+
+
+ + + Method 1 + + +
+
+

+ API key +

+
+
+ +
+
+

+ Pulled from spec. Remove to override. +

+
+
+ +
+
+ + + Method 2 + + +
+
+

+ OAuth +

+
+
+ Authorize + + https://us.posthog.com/oauth/authorize + +
+
+ Token + + https://us.posthog.com/oauth/token + +
+
+

+ Pulled from spec. Remove to override. +

+
+
+
+ +

+ Every method here is registered with the integration. Connect an account from the + integration page after adding. +

+
+ + {/* AddOpenApiHealthCheckSection */} +
+ {/* oxlint-disable-next-line react/forbid-elements */} + + {healthCheckOpen && ( +
+ Operation + +
+ )} +
+ + {/* FloatActions */} + +
+ + +
+
+
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/connect-dialog.tsx b/packages/onboarding-demo/src/screens/connect-dialog.tsx new file mode 100644 index 000000000..ba3788a62 --- /dev/null +++ b/packages/onboarding-demo/src/screens/connect-dialog.tsx @@ -0,0 +1,279 @@ +// Reproduction of the ConnectDialog in packages/react/src/pages/integrations.tsx. +// +// Faithful to the parts that shape the experience: one input that silently +// switches between URL-detect and preset-filter, plugin chips above the list, +// a fixed 16rem scroll window over ~30 curated presets in plugin order, and +// the integrations.sh long tail that only appears once you have typed two +// characters. Picking anything leaves the dialog for a full-page form. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@executor-js/react/components/button"; +import { Input } from "@executor-js/react/components/input"; +import { Badge } from "@executor-js/react/components/badge"; +import { Skeleton } from "@executor-js/react/components/skeleton"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; +import { + catalogLogoUrl, + curatedPresets, + integrationPlugins, + searchCatalog, + type CatalogEntry, + type DemoPreset, + type PluginKey, +} from "../fixtures"; + +const CATALOG_KIND_LABEL: Record = { + mcp: "MCP", + openapi: "OpenAPI", + graphql: "GraphQL", +}; + +const looksLikeUrl = (raw: string): boolean => { + const v = raw.trim(); + if (v.length === 0) return false; + if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; + if (v.includes("/")) return true; + if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; + return false; +}; + +const SEARCH_DEBOUNCE_MS = 250; +const MIN_QUERY_LENGTH = 2; + +function useCatalogSearch(rawQuery: string) { + const query = rawQuery.trim().toLowerCase(); + const [state, setState] = useState<{ + readonly entries: readonly CatalogEntry[]; + readonly loading: boolean; + }>({ entries: [], loading: false }); + const generation = useRef(0); + + useEffect(() => { + const requestId = ++generation.current; + if (query.length < MIN_QUERY_LENGTH) { + setState({ entries: [], loading: false }); + return; + } + setState((previous) => ({ ...previous, loading: true })); + const timer = setTimeout(() => { + void searchCatalog(query).then((entries) => { + if (generation.current !== requestId) return; + setState({ entries, loading: false }); + }); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query]); + + return state; +} + +export function ConnectDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onPickPreset: (preset: DemoPreset) => void; + readonly onPickPlugin: (pluginKey: PluginKey) => void; + readonly onPickCatalogEntry: (entry: CatalogEntry) => void; +}) { + const [query, setQuery] = useState(""); + const isUrl = looksLikeUrl(query); + const presetSearch = isUrl ? "" : query; + + const filtered = useMemo(() => { + const q = presetSearch.trim().toLowerCase(); + if (q.length === 0) return curatedPresets; + return curatedPresets.filter(({ name, summary, pluginLabel }) => + `${name} ${summary} ${pluginLabel}`.toLowerCase().includes(q), + ); + }, [presetSearch]); + + const catalog = useCatalogSearch(presetSearch); + const showCatalogSection = + presetSearch.trim().length > 0 && (catalog.entries.length > 0 || catalog.loading); + + const close = () => { + setQuery(""); + props.onOpenChange(false); + }; + + return ( + { + if (!open) close(); + else props.onOpenChange(open); + }} + > + + + Connect an integration + + Search the preset library, or paste a URL to auto-detect. + + + +
+
+
+ setQuery((event.target as HTMLInputElement).value)} + placeholder="Search or paste a URL…" + className="flex-1" + /> + {isUrl && } +
+
+ +
+

Or add manually

+
+ {integrationPlugins.map((plugin) => ( + // oxlint-disable-next-line react/forbid-elements + + ))} +
+
+ +
+

Popular integrations

+ + + {filtered.length === 0 && !showCatalogSection ? ( +
+

No matching integrations

+

+ Paste a URL above to auto-detect, or pick an integration type manually. +

+
+ ) : ( + filtered.map((preset) => ( + + {/* oxlint-disable-next-line react/forbid-elements */} + + + )) + )} + + {showCatalogSection && ( + <> + {catalog.entries.map((entry) => ( + + {/* oxlint-disable-next-line react/forbid-elements */} + + + ))} + {catalog.loading && + catalog.entries.length === 0 && + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} + + )} +
+
+
+
+
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/integration-detail.tsx b/packages/onboarding-demo/src/screens/integration-detail.tsx new file mode 100644 index 000000000..f503c3eaa --- /dev/null +++ b/packages/onboarding-demo/src/screens/integration-detail.tsx @@ -0,0 +1,105 @@ +// Reproduction of packages/react/src/pages/integration-detail.tsx plus the +// AccountsSection from packages/react/src/components/accounts-section.tsx. +// +// This is where a just-added integration lands. It has an Edit / Refresh / +// Delete header, an Accounts / Tools tab strip, a "Connections" heading, and a +// dashed empty card whose CTA is "Add connection" — the second add, for a thing +// the user did not know was separate from the first. + +import { useState } from "react"; +import { Button } from "@executor-js/react/components/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; +import type { DemoIntegration } from "../fixtures"; + +export function IntegrationDetailPage(props: { + readonly integration: DemoIntegration; + readonly onAddConnection: () => void; +}) { + const [tab, setTab] = useState<"accounts" | "tools">("accounts"); + + return ( +
+
+
+

+ {props.integration.name} +

+ + {props.integration.toolCount} tools + +
+
+ + + +
+
+ + setTab(value === "tools" ? "tools" : "accounts")} + className="min-h-0 flex-1 gap-0 overflow-hidden" + > +
+ + Accounts + Tools + +
+ + +
+
+
+

+ Connections +

+
+
+

No connections yet

+

+ Add a connection to make this integration's tools available. +

+ +
+
+
+
+ + +
+
+

No tools yet

+
+
+
+

No tools yet

+

+ Add a connection to unlock this integration's tools. +

+ +
+
+
+
+
+
+ ); +} diff --git a/packages/onboarding-demo/src/screens/integrations-page.tsx b/packages/onboarding-demo/src/screens/integrations-page.tsx new file mode 100644 index 000000000..f99bc2d75 --- /dev/null +++ b/packages/onboarding-demo/src/screens/integrations-page.tsx @@ -0,0 +1,142 @@ +// Reproduction of packages/react/src/pages/integrations.tsx — the screen that +// follows CLI setup. The "Connect an agent" card sits above the fold; finding +// an integration is a dialog behind one button in the header. + +import { PlusIcon } from "lucide-react"; +import { Button } from "@executor-js/react/components/button"; +import { PageContainer, PageHeader } from "@executor-js/react/components/page"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, + CardStackHeader, +} from "@executor-js/react/components/card-stack"; +import { Badge } from "@executor-js/react/components/badge"; +import { Tabs, TabsList, TabsTrigger } from "@executor-js/react/components/tabs"; +import type { DemoIntegration } from "../fixtures"; + +function McpInstallCard() { + return ( + + + + Remote HTTP + Standard I/O + + } + > +
+ Connect an agent + + Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool + you connect here. + +
+
+ +
+
+ npx add-mcp http://localhost:3123/mcp --transport http --name executor +
+
Advanced ⌄
+
+
+ Work with your agent + Cursor + Claude + OpenCode + and more +
+
+
+
+ ); +} + +export function IntegrationsPage(props: { + readonly integrations: readonly DemoIntegration[]; + readonly onConnect: () => void; + readonly onOpenIntegration: (slug: string) => void; +}) { + return ( + + + + Connect + + } + /> + +
+ +
+ +
+ + {props.integrations.length === 0 ? ( +
+
+ +
+

No integrations yet

+

+ Connect an integration to start curating tools. +

+ +
+ ) : ( +
+ + + {props.integrations.map((integration) => ( + + {/* oxlint-disable-next-line react/forbid-elements */} + + + ))} + + +
+ )} + + ); +} diff --git a/packages/onboarding-demo/src/shell.tsx b/packages/onboarding-demo/src/shell.tsx new file mode 100644 index 000000000..7104a39f6 --- /dev/null +++ b/packages/onboarding-demo/src/shell.tsx @@ -0,0 +1,97 @@ +// Reproduction of the console chrome (packages/app/src/web/shell.tsx): the +// wordmark bar, the five nav items, the second INTEGRATIONS heading that +// repeats the first nav item, and the four-link footer. + +import type { ReactNode } from "react"; +import { cn } from "@executor-js/react/lib/utils"; +import type { DemoIntegration } from "./fixtures"; + +const NAV_ITEMS = ["Integrations", "Secrets", "Policies", "Toolkits", "Artifacts"] as const; + +function NavItem(props: { readonly label: string; readonly active: boolean }) { + return ( + + {props.label} + + ); +} + +export function Shell(props: { + readonly integrations: readonly DemoIntegration[]; + readonly children: ReactNode; +}) { + return ( +
+ + +
+ {props.children} +
+
+ ); +} diff --git a/packages/onboarding-demo/src/step-state.ts b/packages/onboarding-demo/src/step-state.ts new file mode 100644 index 000000000..f7f639cfc --- /dev/null +++ b/packages/onboarding-demo/src/step-state.ts @@ -0,0 +1,45 @@ +// Which screens carry which workspace state. Jumping straight to a late step +// has to bring that step's state with it, or the screen is not the one the +// step is about. + +import type { StepId } from "./flow"; +import { gmailIntegration, posthogIntegration, type DemoIntegration } from "./fixtures"; + +export type AccountModal = "none" | "credential" | "place" | "oauth-stuck"; +export type ScreenView = "integrations" | "add" | "detail"; + +export interface StepState { + readonly integrations: readonly DemoIntegration[]; + readonly connectOpen: boolean; + readonly accountModal: AccountModal; + readonly view: ScreenView; +} + +const EMPTY: StepState = { + integrations: [], + connectOpen: false, + accountModal: "none", + view: "integrations", +}; + +const WITH_POSTHOG: StepState = { + integrations: [posthogIntegration], + connectOpen: false, + accountModal: "none", + view: "detail", +}; + +export const stepState: Readonly> = { + "integrations-empty": EMPTY, + "connect-dialog": { ...EMPTY, connectOpen: true }, + "add-openapi": { ...EMPTY, view: "add" }, + "detail-accounts": WITH_POSTHOG, + "add-account-credential": { ...WITH_POSTHOG, accountModal: "credential" }, + "add-account-place": { ...WITH_POSTHOG, accountModal: "place" }, + "oauth-stuck": { + ...WITH_POSTHOG, + integrations: [gmailIntegration], + accountModal: "oauth-stuck", + }, + "integrations-populated": { ...WITH_POSTHOG, view: "integrations" }, +}; diff --git a/packages/onboarding-demo/src/styles.css b/packages/onboarding-demo/src/styles.css new file mode 100644 index 000000000..4860fd2e0 --- /dev/null +++ b/packages/onboarding-demo/src/styles.css @@ -0,0 +1,5 @@ +/* The console's real stylesheet — same tokens, same primitives. The extra + @source is this package's own sources, which the react package's globs + (rooted at packages/react/src) do not cover. */ +@import "@executor-js/react/globals.css"; +@source "./**/*.{ts,tsx}"; diff --git a/packages/onboarding-demo/tsconfig.json b/packages/onboarding-demo/tsconfig.json new file mode 100644 index 000000000..8269f3f91 --- /dev/null +++ b/packages/onboarding-demo/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/onboarding-demo/vite.config.ts b/packages/onboarding-demo/vite.config.ts new file mode 100644 index 000000000..7d364fd3c --- /dev/null +++ b/packages/onboarding-demo/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +// Standalone SPA reproduction of the current add-integration flow. No executor +// API, no auth, no backend — every screen runs off `src/fixtures.ts` plus the +// live public integrations.sh catalog, so the flow can be redesigned without +// standing up a tenant. +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { port: 5199 }, +}); From 25e04cc55837819c33a7c468f18ce69688b16f0d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:10:14 -0700 Subject: [PATCH 02/59] Add reworked add-integration flow alongside the current one --- packages/onboarding-demo/src/app.tsx | 113 ++++++++- packages/onboarding-demo/src/catalog.ts | 130 ++++++++++ packages/onboarding-demo/src/next-flow.tsx | 139 +++++++++++ .../src/screens/next/authenticate.tsx | 229 ++++++++++++++++++ .../src/screens/next/browse.tsx | 214 ++++++++++++++++ .../src/screens/next/integration-detail.tsx | 140 +++++++++++ 6 files changed, 960 insertions(+), 5 deletions(-) create mode 100644 packages/onboarding-demo/src/catalog.ts create mode 100644 packages/onboarding-demo/src/next-flow.tsx create mode 100644 packages/onboarding-demo/src/screens/next/authenticate.tsx create mode 100644 packages/onboarding-demo/src/screens/next/browse.tsx create mode 100644 packages/onboarding-demo/src/screens/next/integration-detail.tsx diff --git a/packages/onboarding-demo/src/app.tsx b/packages/onboarding-demo/src/app.tsx index 3b1d2ea31..0aee8dc81 100644 --- a/packages/onboarding-demo/src/app.tsx +++ b/packages/onboarding-demo/src/app.tsx @@ -9,6 +9,7 @@ import { IntegrationDetailPage } from "./screens/integration-detail"; import { AddAccountModal } from "./screens/add-account-modal"; import { flowSteps, type FlowStep, type StepId } from "./flow"; import { stepState, type AccountModal, type ScreenView } from "./step-state"; +import { NextFlow } from "./next-flow"; import { gmailAuthMethods, gmailIntegration, @@ -33,13 +34,12 @@ function Inspector(props: { readonly step: FlowStep; readonly onSelect: (id: StepId) => void; readonly onClose: () => void; + readonly modeSwitch: React.ReactNode; }) { return (