Files
openswarm/frontend/src/app/Main.tsx
T
ciregenz 828dfdb18b [eric] OpenSwarm Pro lifecycle fixes: cancel-in-grace shows access-until-X with Resubscribe, distinct ended and reconnect states, app-launch /sync reconciles
with Stripe, auto-revert to own_key on revoke/expire, PostHog identify segments events by plan, browser agent + capacity UX: browser sub-agents inherit the parent's Anthropic
  pick properly, spawned browser cards auto-close on natural completion, OpenSwarm-servers-maxed card with Discord waitlist CTA, auto-clear local subscription state on cloud
  revoke/expire
2026-04-16 13:56:34 -07:00

301 lines
10 KiB
TypeScript

import React, { useMemo, useEffect } from 'react';
import { Provider } from 'react-redux';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
import { store } from '../shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { API_BASE } from '@/shared/config';
import {
setAppVersion,
setUpdateAvailable,
setUpdateNotAvailable,
setDownloading,
setUpdateDownloaded,
setUpdateError,
} from '@/shared/state/updateSlice';
import AppShell from './components/Layout/AppShell';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import Skills from './pages/Skills/Skills';
import Tools from './pages/Tools/Tools';
import Modes from './pages/Modes/Modes';
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 { useDeepLink } from '@/shared/hooks/useDeepLink';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') {
return createTheme({
palette: {
mode,
background: {
default: c.bg.page,
paper: c.bg.surface,
},
primary: {
main: c.accent.primary,
dark: c.accent.pressed,
light: c.accent.hover,
},
text: {
primary: c.text.primary,
secondary: c.text.muted,
disabled: c.text.tertiary,
},
divider: c.border.medium,
error: { main: c.status.error },
warning: { main: c.status.warning },
success: { main: c.status.success },
info: { main: c.status.info },
},
typography: {
fontFamily: c.font.sans,
h1: { fontWeight: 600 },
h2: { fontWeight: 600 },
h3: { fontWeight: 600 },
h5: { fontWeight: 600 },
h6: { fontWeight: 600 },
button: { textTransform: 'none' as const, fontWeight: 500 },
},
shape: {
borderRadius: c.radius.xl,
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundColor: c.bg.page,
color: c.text.primary,
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.strong} transparent`,
},
'*': {
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.strong} transparent`,
},
'*::-webkit-scrollbar': {
width: '6px',
height: '6px',
},
'*::-webkit-scrollbar-track': {
background: 'transparent',
},
'*::-webkit-scrollbar-thumb': {
background: c.border.strong,
borderRadius: '3px',
},
'*::-webkit-scrollbar-thumb:hover': {
background: c.text.ghost,
},
'*::-webkit-scrollbar-corner': {
background: 'transparent',
},
},
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: c.radius.lg,
transition: c.transition,
textTransform: 'none' as const,
'&:active': { transform: 'scale(0.98)' },
},
contained: {
boxShadow: 'none',
'&:hover': { boxShadow: 'none' },
},
},
},
MuiPaper: {
styleOverrides: {
root: {
boxShadow: c.shadow.md,
border: `1px solid ${c.border.subtle}`,
backgroundImage: 'none',
},
},
},
MuiChip: {
styleOverrides: {
root: {
fontWeight: 500,
borderRadius: c.radius.md,
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 16,
boxShadow: c.shadow.lg,
border: `1px solid ${c.border.subtle}`,
},
},
},
MuiTooltip: {
styleOverrides: {
tooltip: {
backgroundColor: c.bg.inverse,
color: c.text.inverse,
fontSize: '0.75rem',
},
},
},
},
});
}
const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useKeyboardShortcuts();
return <>{children}<KeyboardShortcutsHelp /></>;
};
const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useDeepLink();
return <>{children}</>;
};
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const { setMode: setThemeMode } = useThemeMode();
const theme = useAppSelector((s) => s.settings.data.theme);
const loaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
// Reconcile OpenSwarm Pro state with Stripe on every launch so a
// missed webhook (cancel, upgrade, renewal) can't leave the user
// wedged on stale info. Fire-and-forget; if the cloud is unreachable
// we simply keep whatever local state we already had.
fetch(`${API_BASE}/subscription/sync`, { method: 'POST' })
.then((r) => {
if (r.ok) dispatch(fetchSettings());
})
.catch(() => { /* offline — next launch will reconcile */ });
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
}, [loaded, theme, setThemeMode]);
return <>{children}</>;
};
const UpdateListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
if (!api?.getAppVersion) return;
api.getAppVersion().then((v: string) => dispatch(setAppVersion(v)));
api.getUpdateStatus?.().then((cached) => {
if (!cached) return;
if (cached.status === 'available' && cached.info?.version) {
dispatch(setUpdateAvailable(cached.info.version));
} else if (cached.status === 'not-available') {
dispatch(setUpdateNotAvailable());
} else if (cached.status === 'downloading' && cached.info?.percent != null) {
dispatch(setDownloading(cached.info.percent));
} else if (cached.status === 'downloaded') {
dispatch(setUpdateDownloaded());
} else if (cached.status === 'error' && cached.error) {
dispatch(setUpdateError(cached.error));
}
});
const cleanups = [
api.onUpdateAvailable?.((info: OpenSwarmUpdateInfo) => dispatch(setUpdateAvailable(info.version))),
api.onUpdateNotAvailable?.(() => dispatch(setUpdateNotAvailable())),
api.onDownloadProgress?.((p: OpenSwarmDownloadProgress) => dispatch(setDownloading(p.percent))),
api.onUpdateDownloaded?.(() => dispatch(setUpdateDownloaded())),
api.onUpdateError?.((msg: string) => dispatch(setUpdateError(msg))),
];
return () => cleanups.forEach((fn: (() => void) | undefined) => fn?.());
}, [dispatch]);
return <>{children}</>;
};
const ThemedApp: React.FC = () => {
const c = useClaudeTokens();
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 />
<HashRouter>
<ShortcutsProvider>
<SettingsLoader>
<UpdateListener>
<DeepLinkListener>
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<DashboardSelection />} />
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
persistently inside AppShell so its webviews survive navigation between
routes. This route exists only so React Router matches the URL. */}
<Route path="/dashboard/:id" element={null} />
<Route path="/customization" element={<Customization />} />
<Route path="/skills" element={<Skills />} />
<Route path="/actions" element={<Tools />} />
<Route path="/modes" element={<Modes />} />
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
<OnboardingModal />
</DeepLinkListener>
</UpdateListener>
</SettingsLoader>
</ShortcutsProvider>
</HashRouter>
</MuiThemeProvider>
);
};
const Main: React.FC = () => {
return (
<Provider store={store}>
<ThemeProvider>
<ThemedApp />
</ThemeProvider>
</Provider>
);
};
export default Main;