mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] onboarding: v3 connect-first flow, live theme pad, personalized reveal
This commit is contained in:
@@ -28,6 +28,7 @@ import ErrorBoundary from './components/feedback/ErrorBoundary';
|
||||
import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice';
|
||||
|
||||
const Analytics = React.lazy(() => import('./pages/Analytics/Analytics'));
|
||||
const OnboardingV3Root = React.lazy(() => import('./components/OnboardingV3/OnboardingV3Root'));
|
||||
const OnboardingRoot = React.lazy(() =>
|
||||
import('./components/Onboarding').then((m) => ({ default: m.OnboardingRoot })),
|
||||
);
|
||||
@@ -68,7 +69,7 @@ import { useRouteTracker } from '@/shared/hooks/useRouteTracker';
|
||||
import { useDeepLink } from '@/shared/hooks/useDeepLink';
|
||||
import { useWindowFocus } from '@/shared/hooks/useWindowFocus';
|
||||
import { useInteractionHeartbeat } from '@/shared/hooks/useInteractionHeartbeat';
|
||||
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { ThemeProvider, useThemeMode, useThemeAccent, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
|
||||
function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') {
|
||||
@@ -202,7 +203,9 @@ const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children })
|
||||
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { setMode: setThemeMode } = useThemeMode();
|
||||
const { setAccent } = useThemeAccent();
|
||||
const theme = useAppSelector((s) => s.settings.data.theme);
|
||||
const accentColor = useAppSelector((s) => s.settings.data.accent_color);
|
||||
const loaded = useAppSelector((s) => s.settings.loaded);
|
||||
const allowExperimentalUpdates = useAppSelector((s) => s.settings.data.allow_experimental_updates);
|
||||
useEffect(() => {
|
||||
@@ -255,6 +258,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
if (loaded) setThemeMode(theme as 'light' | 'dark');
|
||||
}, [loaded, theme, setThemeMode]);
|
||||
|
||||
// Effect re-fires only when the persisted value changes, so live pad drags (context-only until finish() patches) never get snapped back by a mid-drag settings refetch.
|
||||
useEffect(() => {
|
||||
if (loaded) setAccent(accentColor ?? null);
|
||||
}, [loaded, accentColor, setAccent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
(window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates);
|
||||
@@ -525,6 +533,11 @@ const ThemedApp: React.FC = () => {
|
||||
<OnboardingRoot />
|
||||
</Suspense>
|
||||
</OnboardingErrorGuard>
|
||||
<OnboardingErrorGuard>
|
||||
<Suspense fallback={null}>
|
||||
<OnboardingV3Root />
|
||||
</Suspense>
|
||||
</OnboardingErrorGuard>
|
||||
</DeepLinkListener>
|
||||
</UpdateListener>
|
||||
</DefaultModelGuard>
|
||||
|
||||
@@ -65,10 +65,12 @@ const OnboardingRoot: React.FC = () => {
|
||||
!(s.onboardingProgress.completedSteps ?? []).includes('launch_agent') &&
|
||||
Object.keys(s.agents?.sessions ?? {}).length === 0,
|
||||
);
|
||||
// Onboarding v3 owns the window on a fresh install; the v2 cursor tour stays dormant until the flow resolves.
|
||||
const v3Owns = useAppSelector((s) => s.onboardingV3.flowActive);
|
||||
const welcomeFiredRef = useRef(false);
|
||||
const welcomeTimerRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (welcomeFiredRef.current || !progress.initialized || !welcomeOpenReady) return;
|
||||
if (v3Owns || welcomeFiredRef.current || !progress.initialized || !welcomeOpenReady) return;
|
||||
if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return;
|
||||
welcomeFiredRef.current = true;
|
||||
welcomeTimerRef.current = window.setTimeout(() => {
|
||||
@@ -77,7 +79,7 @@ const OnboardingRoot: React.FC = () => {
|
||||
onboardingDirector.startStep('welcome_open', { x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||
}, 600);
|
||||
return () => { if (welcomeTimerRef.current) window.clearTimeout(welcomeTimerRef.current); };
|
||||
}, [progress.initialized, welcomeOpenReady]);
|
||||
}, [progress.initialized, welcomeOpenReady, v3Owns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress.initialized) return;
|
||||
@@ -264,6 +266,7 @@ const OnboardingRoot: React.FC = () => {
|
||||
return () => onboardingDirector.detach();
|
||||
}, [store, tokens.accent.primary]);
|
||||
|
||||
if (v3Owns) return null;
|
||||
if (!settingsLoaded) return null;
|
||||
if (!progress.initialized) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import { INTEGRATIONS } from '@/app/pages/Tools/integrations';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// Order matters: the first row should read as "your work lives here" for the widest audience.
|
||||
const PICKER_IDS = ['google-workspace', 'notion', 'slack', 'github', 'discord', 'microsoft-365', 'reddit', 'youtube', 'x', 'airtable', 'hubspot', 'tiktok'];
|
||||
|
||||
// Picks do double duty: they brief the prep call on what this person's work looks like, and they seed which integrations we suggest connecting later. Nothing installs here; the MCP gate stays untouched.
|
||||
const BeatApps: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
picks: string[];
|
||||
setPicks: (ids: string[]) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, picks, setPicks, onNext, onBack }) => {
|
||||
const entries = PICKER_IDS
|
||||
.map((id) => INTEGRATIONS.find((i) => i.id === id))
|
||||
.filter((i): i is NonNullable<typeof i> => !!i);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Choose the apps you live in."
|
||||
body="I'll shape your starting canvas around them, and I can connect to them later so your agents work where you already do."
|
||||
nextLabel={picks.length > 0 ? 'Continue' : 'Skip for now'}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div style={{ width: 'min(520px, 100%)', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(112px, 1fr))', gap: 12 }}>
|
||||
{entries.map((entry, i) => {
|
||||
const picked = picks.includes(entry.id);
|
||||
return (
|
||||
<motion.button
|
||||
key={entry.id}
|
||||
onClick={() => toggle(entry.id)}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.06 + i * 0.04 }}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
|
||||
padding: '18px 10px 14px', borderRadius: c.radius.md,
|
||||
border: `1.5px solid ${picked ? c.accent.primary : c.border.medium}`,
|
||||
background: c.bg.surface, cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: picked ? `0 0 0 3px ${c.accent.primary}22` : c.shadow.sm,
|
||||
transition: 'border-color 140ms ease, box-shadow 140ms ease',
|
||||
}}
|
||||
>
|
||||
{picked && (
|
||||
<span style={{ position: 'absolute', top: 7, right: 7, width: 18, height: 18, borderRadius: 999, background: c.accent.primary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Check size={12} color="#fff" />
|
||||
</span>
|
||||
)}
|
||||
<span style={{ width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{entry.icon}</span>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 500, color: c.text.secondary, textAlign: 'center' }}>{entry.name}</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatApps;
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { fetchSubscriptionStatus, markSubscriptionConnected } from '@/shared/state/subscriptionsSlice';
|
||||
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
|
||||
import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { SUBSCRIPTION_PROVIDERS } from '@/app/pages/Settings/sections/subscription/subscriptionProviders';
|
||||
import { runConnectFlow } from '@/app/pages/Settings/sections/subscription/subscriptionConnect';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import type { ProviderIdentity } from './onboardingV3Api';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
// The single ask of the whole flow. Reuses the proven Settings connect flow verbatim; the scan disclosure lives here as one honest line with an opt-out, and the scan runs during the OAuth wait.
|
||||
const BeatConnect: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
identity: ProviderIdentity[];
|
||||
scanConsent: boolean;
|
||||
setScanConsent: (v: boolean) => void;
|
||||
onConnected: () => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, identity, scanConsent, setScanConsent, onConnected, onNext, onBack }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const connected = useAppSelector((s) => hasModelConnected(s));
|
||||
const freeTrial = useAppSelector((s) => hasFreeTrialActive(s));
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [showKeys, setShowKeys] = useState(false);
|
||||
const [keyDraft, setKeyDraft] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const connectedOnce = useRef(false);
|
||||
|
||||
useEffect(() => () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && !connectedOnce.current) {
|
||||
connectedOnce.current = true;
|
||||
onConnected();
|
||||
}
|
||||
}, [connected, onConnected]);
|
||||
|
||||
const handleConnect = useCallback(async (providerId: string) => {
|
||||
setConnecting(providerId);
|
||||
setUserCode('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(String(data?.detail ?? 'connect failed'));
|
||||
runConnectFlow({
|
||||
providerId,
|
||||
data,
|
||||
setConnecting,
|
||||
setUserCode,
|
||||
setPollTimer: (t) => { pollTimerRef.current = t; },
|
||||
fetchStatus: (opts) => dispatch(fetchSubscriptionStatus(opts)).unwrap(),
|
||||
refreshPickerModels: () => { dispatch(fetchModels()); },
|
||||
markConnected: (provider) => { dispatch(markSubscriptionConnected({ provider })); },
|
||||
});
|
||||
} catch {
|
||||
setConnecting(null);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const saveKey = useCallback(() => {
|
||||
const v = keyDraft.trim();
|
||||
if (!v) return;
|
||||
const field = v.startsWith('sk-ant-') ? 'anthropic_api_key' : v.startsWith('sk-or-') ? 'openrouter_api_key' : v.startsWith('AIza') ? 'google_api_key' : 'openai_api_key';
|
||||
dispatch(updateSettingsPatch({ [field]: v }));
|
||||
setKeyDraft('');
|
||||
}, [keyDraft, dispatch]);
|
||||
|
||||
const connectedIdentity = identity.length > 0 ? identity[0] : null;
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Connect your AI."
|
||||
body="Use the subscription you already pay for. OpenSwarm runs on your own Claude, ChatGPT, or Gemini account, right from this Mac."
|
||||
nextLabel={connected ? 'Continue' : 'Continue without connecting'}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div style={{ width: 'min(460px, 100%)', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map((p, i) => (
|
||||
<motion.button
|
||||
key={p.id}
|
||||
onClick={() => !connected && handleConnect(p.id)}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 320, damping: 26, delay: 0.1 + i * 0.08 }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '16px 18px', textAlign: 'left',
|
||||
borderRadius: c.radius.md, border: `1px solid ${c.border.medium}`, background: c.bg.surface,
|
||||
cursor: connected ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: c.shadow.sm,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 12, height: 12, borderRadius: 999, background: p.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>{p.name}</span>
|
||||
<span style={{ display: 'block', fontSize: '0.82rem', color: c.text.tertiary }}>{p.desc}</span>
|
||||
</span>
|
||||
{connecting === p.id && !connected && <span style={{ fontSize: '0.8rem', color: c.text.tertiary }}>waiting for sign-in...</span>}
|
||||
{connected && connecting === p.id && <Check size={18} color={c.status.success} />}
|
||||
</motion.button>
|
||||
))}
|
||||
{userCode && !connected && (
|
||||
<div style={{ textAlign: 'center', padding: '10px 0', fontSize: '0.9rem', color: c.text.secondary }}>
|
||||
Your code: <strong style={{ fontFamily: c.font.mono, letterSpacing: '0.08em' }}>{userCode}</strong>
|
||||
</div>
|
||||
)}
|
||||
{connected && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
style={{
|
||||
padding: '12px 16px', borderRadius: c.radius.md, background: c.status.successBg,
|
||||
color: c.status.success, fontSize: '0.9rem', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Connected{connectedIdentity?.email ? ` as ${connectedIdentity.email}` : ''}
|
||||
{connectedIdentity?.plan ? ` · ${connectedIdentity.label} ${connectedIdentity.plan}` : ''}
|
||||
</motion.div>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 9, marginTop: 6, cursor: 'pointer', fontSize: '0.8rem', color: c.text.tertiary, lineHeight: 1.5 }}>
|
||||
<input type="checkbox" checked={scanConsent} onChange={(e) => setScanConsent(e.target.checked)} style={{ marginTop: 2 }} />
|
||||
<span>
|
||||
While you sign in, take a quick local look around (app names and folder counts only) to personalize my suggestions.
|
||||
Nothing is stored or sent anywhere except to your own AI.
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 18, marginTop: 4 }}>
|
||||
<button onClick={() => setShowKeys(!showKeys)} style={{ border: 'none', background: 'transparent', padding: 0, color: c.text.ghost, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>
|
||||
Use an API key instead
|
||||
</button>
|
||||
{freeTrial && !connected && (
|
||||
<button onClick={onNext} style={{ border: 'none', background: 'transparent', padding: 0, color: c.text.ghost, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>
|
||||
Start free, connect later
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showKeys && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
placeholder="Paste an Anthropic, OpenAI, Google, or OpenRouter key"
|
||||
style={{
|
||||
flex: 1, padding: '10px 12px', borderRadius: c.radius.sm, border: `1px solid ${c.border.medium}`,
|
||||
background: c.bg.surface, color: c.text.primary, fontSize: '0.85rem', fontFamily: c.font.mono,
|
||||
}}
|
||||
/>
|
||||
<button onClick={saveKey} style={{ padding: '10px 16px', borderRadius: c.radius.sm, border: 'none', background: c.accent.primary, color: '#fff', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatConnect;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
|
||||
// Split-stage layout for the interactive beats: dark copy panel left, live artifact right. One loud button, a whisper Back, no progress bar; each beat is a room, not step 3 of 9.
|
||||
const BeatShell: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
title: string;
|
||||
body: string;
|
||||
nextLabel: string;
|
||||
nextDisabled?: boolean;
|
||||
onNext: () => void;
|
||||
onBack?: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children }) => {
|
||||
// Zen steal: controls stay inert until the entrance animation lands so a double-click from the prior beat can't fire them.
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setArmed(true), 450);
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{
|
||||
width: 'min(400px, 36%)', flexShrink: 0, display: 'flex', flexDirection: 'column',
|
||||
justifyContent: 'center', padding: '48px 44px', boxSizing: 'border-box',
|
||||
background: c.bg.inverse, color: c.text.inverse,
|
||||
}}
|
||||
>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={() => armed && onBack()}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, alignSelf: 'flex-start',
|
||||
marginBottom: 18, padding: 0, border: 'none', background: 'transparent',
|
||||
color: c.text.inverse + '77', fontSize: '0.85rem', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</button>
|
||||
)}
|
||||
<h1 style={{ margin: 0, fontSize: 'clamp(1.9rem, 3.2vw, 2.6rem)', lineHeight: 1.12, fontWeight: 700, letterSpacing: '-0.01em' }}>
|
||||
{title}
|
||||
</h1>
|
||||
<p style={{ margin: '16px 0 0', fontSize: '0.98rem', lineHeight: 1.55, color: c.text.inverse + '99', maxWidth: '34ch' }}>
|
||||
{body}
|
||||
</p>
|
||||
<div style={{ marginTop: 40 }}>
|
||||
<button
|
||||
onClick={() => armed && !nextDisabled && onNext()}
|
||||
disabled={!!nextDisabled}
|
||||
style={{
|
||||
width: '100%', padding: '13px 18px', borderRadius: c.radius.md,
|
||||
border: 'none', background: c.accent.primary, color: '#fff',
|
||||
fontSize: '0.98rem', fontWeight: 600, cursor: nextDisabled ? 'default' : 'pointer',
|
||||
opacity: nextDisabled ? 0.45 : 1, fontFamily: 'inherit',
|
||||
transition: 'background 150ms ease, opacity 150ms ease',
|
||||
}}
|
||||
>
|
||||
{nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.55, delay: 0.12 }}
|
||||
style={{
|
||||
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: c.bg.page, padding: 36, boxSizing: 'border-box', overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatShell;
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import BeatShell from './BeatShell';
|
||||
|
||||
const PRESETS = ['#ae5630', '#b0453c', '#8e5cb8', '#3a6fc4', '#2e8f6f', '#b08b2e', '#c2588f', '#5c6470'];
|
||||
|
||||
// The IKEA-effect beat: dragging on the pad drives the REAL app theme live through ThemeContext, so the product becomes theirs before they've entered it. Persistence happens at finish(), not here.
|
||||
const BeatTheme: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}> = ({ c, onNext, onBack }) => {
|
||||
const { accent, setAccent } = useThemeAccent();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const padRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const lastApplyRef = useRef(0);
|
||||
|
||||
const applyFromEvent = useCallback((clientX: number, clientY: number) => {
|
||||
const pad = padRef.current;
|
||||
if (!pad) return;
|
||||
// ~30ms throttle: every apply re-derives tokens and re-renders the tree, and pointermove fires far faster than paint needs.
|
||||
const now = performance.now();
|
||||
if (now - lastApplyRef.current < 30) return;
|
||||
lastApplyRef.current = now;
|
||||
const rect = pad.getBoundingClientRect();
|
||||
const fx = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||
const fy = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height));
|
||||
setAccent(hslToHex({ h: fx, s: 0.72, l: 0.62 - fy * 0.34 }));
|
||||
}, [setAccent]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
draggingRef.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
lastApplyRef.current = 0;
|
||||
applyFromEvent(e.clientX, e.clientY);
|
||||
}, [applyFromEvent]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (draggingRef.current) applyFromEvent(e.clientX, e.clientY);
|
||||
}, [applyFromEvent]);
|
||||
|
||||
const onPointerUp = useCallback(() => { draggingRef.current = false; }, []);
|
||||
|
||||
const dot = accent ? hexToHsl(accent) : null;
|
||||
|
||||
return (
|
||||
<BeatShell
|
||||
c={c}
|
||||
title="Make it yours."
|
||||
body="Pick a color, any color. The whole app repaints as you drag; this is your home now."
|
||||
nextLabel="Continue"
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
>
|
||||
<div style={{ width: 'min(420px, 100%)', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<motion.div
|
||||
ref={padRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
style={{
|
||||
position: 'relative', height: 240, borderRadius: c.radius.lg, cursor: 'crosshair',
|
||||
border: `1px solid ${c.border.medium}`, touchAction: 'none',
|
||||
background: 'linear-gradient(to bottom, rgba(255,255,255,0.55), rgba(0,0,0,0.45)), linear-gradient(to right, hsl(0,72%,55%), hsl(60,72%,55%), hsl(120,72%,55%), hsl(180,72%,55%), hsl(240,72%,55%), hsl(300,72%,55%), hsl(360,72%,55%))',
|
||||
}}
|
||||
>
|
||||
{dot && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: `${dot.h * 100}%`,
|
||||
top: `${((0.62 - dot.l) / 0.34) * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 26, height: 26, borderRadius: 999, background: accent ?? 'transparent',
|
||||
border: '3px solid #fff', boxShadow: '0 2px 8px rgba(0,0,0,0.35)', pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
</motion.div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
{PRESETS.map((hex) => (
|
||||
<button
|
||||
key={hex}
|
||||
onClick={() => setAccent(hex)}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: 999, background: hex, cursor: 'pointer',
|
||||
border: accent === hex ? '2.5px solid #fff' : '2.5px solid transparent',
|
||||
boxShadow: accent === hex ? `0 0 0 2px ${hex}` : 'none', padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setAccent(null)}
|
||||
style={{
|
||||
marginLeft: 'auto', border: 'none', background: 'transparent', padding: 0,
|
||||
color: c.text.ghost, fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['light', 'dark'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '11px 0', borderRadius: c.radius.md, fontFamily: 'inherit', fontSize: '0.88rem', fontWeight: 500,
|
||||
border: `1.5px solid ${mode === m ? c.accent.primary : c.border.medium}`,
|
||||
background: c.bg.surface, color: c.text.secondary, cursor: 'pointer',
|
||||
transition: 'border-color 140ms ease',
|
||||
}}
|
||||
>
|
||||
{m === 'light' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
{m === 'light' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BeatShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeatTheme;
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
|
||||
import { setFlowActive } from '@/shared/state/onboardingV3Slice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline';
|
||||
import BeatConnect from './BeatConnect';
|
||||
import BeatApps from './BeatApps';
|
||||
import BeatTheme from './BeatTheme';
|
||||
|
||||
type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme';
|
||||
|
||||
const V2_STORAGE_KEY = 'openswarm.onboarding.v2';
|
||||
|
||||
// Decides whether the v3 full-screen flow owns this launch. Only genuinely fresh installs see it: anyone with the v2 tour key or existing sessions is auto-marked skipped so an update never re-onboards a veteran.
|
||||
function useOnboardingV3Gate(): boolean {
|
||||
const dispatch = useAppDispatch();
|
||||
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
|
||||
const v3State = useAppSelector((s) => s.settings.data.onboarding_v3);
|
||||
const flowActive = useAppSelector((s) => s.onboardingV3.flowActive);
|
||||
const sessionCount = useAppSelector((s) => Object.keys(s.agents.sessions).length);
|
||||
|
||||
const hasV2History = useMemo(() => {
|
||||
try { return localStorage.getItem(V2_STORAGE_KEY) !== null; } catch { return false; }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded || v3State) return;
|
||||
if (hasV2History) {
|
||||
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' }));
|
||||
return;
|
||||
}
|
||||
dispatch(setFlowActive(true));
|
||||
}, [settingsLoaded, v3State, hasV2History, dispatch]);
|
||||
|
||||
// Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install.
|
||||
useEffect(() => {
|
||||
if (!flowActive || sessionCount === 0) return;
|
||||
dispatch(setFlowActive(false));
|
||||
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' }));
|
||||
}, [flowActive, sessionCount, dispatch]);
|
||||
|
||||
return flowActive && settingsLoaded && !v3State;
|
||||
}
|
||||
|
||||
// Full-bleed intro rooms: a soft accent blob blooms behind giant type, one arrow, nothing else.
|
||||
const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: () => void }> = ({ c, line, sub, onNext }) => (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: c.bg.inverse, overflow: 'hidden' }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.35, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 0.55 }}
|
||||
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{
|
||||
position: 'absolute', width: 560, height: 560, borderRadius: 999,
|
||||
background: `radial-gradient(circle at 42% 38%, ${c.accent.hover}, ${c.accent.primary} 55%, transparent 75%)`,
|
||||
filter: 'blur(70px)', pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 16, filter: 'blur(8px)' }}
|
||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||
transition={{ duration: 0.7, delay: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ position: 'relative', margin: 0, fontSize: 'clamp(2.6rem, 6vw, 4.4rem)', fontWeight: 700, color: c.text.inverse, letterSpacing: '-0.02em', textAlign: 'center', padding: '0 24px' }}
|
||||
>
|
||||
{line}
|
||||
</motion.h1>
|
||||
{sub && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
style={{ position: 'relative', margin: '14px 0 0', fontSize: '1.05rem', color: c.text.inverse + '99' }}
|
||||
>
|
||||
{sub}
|
||||
</motion.p>
|
||||
)}
|
||||
<motion.button
|
||||
onClick={onNext}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 1.05 }}
|
||||
whileHover={{ scale: 1.06 }}
|
||||
style={{
|
||||
position: 'relative', marginTop: 44, width: 54, height: 40, borderRadius: 12, border: 'none',
|
||||
background: 'rgba(255,255,255,0.92)', color: '#1a1a18', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={19} />
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Onboarding v3: connect-first, Arc/Zen-style staged rooms over the live app. Each beat commits its side effect on exit; the overlay dissolving IS the reveal (the seeder has already dressed the canvas behind it).
|
||||
const OnboardingV3Root: React.FC = () => {
|
||||
const active = useOnboardingV3Gate();
|
||||
const c = useClaudeTokens();
|
||||
const pipeline = useOnboardingV3Pipeline();
|
||||
const [beat, setBeat] = useState<Beat>('welcome');
|
||||
const [scanConsent, setScanConsent] = useState(true);
|
||||
const [picks, setPicks] = useState<string[]>([]);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
|
||||
const { kickIdentity, kickScan, kickPrep, finish } = pipeline;
|
||||
|
||||
const onConnected = useCallback(() => {
|
||||
kickIdentity();
|
||||
kickScan(scanConsent);
|
||||
}, [kickIdentity, kickScan, scanConsent]);
|
||||
|
||||
const leaveConnect = useCallback(() => {
|
||||
kickScan(scanConsent);
|
||||
setBeat('apps');
|
||||
}, [kickScan, scanConsent]);
|
||||
|
||||
const leaveApps = useCallback(() => {
|
||||
kickPrep(picks);
|
||||
setBeat('theme');
|
||||
}, [kickPrep, picks]);
|
||||
|
||||
const leaveTheme = useCallback(async () => {
|
||||
setFinishing(true);
|
||||
await finish('done');
|
||||
}, [finish]);
|
||||
|
||||
const skipAll = useCallback(() => { void finish('skipped'); }, [finish]);
|
||||
|
||||
// AnimatePresence stays mounted so the overlay's exit fade (the curtain lift) actually plays when active flips false.
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
key="onboarding-v3"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 100000, background: c.bg.page }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={beat}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.32 }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{beat === 'welcome' && <IntroBeat c={c} line="Welcome." onNext={() => setBeat('newos')} />}
|
||||
{beat === 'newos' && <IntroBeat c={c} line="This is your new OS." sub="A canvas where AI agents do real work for you." onNext={() => setBeat('connect')} />}
|
||||
{beat === 'connect' && (
|
||||
<BeatConnect
|
||||
c={c}
|
||||
identity={pipeline.identity}
|
||||
scanConsent={scanConsent}
|
||||
setScanConsent={setScanConsent}
|
||||
onConnected={onConnected}
|
||||
onNext={leaveConnect}
|
||||
onBack={() => setBeat('newos')}
|
||||
/>
|
||||
)}
|
||||
{beat === 'apps' && <BeatApps c={c} picks={picks} setPicks={setPicks} onNext={leaveApps} onBack={() => setBeat('connect')} />}
|
||||
{beat === 'theme' && <BeatTheme c={c} onNext={() => { void leaveTheme(); }} onBack={() => setBeat('apps')} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
{finishing && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: c.bg.page }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{ fontSize: '1.05rem', color: c.text.tertiary }}
|
||||
>
|
||||
Setting up your canvas...
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
{!finishing && (
|
||||
<button
|
||||
onClick={skipAll}
|
||||
style={{
|
||||
position: 'absolute', bottom: 18, left: 20, border: 'none', background: 'transparent',
|
||||
color: c.text.tertiary, fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', padding: 4,
|
||||
}}
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingV3Root;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { PersonalizedStarter } from '@/shared/state/settingsSlice';
|
||||
|
||||
export interface ProviderIdentity {
|
||||
provider: string;
|
||||
label: string;
|
||||
email?: string | null;
|
||||
plan?: string | null;
|
||||
}
|
||||
|
||||
export interface FolderSummary {
|
||||
name: string;
|
||||
entry_count: number;
|
||||
screenshot_count: number;
|
||||
top_extensions: string[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
apps: string[];
|
||||
folders: FolderSummary[];
|
||||
git_repo_count: number;
|
||||
has_gitconfig: boolean;
|
||||
}
|
||||
|
||||
export interface PrepResponse {
|
||||
greeting: string;
|
||||
starters: PersonalizedStarter[];
|
||||
}
|
||||
|
||||
export async function fetchIdentity(): Promise<ProviderIdentity[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/onboarding/identity`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data?.providers) ? data.providers : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScan(): Promise<ScanResult | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/onboarding/scan`, { method: 'POST' });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as ScanResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPrep(scan: ScanResult | null, pickedApps: string[]): Promise<PrepResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/onboarding/prep`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scan, picked_apps: pickedApps }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as PrepResponse;
|
||||
return Array.isArray(data?.starters) && data.starters.length > 0 ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// One human-readable line about what the scan found, reused by the reveal note so the user can see exactly what informed their starters.
|
||||
export function summarizeScan(scan: ScanResult | null): string | null {
|
||||
if (!scan) return null;
|
||||
const parts: string[] = [];
|
||||
for (const f of scan.folders) {
|
||||
if (f.screenshot_count > 20) parts.push(`${f.screenshot_count} screenshots on your ${f.name}`);
|
||||
else if (f.entry_count > 300) parts.push(`${f.entry_count} items in ${f.name}`);
|
||||
}
|
||||
if (scan.git_repo_count > 0) parts.push(`${scan.git_repo_count} git repo${scan.git_repo_count === 1 ? '' : 's'}`);
|
||||
return parts.length > 0 ? parts.slice(0, 3).join(', ') : null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
|
||||
import { setFlowActive, stageReveal } from '@/shared/state/onboardingV3Slice';
|
||||
import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import {
|
||||
fetchIdentity, runPrep, runScan, summarizeScan,
|
||||
type PrepResponse, type ProviderIdentity, type ScanResult,
|
||||
} from './onboardingV3Api';
|
||||
|
||||
// The curtain machinery: scan kicks off during the OAuth wait, prep during the theme beat, so by the reveal everything personal is already sitting in memory. Every stage fails soft; the flow never blocks on any of it.
|
||||
export function useOnboardingV3Pipeline() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { accent } = useThemeAccent();
|
||||
const { mode } = useThemeMode();
|
||||
const [identity, setIdentity] = useState<ProviderIdentity[]>([]);
|
||||
const scanRef = useRef<Promise<ScanResult | null> | null>(null);
|
||||
const prepRef = useRef<Promise<PrepResponse | null> | null>(null);
|
||||
const scanResultRef = useRef<ScanResult | null>(null);
|
||||
|
||||
const kickIdentity = useCallback(() => {
|
||||
fetchIdentity().then(setIdentity).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const kickScan = useCallback((consented: boolean) => {
|
||||
if (scanRef.current) return;
|
||||
scanRef.current = consented
|
||||
? runScan().then((r) => { scanResultRef.current = r; return r; }).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
}, []);
|
||||
|
||||
const kickPrep = useCallback((pickedApps: string[]) => {
|
||||
if (prepRef.current) return;
|
||||
const scanPromise = scanRef.current ?? Promise.resolve(null);
|
||||
prepRef.current = scanPromise.then((scan) => runPrep(scan, pickedApps)).catch(() => null);
|
||||
}, []);
|
||||
|
||||
const finish = useCallback(async (outcome: 'done' | 'skipped') => {
|
||||
if (outcome === 'skipped') {
|
||||
dispatch(setFlowActive(false));
|
||||
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped', accent_color: accent, theme: mode }));
|
||||
return;
|
||||
}
|
||||
// Cap the wait so a slow aux call degrades to generic starters instead of a hung curtain.
|
||||
const timeout = new Promise<null>((resolve) => { window.setTimeout(() => resolve(null), 15000); });
|
||||
const prep = await Promise.race([prepRef.current ?? Promise.resolve(null), timeout]);
|
||||
const greeting = prep?.greeting?.trim() || null;
|
||||
const starters = prep?.starters ?? [];
|
||||
// Await the PATCH so personalized_greeting/starters are IN settings before the reveal seeds the welcome chat; the greeting stream snapshots settings at mount.
|
||||
try {
|
||||
await dispatch(updateSettingsPatch({
|
||||
onboarding_v3: 'done',
|
||||
accent_color: accent,
|
||||
theme: mode,
|
||||
personalized_greeting: greeting,
|
||||
personalized_starters: starters,
|
||||
})).unwrap();
|
||||
} catch {}
|
||||
dispatch(stageReveal({ greeting, starters, scanSummary: summarizeScan(scanResultRef.current) }));
|
||||
dispatch(setFlowActive(false));
|
||||
}, [dispatch, accent, mode]);
|
||||
|
||||
return { identity, kickIdentity, kickScan, kickPrep, finish };
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { STARTER_CATEGORIES } from '@/shared/starterCategories';
|
||||
|
||||
@@ -13,6 +14,9 @@ const WelcomeQuickReplies: React.FC<{
|
||||
onPickBuilder: (prompt: string) => void;
|
||||
}> = ({ c, onPick, onPickBuilder }) => {
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
// Onboarding v3's prep wrote starters about THIS user's machine and apps; they lead, generic categories demote to "More ideas".
|
||||
const personalized = useAppSelector((s) => s.settings.data.personalized_starters ?? []);
|
||||
const [showCategories, setShowCategories] = React.useState(personalized.length === 0);
|
||||
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
|
||||
const isAppBuilder = currentCategory?.target === 'app-builder';
|
||||
const currentPrompts = currentCategory?.prompts ?? [];
|
||||
@@ -30,8 +34,64 @@ const WelcomeQuickReplies: React.FC<{
|
||||
style={{ padding: '4px 18px 8px 18px', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{expanded === null ? (
|
||||
{!showCategories && expanded === null ? (
|
||||
<motion.div key="personal" initial={false} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1.1 }}>
|
||||
made for you, or just type below
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.7 }}>
|
||||
{personalized.map((s, i) => (
|
||||
<motion.button
|
||||
key={s.title}
|
||||
onClick={() => onPick(s.prompt)}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 26, delay: 0.08 + i * 0.06 }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left',
|
||||
padding: '10px 14px', borderRadius: 11,
|
||||
border: `1px solid ${c.border.medium}`, background: c.bg.surface,
|
||||
color: c.text.secondary, fontSize: '0.88rem', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Sparkles size={14} color={c.accent.primary} style={{ flexShrink: 0 }} />
|
||||
{s.title}
|
||||
</motion.button>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => setShowCategories(true)}
|
||||
sx={{
|
||||
alignSelf: 'flex-start', mt: 0.9, px: 0.6, py: 0.3,
|
||||
border: 'none', background: 'transparent',
|
||||
color: c.text.ghost, fontSize: '0.82rem',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
'&:hover': { color: c.text.secondary },
|
||||
}}
|
||||
>
|
||||
More ideas
|
||||
</Box>
|
||||
</motion.div>
|
||||
) : expanded === null ? (
|
||||
<motion.div key="categories" initial={false} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{personalized.length > 0 && (
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => setShowCategories(false)}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5,
|
||||
alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3,
|
||||
border: 'none', background: 'transparent',
|
||||
color: c.text.ghost, fontSize: '0.85rem',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
'&:hover': { color: c.text.secondary },
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> your starters
|
||||
</Box>
|
||||
)}
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1.1 }}>
|
||||
pick one, or just type below
|
||||
</Typography>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { streamStart, streamDelta } from '@/shared/state/streamingSlice';
|
||||
import { addMessage, type AgentSession } from '@/shared/state/agentsSlice';
|
||||
|
||||
@@ -23,6 +23,11 @@ export function useWelcomeGreeting(
|
||||
const eligible = isDraft && !!session?.is_welcome_draft && (session?.messages?.length ?? 0) === 0;
|
||||
const sessionId = session?.id;
|
||||
const branchId = session?.active_branch_id || 'main';
|
||||
// Onboarding v3's prep wrote a greeting about THIS machine; when present it replaces the stock opener.
|
||||
const personalized = useAppSelector((s) => s.settings.data.personalized_greeting);
|
||||
const greetingText = personalized?.trim()
|
||||
? `${personalized.trim()}\n\nWhere do you want to start?`
|
||||
: WELCOME_GREETING;
|
||||
|
||||
useEffect(() => {
|
||||
if (!eligible || !sessionId || startedRef.current) return;
|
||||
@@ -30,8 +35,9 @@ export function useWelcomeGreeting(
|
||||
|
||||
dispatch(streamStart({ sessionId, messageId: GREETING_MSG_ID, role: 'assistant' }));
|
||||
|
||||
// Feed word-by-word at a real-reply cadence; useSmoothText trails it for the typed look.
|
||||
const tokens = WELCOME_GREETING.split(/(\s+)/);
|
||||
// Snapshot the text at stream start: greetingText stays OUT of the deps because a mid-stream settings refetch changing it would tear down the interval and the one-shot ref blocks a restart (greeting freezes after two words).
|
||||
const streamText = greetingText;
|
||||
const tokens = streamText.split(/(\s+)/);
|
||||
let i = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
const chunk = (tokens[i] ?? '') + (tokens[i + 1] ?? '');
|
||||
@@ -45,7 +51,7 @@ export function useWelcomeGreeting(
|
||||
message: {
|
||||
id: GREETING_MSG_ID,
|
||||
role: 'assistant',
|
||||
content: WELCOME_GREETING,
|
||||
content: streamText,
|
||||
timestamp: new Date().toISOString(),
|
||||
branch_id: branchId,
|
||||
parent_id: null,
|
||||
@@ -56,6 +62,7 @@ export function useWelcomeGreeting(
|
||||
}, 50);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eligible, sessionId, branchId, dispatch]);
|
||||
|
||||
return { greetingDone };
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { addNote, DEFAULT_CARD_W } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { clearReveal } from '@/shared/state/onboardingV3Slice';
|
||||
|
||||
interface Args {
|
||||
isActive: boolean;
|
||||
canvasEmpty: boolean;
|
||||
viewportRef: RefObject<HTMLDivElement | null>;
|
||||
canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>;
|
||||
createWelcomeDraft: () => void;
|
||||
}
|
||||
|
||||
// The reveal: onboarding v3 finished behind the curtain, so dress the canvas BEFORE the overlay's exit fade lands. Welcome chat (with personalized greeting + chips) at center, a "while you were setting up" note docked beside it. Everything seeded is a draft or a note; nothing runs until the user clicks.
|
||||
export function useOnboardingRevealSeed({ isActive, canvasEmpty, viewportRef, canvasStateRef, createWelcomeDraft }: Args): void {
|
||||
const dispatch = useAppDispatch();
|
||||
const revealPending = useAppSelector((s) => s.onboardingV3.revealPending);
|
||||
const greeting = useAppSelector((s) => s.onboardingV3.greeting);
|
||||
const starters = useAppSelector((s) => s.onboardingV3.starters);
|
||||
const scanSummary = useAppSelector((s) => s.onboardingV3.scanSummary);
|
||||
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealPending || seededRef.current || !isActive || !canvasEmpty || !settingsLoaded) return;
|
||||
seededRef.current = true;
|
||||
try {
|
||||
const vp = viewportRef.current;
|
||||
const cs = canvasStateRef.current;
|
||||
if (vp && cs) {
|
||||
const vr = vp.getBoundingClientRect();
|
||||
const cx = (vr.width / 2 - cs.panX) / cs.zoom;
|
||||
const cy = (vr.height / 2 - cs.panY) / cs.zoom;
|
||||
const lines: string[] = ['While you were setting up, I got some ideas ready.'];
|
||||
if (scanSummary) lines.push(`Spotted on this Mac: ${scanSummary}.`);
|
||||
if (starters.length > 0) lines.push(`Ready to run:\n${starters.map((s) => `- ${s.title}`).join('\n')}`);
|
||||
lines.push('Pick one in the chat, or just type what you need.');
|
||||
dispatch(addNote({ x: cx + DEFAULT_CARD_W / 2 + 48, y: cy - 140, color: 'yellow', content: lines.join('\n\n') }));
|
||||
}
|
||||
createWelcomeDraft();
|
||||
} finally {
|
||||
dispatch(clearReveal());
|
||||
}
|
||||
}, [revealPending, isActive, canvasEmpty, settingsLoaded, greeting, starters, scanSummary, viewportRef, canvasStateRef, createWelcomeDraft, dispatch]);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useCardDrag } from '../interaction/useCardDrag';
|
||||
import { useSubAgentLifecycle } from '../lifecycle/useSubAgentLifecycle';
|
||||
import { useDashboardLifecycle } from '../lifecycle/useDashboardLifecycle';
|
||||
import { useWelcomeDraft } from '../lifecycle/useWelcomeDraft';
|
||||
import { useOnboardingRevealSeed } from '../lifecycle/useOnboardingRevealSeed';
|
||||
import { useDashboardThumbnail } from './useDashboardThumbnail';
|
||||
import { useSiblingRestack } from '../lifecycle/useSiblingRestack';
|
||||
import { useAgentSpawn } from '../lifecycle/useAgentSpawn';
|
||||
@@ -163,6 +164,15 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
|
||||
spawnOriginsRef,
|
||||
});
|
||||
|
||||
// Onboarding v3 reveal: seeds the personalized note + welcome chat the instant the flow's curtain lifts.
|
||||
useOnboardingRevealSeed({
|
||||
isActive,
|
||||
canvasEmpty,
|
||||
viewportRef: canvas.viewportRef,
|
||||
canvasStateRef,
|
||||
createWelcomeDraft,
|
||||
});
|
||||
|
||||
// ---- Auto-reveal / collapse / unreveal sub-agent cards ----
|
||||
useSubAgentLifecycle({
|
||||
isActive,
|
||||
|
||||
@@ -1369,7 +1369,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
|
||||
addNote(
|
||||
state,
|
||||
action: PayloadAction<{ x?: number; y?: number; expandedSessionIds?: string[]; color?: NoteColor }>,
|
||||
action: PayloadAction<{ x?: number; y?: number; expandedSessionIds?: string[]; color?: NoteColor; content?: string }>,
|
||||
) {
|
||||
const id = `note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
let posX: number, posY: number;
|
||||
@@ -1388,7 +1388,7 @@ const dashboardLayoutSlice = createSlice({
|
||||
y: posY,
|
||||
width: DEFAULT_NOTE_W,
|
||||
height: DEFAULT_NOTE_H,
|
||||
content: '',
|
||||
content: action.payload.content ?? '',
|
||||
color: action.payload.color || 'yellow',
|
||||
zOrder: state.nextZOrder++,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { PersonalizedStarter } from '@/shared/state/settingsSlice';
|
||||
|
||||
// Transient bridge between the v3 flow overlay and the dashboard: the overlay finishes, stashes the prep payload here, and the dashboard's reveal hook consumes it exactly once to seed the canvas.
|
||||
|
||||
export interface OnboardingV3State {
|
||||
/** True while the full-screen flow owns the window; suppresses onboarding v2. */
|
||||
flowActive: boolean;
|
||||
/** One-shot: set on finish, cleared by the reveal seeder after cards land. */
|
||||
revealPending: boolean;
|
||||
greeting: string | null;
|
||||
starters: PersonalizedStarter[];
|
||||
scanSummary: string | null;
|
||||
}
|
||||
|
||||
const initialState: OnboardingV3State = {
|
||||
flowActive: false,
|
||||
revealPending: false,
|
||||
greeting: null,
|
||||
starters: [],
|
||||
scanSummary: null,
|
||||
};
|
||||
|
||||
const onboardingV3Slice = createSlice({
|
||||
name: 'onboardingV3',
|
||||
initialState,
|
||||
reducers: {
|
||||
setFlowActive(state, action: PayloadAction<boolean>) {
|
||||
state.flowActive = action.payload;
|
||||
},
|
||||
stageReveal(state, action: PayloadAction<{ greeting: string | null; starters: PersonalizedStarter[]; scanSummary: string | null }>) {
|
||||
state.revealPending = true;
|
||||
state.greeting = action.payload.greeting;
|
||||
state.starters = action.payload.starters;
|
||||
state.scanSummary = action.payload.scanSummary;
|
||||
},
|
||||
clearReveal(state) {
|
||||
state.revealPending = false;
|
||||
state.scanSummary = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setFlowActive, stageReveal, clearReveal } = onboardingV3Slice.actions;
|
||||
export default onboardingV3Slice.reducer;
|
||||
@@ -75,6 +75,17 @@ export interface AppSettings {
|
||||
signin_method?: 'google' | 'email' | 'stripe' | null;
|
||||
/** Anonymous device id (first-run generated); stitches anon to authed PostHog Persons. */
|
||||
installation_id?: string | null;
|
||||
/** Onboarding v3 lifecycle: absent = never seen, 'done'/'skipped' once resolved. */
|
||||
onboarding_v3?: string | null;
|
||||
/** User-picked accent hex from the onboarding theme pad; null = stock accent. */
|
||||
accent_color?: string | null;
|
||||
personalized_greeting?: string | null;
|
||||
personalized_starters?: PersonalizedStarter[];
|
||||
}
|
||||
|
||||
export interface PersonalizedStarter {
|
||||
title: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface ActivateSubscriptionPayload {
|
||||
|
||||
@@ -18,6 +18,7 @@ import subscriptionsReducer from './subscriptionsSlice';
|
||||
import workflowsReducer from './workflowsSlice';
|
||||
import missedRunsReducer from './missedRunsSlice';
|
||||
import onboardingProgressReducer from '@/shared/state/onboardingProgressSlice';
|
||||
import onboardingV3Reducer from '@/shared/state/onboardingV3Slice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -40,6 +41,7 @@ export const store = configureStore({
|
||||
workflows: workflowsReducer,
|
||||
missedRuns: missedRunsReducer,
|
||||
onboardingProgress: onboardingProgressReducer,
|
||||
onboardingV3: onboardingV3Reducer,
|
||||
},
|
||||
// Disable Redux Toolkit's dev-mode invariant middleware (serializable + immutable checks). These deep-walk the entire state on every dispatch, and our state is large enough to trigger 30-50ms pauses on hot paths (agent streaming, websocket heartbeats, settings sync). Console warns "SerializableStateInvariantMiddleware took 41ms" repeatedly under load. Production builds skip these middlewares anyway, so disabling them in dev makes dev behavior match prod, no surprises at packaging time. Trade-off: serializability bugs (e.g. accidentally putting a Map or Date directly into state) won't be caught at dev time. We've shipped many versions with stable slice shapes; that risk is now low.
|
||||
middleware: (getDefault) =>
|
||||
|
||||
Reference in New Issue
Block a user