From 5c3e91cfd2382ed00c303b6603f751f413b260b1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 23 May 2026 06:26:30 -0700 Subject: [PATCH] [eric] split: extract Settings subscription + account + usage cards --- .../pages/Settings/sections/AccountCard.tsx | 135 ++++++++ .../Settings/sections/OpenSwarmProCard.tsx | 292 ++++++++++++++++++ .../app/pages/Settings/sections/PixelBar.tsx | 26 ++ .../Settings/sections/SubscriptionCard.tsx | 73 +++++ .../Settings/sections/SubscriptionCards.tsx | 173 +++++++++++ .../pages/Settings/sections/UsageStats.tsx | 233 ++++++++++++++ .../Settings/sections/subscriptionConnect.ts | 224 ++++++++++++++ .../sections/subscriptionProviders.ts | 8 + 8 files changed, 1164 insertions(+) create mode 100644 frontend/src/app/pages/Settings/sections/AccountCard.tsx create mode 100644 frontend/src/app/pages/Settings/sections/OpenSwarmProCard.tsx create mode 100644 frontend/src/app/pages/Settings/sections/PixelBar.tsx create mode 100644 frontend/src/app/pages/Settings/sections/SubscriptionCard.tsx create mode 100644 frontend/src/app/pages/Settings/sections/SubscriptionCards.tsx create mode 100644 frontend/src/app/pages/Settings/sections/UsageStats.tsx create mode 100644 frontend/src/app/pages/Settings/sections/subscriptionConnect.ts create mode 100644 frontend/src/app/pages/Settings/sections/subscriptionProviders.ts 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];