From a5d014e10f024c31133a0f1b631012eff20b086e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 23 Mar 2026 13:35:52 -0700 Subject: [PATCH] [eric] first-launch onboarding modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows when no API keys configured AND no 9Router subscriptions connected: - "Welcome to OpenSwarm — Connect an AI model to get started" - Subscription buttons: Claude, ChatGPT, GitHub Copilot, Gemini (click → OAuth flow directly, no 9Router GUI) - "I have an API key" → dismisses modal, user goes to Settings - "Skip for now" → dismisses modal - Auto-checks 9Router status on mount - Dismissed state persists for session (doesn't nag) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/app/Main.tsx | 2 + .../src/app/components/OnboardingModal.tsx | 215 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 frontend/src/app/components/OnboardingModal.tsx diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 1fbe8af1..81c973d1 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -29,6 +29,7 @@ import AnalyticsOptIn from './components/AnalyticsOptIn'; import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts'; import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp'; import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext'; +import OnboardingModal from './components/OnboardingModal'; import { ClaudeTokens } from '@/shared/styles/claudeTokens'; function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') { @@ -238,6 +239,7 @@ const ThemedApp: React.FC = () => { + diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx new file mode 100644 index 00000000..2aa18330 --- /dev/null +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -0,0 +1,215 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Typography, Modal, Button } from '@mui/material'; +import { useAppSelector } from '@/shared/hooks'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { API_BASE } from '@/shared/config'; + +const SUBSCRIPTION_PROVIDERS = [ + { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A' }, + { id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C' }, + { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E' }, + { id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4' }, +]; + +const OnboardingModal: React.FC = () => { + const c = useClaudeTokens(); + const settings = useAppSelector((s) => s.settings); + const [open, setOpen] = useState(false); + const [dismissed, setDismissed] = useState(false); + const [connecting, setConnecting] = useState(null); + const [nineRouterStatus, setNineRouterStatus] = useState(null); + + // Check if user has any credentials configured + const hasAnyKey = !!( + settings.anthropic_api_key || + settings.openai_api_key || + settings.google_api_key || + settings.openrouter_api_key + ); + + // Check 9Router subscription status + useEffect(() => { + fetch(`${API_BASE}/agents/subscriptions/status`) + .then((r) => r.json()) + .then(setNineRouterStatus) + .catch(() => setNineRouterStatus(null)); + }, []); + + const hasSubscription = (() => { + if (!nineRouterStatus?.running) return false; + const connections = nineRouterStatus?.providers?.connections || []; + return connections.some((p: any) => p.isActive); + })(); + + // Show modal if no keys AND no subscriptions AND not dismissed + useEffect(() => { + if (!hasAnyKey && !hasSubscription && !dismissed && nineRouterStatus !== null) { + setOpen(true); + } else { + setOpen(false); + } + }, [hasAnyKey, hasSubscription, dismissed, nineRouterStatus]); + + const handleConnect = async (providerId: string) => { + setConnecting(providerId); + try { + const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: providerId }), + }); + const data = await r.json(); + + if (data.flow === 'device_code') { + const verifyUrl = data.verification_uri; + if (verifyUrl) window.open(verifyUrl, '_blank'); + // Poll for completion + const timer = setInterval(async () => { + try { + const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }), + }); + const pd = await pr.json(); + if (pd.success) { + clearInterval(timer); + setConnecting(null); + setOpen(false); + } + } catch {} + }, 5000); + setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000); + } else if (data.flow === 'authorization_code') { + const popup = window.open(data.auth_url, 'oauth', 'popup,width=600,height=700'); + const handler = async (event: MessageEvent) => { + const d = event.data; + if (d?.code || d?.type === 'oauth-callback') { + window.removeEventListener('message', handler); + try { + await fetch(`${API_BASE}/agents/subscriptions/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: providerId, code: d.code, + redirect_uri: data.redirect_uri, code_verifier: data.code_verifier, + state: d.state || data.state, + }), + }); + } catch {} + if (popup && !popup.closed) popup.close(); + setConnecting(null); + setOpen(false); + } + }; + window.addEventListener('message', handler); + // Fallback poll + const timer = setInterval(async () => { + try { + const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); + const sd = await sr.json(); + const conns = sd.providers?.connections || []; + if (conns.some((p: any) => p.provider === providerId && p.isActive)) { + clearInterval(timer); + window.removeEventListener('message', handler); + setConnecting(null); + setOpen(false); + } + } catch {} + }, 3000); + setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000); + } + } catch { + setConnecting(null); + } + }; + + const handleApiKey = () => { + setDismissed(true); + setOpen(false); + // User will manually go to Settings → Models to add API keys + }; + + const handleSkip = () => { + setDismissed(true); + setOpen(false); + }; + + if (!open) return null; + + return ( + + + + Welcome to OpenSwarm + + + Connect an AI model to get started + + + {/* Subscription options */} + + Use your existing subscription + + + {SUBSCRIPTION_PROVIDERS.map((p) => ( + !connecting && handleConnect(p.id)} + sx={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, + cursor: connecting ? 'wait' : 'pointer', + transition: 'border-color 0.15s, background 0.15s', + '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` }, + }} + > + + {p.name} + {p.desc} + + + {connecting === p.id ? 'Connecting...' : 'Connect →'} + + + ))} + + + {/* API key option */} + + Or use an API key + + + + I have an API key + + + Go to Settings → Models to enter your key + + + + {/* Skip */} + + + + ); +}; + +export default OnboardingModal;