diff --git a/frontend/src/app/pages/Settings/sections/AccountCard.tsx b/frontend/src/app/pages/Settings/sections/AccountCard.tsx
new file mode 100644
index 00000000..3fcc3d14
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/AccountCard.tsx
@@ -0,0 +1,135 @@
+import React, { useState } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import CircularProgress from '@mui/material/CircularProgress';
+import { useAppDispatch, useAppSelector } from '@/shared/hooks';
+import { signOut } from '@/shared/state/settingsSlice';
+import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+/** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */
+const AccountCard: React.FC = () => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ // Narrow primitive selectors so unrelated settings edits (theme, etc.) don't re-render this card.
+ const userEmail = useAppSelector((s) => s.settings.data.user_email ?? null);
+ const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
+ const signinMethod = useAppSelector((s) => s.settings.data.signin_method ?? null);
+ const hasBearer = useAppSelector((s) => Boolean(s.settings.data.openswarm_bearer_token));
+ const installId = useAppSelector((s) => s.settings.data.installation_id ?? '');
+ const proxyUrl = useAppSelector((s) => s.settings.data.openswarm_proxy_url || OPENSWARM_DEFAULT_PROXY_URL);
+ const [signingOut, setSigningOut] = useState(false);
+
+ const methodLabel = (() => {
+ switch (signinMethod) {
+ case 'google': return 'Signed in with Google';
+ case 'stripe': return 'Signed in via Stripe checkout';
+ default: return null;
+ }
+ })();
+
+ const onSignOut = async () => {
+ setSigningOut(true);
+ try {
+ await dispatch(signOut()).unwrap();
+ } catch (e) {
+ console.error('Sign out failed:', e);
+ } finally {
+ setSigningOut(false);
+ }
+ };
+
+ const onSignIn = () => {
+ // Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424).
+ const localPort = (window as any).__OPENSWARM_PORT__ || 8324;
+ const params = new URLSearchParams({
+ install_id: installId,
+ local_port: String(localPort),
+ });
+ const startUrl = proxyUrl.replace(/\/$/, '') + '/api/auth/google/start?' + params.toString();
+ const api = (window as any).openswarm;
+ if (api?.openExternal) api.openExternal(startUrl);
+ else window.open(startUrl, '_blank');
+ };
+
+ // Not signed in at all (no bearer, no user_id); inline CTA.
+ if (!userId && !hasBearer) {
+ return (
+
+ Not signed in
+
+ Sign in to sync settings across devices and back up your data.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {userEmail || 'Signed in'}
+
+ {methodLabel && (
+ {methodLabel}
+ )}
+ {!userId && hasBearer && (
+
+ Subscription connected. Sign in to also link this device to your account.
+
+ )}
+
+
+ {!userId && hasBearer && (
+
+ )}
+
+
+
+
+ );
+};
+
+export default AccountCard;
diff --git a/frontend/src/app/pages/Settings/sections/OpenSwarmProCard.tsx b/frontend/src/app/pages/Settings/sections/OpenSwarmProCard.tsx
new file mode 100644
index 00000000..9ffecf62
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/OpenSwarmProCard.tsx
@@ -0,0 +1,292 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { report } from '@/shared/serviceClient';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import LinearProgress from '@mui/material/LinearProgress';
+import { useAppDispatch } from '@/shared/hooks';
+import { disconnectSubscription } from '@/shared/state/settingsSlice';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import { API_BASE } from '@/shared/config';
+import PlanPicker from '@/app/components/PlanPicker';
+import type { OpenSwarmPlan } from '@/shared/subscription/checkout';
+
+/** Pro managed-subscription card: Subscribe CTA when disconnected, live usage + Manage/Disconnect when active. */
+interface OpenSwarmProStatus {
+ connected: boolean;
+ connection_mode?: string;
+ plan?: string | null;
+ status?: string | null;
+ expires?: string | null;
+ // Backend returns reason + last_plan on 401/402 so UI distinguishes "subscription ended" from "never subscribed".
+ reason?: 'revoked' | 'expired' | null;
+ last_plan?: string | null;
+ usage?: {
+ // Live utilization (0-100%) of the shared pool subscription's 5h window; polled ~30s.
+ utilization?: number;
+ window_hours?: number;
+ window_ends_at?: number;
+ pool_active_accounts?: number;
+ } | null;
+}
+
+/** Clamp arbitrary cloud plan name to one of the three picker tiers; defaults to pro_plus. */
+const clampPickerPlan = (plan: string | null | undefined): OpenSwarmPlan => {
+ if (plan === 'pro' || plan === 'pro_plus' || plan === 'ultra') return plan;
+ return 'pro_plus';
+};
+
+const OpenSwarmProCard: React.FC = () => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ const [status, setStatus] = useState(null);
+ const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null);
+ // Track fired usage thresholds so the event doesn't spam every 30s while counter hovers past the line.
+ const firedUsageThresholds = useRef>(new Set());
+
+ const refresh = useCallback(async () => {
+ try {
+ const r = await fetch(`${API_BASE}/subscription/status`);
+ if (r.ok) setStatus(await r.json());
+ } catch {
+ // silently ignore; cloud might be offline
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ const id = setInterval(refresh, 30_000);
+ return () => clearInterval(id);
+ }, [refresh]);
+
+ const handleManage = async () => {
+ report('subscription', 'manage_clicked', {
+ plan: status?.plan ?? null,
+ status: status?.status ?? null,
+ });
+ setBusy('manage');
+ try {
+ const r = await fetch(`${API_BASE}/subscription/portal`, { method: 'POST' });
+ if (r.ok) {
+ const { url } = await r.json();
+ const api = (window as any).openswarm;
+ if (url && api?.openExternal) api.openExternal(url);
+ else if (url) window.open(url, '_blank');
+ }
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const handleDisconnect = async () => {
+ setBusy('disconnect');
+ try {
+ await dispatch(disconnectSubscription()).unwrap();
+ await refresh();
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ // Fire usage_warning once per threshold (80%, 90%); placed before the early return so hook chain stays stable.
+ useEffect(() => {
+ if (!status?.connected) return;
+ const rawPct = status.usage?.utilization ?? 0;
+ const current = Math.max(0, Math.min(100, Math.round(rawPct)));
+ for (const threshold of [80, 90] as const) {
+ if (current >= threshold && !firedUsageThresholds.current.has(threshold)) {
+ firedUsageThresholds.current.add(threshold);
+ report('subscription', 'usage_warning', {
+ plan: status.plan ?? null,
+ utilization: current,
+ threshold,
+ });
+ }
+ }
+ }, [status]);
+
+ // Don't flash a CTA that disappears on first fetch.
+ if (!status) return null;
+
+ const isConnected = !!status.connected;
+ const usage = status.usage;
+ // Pool utilization (0-100%) for the current 5h window of the routed subscription.
+ const pct = Math.max(0, Math.min(100, Math.round(usage?.utilization ?? 0)));
+ const windowEndsAt = usage?.window_ends_at;
+
+ const expiresLabel = (() => {
+ if (!status.expires) return null;
+ try {
+ const d = new Date(status.expires);
+ return d.toLocaleDateString(undefined, {
+ month: 'short', day: 'numeric', year: 'numeric',
+ });
+ } catch {
+ return null;
+ }
+ })();
+
+ const planLabel = (() => {
+ if (!status.plan) return 'Pro';
+ return status.plan
+ .replace(/_/g, '+')
+ .replace(/\b\w/g, (s) => s.toUpperCase());
+ })();
+
+ return (
+
+
+
+
+ OpenSwarm Pro
+
+ {isConnected && (
+
+ )}
+ {!isConnected && (
+
+
+ RECOMMENDED
+
+
+ )}
+
+
+
+ {isConnected ? (
+ <>
+ {/* Canceled-in-grace banner: canceled in Stripe but still inside paid period. */}
+ {status.status === 'canceled' && (
+
+
+ Subscription canceled. You still have access until {expiresLabel || 'the end of the period'}.
+
+
+ )}
+
+ {/* Usage bar; percentage only, no raw counts. */}
+
+
+
+ Current usage
+
+
+ {pct}% used
+
+
+ = 90 ? c.status.warning : pct >= 70 ? c.status.info : c.accent.primary,
+ borderRadius: 999,
+ },
+ }}
+ />
+ {windowEndsAt && (
+
+ Resets {(() => {
+ const diff = windowEndsAt - Date.now();
+ if (diff <= 0) return 'soon';
+ const hrs = Math.floor(diff / 3600000);
+ const mins = Math.floor((diff % 3600000) / 60000);
+ if (hrs > 0) return `in ${hrs} hr ${mins} min`;
+ return `in ${mins} min`;
+ })()}
+
+ )}
+
+ {expiresLabel && status.status !== 'canceled' && (
+
+ Renews on {expiresLabel}
+
+ )}
+
+
+
+
+ {/* Canceled-in-grace: 3-tier picker inline for resubscribe; active subs use Stripe's portal instead. */}
+ {status.status === 'canceled' && (
+ <>
+
+
+ Resubscribe to keep access past {expiresLabel || 'your end date'}
+
+
+ Pick any plan below; you can keep your current tier or switch.
+
+
+
+ >
+ )}
+ >
+ ) : status.reason === 'expired' && status.last_plan ? (
+ // Expired: bearer's sub ended past grace; show picker with prior plan preselected.
+ <>
+
+ Your OpenSwarm Pro subscription has ended. Pick a plan to keep using Claude Sonnet, Opus, and Haiku without a Claude account.
+
+
+ >
+ ) : status.reason === 'revoked' && status.last_plan ? (
+ // Token revoked but sub existed; CTA language differs so user knows this isn't billing.
+ <>
+
+ Your OpenSwarm Pro access token was revoked. Pick a plan to reconnect.
+
+
+ >
+ ) : (
+ // Genuine new user; never had a subscription on this machine.
+ <>
+
+ One subscription, no Claude account needed. We handle everything behind the scenes.
+
+
+ >
+ )}
+
+ );
+};
+
+export default OpenSwarmProCard;
diff --git a/frontend/src/app/pages/Settings/sections/PixelBar.tsx b/frontend/src/app/pages/Settings/sections/PixelBar.tsx
new file mode 100644
index 00000000..87192c2d
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/PixelBar.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+
+export const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
+export const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
+
+export const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => {
+ const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0;
+ return (
+
+ {Array.from({ length: width }, (_, i) => (
+
+ ))}
+
+ );
+};
diff --git a/frontend/src/app/pages/Settings/sections/SubscriptionCard.tsx b/frontend/src/app/pages/Settings/sections/SubscriptionCard.tsx
new file mode 100644
index 00000000..1096264a
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/SubscriptionCard.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import CircularProgress from '@mui/material/CircularProgress';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import type { SubscriptionProvider } from './subscriptionProviders';
+
+const SubscriptionCard: React.FC<{ provider: SubscriptionProvider; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
+ const c = useClaudeTokens();
+ const isPreview = (provider as any).preview;
+ return (
+
+
+
+
+
+ {provider.name}
+
+ {connecting ? 'Waiting for authorization...' : provider.desc}
+
+
+
+ {isPreview ? (
+
+ Coming soon
+
+ ) : connected ? (
+ disconnecting ? (
+
+ ) : (
+
+ Disconnect
+
+ )
+ ) : connecting && userCode ? (
+
+ Enter code:
+ {userCode}
+
+ ) : connecting ? (
+
+
+ Connecting...
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default SubscriptionCard;
diff --git a/frontend/src/app/pages/Settings/sections/SubscriptionCards.tsx b/frontend/src/app/pages/Settings/sections/SubscriptionCards.tsx
new file mode 100644
index 00000000..70d82e75
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/SubscriptionCards.tsx
@@ -0,0 +1,173 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import CircularProgress from '@mui/material/CircularProgress';
+import { useAppDispatch, useAppSelector } from '@/shared/hooks';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import { fetchModels } from '@/shared/state/modelsSlice';
+import {
+ fetchSubscriptionStatus,
+ setSubscriptionStatus,
+ selectSubscriptionConnections,
+} from '@/shared/state/subscriptionsSlice';
+import { API_BASE } from '@/shared/config';
+import { SUBSCRIPTION_PROVIDERS } from './subscriptionProviders';
+import SubscriptionCard from './SubscriptionCard';
+import { runConnectFlow } from './subscriptionConnect';
+
+const SubscriptionCards: React.FC = () => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ // status + connections live in subscriptionsSlice so the onboarding gate (hasModelConnected) sees OAuth connections immediately.
+ const status = useAppSelector((s) => s.subscriptions.status);
+ const connections = useAppSelector(selectSubscriptionConnections);
+ const [connecting, setConnecting] = useState(null);
+ const [disconnecting, setDisconnecting] = useState(null);
+ const [userCode, setUserCode] = useState('');
+ const [pollTimer, setPollTimer] = useState(null);
+
+ // Thin wrapper that returns the resolved status so call sites inspecting the payload keep working.
+ const fetchStatus = useCallback(
+ async (opts?: { preserveTransient?: boolean }) => {
+ return dispatch(fetchSubscriptionStatus(opts)).unwrap();
+ },
+ [dispatch],
+ );
+
+ // Refetch model picker after sub changes so newly-connected providers surface in the dropdown immediately.
+ const refreshPickerModels = () => { dispatch(fetchModels()); };
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ // Retry initial load; a single transient probe miss would otherwise wedge the spinner until reopen.
+ for (const delay of [0, 800, 2000]) {
+ if (cancelled) return;
+ if (delay) await new Promise(r => setTimeout(r, delay));
+ const data = await fetchStatus();
+ if (data?.running) break;
+ }
+ })();
+ const interval = setInterval(() => fetchStatus({ preserveTransient: true }), 30000);
+ return () => { cancelled = true; clearInterval(interval); };
+ }, [fetchStatus]);
+
+ const isConnected = (providerId: string) =>
+ connections.some(
+ (p: any) =>
+ p.provider === providerId && (p.isActive || p.testStatus === 'active'),
+ );
+
+ const handleConnect = async (providerId: string) => {
+ if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); }
+ setConnecting(providerId);
+ setUserCode('');
+
+ // Small delay on retry to avoid Claude's rate limit.
+ await new Promise(r => setTimeout(r, 500));
+
+ try {
+ const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId }),
+ });
+ if (!r.ok) { setConnecting(null); return; }
+ const data = await r.json();
+ runConnectFlow({ providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels });
+ } catch { setConnecting(null); }
+ };
+
+ const handleDisconnect = async (providerId: string) => {
+ setDisconnecting(providerId);
+ try {
+ await fetch(`${API_BASE}/agents/subscriptions/disconnect`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId }),
+ });
+ } catch {}
+ // Wait briefly for 9Router to process, then refresh subscription status + model picker.
+ setTimeout(() => {
+ fetchStatus();
+ refreshPickerModels();
+ setDisconnecting(null);
+ }, 500);
+ };
+
+ // 4s safety-net poller while connecting; clears Connecting state whenever 9Router reports the provider isActive (handles Windows postMessage failures).
+ useEffect(() => {
+ if (!connecting) return;
+ let cancelled = false;
+ const tick = async () => {
+ try {
+ const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
+ const d = await r.json();
+ if (cancelled) return;
+ const conns = d?.providers?.connections || [];
+ if (conns.some((p: any) => p.provider === connecting && (p.isActive || p.testStatus === 'active'))) {
+ dispatch(setSubscriptionStatus(d));
+ setConnecting(null);
+ setUserCode('');
+ refreshPickerModels();
+ }
+ } catch {}
+ };
+ const id = setInterval(tick, 4000);
+ return () => { cancelled = true; clearInterval(id); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [connecting]);
+
+ if (!status) {
+ return (
+
+ {SUBSCRIPTION_PROVIDERS.map(p => (
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (!status?.running) {
+ return (
+
+
+
+ Starting subscription service...
+
+
+ This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed.
+
+
+ );
+ }
+
+ return (
+
+ {SUBSCRIPTION_PROVIDERS.map(p => (
+ handleConnect(p.id)}
+ onDisconnect={() => handleDisconnect(p.id)}
+ connecting={connecting === p.id}
+ disconnecting={disconnecting === p.id}
+ userCode={connecting === p.id ? userCode : undefined}
+ />
+ ))}
+
+ );
+};
+
+export default SubscriptionCards;
diff --git a/frontend/src/app/pages/Settings/sections/UsageStats.tsx b/frontend/src/app/pages/Settings/sections/UsageStats.tsx
new file mode 100644
index 00000000..270863fa
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/UsageStats.tsx
@@ -0,0 +1,233 @@
+import React, { useState, useEffect } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import { API_BASE } from '@/shared/config';
+import { PixelBarOuter, PIXEL_BLUE } from './PixelBar';
+
+const UsageStats: React.FC = () => {
+ const c = useClaudeTokens();
+ const [stats, setStats] = useState(null);
+
+ useEffect(() => {
+ fetch(`${API_BASE}/service/usage-summary`)
+ .then(r => r.json())
+ .then(setStats)
+ .catch(() => {});
+ }, []);
+
+ if (!stats) {
+ const skeletonPulse = {
+ animation: 'skeleton-pulse 1.5s ease-in-out infinite',
+ '@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
+ };
+ const skeletonCard = {
+ p: 1.5, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated,
+ border: `1px solid ${c.border.subtle}`, ...skeletonPulse,
+ };
+ return (
+
+
+ {Array.from({ length: 4 }, (_, i) => (
+
+
+
+
+
+ ))}
+
+
+ {Array.from({ length: 4 }, (_, i) => (
+
+
+
+
+
+ ))}
+
+
+ {Array.from({ length: 2 }, (_, i) => (
+
+
+ {Array.from({ length: 3 }, (_, j) => (
+
+
+
+
+
+
+ {Array.from({ length: 16 }, (_, k) => (
+
+ ))}
+
+
+ ))}
+
+ ))}
+
+
+ );
+ }
+
+ const formatCost = (v: number) => {
+ if (v === 0) return '$0.00';
+ if (v < 0.001) return `$${v.toFixed(6)}`;
+ if (v < 0.01) return `$${v.toFixed(5)}`;
+ if (v < 1) return `$${v.toFixed(4)}`;
+ return `$${v.toFixed(2)}`;
+ };
+ const formatDuration = (s: number) => {
+ if (s === 0) return '0s';
+ if (s < 60) return `${s.toFixed(1)}s`;
+ if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
+ return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
+ };
+ const formatTotalTime = (s: number) => {
+ if (s < 60) return `${s.toFixed(1)}s`;
+ if (s < 3600) return `${(s / 60).toFixed(1)} min`;
+ return `${(s / 3600).toFixed(1)} hrs`;
+ };
+
+ const cardSx = {
+ p: 1.5,
+ borderRadius: `${c.radius.md}px`,
+ bgcolor: c.bg.elevated,
+ border: `1px solid ${c.border.subtle}`,
+ };
+ const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 };
+ const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 };
+ const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 };
+
+ const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
+ const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
+ const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][];
+ const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
+ const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
+
+ const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
+
+ );
+
+ const totalTime = stats.avg_duration_seconds * stats.total_sessions;
+ const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0';
+ const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0';
+ const formatTokens = (n: number) => {
+ if (n === 0) return '0';
+ if (n < 1000) return String(n);
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
+ return `${(n / 1_000_000).toFixed(2)}M`;
+ };
+ const isSubscription = stats.cost_source === '9router';
+ const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : '';
+
+ return (
+
+
+
+ Total Sessions
+ {stats.total_sessions.toLocaleString()}
+
+ {statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
+
+
+
+ {isSubscription ? 'You Saved' : 'Total Cost'}
+ {formatCost(stats.total_cost_usd)}
+
+ {isSubscription
+ ? `${formatCost(stats.avg_cost_per_session)} avg, saved with your subscription`
+ : costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg, ${costSourceLabel}` : 'no cost data'}
+
+
+
+ Total Messages
+ {stats.total_messages.toLocaleString()}
+
+ {msgsPerSession} avg per session
+
+
+
+ Total Tool Calls
+ {stats.total_tool_calls.toLocaleString()}
+
+ {toolsPerSession} avg per session
+
+
+
+
+
+
+ Total Run Time
+ {formatTotalTime(totalTime)}
+ across all sessions
+
+
+ Avg Session
+ {formatDuration(stats.avg_duration_seconds)}
+ per session duration
+
+
+ Completion Rate
+ {(stats.completion_rate * 100).toFixed(1)}%
+
+ sessions finished successfully
+
+
+
+ Tokens Used
+
+ {stats.total_prompt_tokens || stats.total_completion_tokens
+ ? formatTokens((stats.total_prompt_tokens || 0) + (stats.total_completion_tokens || 0))
+ : Object.keys(stats.providers_used || {}).length}
+
+
+ {stats.total_prompt_tokens || stats.total_completion_tokens
+ ? `${formatTokens(stats.total_prompt_tokens || 0)} in, ${formatTokens(stats.total_completion_tokens || 0)} out`
+ : providerEntries.map(([p]) => p).join(', ') || 'none'}
+
+
+
+
+
+
+ Models Used
+ {modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
+ const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
+ return (
+
+
+ {model}
+
+ {count} ({pct}%)
+
+
+
+
+ );
+ }) : No sessions yet}
+
+
+
+ Top Tools
+ {toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
+ const shortName = tool.includes('__') ? tool.split('__').pop() : tool;
+ const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0';
+ return (
+
+
+ {shortName}
+
+ {count} call{count !== 1 ? 's' : ''} ({pct}%)
+
+
+
+
+ );
+ }) : No tool calls yet}
+
+
+
+ );
+};
+
+export default UsageStats;
diff --git a/frontend/src/app/pages/Settings/sections/subscriptionConnect.ts b/frontend/src/app/pages/Settings/sections/subscriptionConnect.ts
new file mode 100644
index 00000000..cbd657e4
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/subscriptionConnect.ts
@@ -0,0 +1,224 @@
+import { API_BASE } from '@/shared/config';
+
+interface ConnectCtx {
+ providerId: string;
+ data: any;
+ setConnecting: (v: string | null) => void;
+ setUserCode: (v: string) => void;
+ setPollTimer: (v: any) => void;
+ fetchStatus: (opts?: { preserveTransient?: boolean }) => Promise;
+ refreshPickerModels: () => void;
+}
+
+// Device-code OAuth flow: popup + dual poller (device-code + status) + focus-listener safety net + 5min hard timeout.
+function runDeviceCodeFlow(ctx: ConnectCtx) {
+ const { providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels } = ctx;
+ const code = data.user_code || '';
+ setUserCode(code);
+ // Named window + features so Electron's setWindowOpenHandler spawns a BrowserWindow popup, not a webview tab.
+ let devicePopup: Window | null = null;
+ if (data.verification_uri) {
+ devicePopup = window.open(data.verification_uri, 'oauth_connect', 'width=600,height=720');
+ }
+
+ // Shared cleanup; whichever detection path fires first calls this.
+ let stopped = false;
+ const onDeviceSuccess = () => {
+ if (stopped) return;
+ stopped = true;
+ clearInterval(devicePollTimer);
+ clearInterval(statusPollTimer);
+ setPollTimer(null);
+ setConnecting(null);
+ setUserCode('');
+ fetchStatus();
+ refreshPickerModels();
+ // Auto-close popup 2s after success so the "Congratulations" page is briefly visible then closes.
+ setTimeout(() => {
+ if (devicePopup && !devicePopup.closed) {
+ try { devicePopup.close(); } catch {}
+ }
+ }, 2000);
+ };
+
+ // Path 1: device-code poll via backend/9Router; primary path.
+ const pollOnce = async () => {
+ if (stopped) return;
+ 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 }),
+ });
+ if (!pr.ok) {
+ console.warn(`[subscription-poll] ${providerId}: HTTP ${pr.status}`);
+ return;
+ }
+ const pd = await pr.json();
+ if (pd.success) {
+ onDeviceSuccess();
+ } else if (!pd.pending) {
+ console.warn(`[subscription-poll] ${providerId}: not success, not pending:`, pd);
+ }
+ } catch (e) {
+ console.warn(`[subscription-poll] ${providerId}: error:`, e);
+ }
+ };
+ pollOnce();
+ const devicePollTimer = setInterval(pollOnce, 5000);
+
+ // Path 2: status poller every 2s; catches connection even when device-code poll silently errors.
+ const statusPollTimer = setInterval(async () => {
+ if (stopped) return;
+ try {
+ const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
+ const sd = await sr.json();
+ const connections = sd.providers?.connections || [];
+ if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
+ onDeviceSuccess();
+ }
+ } catch {}
+ }, 2000);
+
+ setPollTimer(devicePollTimer);
+
+ // Listen for main-window focus; Electron's popup.closed is unreliable when child BrowserWindow is destroyed.
+ let focusCheckDone = false;
+ const onFocus = async () => {
+ if (stopped || focusCheckDone) return;
+ focusCheckDone = true;
+ window.removeEventListener('focus', onFocus);
+ // Give 9Router 3s to process the token exchange before the final status check.
+ await new Promise(r => setTimeout(r, 3000));
+ if (stopped) return;
+ try {
+ const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
+ const sd = await sr.json();
+ const connections = sd.providers?.connections || [];
+ if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
+ onDeviceSuccess();
+ return;
+ }
+ } catch {}
+ // Connection not found; reset card.
+ stopped = true;
+ clearInterval(devicePollTimer);
+ clearInterval(statusPollTimer);
+ setPollTimer(null);
+ setConnecting(null);
+ setUserCode('');
+ fetchStatus();
+ };
+ // Delay focus listener; popup open can blur/refocus the parent and falsely trigger it.
+ setTimeout(() => {
+ if (!stopped) window.addEventListener('focus', onFocus);
+ }, 2000);
+
+ // 5-minute hard timeout; cleans up everything.
+ setTimeout(() => {
+ if (stopped) return;
+ stopped = true;
+ window.removeEventListener('focus', onFocus);
+ clearInterval(devicePollTimer);
+ clearInterval(statusPollTimer);
+ setPollTimer(null);
+ setConnecting(null);
+ setUserCode('');
+ if (devicePopup && !devicePopup.closed) {
+ try { devicePopup.close(); } catch {}
+ }
+ }, 300000);
+}
+
+// Authorization-code flow: external-browser or popup + status poller + postMessage/IPC relay + bounded timeout.
+function runAuthCodeFlow(ctx: ConnectCtx) {
+ const { providerId, data, setConnecting, setPollTimer, fetchStatus, refreshPickerModels } = ctx;
+ // Gemini/Google block embedded browsers; backend sets use_external_browser and exchange happens server-side via /api/subscriptions/callback. Detect via status poller (no postMessage possible).
+ const useExternal = !!data.use_external_browser;
+ let popup: Window | null = null;
+ if (useExternal && (window as any).openswarm?.openExternal) {
+ (window as any).openswarm.openExternal(data.auth_url);
+ } else {
+ popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
+ }
+
+ // Status polling: primary for external-browser flow, secondary for popup flow (postMessage is faster).
+ const statusPoller = setInterval(async () => {
+ try {
+ const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
+ const sd = await sr.json();
+ const connections = sd.providers?.connections || [];
+ if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) {
+ clearInterval(statusPoller);
+ setPollTimer(null);
+ if (!useExternal) window.removeEventListener('message', msgHandler);
+ setConnecting(null);
+ fetchStatus();
+ refreshPickerModels();
+ }
+ } catch {}
+ }, 2000);
+ setPollTimer(statusPoller);
+
+ // Shared exchange helper invoked by whichever relay path delivers the code first.
+ let exchanged = false;
+ const runExchange = async (code: string, state?: string) => {
+ if (exchanged) return;
+ exchanged = true;
+ window.removeEventListener('message', msgHandler);
+ if (ipcUnsub) ipcUnsub();
+ clearInterval(statusPoller);
+ setPollTimer(null);
+ if (popup && !popup.closed) popup.close();
+ try {
+ await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ provider: providerId, code,
+ redirect_uri: data.redirect_uri, code_verifier: data.code_verifier,
+ state: state || data.state,
+ }),
+ });
+ } catch {}
+ setConnecting(null);
+ fetchStatus();
+ refreshPickerModels();
+ };
+
+ // postMessage listener; no-ops when cross-origin redirects sever window.opener.
+ const msgHandler = async (event: MessageEvent) => {
+ const d = event.data;
+ const callbackData = d?.type === 'oauth_callback' ? d.data : d;
+ if (callbackData?.code) await runExchange(callbackData.code, callbackData.state);
+ };
+ if (!useExternal) window.addEventListener('message', msgHandler);
+
+ // Electron IPC fallback; main.js forwards callback params so exchange works when opener postMessage fails.
+ let ipcUnsub: (() => void) | null = null;
+ const ow = (window as any).openswarm;
+ if (ow && typeof ow.onOauthCallback === 'function') {
+ ipcUnsub = ow.onOauthCallback(async (cb: { code?: string; state?: string; error?: string }) => {
+ if (cb?.code) await runExchange(cb.code, cb.state);
+ });
+ }
+
+ // 3min popup / 5min external-browser; bounds the Connecting indicator, safety-net poller is the real exit.
+ const timeoutMs = useExternal ? 300_000 : 180_000;
+ setTimeout(() => {
+ clearInterval(statusPoller);
+ setPollTimer(null);
+ if (!useExternal) window.removeEventListener('message', msgHandler);
+ if (ipcUnsub) ipcUnsub();
+ setConnecting(null);
+ }, timeoutMs);
+}
+
+// Dispatch on the flow the backend chose; mirrors the original inline branch exactly.
+export function runConnectFlow(ctx: ConnectCtx) {
+ if (ctx.data.flow === 'device_code') {
+ runDeviceCodeFlow(ctx);
+ } else if (ctx.data.flow === 'authorization_code') {
+ runAuthCodeFlow(ctx);
+ } else {
+ ctx.setConnecting(null);
+ }
+}
diff --git a/frontend/src/app/pages/Settings/sections/subscriptionProviders.ts b/frontend/src/app/pages/Settings/sections/subscriptionProviders.ts
new file mode 100644
index 00000000..ad130a69
--- /dev/null
+++ b/frontend/src/app/pages/Settings/sections/subscriptionProviders.ts
@@ -0,0 +1,8 @@
+export const SUBSCRIPTION_PROVIDERS = [
+ { id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet 4.6, Opus 4.6, Haiku 4.5', color: '#E8927A', preview: false },
+ // "Gemini" routes through Antigravity OAuth (same Google sign-in, higher quota than Gemini CLI's free tier).
+ { id: 'antigravity', name: 'Gemini Advanced', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro, 2.5 Flash', color: '#4285F4', preview: false },
+ { id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
+];
+
+export type SubscriptionProvider = typeof SUBSCRIPTION_PROVIDERS[0];