[eric] first-launch onboarding modal

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) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-23 13:35:52 -07:00
co-authored by Claude Opus 4.6
parent 24ad784cd0
commit a5d014e10f
2 changed files with 217 additions and 0 deletions
+2
View File
@@ -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 = () => {
</Route>
</Routes>
<AnalyticsOptIn />
<OnboardingModal />
</UpdateListener>
</SettingsLoader>
</ShortcutsProvider>
@@ -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<string | null>(null);
const [nineRouterStatus, setNineRouterStatus] = useState<any>(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 (
<Modal open={open} onClose={handleSkip} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{
width: 480, maxWidth: '90vw', bgcolor: c.bg.surface, borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`, p: 3.5, outline: 'none',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
Connect an AI model to get started
</Typography>
{/* Subscription options */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Use your existing subscription
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{SUBSCRIPTION_PROVIDERS.map((p) => (
<Box
key={p.id}
onClick={() => !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` },
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{p.desc}</Typography>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: connecting === p.id ? c.accent.primary : c.text.tertiary }}>
{connecting === p.id ? 'Connecting...' : 'Connect →'}
</Typography>
</Box>
))}
</Box>
{/* API key option */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Or use an API key
</Typography>
<Box
onClick={handleApiKey}
sx={{
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
cursor: 'pointer', mb: 2.5,
'&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
}}
>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
I have an API key
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>
Go to Settings Models to enter your key
</Typography>
</Box>
{/* Skip */}
<Button
onClick={handleSkip}
fullWidth
sx={{ textTransform: 'none', fontSize: '0.72rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</Box>
</Modal>
);
};
export default OnboardingModal;