mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[eric] split: extract Settings subscription + account + usage cards
This commit is contained in:
@@ -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 (
|
||||
<Box sx={{ p: 2, mb: 2, borderRadius: `${c.radius.lg}px`, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
|
||||
<Typography sx={{ fontSize: '0.85rem', color: c.text.primary, mb: 0.5 }}>Not signed in</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.25 }}>
|
||||
Sign in to sync settings across devices and back up your data.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onSignIn}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.8rem',
|
||||
borderColor: c.border.medium,
|
||||
color: c.text.primary,
|
||||
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary, bgcolor: 'transparent' },
|
||||
}}
|
||||
>
|
||||
Sign in to OpenSwarm
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, mb: 2, borderRadius: `${c.radius.lg}px`, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.9rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{userEmail || 'Signed in'}
|
||||
</Typography>
|
||||
{methodLabel && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mt: 0.25 }}>{methodLabel}</Typography>
|
||||
)}
|
||||
{!userId && hasBearer && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mt: 0.25 }}>
|
||||
Subscription connected. Sign in to also link this device to your account.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
|
||||
{!userId && hasBearer && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onSignIn}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
borderColor: c.border.medium,
|
||||
color: c.text.primary,
|
||||
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary, bgcolor: 'transparent' },
|
||||
}}
|
||||
>
|
||||
Link account
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={onSignOut}
|
||||
disabled={signingOut}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.75rem',
|
||||
color: c.text.muted,
|
||||
'&:hover': { color: c.status.error, bgcolor: 'transparent' },
|
||||
}}
|
||||
>
|
||||
{signingOut ? <CircularProgress size={14} sx={{ color: c.text.muted }} /> : 'Sign out'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountCard;
|
||||
@@ -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<OpenSwarmProStatus | null>(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<Set<number>>(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 (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${isConnected ? c.accent.primary : c.border.subtle}`,
|
||||
bgcolor: isConnected ? `${c.accent.primary}08` : c.bg.surface,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: isConnected ? 1.5 : 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary }}>
|
||||
OpenSwarm Pro
|
||||
</Typography>
|
||||
{isConnected && (
|
||||
<Box
|
||||
component="img"
|
||||
src="./logo.png"
|
||||
alt={planLabel}
|
||||
title={planLabel}
|
||||
sx={{ width: 18, height: 18, borderRadius: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}15` }}>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.accent.primary, fontWeight: 600 }}>
|
||||
RECOMMENDED
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isConnected ? (
|
||||
<>
|
||||
{/* Canceled-in-grace banner: canceled in Stripe but still inside paid period. */}
|
||||
{status.status === 'canceled' && (
|
||||
<Box sx={{
|
||||
px: 1.2, py: 0.6, mb: 1.2, borderRadius: `${c.radius.sm}px`,
|
||||
bgcolor: `${c.status.warning}15`, border: `1px solid ${c.status.warning}40`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.status.warning, fontWeight: 500 }}>
|
||||
Subscription canceled. You still have access until {expiresLabel || 'the end of the period'}.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Usage bar; percentage only, no raw counts. */}
|
||||
<Box sx={{ mb: 1.2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, fontWeight: 500 }}>
|
||||
Current usage
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
|
||||
{pct}% used
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
bgcolor: `${c.accent.primary}15`,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: pct >= 90 ? c.status.warning : pct >= 70 ? c.status.info : c.accent.primary,
|
||||
borderRadius: 999,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{windowEndsAt && (
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted, mt: 0.4 }}>
|
||||
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`;
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{expiresLabel && status.status !== 'canceled' && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mb: 1.5 }}>
|
||||
Renews on {expiresLabel}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
onClick={handleManage}
|
||||
disabled={busy !== null}
|
||||
size="small"
|
||||
variant={status.status === 'canceled' ? 'outlined' : 'contained'}
|
||||
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
|
||||
>
|
||||
{busy === 'manage' ? 'Opening…' : 'Manage in Stripe'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Canceled-in-grace: 3-tier picker inline for resubscribe; active subs use Stripe's portal instead. */}
|
||||
{status.status === 'canceled' && (
|
||||
<>
|
||||
<Box sx={{ mt: 2.5, mb: 1.5, borderTop: `1px solid ${c.border.subtle}`, pt: 2 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, fontWeight: 500, mb: 0.3 }}>
|
||||
Resubscribe to keep access past {expiresLabel || 'your end date'}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.muted }}>
|
||||
Pick any plan below; you can keep your current tier or switch.
|
||||
</Typography>
|
||||
</Box>
|
||||
<PlanPicker
|
||||
source="settings"
|
||||
defaultPlan={clampPickerPlan(status.plan ?? status.last_plan)}
|
||||
currentPlan={clampPickerPlan(status.plan ?? status.last_plan)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : status.reason === 'expired' && status.last_plan ? (
|
||||
// Expired: bearer's sub ended past grace; show picker with prior plan preselected.
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
|
||||
Your OpenSwarm Pro subscription has ended. Pick a plan to keep using Claude Sonnet, Opus, and Haiku without a Claude account.
|
||||
</Typography>
|
||||
<PlanPicker
|
||||
source="settings"
|
||||
defaultPlan={clampPickerPlan(status.last_plan)}
|
||||
currentPlan={clampPickerPlan(status.last_plan)}
|
||||
/>
|
||||
</>
|
||||
) : status.reason === 'revoked' && status.last_plan ? (
|
||||
// Token revoked but sub existed; CTA language differs so user knows this isn't billing.
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, mb: 1.5 }}>
|
||||
Your OpenSwarm Pro access token was revoked. Pick a plan to reconnect.
|
||||
</Typography>
|
||||
<PlanPicker
|
||||
source="settings"
|
||||
defaultPlan={clampPickerPlan(status.last_plan)}
|
||||
currentPlan={clampPickerPlan(status.last_plan)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Genuine new user; never had a subscription on this machine.
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.5 }}>
|
||||
One subscription, no Claude account needed. We handle everything behind the scenes.
|
||||
</Typography>
|
||||
<PlanPicker source="settings" defaultPlan="pro_plus" />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenSwarmProCard;
|
||||
@@ -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 (
|
||||
<Box sx={{ display: 'flex', gap: '1px', mt: 0.25 }}>
|
||||
{Array.from({ length: width }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 5,
|
||||
height: 5,
|
||||
bgcolor: i < filled
|
||||
? palette[Math.min(palette.length - 1, Math.floor((i / Math.max(filled - 1, 1)) * (palette.length - 1)))]
|
||||
: c.border.subtle,
|
||||
opacity: i < filled ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Box sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
|
||||
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
|
||||
opacity: isPreview ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
|
||||
transition: 'background-color 0.3s ease',
|
||||
...(connecting ? {
|
||||
animation: 'pulse-dot 1.5s ease-in-out infinite',
|
||||
'@keyframes pulse-dot': {
|
||||
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
|
||||
},
|
||||
} : {}),
|
||||
}} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
|
||||
{connecting ? 'Waiting for authorization...' : provider.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isPreview ? (
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
|
||||
Coming soon
|
||||
</Typography>
|
||||
) : connected ? (
|
||||
disconnecting ? (
|
||||
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
|
||||
) : (
|
||||
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
|
||||
Disconnect
|
||||
</Typography>
|
||||
)
|
||||
) : connecting && userCode ? (
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
|
||||
</Box>
|
||||
) : connecting ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
|
||||
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionCard;
|
||||
@@ -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<string | null>(null);
|
||||
const [disconnecting, setDisconnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [pollTimer, setPollTimer] = useState<any>(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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map(p => (
|
||||
<Box key={p.id} sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
'@keyframes skeleton-pulse': { '0%, 100%': { opacity: 0.5 }, '50%': { opacity: 0.25 } },
|
||||
}}>
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.border.medium, flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ width: 100, height: 12, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 180, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status?.running) {
|
||||
return (
|
||||
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, textAlign: 'center' }}>
|
||||
<CircularProgress size={18} sx={{ color: c.text.ghost, mb: 1 }} />
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5 }}>
|
||||
Starting subscription service...
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
|
||||
This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{SUBSCRIPTION_PROVIDERS.map(p => (
|
||||
<SubscriptionCard
|
||||
key={p.id}
|
||||
provider={p}
|
||||
connected={isConnected(p.id)}
|
||||
onConnect={() => handleConnect(p.id)}
|
||||
onDisconnect={() => handleDisconnect(p.id)}
|
||||
connecting={connecting === p.id}
|
||||
disconnecting={disconnecting === p.id}
|
||||
userCode={connecting === p.id ? userCode : undefined}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionCards;
|
||||
@@ -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<any>(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 (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Box key={i} sx={skeletonCard}>
|
||||
<Box sx={{ width: 60, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
|
||||
<Box sx={{ width: 50, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 90, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Box key={i} sx={skeletonCard}>
|
||||
<Box sx={{ width: 70, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 1 }} />
|
||||
<Box sx={{ width: 45, height: 18, bgcolor: c.border.subtle, borderRadius: 1, mb: 0.5 }} />
|
||||
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Box key={i} sx={{ ...skeletonCard, p: 2 }}>
|
||||
<Box sx={{ width: 80, height: 8, bgcolor: c.border.subtle, borderRadius: 1, mb: 2 }} />
|
||||
{Array.from({ length: 3 }, (_, j) => (
|
||||
<Box key={j} sx={{ mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box sx={{ width: 60 + j * 15, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
<Box sx={{ width: 35, height: 10, bgcolor: c.border.subtle, borderRadius: 1 }} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: '1px' }}>
|
||||
{Array.from({ length: 16 }, (_, k) => (
|
||||
<Box key={k} sx={{ width: 5, height: 5, bgcolor: c.border.subtle, opacity: k < 8 - j * 2 ? 0.6 : 0.2 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<PixelBarOuter {...props} tokens={c} />
|
||||
);
|
||||
|
||||
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 (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Sessions</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_sessions.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>{isSubscription ? 'You Saved' : 'Total Cost'}</Typography>
|
||||
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{isSubscription
|
||||
? `${formatCost(stats.avg_cost_per_session)} avg, saved with your subscription`
|
||||
: costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg, ${costSourceLabel}` : 'no cost data'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Messages</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_messages.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{msgsPerSession} avg per session
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Tool Calls</Typography>
|
||||
<Typography sx={valueSx}>{stats.total_tool_calls.toLocaleString()}</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{toolsPerSession} avg per session
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Total Run Time</Typography>
|
||||
<Typography sx={valueSx}>{formatTotalTime(totalTime)}</Typography>
|
||||
<Typography sx={subSx}>across all sessions</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Avg Session</Typography>
|
||||
<Typography sx={valueSx}>{formatDuration(stats.avg_duration_seconds)}</Typography>
|
||||
<Typography sx={subSx}>per session duration</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Completion Rate</Typography>
|
||||
<Typography sx={valueSx}>{(stats.completion_rate * 100).toFixed(1)}%</Typography>
|
||||
<Typography sx={subSx}>
|
||||
sessions finished successfully
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={cardSx}>
|
||||
<Typography sx={labelSx}>Tokens Used</Typography>
|
||||
<Typography sx={valueSx}>
|
||||
{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}
|
||||
</Typography>
|
||||
<Typography sx={subSx}>
|
||||
{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'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
|
||||
<Box sx={{ ...cardSx, p: 2 }}>
|
||||
<Typography sx={{ ...labelSx, mb: 1.5 }}>Models Used</Typography>
|
||||
{modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
|
||||
const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
|
||||
return (
|
||||
<Box key={model} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, fontWeight: 500 }}>{model}</Typography>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
|
||||
{count} ({pct}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<PixelBar value={count} max={stats.total_sessions} palette={PIXEL_BLUE} />
|
||||
</Box>
|
||||
);
|
||||
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No sessions yet</Typography>}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ...cardSx, p: 2 }}>
|
||||
<Typography sx={{ ...labelSx, mb: 1.5 }}>Top Tools</Typography>
|
||||
{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 (
|
||||
<Box key={tool} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, fontWeight: 500 }}>{shortName}</Typography>
|
||||
<Typography sx={{ fontSize: '0.62rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
|
||||
{count} call{count !== 1 ? 's' : ''} ({pct}%)
|
||||
</Typography>
|
||||
</Box>
|
||||
<PixelBar value={count} max={maxToolCount} />
|
||||
</Box>
|
||||
);
|
||||
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No tool calls yet</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageStats;
|
||||
@@ -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<any>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
Reference in New Issue
Block a user