From e911d872e04bb55c84c6159c5ecb0bf58610c14f Mon Sep 17 00:00:00 2001 From: Matteo Morelli Date: Mon, 5 Oct 2026 15:56:45 +0200 Subject: [PATCH] Welcome: lead with the apps workspaces connect, ranked from usage; no-key demos opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /welcome showed a fixed list of apps and, below it, the keyless starter pack with three demos ticked. In the first days of October the demos were installed by 20-78 new workspaces each and called by 0-2, while taking the trial's connector slots from the app the user came for. Telegram was set up by 149 workspaces and worked for 133; Etsy 155 and 62; Odoo 20 and 17. - GET /api/adapters/popular-connectors ranks catalog adapters by how many workspaces had a successful call on them in the last 30 days (aggregate counts, at least 3 workspaces, keyless ones left out), cached for an hour, filled from a fallback list on a quiet deployment or if the query fails. The EXISTS goes through tool_invocations(connector_id, created_at): 33 ms on the cloud. - Each card says what the setup asks for ("Bot token", "Sign in · Keystring and Shared secret") and marks apps the workspace already has. - The starter pack has nothing preselected, moves below the marketplace and custom-API cards, and says each demo counts as a connector on the plan. --- .../src/adapters/adapters.controller.ts | 15 +++- .../src/adapters/adapters.service.spec.ts | 71 ++++++++++++++- .../backend/src/adapters/adapters.service.ts | 89 +++++++++++++++++++ .../src/adapters/popular-connectors.ts | 30 +++++++ packages/backend/src/adapters/starter-pack.ts | 15 +++- packages/frontend/src/app/welcome/page.tsx | 58 ++++++------ .../frontend/src/components/starter-pack.tsx | 19 ++-- packages/frontend/src/lib/api.ts | 14 +++ packages/frontend/src/lib/setup-needs.ts | 8 ++ .../frontend/tests/e2e/starter-pack.spec.ts | 58 +++++++++--- 10 files changed, 320 insertions(+), 57 deletions(-) create mode 100644 packages/backend/src/adapters/popular-connectors.ts create mode 100644 packages/frontend/src/lib/setup-needs.ts diff --git a/packages/backend/src/adapters/adapters.controller.ts b/packages/backend/src/adapters/adapters.controller.ts index 1cd5c067..3c79985f 100644 --- a/packages/backend/src/adapters/adapters.controller.ts +++ b/packages/backend/src/adapters/adapters.controller.ts @@ -49,8 +49,19 @@ export class AdaptersController { private readonly productEvents: ProductEventService, ) {} - // Declared before `:slug`, which would otherwise take "starter-pack" for - // an adapter slug. + // Declared before `:slug`, which would otherwise take these paths for an + // adapter slug. + @Get('popular-connectors') + @ApiOperation({ + summary: 'Apps new workspaces connect and get working', + description: + 'Catalog adapters ranked by how many workspaces had a successful call on them in the last 30 days ' + + '(aggregate counts only), with what each setup asks for and whether this workspace already has it.', + }) + async popularConnectors(@Req() req: any) { + return { items: await this.adaptersService.popularConnectors(req.user.organizationId) }; + } + @Get('starter-pack') @ApiOperation({ summary: 'Connectors offered to a new workspace', diff --git a/packages/backend/src/adapters/adapters.service.spec.ts b/packages/backend/src/adapters/adapters.service.spec.ts index ff83bd29..e6c31420 100644 --- a/packages/backend/src/adapters/adapters.service.spec.ts +++ b/packages/backend/src/adapters/adapters.service.spec.ts @@ -325,7 +325,9 @@ describe('AdaptersService starter pack', () => { expect({ slug: entry.slug, selfHostOnly: !!a.selfHostOnly }).toEqual({ slug: entry.slug, selfHostOnly: false }); expect(entry.pitch.length).toBeLessThanOrEqual(110); } - expect(STARTER_PACK.filter((e: any) => e.preselected).length).toBeGreaterThanOrEqual(2); + // Preselected demos took the trial's connector slots from the app the + // user came for; the pack is opt-in now. + expect(STARTER_PACK.filter((e: any) => e.preselected)).toEqual([]); }); it('leaves out Deutsche Bahn unless the operator provides MOTIS', async () => { @@ -341,7 +343,7 @@ describe('AdaptersService starter pack', () => { it('marks what the workspace already has and carries the card fields', async () => { const items = await service({ installed: ['hackernews'] }).starterPack('org1'); const hn = items.find((i) => i.slug === 'hackernews')!; - expect(hn).toMatchObject({ installed: true, name: 'Hacker News', icon: 'hackernews', preselected: true }); + expect(hn).toMatchObject({ installed: true, name: 'Hacker News', icon: 'hackernews', preselected: false }); expect(hn.toolCount).toBeGreaterThan(0); expect(items.find((i) => i.slug === 'nominatim')!.installed).toBe(false); // Order follows the pack definition. @@ -451,3 +453,68 @@ describe('AdaptersService unlisted adapters', () => { for (const slug of unlisted) expect(getAdapter(slug)?.unlisted).toBe(true); }); }); + +describe('AdaptersService popular connectors', () => { + function service(opts: { rows?: Array<{ slug: string; workspaces: bigint }>; fail?: boolean; installed?: string[] } = {}) { + const svc = Object.create(AdaptersService.prototype) as AdaptersService; + (svc as any).logger = { warn: jest.fn() }; + (svc as any).configService = { get: (k: string) => (k === 'DEPLOYMENT_MODE' ? 'cloud' : undefined) }; + const queryRaw = opts.fail + ? jest.fn().mockRejectedValue(new Error('db down')) + : jest.fn().mockResolvedValue(opts.rows ?? []); + (svc as any).prisma = { + $queryRaw: queryRaw, + connector: { + findMany: jest.fn().mockResolvedValue((opts.installed ?? []).map((slug) => ({ config: { adapterSlug: slug } }))), + }, + }; + return { svc, queryRaw }; + } + + it('leads with what works for the most workspaces, then fills from the fallback', async () => { + const { svc } = service({ + rows: [ + { slug: 'odoo', workspaces: 17n }, + { slug: 'telegram-bot', workspaces: 133n }, + ].sort((a, b) => Number(b.workspaces - a.workspaces)), + }); + const items = await svc.popularConnectors('org1'); + expect(items.map((i) => i.slug).slice(0, 2)).toEqual(['telegram-bot', 'odoo']); + expect(items).toHaveLength(8); + expect(new Set(items.map((i) => i.slug)).size).toBe(8); + }); + + it('leaves keyless adapters to the starter pack', async () => { + const { svc } = service({ rows: [{ slug: 'hackernews', workspaces: 50n }, { slug: 'etsy', workspaces: 40n }] }); + const items = await svc.popularConnectors('org1'); + expect(items.map((i) => i.slug)).not.toContain('hackernews'); + expect(items[0].slug).toBe('etsy'); + }); + + it('says what each setup asks for, without the token a sign-in fills in', async () => { + const { svc } = service({ rows: [{ slug: 'etsy', workspaces: 40n }] }); + const etsy = (await svc.popularConnectors('org1')).find((i) => i.slug === 'etsy')!; + expect(etsy.setupKind).toBe('oauth_browser'); + expect(etsy.needs).toEqual(['Keystring', 'Shared secret']); + }); + + it('marks what the workspace already has', async () => { + const { svc } = service({ rows: [{ slug: 'telegram-bot', workspaces: 9n }], installed: ['telegram-bot'] }); + const items = await svc.popularConnectors('org1'); + expect(items.find((i) => i.slug === 'telegram-bot')!.installed).toBe(true); + }); + + it('ranks once an hour, not on every page view', async () => { + const { svc, queryRaw } = service({ rows: [{ slug: 'etsy', workspaces: 40n }] }); + await svc.popularConnectors('org1'); + await svc.popularConnectors('org2'); + expect(queryRaw).toHaveBeenCalledTimes(1); + }); + + it('serves the fallback list when the ranking query fails', async () => { + const { svc } = service({ fail: true }); + const items = await svc.popularConnectors('org1'); + expect(items[0].slug).toBe('telegram-bot'); + expect(items.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 473fa9d2..32dfba48 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -18,6 +18,13 @@ import { import { pickProbe } from './probe.util'; import { normalizeBaseUrlVariables } from '../common/base-url-variable.util'; import { STARTER_PACK } from './starter-pack'; +import { + POPULAR_CACHE_MS, + POPULAR_FALLBACK, + POPULAR_MAX, + POPULAR_MIN_WORKSPACES, + POPULAR_WINDOW_DAYS, +} from './popular-connectors'; import { ConnectorsService } from '../connectors/connectors.service'; import { classifyToolExecutionError } from '../connectors/connector-error.util'; import { applyResponseTransform } from '../connectors/response-transform.util'; @@ -118,6 +125,77 @@ export class AdaptersService { return items; } + private popularCache: { at: number; slugs: string[] } | null = null; + + /** + * The apps /welcome offers first, most-working first (see + * ./popular-connectors.ts), each with what its setup asks for and whether + * this workspace already has it. + */ + async popularConnectors(organizationId: string): Promise { + const [ranked, installed] = await Promise.all([ + this.rankedPopularSlugs(), + this.installedAdapterSlugs(organizationId), + ]); + const items: PopularConnectorItem[] = []; + for (const slug of [...ranked, ...POPULAR_FALLBACK]) { + if (items.length >= POPULAR_MAX) break; + if (items.some((i) => i.slug === slug)) continue; + const adapter = getAdapter(slug); + if (!adapter || !this.isInstallableHere(adapter)) continue; + const requiredEnvVars = withoutOperatorProvided(adapter.requiredEnvVars) ?? []; + const kind = setupKind({ ...adapter, requiredEnvVars }); + if (kind === 'none') continue; + const needs = describeAdapterEnvVars({ ...adapter, requiredEnvVars }) + .filter((v) => v.required && !v.advanced) + .map((v) => v.label); + items.push({ + slug, + name: adapter.name, + icon: adapter.icon, + category: adapter.category, + setupKind: kind, + needs, + installed: installed.has(slug), + }); + } + return items; + } + + /** Adapter slugs by successful workspaces over the window, cached for an hour. */ + private async rankedPopularSlugs(): Promise { + const now = Date.now(); + if (this.popularCache && now - this.popularCache.at < POPULAR_CACHE_MS) { + return this.popularCache.slugs; + } + let slugs: string[] = []; + try { + // tool_invocations is large; the EXISTS goes through its + // (connector_id, created_at) index, one recent connector at a time. + const rows = await this.prisma.$queryRaw>` + SELECT c.config->>'adapterSlug' AS slug, + COUNT(DISTINCT c.organization_id) AS workspaces + FROM connectors c + WHERE c.created_at > now() - make_interval(days => ${POPULAR_WINDOW_DAYS}) + AND c.config ? 'adapterSlug' + AND EXISTS ( + SELECT 1 FROM tool_invocations ti + WHERE ti.connector_id = c.id AND ti.status = 'SUCCESS' + ) + GROUP BY 1 + HAVING COUNT(DISTINCT c.organization_id) >= ${POPULAR_MIN_WORKSPACES} + ORDER BY 2 DESC, 1 + LIMIT 40`; + slugs = rows.map((r) => r.slug).filter((s): s is string => typeof s === 'string'); + } catch (err: any) { + // The page still has the fallback list; a ranking that fails is not + // worth an error on a new user's first screen. + this.logger.warn(`Could not rank popular connectors: ${err?.message ?? err}`); + } + this.popularCache = { at: now, slugs }; + return slugs; + } + /** Catalog slugs this workspace already has a connector for. */ async installedAdapterSlugs(organizationId: string): Promise> { const rows = await this.prisma.connector.findMany({ @@ -698,6 +776,17 @@ export class AdaptersService { } } +export interface PopularConnectorItem { + slug: string; + name: string; + icon: string; + category: string; + setupKind: SetupKind; + /** Labels of the values the setup asks for, e.g. ["Keystring", "Shared secret"]. */ + needs: string[]; + installed: boolean; +} + export interface StarterPackItem { slug: string; name: string; diff --git a/packages/backend/src/adapters/popular-connectors.ts b/packages/backend/src/adapters/popular-connectors.ts new file mode 100644 index 00000000..9481e10b --- /dev/null +++ b/packages/backend/src/adapters/popular-connectors.ts @@ -0,0 +1,30 @@ +/** + * The apps /welcome offers first: the ones workspaces connect and get working. + * + * Ranked from this deployment's own data: how many workspaces installed the + * adapter in the last WINDOW_DAYS and had at least one successful call on it. + * Installs alone would rank the wrong things: in the first days of October + * 2026 the keyless starter pack was installed by 78 workspaces (Hacker News) + * and used by 2, while Telegram was set up by 149 and worked for 133. + * + * Only aggregate counts are read, and an adapter needs MIN_WORKSPACES + * successful workspaces before it can be ranked, so one tenant's usage never + * shows through. Keyless adapters are left out: they have their own section. + * A deployment with little traffic (a fresh self-host) fills the list from + * FALLBACK, which is what the cloud ranked when this was written. + */ +export const POPULAR_WINDOW_DAYS = 30; +export const POPULAR_MIN_WORKSPACES = 3; +export const POPULAR_MAX = 8; +export const POPULAR_CACHE_MS = 60 * 60 * 1000; + +export const POPULAR_FALLBACK: readonly string[] = [ + 'telegram-bot', + 'etsy', + 'odoo', + 'woocommerce', + 'weclapp', + 'lexware-office', + 'getmyinvoices', + 'google-search-console', +]; diff --git a/packages/backend/src/adapters/starter-pack.ts b/packages/backend/src/adapters/starter-pack.ts index 2671f547..a0c14608 100644 --- a/packages/backend/src/adapters/starter-pack.ts +++ b/packages/backend/src/adapters/starter-pack.ts @@ -5,7 +5,7 @@ * a third of workspaces ever add a connector. Installing these for everyone * would not fix that and would cost memory, because every connector's tools * live in the in-memory registry. So the pack is offered, not installed: a - * few connectors that need no key, ticked by default, added in one click. + * few connectors that need no key, added in one click for those who want them. * * Membership is chosen from production data: each one installs with no input * at all and answered its calls reliably over the previous 30 days. An entry @@ -21,21 +21,28 @@ export interface StarterPackEntry { pitch: string; } +/* + * Nothing is preselected any more (October 2026). Preselected, the pack took + * the trial's connector slots with demos: Hacker News was installed by 78 new + * workspaces and called by 2, which then had no room left for the app they + * came for. /welcome now leads with the apps people actually connect + * (./popular-connectors.ts) and offers these as an optional try-out. + */ export const STARTER_PACK: readonly StarterPackEntry[] = [ { slug: 'agent-skills', - preselected: true, + preselected: false, pitch: 'Find a ready-made skill for a task, like a README or release notes, and have your AI follow it.', }, { slug: 'hackernews', - preselected: true, + preselected: false, pitch: 'Top, new and Ask HN stories with their comments, for tech news and research.', }, { slug: 'nominatim', - preselected: true, + preselected: false, pitch: 'Turn addresses into coordinates and back, worldwide, with OpenStreetMap.', }, { diff --git a/packages/frontend/src/app/welcome/page.tsx b/packages/frontend/src/app/welcome/page.tsx index 773f4ab9..05816867 100644 --- a/packages/frontend/src/app/welcome/page.tsx +++ b/packages/frontend/src/app/welcome/page.tsx @@ -5,25 +5,15 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; import { adapters, users } from '@/lib/api'; +import { setupNeeds } from '@/lib/setup-needs'; import { LogoIcon } from '@/components/logo-icon'; import { ConnectorLogo } from '@/components/connector-logo'; import { StarterPack } from '@/components/starter-pack'; import { matchesSearch } from '@/lib/marketplace-search'; -// What people connect most and get working, from production (installs that -// went on to a successful call, September-October 2026). Keyless demos have -// their own section below: they are installed often and used almost never, -// so they no longer lead. -const STARTER_SLUGS = [ - 'etsy', - 'telegram-bot', - 'odoo', - 'weclapp', - 'lexware-office', - 'sendcloud', - 'getmyinvoices', - 'google-search-console', -]; +// Shown when the ranking can't be loaded: what the cloud ranked in October +// 2026. The live list comes from /api/adapters/popular-connectors. +const FALLBACK_SLUGS = ['telegram-bot', 'etsy', 'odoo', 'woocommerce', 'weclapp', 'lexware-office', 'getmyinvoices', 'google-search-console']; export default function WelcomePage() { const { token, user, isLoading } = useAuth(); @@ -45,16 +35,19 @@ export default function WelcomePage() { if (!token) return; adapters .list(token) - .then((all: any[]) => { - setCatalog(all); - const bySlug = new Map(all.map((a) => [a.slug, a])); - setStarters( - STARTER_SLUGS.map((s) => bySlug.get(s)).filter(Boolean) as any[], - ); - }) + .then((all: any[]) => setCatalog(Array.isArray(all) ? all : [])) + .catch(() => setCatalog([])); + adapters + .popularConnectors(token) + .then((res) => setStarters(Array.isArray(res?.items) ? res.items : [])) .catch(() => setStarters([])); }, [token]); + const popular = + starters.length > 0 + ? starters + : (FALLBACK_SLUGS.map((slug) => catalog.find((a) => a.slug === slug)).filter(Boolean) as any[]); + const handleSkip = async () => { if (!token || skipping) return; setSkipping(true); @@ -127,10 +120,15 @@ export default function WelcomePage() { placeholder="Etsy, Odoo, weclapp, Lexware, Shopify…" className="w-full rounded-[10px] border border-[var(--border)] bg-[var(--surface)] px-4 py-3 text-[16px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:border-[var(--border-strong)] focus:outline-none" /> + {!query.trim() && popular.length > 0 && ( +

+ What new workspaces connect most +

+ )}
{(query.trim() ? catalog.filter((a) => matchesSearch([a.name, a.slug, a.description ?? '', a.category ?? ''], query)).slice(0, 8) - : starters + : popular ).map((a) => (
{a.name}
-
- {a.setupKind === 'none' ? 'No account needed' : a.setupKind === 'oauth_browser' ? 'Sign in with your account' : 'API key'} +
+ {a.installed ? 'Already added' : setupNeeds(a)}
@@ -157,12 +155,6 @@ export default function WelcomePage() { )} - {/* Keyless demos, added in one click, for trying AnythingMCP before - picking a real app. Viewers can't add connectors. */} - {token && user.role !== 'VIEWER' && ( - - )} - {/* Two big paths — marketplace vs custom */}
+ {/* Keyless demos, opt-in, for trying AnythingMCP before setting up a + real app. Viewers can't add connectors. */} + {token && user.role !== 'VIEWER' && ( + + )} + {/* Differentiator teaser — what makes AnythingMCP "smart" beyond a proxy. It's empty for a brand-new account, so this is a concept hook (no data, no AI), nudging toward the graph once tools exist. */} diff --git a/packages/frontend/src/components/starter-pack.tsx b/packages/frontend/src/components/starter-pack.tsx index 270f88ee..95586ee7 100644 --- a/packages/frontend/src/components/starter-pack.tsx +++ b/packages/frontend/src/components/starter-pack.tsx @@ -13,10 +13,11 @@ import { Button } from '@/components/ui/button'; import { ConnectorLogo } from '@/components/connector-logo'; /** - * The starter pack on /welcome: a few keyless connectors, ticked by default, - * added in one click and put on the user's MCP server. A new workspace used - * to start empty; this gives it something that works before any credentials - * are asked for, without installing anything the user did not choose. + * The no-key demos on /welcome: a few keyless connectors the user can tick and + * add in one click, for trying AnythingMCP before setting up a real app. They + * used to be ticked by default and lead the page; now the apps people actually + * connect come first and these are opt-in, since every demo takes a connector + * slot on the plan. */ export function StarterPack({ token }: { token: string }) { const [items, setItems] = useState(null); @@ -97,15 +98,15 @@ export function StarterPack({ token }: { token: string }) { return (
-

- Starter pack +

+ No-key demos

- Start with connectors that need no keys + Want to try it before setting up an app?

- We add them to your MCP server, so your AI can use them as soon as you connect it. Untick - what you don't need; you can remove any of them later. + These need no account. Tick the ones you want and we add them to your MCP server; each one + counts as a connector on your plan, and you can remove it later.

{maxConnectors != null && !outcome && !allInstalled && (

diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index 486a06c9..2eadf4bf 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -518,6 +518,8 @@ export const adapters = { token, body: { credentials, ...(connectorId ? { connectorId } : {}) }, }), + popularConnectors: (token: string) => + request<{ items: PopularConnectorItem[] }>('/api/adapters/popular-connectors', { token }), starterPack: (token: string) => request('/api/adapters/starter-pack', { token }), installStarterPack: (slugs: string[], token: string) => @@ -542,6 +544,18 @@ export interface LicenseBilling { interval: 'day' | 'week' | 'month' | 'year' | null; } +/** An app new workspaces connect and get working (see /welcome). */ +export interface PopularConnectorItem { + slug: string; + name: string; + icon: string; + category: string; + setupKind: 'none' | 'credentials' | 'oauth_browser'; + /** Labels of the values the setup asks for, e.g. ["Keystring", "Shared secret"]. */ + needs: string[]; + installed: boolean; +} + /** A keyless connector offered to a new workspace (see /welcome). */ export interface StarterPackItem { slug: string; diff --git a/packages/frontend/src/lib/setup-needs.ts b/packages/frontend/src/lib/setup-needs.ts new file mode 100644 index 00000000..45726011 --- /dev/null +++ b/packages/frontend/src/lib/setup-needs.ts @@ -0,0 +1,8 @@ +/** "Bot token", "Sign in · Keystring and Shared secret", ... (sign-in first: cards truncate the end). */ +export function setupNeeds(a: { setupKind?: string; needs?: string[] }): string { + const needs = a.needs ?? []; + const list = needs.length <= 1 ? needs.join('') : `${needs.slice(0, -1).join(', ')} and ${needs[needs.length - 1]}`; + if (a.setupKind === 'none') return 'No account needed'; + if (a.setupKind === 'oauth_browser') return list ? `Sign in · ${list}` : 'Sign in with your account'; + return list || 'API key'; +} diff --git a/packages/frontend/tests/e2e/starter-pack.spec.ts b/packages/frontend/tests/e2e/starter-pack.spec.ts index 32823bd0..a0d77b3e 100644 --- a/packages/frontend/tests/e2e/starter-pack.spec.ts +++ b/packages/frontend/tests/e2e/starter-pack.spec.ts @@ -1,8 +1,9 @@ import { devices, expect, test, type Page } from '@playwright/test'; /** - * The starter pack on /welcome: keyless connectors, ticked by default, added - * in one click. Covers the choice, the request it sends, the outcome with its + * The starter pack on /welcome: keyless demos the user can tick and add in + * one click (the server sends none preselected; these mocks do, to cover the + * selection logic). Covers the choice, the request it sends, the outcome with its * "Try it" links and the path to connecting a client, partial failures, and * a phone-width layout with no horizontal scrolling in either state. * @@ -28,6 +29,12 @@ const PACK = [ { slug: 'openplz', name: 'OpenPLZ Germany', pitch: 'German postal codes, towns and streets, for checking addresses.', icon: 'openplz', category: 'government', toolCount: 5, preselected: false, installed: false }, ]; +const POPULAR = [ + { slug: 'telegram-bot', name: 'Telegram Bot API', icon: 'telegram', category: 'messaging', setupKind: 'credentials', needs: ['Bot token'], installed: false }, + { slug: 'etsy', name: 'Etsy Open API v3', icon: 'etsy', category: 'ecommerce', setupKind: 'oauth_browser', needs: ['Keystring', 'Shared secret'], installed: false }, + { slug: 'odoo', name: 'Odoo JSON-2 API', icon: 'odoo', category: 'erp', setupKind: 'credentials', needs: ['URL', 'DB', 'API key'], installed: true }, +]; + type InstallReply = { status: number; body: unknown }; async function openWelcome( @@ -36,6 +43,8 @@ async function openWelcome( pack?: unknown; connectors?: { current: number; max: number | null; remaining: number | null }; install?: (slugs: string[]) => InstallReply; + popular?: unknown[] | 'fail'; + catalog?: unknown[]; } = {}, ) { const posted: string[][] = []; @@ -74,7 +83,10 @@ async function openWelcome( connectors: opts.connectors ?? { current: 0, max: null, remaining: null }, }); } - if (p.endsWith('/adapters')) return json([]); + if (p.endsWith('/adapters/popular-connectors')) { + return opts.popular === 'fail' ? json({ message: 'boom' }, 500) : json({ items: opts.popular ?? POPULAR }); + } + if (p.endsWith('/adapters')) return json(opts.catalog ?? []); if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: null }); if (p.endsWith('/users/me')) return json(USER); if (p.endsWith('/organizations/current')) return json({ id: 'o1', name: 'Acme', createdAt: '2026-01-01' }); @@ -97,7 +109,7 @@ test.describe('starter pack on /welcome', () => { test('ticks the preselected connectors and adds exactly the ones left ticked', async ({ page }) => { const posted = await openWelcome(page); - const pack = page.getByRole('region', { name: 'Start with connectors that need no keys' }); + const pack = page.getByRole('region', { name: 'Want to try it before setting up an app?' }); await expect(pack).toBeVisible(); await expect(pack.getByRole('checkbox')).toHaveCount(5); await expect(pack.getByRole('checkbox', { checked: true })).toHaveCount(3); @@ -129,7 +141,7 @@ test.describe('starter pack on /welcome', () => { test('cannot submit an empty selection', async ({ page }) => { const posted = await openWelcome(page); - const pack = page.getByRole('region', { name: 'Start with connectors that need no keys' }); + const pack = page.getByRole('region', { name: 'Want to try it before setting up an app?' }); for (const name of ['Agent Skills Finder', 'Hacker News', 'Nominatim (OpenStreetMap)']) { await pack.getByText(name, { exact: true }).click(); } @@ -140,7 +152,7 @@ test.describe('starter pack on /welcome', () => { test('shows what the workspace already has as done, and does not resend it', async ({ page }) => { const pack = PACK.map((i) => (i.slug === 'hackernews' ? { ...i, installed: true } : i)); const posted = await openWelcome(page, { pack }); - const region = page.getByRole('region', { name: 'Start with connectors that need no keys' }); + const region = page.getByRole('region', { name: 'Want to try it before setting up an app?' }); await expect(region.getByText('Already added')).toBeVisible(); await expect(region.getByRole('checkbox', { name: /Hacker News/ })).toBeDisabled(); @@ -163,7 +175,7 @@ test.describe('starter pack on /welcome', () => { }, }), }); - const region = page.getByRole('region', { name: 'Start with connectors that need no keys' }); + const region = page.getByRole('region', { name: 'Want to try it before setting up an app?' }); await region.getByRole('button', { name: 'Add 3 connectors' }).click(); await expect(region.getByRole('status')).toHaveText('Added 2 connectors to Default (Test).'); @@ -175,7 +187,7 @@ test.describe('starter pack on /welcome', () => { await openWelcome(page, { install: () => ({ status: 500, body: { message: 'Server unavailable' } }), }); - const region = page.getByRole('region', { name: 'Start with connectors that need no keys' }); + const region = page.getByRole('region', { name: 'Want to try it before setting up an app?' }); await region.getByRole('button', { name: 'Add 3 connectors' }).click(); await expect(region.getByRole('alert')).toBeVisible(); @@ -193,7 +205,7 @@ test.describe('starter pack on /welcome', () => { const posted = await openWelcome(page, { connectors: { current: 0, max: 2, remaining: 2 }, }); - const region = page.getByRole('region', { name: 'Start with connectors that need no keys' }); + const region = page.getByRole('region', { name: 'Want to try it before setting up an app?' }); // Three are preselected by the catalogue, but only two fit, so the button // says "Add 2" and a third, unticked card is disabled. @@ -221,7 +233,7 @@ test.describe('starter pack at phone width', () => { test('fits the screen before and after adding, with a full-width button', async ({ page }) => { await openWelcome(page); - const region = page.getByRole('region', { name: 'Start with connectors that need no keys' }); + const region = page.getByRole('region', { name: 'Want to try it before setting up an app?' }); await expect(region.getByRole('checkbox')).toHaveCount(5); await page.waitForTimeout(300); @@ -245,3 +257,29 @@ test.describe('starter pack at phone width', () => { await shot(page, 'mobile-done'); }); }); + +test.describe('popular apps on /welcome', () => { + test('leads with the apps workspaces connect, saying what each setup asks for', async ({ page }) => { + await openWelcome(page); + const telegram = page.getByRole('link', { name: /Telegram Bot API/ }); + await expect(telegram).toHaveAttribute('href', '/connectors/setup/telegram-bot'); + await expect(telegram).toContainText('Bot token'); + await expect(page.getByRole('link', { name: /Etsy Open API v3/ })).toContainText('Sign in · Keystring and Shared secret'); + await expect(page.getByRole('link', { name: /Odoo JSON-2 API/ })).toContainText('Already added'); + }); + + test('puts the popular apps above the no-key demos', async ({ page }) => { + await openWelcome(page); + const popularTop = (await page.getByRole('link', { name: /Telegram Bot API/ }).boundingBox())!.y; + const demosTop = (await page.getByRole('region', { name: 'Want to try it before setting up an app?' }).boundingBox())!.y; + expect(popularTop).toBeLessThan(demosTop); + }); + + test('falls back to a fixed list from the catalog when the ranking fails', async ({ page }) => { + await openWelcome(page, { + popular: 'fail', + catalog: [{ slug: 'telegram-bot', name: 'Telegram Bot API', icon: 'telegram', category: 'messaging', setupKind: 'credentials', requiredEnvVars: ['TELEGRAM_BOT_TOKEN'] }], + }); + await expect(page.getByRole('link', { name: /Telegram Bot API/ })).toHaveAttribute('href', '/connectors/setup/telegram-bot'); + }); +});