mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-25 05:52:22 +02:00
[eric] onboarding flow, user ui/ux
This commit is contained in:
@@ -25,6 +25,7 @@ import Views from './pages/Views/Views';
|
||||
import Customization from './pages/Customization/Customization';
|
||||
import Analytics from './pages/Analytics/Analytics';
|
||||
import OnboardingModal from './components/OnboardingModal';
|
||||
import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics';
|
||||
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
|
||||
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
|
||||
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -214,6 +215,30 @@ const ThemedApp: React.FC = () => {
|
||||
const { mode } = useThemeMode();
|
||||
const muiTheme = useMemo(() => buildMuiTheme(c, mode), [c, mode]);
|
||||
|
||||
// Track last action before user leaves and uncaught errors
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
trackEvent('app.last_action', {
|
||||
last_page: getLastPage(),
|
||||
last_action: getLastAction(),
|
||||
time_spent_seconds: getTimeSpent(),
|
||||
}, true); // useBeacon for reliable delivery during unload
|
||||
};
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
trackEvent('app.error', {
|
||||
error_message: event.message,
|
||||
error_stack: event.error?.stack?.slice(0, 500),
|
||||
last_page: getLastPage(),
|
||||
});
|
||||
};
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
window.addEventListener('error', handleError);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
window.removeEventListener('error', handleError);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MuiThemeProvider theme={muiTheme}>
|
||||
<CssBaseline />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Box, Typography, Modal, Button, CircularProgress, TextField } from '@mu
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { trackEvent } from '@/shared/analytics';
|
||||
|
||||
const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
|
||||
@@ -81,6 +82,7 @@ const OnboardingModal: React.FC = () => {
|
||||
if (nineRouterReady === null) return; // still checking
|
||||
|
||||
setOpen(true);
|
||||
trackEvent('onboarding.started', { step: 'profile' });
|
||||
}, [nineRouterReady]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
@@ -109,6 +111,7 @@ const OnboardingModal: React.FC = () => {
|
||||
if (dashboard?.id) {
|
||||
const seedRes = await fetch(`${API_BASE}/dashboards/${dashboard.id}/seed-demo`, { method: 'POST' });
|
||||
if (seedRes.ok) {
|
||||
trackEvent('onboarding.completed', { dashboard_id: dashboard.id });
|
||||
localStorage.setItem('openswarm_walkthrough_pending', 'true');
|
||||
setOpen(false);
|
||||
// Force full page load to ensure dashboard mounts fresh with walkthrough
|
||||
@@ -142,7 +145,14 @@ const OnboardingModal: React.FC = () => {
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
trackEvent('onboarding.profile_submitted', {
|
||||
has_name: !!userName.trim(),
|
||||
has_email: !!userEmail.trim(),
|
||||
use_cases: useCases,
|
||||
use_cases_count: useCases.length,
|
||||
});
|
||||
setStep('connect');
|
||||
trackEvent('onboarding.connect_started', { nine_router_ready: nineRouterReady });
|
||||
};
|
||||
|
||||
// Same connect logic as Settings/SubscriptionCards
|
||||
@@ -151,6 +161,7 @@ const OnboardingModal: React.FC = () => {
|
||||
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
|
||||
setConnecting(providerId);
|
||||
trackEvent('onboarding.provider_selected', { provider: providerId });
|
||||
|
||||
// Delay before calling connect — avoids Claude OAuth rate limit on retries
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
@@ -186,6 +197,7 @@ const OnboardingModal: React.FC = () => {
|
||||
if (pd.success) {
|
||||
clearInterval(timer);
|
||||
pollTimerRef.current = null;
|
||||
trackEvent('onboarding.provider_connected', { provider: providerId });
|
||||
dismiss();
|
||||
}
|
||||
} catch {}
|
||||
@@ -209,6 +221,7 @@ const OnboardingModal: React.FC = () => {
|
||||
window.removeEventListener('message', msgHandlerRef.current);
|
||||
msgHandlerRef.current = null;
|
||||
}
|
||||
trackEvent('onboarding.provider_connected', { provider: providerId });
|
||||
dismiss();
|
||||
}
|
||||
} catch {}
|
||||
@@ -237,6 +250,7 @@ const OnboardingModal: React.FC = () => {
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
trackEvent('onboarding.provider_connected', { provider: providerId });
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
@@ -257,8 +271,8 @@ const OnboardingModal: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKey = () => dismiss();
|
||||
const handleSkip = () => dismiss();
|
||||
const handleApiKey = () => { trackEvent('onboarding.api_key_chosen'); dismiss(); };
|
||||
const handleSkip = () => { trackEvent(step === 'profile' ? 'onboarding.profile_skipped' : 'onboarding.connect_skipped'); dismiss(); };
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { trackEvent } from '@/shared/analytics';
|
||||
|
||||
export interface WalkthroughStep {
|
||||
target: string; // data-onboarding="<value>" selector
|
||||
@@ -104,6 +105,18 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
||||
const totalSteps = STEPS.length;
|
||||
const isLastStep = currentStep === totalSteps - 1;
|
||||
|
||||
// Track walkthrough start on mount
|
||||
useEffect(() => {
|
||||
trackEvent('walkthrough.started');
|
||||
}, []);
|
||||
|
||||
// Track each step viewed
|
||||
useEffect(() => {
|
||||
if (step) {
|
||||
trackEvent('walkthrough.step_viewed', { step: currentStep, step_name: step.target || 'done' });
|
||||
}
|
||||
}, [currentStep, step]);
|
||||
|
||||
// Find target element and compute spotlight + tooltip position
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!step) return;
|
||||
@@ -198,15 +211,17 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (isLastStep) {
|
||||
trackEvent('walkthrough.completed', { steps_viewed: currentStep + 1 });
|
||||
onComplete();
|
||||
} else {
|
||||
setCurrentStep((s) => s + 1);
|
||||
}
|
||||
}, [isLastStep, onComplete]);
|
||||
}, [isLastStep, onComplete, currentStep]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
trackEvent('walkthrough.skipped', { step: currentStep, step_name: step?.target || 'done' });
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
}, [onComplete, currentStep, step]);
|
||||
|
||||
// Allow clicking the spotlight target to advance for action steps
|
||||
useEffect(() => {
|
||||
@@ -219,6 +234,7 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
||||
if (!el) return;
|
||||
|
||||
const handler = () => {
|
||||
trackEvent('walkthrough.step_action', { step: currentStep, step_name: step.target });
|
||||
setTimeout(() => handleNext(), 300);
|
||||
};
|
||||
el.addEventListener('click', handler, { once: true });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import { trackEvent } from '@/shared/analytics';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { store } from '@/shared/state/store';
|
||||
import {
|
||||
@@ -283,6 +284,7 @@ const DashboardInner: React.FC = () => {
|
||||
}, [tickEdgePan]);
|
||||
|
||||
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
|
||||
if (didDrag) trackEvent('dashboard.card_dragged');
|
||||
stopEdgePan();
|
||||
if (isMultiDragRef.current && didDrag) {
|
||||
const items = selection.selectedArray()
|
||||
@@ -320,6 +322,7 @@ const DashboardInner: React.FC = () => {
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
|
||||
trackEvent('dashboard.card_clicked', { card_type: type, shift: shiftKey });
|
||||
if (shiftKey) {
|
||||
selection.selectCard(id, type, true);
|
||||
return;
|
||||
@@ -401,11 +404,13 @@ const DashboardInner: React.FC = () => {
|
||||
const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (isCardTarget(e.target, e.currentTarget)) return;
|
||||
trackEvent('dashboard.canvas_double_clicked');
|
||||
canvas.actions.fitToView();
|
||||
}, [canvas.actions]);
|
||||
|
||||
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
|
||||
const handleCardDoubleClick = useCallback((id: string, type: CardType) => {
|
||||
trackEvent('dashboard.card_double_clicked', { card_type: type });
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = null;
|
||||
@@ -422,6 +427,19 @@ const DashboardInner: React.FC = () => {
|
||||
}, 100);
|
||||
}, [getCardRect, canvas.actions, dispatch]);
|
||||
|
||||
// Track dashboard engagement time
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
const startTime = Date.now();
|
||||
trackEvent('dashboard.opened', { dashboard_id: dashboardId });
|
||||
return () => {
|
||||
trackEvent('dashboard.closed', {
|
||||
dashboard_id: dashboardId,
|
||||
time_spent_seconds: Math.round((Date.now() - startTime) / 1000),
|
||||
});
|
||||
};
|
||||
}, [dashboardId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardId) return;
|
||||
hasFittedRef.current = false;
|
||||
@@ -743,6 +761,7 @@ const DashboardInner: React.FC = () => {
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
setSearchPaletteOpen(true);
|
||||
trackEvent('dashboard.search_opened');
|
||||
};
|
||||
window.addEventListener('keydown', handleSearch);
|
||||
return () => window.removeEventListener('keydown', handleSearch);
|
||||
@@ -954,6 +973,7 @@ const DashboardInner: React.FC = () => {
|
||||
}
|
||||
|
||||
// Collapse current, expand + navigate to target + bring to front
|
||||
trackEvent('dashboard.arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
|
||||
dispatch(collapseSession(currentFocused));
|
||||
if (target.type === 'agent') {
|
||||
dispatch(expandSession(target.id));
|
||||
@@ -1033,6 +1053,7 @@ const DashboardInner: React.FC = () => {
|
||||
selectedBrowserIds?: string[],
|
||||
) => {
|
||||
setToolbarOpen(false);
|
||||
trackEvent('dashboard.agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length });
|
||||
|
||||
const draftId = `draft-${Date.now().toString(36)}`;
|
||||
|
||||
@@ -1138,6 +1159,7 @@ const DashboardInner: React.FC = () => {
|
||||
}, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]);
|
||||
|
||||
const handleAddBrowser = useCallback(() => {
|
||||
trackEvent('dashboard.browser_added');
|
||||
const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards));
|
||||
dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds }));
|
||||
setTimeout(() => {
|
||||
@@ -1169,6 +1191,7 @@ const DashboardInner: React.FC = () => {
|
||||
|
||||
// Context-aware fit: if a card is selected, zoom to it; otherwise fit all
|
||||
const handleFitToView = useCallback(() => {
|
||||
trackEvent('dashboard.fit_to_view', { has_selection: selection.selectedIds.size > 0 });
|
||||
if (selection.selectedIds.size === 1) {
|
||||
const [[id, type]] = selection.selectedIds;
|
||||
const rect = getCardRect(id, type);
|
||||
@@ -1181,6 +1204,7 @@ const DashboardInner: React.FC = () => {
|
||||
}, [selection.selectedIds, getCardRect, canvas.actions]);
|
||||
|
||||
const handleTidy = useCallback(() => {
|
||||
trackEvent('dashboard.tidy_layout');
|
||||
const currentExpanded = store.getState().agents.expandedSessionIds;
|
||||
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { API_BASE } from './config';
|
||||
|
||||
let _lastAction = '';
|
||||
let _lastPage = '';
|
||||
let _appStartTime = Date.now();
|
||||
|
||||
export function trackEvent(eventType: string, properties?: Record<string, any>, useBeacon = false) {
|
||||
_lastAction = eventType;
|
||||
_lastPage = window.location.hash || window.location.pathname;
|
||||
|
||||
const body = JSON.stringify({ event_type: eventType, properties });
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
// sendBeacon is guaranteed to complete even during page unload
|
||||
navigator.sendBeacon(`${API_BASE}/analytics/event`, new Blob([body], { type: 'application/json' }));
|
||||
} else {
|
||||
fetch(`${API_BASE}/analytics/event`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastAction() { return _lastAction; }
|
||||
export function getLastPage() { return _lastPage; }
|
||||
export function getTimeSpent() { return Math.round((Date.now() - _appStartTime) / 1000); }
|
||||
Reference in New Issue
Block a user