diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx
index af63b795..b77f6dd7 100644
--- a/ui/src/components/account/flow-viz/account-card.tsx
+++ b/ui/src/components/account/flow-viz/account-card.tsx
@@ -2,39 +2,25 @@
* Account Card Component for Flow Visualization
*/
-import {
- cn,
- formatQuotaPercent,
- getCodexQuotaBreakdown,
- getQuotaFailureInfo,
- getProviderMinQuota,
- getProviderResetTime,
- isClaudeQuotaResult,
- isCodexQuotaResult,
-} from '@/lib/utils';
-import { formatAccountVariantLabel } from '@/lib/account-identity';
-import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
-import {
- GripVertical,
- Loader2,
- Pause,
- Play,
- KeyRound,
- AlertTriangle,
- AlertCircle,
-} from 'lucide-react';
-import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
-import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { formatQuotaPercent, getProviderMinQuota, getQuotaFailureInfo, cn } from '@/lib/utils';
+import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
+import {
+ useAccountQuota,
+ useAccountQuotas,
+ QUOTA_SUPPORTED_PROVIDERS,
+} from '@/hooks/use-cliproxy-stats';
+import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { useTranslation } from 'react-i18next';
-import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { AccountData, DragOffset } from './types';
-import { cleanEmail } from './utils';
import { AccountCardStats } from './account-card-stats';
+import { cleanEmail } from './utils';
type Zone = 'left' | 'right' | 'top' | 'bottom';
+
const QUOTA_PROVIDER_ALIASES = [
'antigravity',
'anthropic',
@@ -57,7 +43,7 @@ interface AccountCardProps {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
- onPauseToggle?: (accountId: string, paused: boolean) => void;
+ onPauseToggle?: (accountIds: string[], paused: boolean) => void;
isPausingAccount?: boolean;
}
@@ -88,6 +74,40 @@ function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties {
}
}
+function getCompactQuotaColor(percentage: number) {
+ if (percentage > 50) return 'bg-emerald-500';
+ if (percentage > 20) return 'bg-amber-500';
+ return 'bg-red-500';
+}
+
+function getVariantMarkerLabel(audience: string, fallbackLabel?: string | null) {
+ if (audience === 'business') return 'Biz';
+ if (audience === 'personal') return 'Pers';
+
+ const normalizedFallback = fallbackLabel?.trim();
+ return normalizedFallback?.[0]?.toUpperCase() ?? '?';
+}
+
+function getGroupedVariantSummaryLabel(
+ variants: Array<{ audience: string; audienceLabel?: string | null; detailLabel?: string | null }>
+) {
+ const audiences = new Set(variants.map((variant) => variant.audience));
+
+ if (audiences.size === 2 && audiences.has('business') && audiences.has('personal')) {
+ return 'B|P';
+ }
+
+ if (variants.length === 1) {
+ const [variant] = variants;
+ return getVariantMarkerLabel(
+ variant.audience,
+ variant.audienceLabel ?? variant.detailLabel ?? null
+ );
+ }
+
+ return null;
+}
+
export function AccountCard({
account,
zone,
@@ -109,87 +129,155 @@ export function AccountCard({
const borderSide = BORDER_SIDE_MAP[zone];
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
-
- // Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
const normalizedProvider = account.provider.toLowerCase();
- const isCliproxyProvider =
+ const showQuota =
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
- const isCodexProvider = normalizedProvider === 'codex';
- const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
+ const hasGroupedVariants = (account.variants?.length ?? 0) > 1;
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
normalizedProvider,
account.id,
- isCliproxyProvider
+ showQuota && !hasGroupedVariants
);
+ const variantQuotaQueries = useAccountQuotas(
+ (account.variants ?? []).map((variant) => ({
+ provider: account.provider,
+ accountId: variant.id,
+ })),
+ showQuota && hasGroupedVariants
+ );
+ const groupedHeaderVariants = hasGroupedVariants
+ ? Array.from(
+ new Map(
+ (account.variants ?? [])
+ .slice()
+ .sort((left, right) => {
+ const order = { business: 0, personal: 1, unknown: 2 } as const;
+ return order[left.audience] - order[right.audience];
+ })
+ .map((variant) => [variant.audienceLabel ?? variant.detailLabel ?? variant.id, variant])
+ ).values()
+ )
+ : [];
+ const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel(groupedHeaderVariants);
- // Use shared helper for provider-specific minimum quota
- const minQuota = getProviderMinQuota(account.provider, quota);
- const resetTime = getProviderResetTime(account.provider, quota);
- const codexBreakdown =
- isCodexProvider && quota && isCodexQuotaResult(quota)
- ? getCodexQuotaBreakdown(quota.windows)
+ const compactMetaBadges = hasGroupedVariants ? (
+ <>
+
variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
+ .join(' • ')}
+ >
+ {groupedVariantSummaryLabel ? (
+
+ {groupedVariantSummaryLabel}
+
+ ) : (
+ groupedHeaderVariants.map((variant, index) => (
+ 0 && 'border-l border-border/50',
+ variant.audience === 'business'
+ ? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300'
+ : variant.audience === 'personal'
+ ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
+ : 'bg-muted text-muted-foreground'
+ )}
+ >
+ {getVariantMarkerLabel(
+ variant.audience,
+ variant.audienceLabel ?? variant.detailLabel ?? null
+ )}
+
+ ))
+ )}
+
+ {account.paused && (
+
+ Paused
+
+ )}
+ >
+ ) : undefined;
+
+ const groupedQuotaRows =
+ hasGroupedVariants && showQuota
+ ? (account.variants ?? []).map((variant, index) => {
+ const quotaQuery = variantQuotaQueries[index];
+ const minQuota = getProviderMinQuota(account.provider, quotaQuery?.data);
+ const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
+ const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
+ const failureInfo = getQuotaFailureInfo(quotaQuery?.data);
+ const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
+
+ return (
+
+
+ {label}
+
+ {quotaQuery?.isLoading
+ ? t('accountCard.quotaLoading')
+ : quotaValue !== null
+ ? `${quotaLabel}%`
+ : failureInfo?.label || t('accountCard.quotaUnavailable')}
+
+
+ {quotaValue !== null && (
+
+ )}
+
+ );
+ })
: null;
- const codexQuotaRows = [
- { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
- { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
- ].filter((row): row is { label: string; value: number } => row.value !== null);
- const claudeQuotaRows =
- isClaudeProvider && quota && isClaudeQuotaResult(quota)
- ? [
- {
- label: '5h',
- value:
- quota.coreUsage?.fiveHour?.remainingPercent ??
- quota.windows.find((window) => window.rateLimitType === 'five_hour')
- ?.remainingPercent ??
- null,
- },
- {
- label: 'Wk',
- value:
- quota.coreUsage?.weekly?.remainingPercent ??
- quota.windows.find((window) =>
- [
- 'seven_day',
- 'seven_day_opus',
- 'seven_day_sonnet',
- 'seven_day_oauth_apps',
- 'seven_day_cowork',
- ].includes(window.rateLimitType)
- )?.remainingPercent ??
- null,
- },
- ].filter((row): row is { label: string; value: number } => row.value !== null)
- : [];
- const compactQuotaRows = isCodexProvider
- ? codexQuotaRows
- : isClaudeProvider
- ? claudeQuotaRows
- : [];
- const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
- const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
- const failureInfo = getQuotaFailureInfo(quota);
- const FailureIcon =
- failureInfo?.label === 'Reauth'
- ? KeyRound
- : failureInfo?.tone === 'warning'
- ? AlertTriangle
- : AlertCircle;
- const failureTextClass =
- failureInfo?.tone === 'warning'
- ? 'text-amber-600 dark:text-amber-400'
- : failureInfo?.tone === 'destructive'
- ? 'text-destructive'
- : 'text-muted-foreground/70';
- // Tier badge (AGY only) - show P for Pro, U for Ultra
- const showTierBadge =
- account.provider === 'agy' &&
- account.tier &&
- account.tier !== 'unknown' &&
- account.tier !== 'free';
- const variantLabel = formatAccountVariantLabel(account.id, account.email);
+ const headerEnd = (
+ <>
+ {onPauseToggle && (
+
+
+
+
+
+
+ {account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
+
+
+
+ )}
+
+ >
+ );
return (
- {/* Header row: Email + Tier | Pause button | Drag handle */}
-
- {/* Email with tier badge inline */}
-
-
- {cleanEmail(account.email)}
-
- {variantLabel && (
-
- {variantLabel}
-
- )}
- {showTierBadge && (
-
- {account.tier}
-
- )}
-
-
- {/* Pause/Resume button */}
- {onPauseToggle && (
-
-
-
-
-
-
- {account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
-
-
-
- )}
-
- {/* Drag handle */}
-
-
-
-
+
+ {groupedQuotaRows && {groupedQuotaRows}
}
+ >
+ }
/>
- {/* Quota bar for CLIProxy accounts */}
- {isCliproxyProvider && (
-
- {quotaLoading ? (
-
-
- {t('accountCard.quotaLoading')}
-
- ) : minQuotaValue !== null ? (
-
-
-
-
-
-
- {t('accountCard.quota')}
-
- 50
- ? 'text-emerald-600 dark:text-emerald-400'
- : minQuotaValue > 20
- ? 'text-amber-500'
- : 'text-red-500'
- )}
- >
- {minQuotaLabel}%
-
-
- {compactQuotaRows.length > 0 && (
-
- {compactQuotaRows.map((row) => (
-
- {row.label} {row.value}%
-
- ))}
-
- )}
-
-
50
- ? 'bg-emerald-500'
- : minQuotaValue > 20
- ? 'bg-amber-500'
- : 'bg-red-500'
- )}
- style={{ width: `${minQuotaValue}%` }}
- />
-
-
-
-
-
-
-
-
- ) : quota?.success ? (
-
- {t('accountCard.quotaUnavailable')}
-
- ) : failureInfo ? (
-
-
-
-
-
- {failureInfo.label}
-
-
-
-
-
{failureInfo.summary}
- {failureInfo.actionHint && (
-
{failureInfo.actionHint}
- )}
- {failureInfo.technicalDetail && (
-
- {failureInfo.technicalDetail}
-
- )}
- {failureInfo.rawDetail && (
-
- {failureInfo.rawDetail}
-
- )}
-
-
-
-
- ) : null}
-
- )}
+
void;
- onPauseToggle?: (accountId: string, paused: boolean) => void;
+ onPauseToggle?: (accountIds: string[], paused: boolean) => void;
isPausingAccount?: boolean;
}
diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx
new file mode 100644
index 00000000..95c4fb57
--- /dev/null
+++ b/ui/src/components/account/shared/account-quota-panel.tsx
@@ -0,0 +1,330 @@
+import {
+ cn,
+ formatQuotaPercent,
+ getCodexQuotaBreakdown,
+ getProviderMinQuota,
+ getProviderResetTime,
+ getQuotaFailureInfo,
+ isClaudeQuotaResult,
+ isCodexQuotaResult,
+} from '@/lib/utils';
+import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
+import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
+import { Badge } from '@/components/ui/badge';
+import { Progress } from '@/components/ui/progress';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import {
+ AlertCircle,
+ AlertTriangle,
+ CheckCircle2,
+ Clock,
+ HelpCircle,
+ KeyRound,
+ Loader2,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+type AccountSurfaceMode = 'compact' | 'detailed';
+
+interface AccountQuotaPanelProps {
+ provider: string;
+ quota?: UnifiedQuotaResult;
+ quotaLoading?: boolean;
+ runtimeLastUsed?: string;
+ mode: AccountSurfaceMode;
+ className?: string;
+}
+
+function getQuotaColor(percentage: number): string {
+ const clamped = Math.max(0, Math.min(100, percentage));
+ if (clamped <= 20) return 'bg-destructive';
+ if (clamped <= 50) return 'bg-yellow-500';
+ return 'bg-green-500';
+}
+
+function formatRelativeTime(dateStr: string | undefined): string {
+ if (!dateStr) return '';
+
+ try {
+ const date = new Date(dateStr);
+ const diff = Date.now() - date.getTime();
+ if (diff < 0) return 'just now';
+
+ const minutes = Math.floor(diff / (1000 * 60));
+ const hours = Math.floor(diff / (1000 * 60 * 60));
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days > 0) return `${days}d ago`;
+ if (hours > 0) return `${hours}h ago`;
+ if (minutes > 0) return `${minutes}m ago`;
+ return 'just now';
+ } catch {
+ return '';
+ }
+}
+
+function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
+ if (!lastUsedAt) return false;
+
+ try {
+ return Date.now() - new Date(lastUsedAt).getTime() < 60 * 60 * 1000;
+ } catch {
+ return false;
+ }
+}
+
+export function AccountQuotaPanel({
+ provider,
+ quota,
+ quotaLoading,
+ runtimeLastUsed,
+ mode,
+ className,
+}: AccountQuotaPanelProps) {
+ const { t } = useTranslation();
+ const normalizedProvider = provider.toLowerCase();
+ const isCodexProvider = normalizedProvider === 'codex';
+ const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
+ const minQuota = getProviderMinQuota(provider, quota);
+ const resetTime = getProviderResetTime(provider, quota);
+ const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
+ const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
+ const failureInfo = getQuotaFailureInfo(quota);
+ const FailureIcon =
+ failureInfo?.label === 'Reauth'
+ ? KeyRound
+ : failureInfo?.tone === 'warning'
+ ? AlertTriangle
+ : AlertCircle;
+
+ const codexBreakdown =
+ isCodexProvider && quota && isCodexQuotaResult(quota)
+ ? getCodexQuotaBreakdown(quota.windows)
+ : null;
+ const compactQuotaRows = isCodexProvider
+ ? [
+ { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
+ {
+ label: mode === 'compact' ? 'Wk' : 'Weekly',
+ value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null,
+ },
+ ]
+ : isClaudeProvider && quota && isClaudeQuotaResult(quota)
+ ? [
+ {
+ label: '5h',
+ value:
+ quota.coreUsage?.fiveHour?.remainingPercent ??
+ quota.windows.find((window) => window.rateLimitType === 'five_hour')
+ ?.remainingPercent ??
+ null,
+ },
+ {
+ label: mode === 'compact' ? 'Wk' : 'Weekly',
+ value:
+ quota.coreUsage?.weekly?.remainingPercent ??
+ quota.windows.find((window) =>
+ [
+ 'seven_day',
+ 'seven_day_opus',
+ 'seven_day_sonnet',
+ 'seven_day_oauth_apps',
+ 'seven_day_cowork',
+ ].includes(window.rateLimitType)
+ )?.remainingPercent ??
+ null,
+ },
+ ]
+ : [];
+ const quotaRows = compactQuotaRows.filter(
+ (row): row is { label: string; value: number } => row.value !== null
+ );
+
+ if (quotaLoading) {
+ return (
+
+
+ {mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}
+
+ );
+ }
+
+ if (minQuotaValue !== null) {
+ return (
+
+
+
+
+ {mode === 'compact' ? (
+
+
+
+ {t('accountCard.quota')}
+
+ 50
+ ? 'text-emerald-600 dark:text-emerald-400'
+ : minQuotaValue > 20
+ ? 'text-amber-500'
+ : 'text-red-500'
+ )}
+ >
+ {minQuotaLabel}%
+
+
+ {quotaRows.length > 0 && (
+
+ {quotaRows.map((row) => (
+
+ {row.label} {row.value}%
+
+ ))}
+
+ )}
+
+
50
+ ? 'bg-emerald-500'
+ : minQuotaValue > 20
+ ? 'bg-amber-500'
+ : 'bg-red-500'
+ )}
+ style={{ width: `${minQuotaValue}%` }}
+ />
+
+
+ ) : (
+
+
+ {isRecentlyUsed(runtimeLastUsed) ? (
+ <>
+
+
+ Active · {formatRelativeTime(runtimeLastUsed)}
+
+ >
+ ) : runtimeLastUsed ? (
+ <>
+
+
+ Last used {formatRelativeTime(runtimeLastUsed)}
+
+ >
+ ) : (
+ <>
+
+ Not used yet
+ >
+ )}
+
+ {quotaRows.length > 0 ? (
+
+ {quotaRows.map((row) => (
+
+
+ {row.label}
+
+
+
{row.value}%
+
+ ))}
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+ if (quota?.success) {
+ return mode === 'compact' ? (
+
+ {t('accountCard.quotaUnavailable')}
+
+ ) : (
+
+
+
+ {t('accountCard.quotaUnavailable')}
+
+
+ );
+ }
+
+ if (!failureInfo) {
+ return null;
+ }
+
+ const failureClass =
+ failureInfo.tone === 'warning'
+ ? 'text-amber-600 dark:text-amber-400 border-amber-500/50'
+ : failureInfo.tone === 'destructive'
+ ? 'text-destructive border-destructive/50'
+ : 'text-muted-foreground/70 border-muted-foreground/50';
+
+ return (
+
+
+
+ {mode === 'compact' ? (
+
+
+ {failureInfo.label}
+
+ ) : (
+
+
+
+ {failureInfo.label}
+
+
+ )}
+
+
+
+
{failureInfo.summary}
+ {failureInfo.actionHint && (
+
{failureInfo.actionHint}
+ )}
+ {failureInfo.technicalDetail && (
+
+ {failureInfo.technicalDetail}
+
+ )}
+ {failureInfo.rawDetail && (
+
+ {failureInfo.rawDetail}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx
new file mode 100644
index 00000000..64a1795c
--- /dev/null
+++ b/ui/src/components/account/shared/account-surface-card.tsx
@@ -0,0 +1,231 @@
+import type { ReactNode } from 'react';
+import { Badge } from '@/components/ui/badge';
+import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
+import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
+import { getAccountIdentityPresentation } from '@/lib/account-identity';
+import { cn } from '@/lib/utils';
+import { Pause, Star, User } from 'lucide-react';
+
+import { AccountQuotaPanel } from './account-quota-panel';
+
+type AccountSurfaceMode = 'compact' | 'detailed';
+type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
+
+interface AccountSurfaceCardProps {
+ mode: AccountSurfaceMode;
+ provider: string;
+ accountId: string;
+ email?: string;
+ displayEmail?: string;
+ tokenFile?: string;
+ tier?: AccountTier;
+ isDefault?: boolean;
+ paused?: boolean;
+ privacyMode?: boolean;
+ showQuota?: boolean;
+ quota?: UnifiedQuotaResult;
+ quotaLoading?: boolean;
+ runtimeLastUsed?: string;
+ beforeIdentity?: ReactNode;
+ headerEnd?: ReactNode;
+ compactMetaBadges?: ReactNode;
+ bodySlot?: ReactNode;
+ footerSlot?: ReactNode;
+ quotaInsetClassName?: string;
+ className?: string;
+}
+
+function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') {
+ if (audience === 'business') {
+ return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
+ }
+
+ if (audience === 'personal') {
+ return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
+ }
+
+ return 'bg-muted text-muted-foreground';
+}
+
+function getTierBadgeClass(tier: AccountTier | undefined) {
+ return tier === 'ultra'
+ ? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
+ : 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
+}
+
+function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unknown') {
+ if (audience === 'business') return 'Biz';
+ if (audience === 'personal') return 'Pers';
+ return '?';
+}
+
+export function AccountSurfaceCard({
+ mode,
+ provider,
+ accountId,
+ email,
+ displayEmail,
+ tokenFile,
+ tier,
+ isDefault,
+ paused,
+ privacyMode,
+ showQuota,
+ quota,
+ quotaLoading,
+ runtimeLastUsed,
+ beforeIdentity,
+ headerEnd,
+ compactMetaBadges,
+ bodySlot,
+ footerSlot,
+ quotaInsetClassName,
+ className,
+}: AccountSurfaceCardProps) {
+ const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
+ const title = displayEmail || identity.email || accountId;
+ const normalizedProvider = provider.toLowerCase();
+ const showTierBadge =
+ (normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
+ tier &&
+ tier !== 'unknown' &&
+ tier !== 'free';
+ const isCompact = mode === 'compact';
+ const defaultCompactMetaBadges = (
+ <>
+ {showTierBadge && (
+
+ {tier}
+
+ )}
+ {identity.audienceLabel && (
+
+ {getCompactAudienceBadgeLabel(identity.audience)}
+
+ )}
+ {paused && (
+
+ Paused
+
+ )}
+ >
+ );
+
+ return (
+
+
+
+ {beforeIdentity}
+ {!isCompact && (
+
+
+
+
+ {showTierBadge && (
+
+ {tier === 'ultra' ? 'U' : 'P'}
+
+ )}
+
+ )}
+
+
+
+
+ {title}
+
+ {isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
+ {!isCompact && identity.audienceLabel && (
+
+ {identity.audienceLabel}
+
+ )}
+ {!isCompact && identity.detailLabel && (
+
+ {identity.detailLabel}
+
+ )}
+ {!isCompact && isDefault && (
+
+
+ Default
+
+ )}
+ {!isCompact && paused && (
+
+
+ Paused
+
+ )}
+
+
+ {bodySlot &&
{bodySlot}
}
+
+
+
+ {headerEnd && (
+
+ {headerEnd}
+
+ )}
+
+
+ {footerSlot}
+
+ {showQuota && (
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx
index 044b0284..4af6f2bd 100644
--- a/ui/src/components/cliproxy/cliproxy-dialog.tsx
+++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx
@@ -221,7 +221,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{providerAccounts.map((acc) => (
))}
diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx
index c749656b..59a401df 100644
--- a/ui/src/components/cliproxy/provider-editor/account-item.tsx
+++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx
@@ -3,9 +3,8 @@
* Displays a single OAuth account with actions and quota bar
*/
+import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
import { Button } from '@/components/ui/button';
-import { Badge } from '@/components/ui/badge';
-import { Progress } from '@/components/ui/progress';
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,88 +12,69 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
+import { getAccountStats } from '@/lib/cliproxy-account-stats';
+import { cn } from '@/lib/utils';
+import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import {
- User,
- Star,
- MoreHorizontal,
- Clock,
- Trash2,
+ AlertTriangle,
+ Check,
+ FolderCode,
Loader2,
- CheckCircle2,
- HelpCircle,
+ MoreHorizontal,
Pause,
Play,
- AlertCircle,
- AlertTriangle,
- FolderCode,
- Check,
- KeyRound,
+ Star,
+ Trash2,
} from 'lucide-react';
-import {
- cn,
- formatQuotaPercent,
- getCodexQuotaBreakdown,
- getQuotaFailureInfo,
- getProviderMinQuota,
- getProviderResetTime,
- isClaudeQuotaResult,
- isCodexQuotaResult,
-} from '@/lib/utils';
-import { formatAccountVariantLabel } from '@/lib/account-identity';
-import { getAccountStats } from '@/lib/cliproxy-account-stats';
-import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
-import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
-import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
-import { useTranslation } from 'react-i18next';
+
import type { AccountItemProps } from './types';
-/**
- * Get color class based on quota percentage
- */
-function getQuotaColor(percentage: number): string {
- const clamped = Math.max(0, Math.min(100, percentage));
- if (clamped <= 20) return 'bg-destructive';
- if (clamped <= 50) return 'bg-yellow-500';
- return 'bg-green-500';
-}
-
-/**
- * Format relative time (e.g., "5m ago", "2h ago")
- */
-function formatRelativeTime(dateStr: string | undefined): string {
- if (!dateStr) return '';
- try {
- const date = new Date(dateStr);
- const now = new Date();
- const diff = now.getTime() - date.getTime();
- if (diff < 0) return 'just now';
-
- const minutes = Math.floor(diff / (1000 * 60));
- const hours = Math.floor(diff / (1000 * 60 * 60));
- const days = Math.floor(diff / (1000 * 60 * 60 * 24));
-
- if (days > 0) return `${days}d ago`;
- if (hours > 0) return `${hours}h ago`;
- if (minutes > 0) return `${minutes}m ago`;
- return 'just now';
- } catch {
- return '';
+function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
+ if (projectId) {
+ return (
+
+
+
+
+
+
+ {projectId}
+
+
+
+
+ GCP Project ID (read-only)
+
+
+
+ );
}
-}
-/**
- * Check if account was used recently (within last hour = token likely refreshed)
- */
-function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
- if (!lastUsedAt) return false;
- try {
- const lastUsed = new Date(lastUsedAt);
- const now = new Date();
- const diff = now.getTime() - lastUsed.getTime();
- return diff < 60 * 60 * 1000; // Within last hour
- } catch {
- return false;
- }
+ return (
+
+
+
+
+
+
+
+
Missing Project ID
+
This may cause errors. Remove the account and re-add it to fetch the project ID.
+
+
+
+
+ );
}
export function AccountItem({
@@ -110,396 +90,119 @@ export function AccountItem({
selected,
onSelectChange,
}: AccountItemProps) {
- const { t } = useTranslation();
const normalizedProvider = account.provider.toLowerCase();
- const isCodexProvider = normalizedProvider === 'codex';
- const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
-
- // Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
const { data: stats } = useCliproxyStats(showQuota);
-
- // Fetch quota for all provider accounts
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
normalizedProvider,
account.id,
showQuota
);
-
- // Get last used time from runtime stats (more accurate than file)
const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt;
- const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
- // Use shared utility functions for provider-specific quota handling
- const minQuota = getProviderMinQuota(account.provider, quota);
- const nextReset = getProviderResetTime(account.provider, quota);
- const codexBreakdown =
- isCodexProvider && quota && isCodexQuotaResult(quota)
- ? getCodexQuotaBreakdown(quota.windows)
- : null;
- const codexQuotaRows = [
- { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
- { label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
- ].filter((row): row is { label: string; value: number } => row.value !== null);
- const claudeQuotaRows =
- isClaudeProvider && quota && isClaudeQuotaResult(quota)
- ? [
- {
- label: '5h',
- value:
- quota.coreUsage?.fiveHour?.remainingPercent ??
- quota.windows.find((window) => window.rateLimitType === 'five_hour')
- ?.remainingPercent ??
- null,
- },
- {
- label: 'Weekly',
- value:
- quota.coreUsage?.weekly?.remainingPercent ??
- quota.windows.find((window) =>
- [
- 'seven_day',
- 'seven_day_opus',
- 'seven_day_sonnet',
- 'seven_day_oauth_apps',
- 'seven_day_cowork',
- ].includes(window.rateLimitType)
- )?.remainingPercent ??
- null,
- },
- ].filter((row): row is { label: string; value: number } => row.value !== null)
- : [];
- const dualWindowQuotaRows = isCodexProvider
- ? codexQuotaRows
- : isClaudeProvider
- ? claudeQuotaRows
- : [];
- const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
- const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
- const failureInfo = getQuotaFailureInfo(quota);
- const FailureIcon =
- failureInfo?.label === 'Reauth'
- ? KeyRound
- : failureInfo?.tone === 'warning'
- ? AlertTriangle
- : AlertCircle;
- const failureBadgeClass =
- failureInfo?.tone === 'warning'
- ? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
- : failureInfo?.tone === 'destructive'
- ? 'border-destructive/50 text-destructive'
- : 'border-muted-foreground/50 text-muted-foreground';
- const variantLabel = formatAccountVariantLabel(account.id, account.email);
+ const beforeIdentity =
+ selectable || onPauseToggle ? (
+
+ {selectable && (
+
+ )}
+ {onPauseToggle && (
+
+
+
+
+
+
+ {account.paused ? 'Resume account' : 'Pause account'}
+
+
+
+ )}
+
+ ) : undefined;
+
+ const headerEnd = (
+
+
+
+
+
+ {!account.isDefault && (
+
+
+ Set as default
+
+ )}
+
+
+ {isRemoving ? 'Removing...' : 'Remove account'}
+
+
+
+ );
return (
-
-
- {/* Selection checkbox for bulk actions */}
- {selectable && (
-
- )}
- {/* Pause/Resume toggle button - visible left of avatar */}
- {onPauseToggle && (
-
-
-
-
-
-
- {account.paused ? 'Resume account' : 'Pause account'}
-
-
-
- )}
- {/* Avatar with tier badge overlay */}
-
-
-
-
- {/* Tier badge - fixed position on avatar */}
- {account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
-
- {account.tier === 'ultra' ? 'U' : 'P'}
-
- )}
-
-
-
-
- {account.email || account.id}
-
- {variantLabel && (
-
- {variantLabel}
-
- )}
- {account.isDefault && (
-
-
- Default
-
- )}
- {account.paused && (
-
-
- Paused
-
- )}
-
- {/* Project ID for Antigravity accounts - read-only */}
- {account.provider === 'agy' && (
-
- {account.projectId ? (
-
-
-
-
-
-
- {account.projectId}
-
-
-
-
- GCP Project ID (read-only)
-
-
-
- ) : (
-
-
-
-
-
-
-
-
Missing Project ID
-
- This may cause errors. Remove the account and re-add it to fetch the
- project ID.
-
-
-
-
-
- )}
-
- )}
- {account.lastUsedAt && (
-
-
- Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
-
- )}
-
-
-
-
-
-
-
-
- {!account.isDefault && (
-
-
- Set as default
-
- )}
-
-
- {isRemoving ? 'Removing...' : 'Remove account'}
-
-
-
-
-
- {/* Quota bar - supports all providers with quota API */}
- {showQuota && (
-
- {quotaLoading ? (
-
-
- Loading quota...
-
- ) : minQuotaValue !== null ? (
-
- {/* Status indicator based on runtime usage, not file state */}
-
- {wasRecentlyUsed ? (
- <>
-
-
- Active · {formatRelativeTime(runtimeLastUsed)}
-
- >
- ) : runtimeLastUsed ? (
- <>
-
-
- Last used {formatRelativeTime(runtimeLastUsed)}
-
- >
- ) : (
- <>
-
- Not used yet
- >
- )}
-
- {/* Quota bar */}
-
-
-
- {dualWindowQuotaRows.length > 0 ? (
-
- {dualWindowQuotaRows.map((row) => (
-
-
- {row.label}
-
-
-
- {row.value}%
-
-
- ))}
-
- ) : (
-
-
-
- {minQuotaLabel}%
-
-
- )}
-
-
-
-
-
-
-
- ) : quota?.success ? (
-
-
-
- {t('accountCard.quotaUnavailable')}
-
-
- ) : failureInfo ? (
-
-
-
-
-
-
- {failureInfo.label}
-
-
-
-
-
-
{failureInfo.summary}
- {failureInfo.actionHint && (
-
{failureInfo.actionHint}
- )}
- {failureInfo.technicalDetail && (
-
- {failureInfo.technicalDetail}
-
- )}
- {failureInfo.rawDetail && (
-
- {failureInfo.rawDetail}
-
- )}
-
-
-
-
- ) : null}
-
- )}
+
);
}
diff --git a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx
index 04ad2534..dc0ea26e 100644
--- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx
+++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx
@@ -113,7 +113,11 @@ export function ProviderCard({
{isMissingProjectId && (
diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts
index a077a07e..0ed653d0 100644
--- a/ui/src/components/monitoring/auth-monitor/hooks.ts
+++ b/ui/src/components/monitoring/auth-monitor/hooks.ts
@@ -5,8 +5,8 @@
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
+import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
import { getProviderDisplayName } from '@/lib/provider-config';
-import { getAccountStats } from '@/lib/cliproxy-account-stats';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import type { AccountRow, ProviderStats } from './types';
import { ACCOUNT_COLORS } from './utils';
@@ -74,28 +74,33 @@ export function useAuthMonitorData(): AuthMonitorData {
const providerData = providerMap.get(providerKey);
if (!providerData) return;
- status.accounts?.forEach((account: OAuthAccount) => {
- const realStats = getAccountStats(statsData, account);
- const success = realStats?.successCount ?? 0;
- const failure = realStats?.failureCount ?? 0;
- tSuccess += success;
- tFailure += failure;
- providerData.success += success;
- providerData.failure += failure;
+ const normalizedAccounts = (status.accounts ?? []).map((account: OAuthAccount) => ({
+ ...account,
+ provider: account.provider || status.provider,
+ }));
+
+ buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
+ tSuccess += groupedAccount.successCount;
+ tFailure += groupedAccount.failureCount;
+ providerData.success += groupedAccount.successCount;
+ providerData.failure += groupedAccount.failureCount;
const row: AccountRow = {
- id: account.id,
- email: account.email || account.id,
+ id: groupedAccount.id,
+ email: groupedAccount.email,
+ tokenFile: groupedAccount.tokenFile,
provider: status.provider,
displayName: status.displayName,
- isDefault: account.isDefault,
- successCount: success,
- failureCount: failure,
- lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
+ isDefault: groupedAccount.isDefault,
+ successCount: groupedAccount.successCount,
+ failureCount: groupedAccount.failureCount,
+ lastUsedAt: groupedAccount.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
- projectId: account.projectId,
- paused: account.paused,
- tier: account.tier,
+ projectId: groupedAccount.projectId,
+ paused: groupedAccount.paused,
+ tier: groupedAccount.tier,
+ memberIds: groupedAccount.memberIds,
+ variants: groupedAccount.variants,
};
accountsList.push(row);
providerData.accounts.push(row);
diff --git a/ui/src/components/monitoring/auth-monitor/index.tsx b/ui/src/components/monitoring/auth-monitor/index.tsx
index 1e40e6d0..04f05f5a 100644
--- a/ui/src/components/monitoring/auth-monitor/index.tsx
+++ b/ui/src/components/monitoring/auth-monitor/index.tsx
@@ -10,7 +10,12 @@ import { STATUS_COLORS } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
-import { usePauseAccount, useResumeAccount } from '@/hooks/use-cliproxy';
+import {
+ useBulkPauseAccounts,
+ useBulkResumeAccounts,
+ usePauseAccount,
+ useResumeAccount,
+} from '@/hooks/use-cliproxy';
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { useAuthMonitorData } from './hooks';
@@ -66,14 +71,37 @@ export function AuthMonitor() {
// Account control mutations for flow viz
const pauseMutation = usePauseAccount();
const resumeMutation = useResumeAccount();
+ const bulkPauseMutation = useBulkPauseAccounts();
+ const bulkResumeMutation = useBulkResumeAccounts();
// Get selected provider data for detail view
const selectedProviderData = effectiveProvider
? providerStats.find((ps) => ps.provider === effectiveProvider)
: null;
- const handlePauseToggle = (accountId: string, paused: boolean) => {
- if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return;
+ const handlePauseToggle = (accountIds: string[], paused: boolean) => {
+ if (
+ !effectiveProvider ||
+ pauseMutation.isPending ||
+ resumeMutation.isPending ||
+ bulkPauseMutation.isPending ||
+ bulkResumeMutation.isPending
+ ) {
+ return;
+ }
+
+ if (accountIds.length > 1) {
+ if (paused) {
+ bulkPauseMutation.mutate({ provider: effectiveProvider, accountIds });
+ } else {
+ bulkResumeMutation.mutate({ provider: effectiveProvider, accountIds });
+ }
+ return;
+ }
+
+ const [accountId] = accountIds;
+ if (!accountId) return;
+
if (paused) {
pauseMutation.mutate({ provider: effectiveProvider, accountId });
} else {
@@ -165,7 +193,12 @@ export function AuthMonitor() {
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
onPauseToggle={handlePauseToggle}
- isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
+ isPausingAccount={
+ pauseMutation.isPending ||
+ resumeMutation.isPending ||
+ bulkPauseMutation.isPending ||
+ bulkResumeMutation.isPending
+ }
/>
) : (
diff --git a/ui/src/components/monitoring/auth-monitor/types.ts b/ui/src/components/monitoring/auth-monitor/types.ts
index 1ddbf44a..793b8c6f 100644
--- a/ui/src/components/monitoring/auth-monitor/types.ts
+++ b/ui/src/components/monitoring/auth-monitor/types.ts
@@ -2,12 +2,15 @@
* Type definitions for Auth Monitor components
*/
+import type { AccountVisualVariant } from '@/lib/account-visual-groups';
+
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
export interface AccountRow {
id: string;
email: string;
+ tokenFile: string;
provider: string;
displayName: string;
isDefault: boolean;
@@ -21,6 +24,10 @@ export interface AccountRow {
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
+ /** Raw member IDs when one visual card represents multiple underlying auth records */
+ memberIds?: string[];
+ /** Raw variant details shown inside grouped visual cards */
+ variants?: AccountVisualVariant[];
}
export interface ProviderStats {
diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx
index 339b1ae7..15f1bdce 100644
--- a/ui/src/components/setup/wizard/steps/account-step.tsx
+++ b/ui/src/components/setup/wizard/steps/account-step.tsx
@@ -3,8 +3,9 @@
*/
import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
-import { formatAccountDisplayName } from '@/lib/account-identity';
+import { getAccountIdentityPresentation } from '@/lib/account-identity';
import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import type { AccountStepProps } from '../types';
@@ -25,29 +26,52 @@ export function AccountStep({
{/* Scrollable account list with max-height for many accounts */}
- {accounts.map((acc) => (
-