From c8c95e429797366142aacae0d998cbc5e54f8e27 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 10 Jun 2026 00:01:37 -0700 Subject: [PATCH] [eric] free-trial: value-first onboarding reorder, arm-on-launch, exhaustion upsell card --- frontend/src/app/Main.tsx | 11 ++++++++++- .../src/app/components/Onboarding/steps/index.ts | 5 ++++- .../components/Onboarding/steps/skipPredicates.ts | 14 ++++++++++++++ .../Onboarding/steps/step01_connectModel.ts | 13 ++++++++----- .../Onboarding/steps/step02_enableActions.ts | 7 ++++--- .../Onboarding/steps/step03_launchAgent.ts | 4 +++- .../Onboarding/steps/step04_useBrowser.ts | 2 +- .../app/pages/AgentChat/bubbles/MessageBubble.tsx | 10 ++++++++++ frontend/src/shared/state/settingsSlice.ts | 6 +++++- 9 files changed, 59 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 34a29123..295be880 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -232,7 +232,16 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = .then((r) => { if (r.ok) dispatch(fetchSettings()); }) - .catch(() => {}); + .catch(() => {}) + .finally(() => { + // Arm the zero-config free trial when nothing is connected so a brand-new + // user can run an agent immediately. The backend no-ops if a real key or + // subscription exists, so this is safe to fire on every launch. + fetch(`${API_BASE}/subscription/free-trial/mint`, { method: 'POST' }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (data && data.armed) dispatch(fetchSettings()); }) + .catch(() => {}); + }); }, [dispatch]); useEffect(() => { diff --git a/frontend/src/app/components/Onboarding/steps/index.ts b/frontend/src/app/components/Onboarding/steps/index.ts index 1ff3ce2e..2c66a1e0 100644 --- a/frontend/src/app/components/Onboarding/steps/index.ts +++ b/frontend/src/app/components/Onboarding/steps/index.ts @@ -8,10 +8,13 @@ import { step06 } from './step06_agentControlAgents'; import { step07 } from './step07_installSkill'; import { step08 } from './step08_makeApp'; +// Value-first order: launch an agent (step03) FIRST so a brand-new user sees +// the product work on the free trial, then connect-your-own-model (step01). +// Everything else is "learn the features", revealed after the first win. export const STEPS: OnboardingStep[] = [ + step03, step01, step02, - step03, step04, step05, step06, diff --git a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts index 913c7873..b4907e4a 100644 --- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts +++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts @@ -26,6 +26,20 @@ export function hasModelConnected(s: RootState): boolean { return false; } +/** True while the server-funded free trial is armed (no key needed yet). */ +export function hasFreeTrialActive(s: RootState): boolean { + const d = s.settings.data as any; + return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token); +} + +/** True when free runs are running out (or already armed-and-spent). Surfaces the connect-model step. */ +export function freeRunsLow(s: RootState): boolean { + const d = s.settings.data as any; + if (!d) return false; + const remaining = d.free_trial_remaining; + return typeof remaining === 'number' && remaining <= 2; +} + export function hasAnyToolEnabled(s: RootState): boolean { const items = s.tools?.items ?? {}; // Match Tools.tsx Switch read: enabled !== false; pre-field tools treat undefined as on. diff --git a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts index 346b4233..f5cdcebc 100644 --- a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts +++ b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts @@ -1,16 +1,19 @@ import type { OnboardingStep } from './types'; import { S } from '../selectors'; -import { hasModelConnected } from './skipPredicates'; +import { hasModelConnected, hasFreeTrialActive, freeRunsLow } from './skipPredicates'; export const step01: OnboardingStep = { id: 'connect_model', stage: 'get_started', - index: 1, - title: 'Connect an AI model', - description: 'This is the brain behind your agents.', + // Moved to last in "Get started": the user only meets this after they've + // seen value, framed as "keep going". Stays suppressed while the free trial + // is armed and runs aren't low; un-suppresses when they're about to run out. + index: 2, + title: 'Keep going: connect your model', + description: 'Your free runs are limited. Add your own model to keep building.', videoSrc: './onboarding-videos/v2/01.mp4', videoDurationLabel: '0:24', - skipIf: hasModelConnected, + skipIf: (s) => hasModelConnected(s) || (hasFreeTrialActive(s) && !freeRunsLow(s)), ops: [ { kind: 'move_to', target: S.sidebarSettingsButton }, { kind: 'popup', text: 'Pop into Settings.' }, diff --git a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts index 67a6bda9..2cf2897b 100644 --- a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts +++ b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts @@ -4,13 +4,14 @@ import { isYoutubeEnabled } from './skipPredicates'; export const step02: OnboardingStep = { id: 'enable_actions', - stage: 'get_started', - index: 2, + // Demoted out of the first-run path: a feature to discover after the first win. + stage: 'learn_features', + index: 3, title: 'Enable agentic actions', description: 'Allow agents to work across your apps.', videoSrc: './onboarding-videos/v2/02.mp4', videoDurationLabel: '0:24', - // Narrowed to YouTube so users with other tools still get walked; step 3 needs YouTube on. + // Narrowed to YouTube so users with other tools still get walked. skipIf: isYoutubeEnabled, ops: [ { kind: 'move_to', target: S.sidebarActions }, diff --git a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts index d1b0429e..26bee206 100644 --- a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts +++ b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts @@ -11,7 +11,9 @@ const FALLBACK_PROMPT = export const step03: OnboardingStep = { id: 'launch_agent', stage: 'get_started', - index: 3, + // Value first: this is now step 1. With the free trial armed it runs with no + // model connected and no sign-in, so the user sees an agent work immediately. + index: 1, title: 'Launch your first Agent', description: 'Click the chat bubble to fire up a new Agent in a dashboard.', videoSrc: './onboarding-videos/v2/03.mp4', diff --git a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts index 40c6f389..20debf01 100644 --- a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts +++ b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts @@ -4,7 +4,7 @@ import { hasAnyBrowserSpawned } from './skipPredicates'; export const step04: OnboardingStep = { id: 'use_browser', - stage: 'get_started', + stage: 'learn_features', index: 4, title: 'Use the built-in browser', description: diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index b9cb6f03..5eac5b2a 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -101,6 +101,16 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro ctaAction: 'upgrade', }; } + if (/free_trial_exhausted|used your free|free OpenSwarm runs/i.test(text)) { + return { + kind: 'cap', + title: "You've used your free runs", + detail: + 'Connect a model to keep going: your own API key, an AI subscription you already pay for, or OpenSwarm Pro.', + ctaLabel: 'Connect a model', + ctaAction: 'settings', + }; + } if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) { return { kind: 'network', diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 70f8e6e5..503df792 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -57,9 +57,13 @@ export interface AppSettings { dev_mode: boolean; allow_experimental_updates: boolean; /** Managed subscription state; surfaces only when user has subscribed via cloud. */ - connection_mode?: 'own_key' | 'openswarm-pro'; + connection_mode?: 'own_key' | 'openswarm-pro' | 'free-trial'; openswarm_bearer_token?: string | null; openswarm_proxy_url?: string | null; + /** Zero-config free trial: server-owned, set by the cloud mint. remaining drives the onboarding "runs low" nudge. */ + free_trial_token?: string | null; + free_trial_remaining?: number | null; + free_trial_runs_limit?: number | null; openswarm_subscription_plan?: string | null; openswarm_subscription_expires?: string | null; openswarm_usage_cached?: SubscriptionUsage | null;