[eric] onboarding flow, user ui/ux

This commit is contained in:
ciregenz
2026-04-05 00:19:36 -07:00
parent a82059e925
commit e135dff436
5 changed files with 109 additions and 4 deletions
+25
View File
@@ -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 }));
+26
View File
@@ -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); }