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}% +
+ ))} +
+ ) : ( +
+ + {minQuotaLabel}% +
+ )} +
+ )} + + + + + + +
+ ); + } + + 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 ( + + + +
+
+
+ +

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 ( + + + +
+ + Project ID: N/A +
+
+ +
+

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 ? ( - - - -
-
-
- -

GCP Project ID (read-only)

-
-
-
- ) : ( - - - -
- - Project ID: N/A -
-
- -
-

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) => ( -
- - - ))} + + + ); + })}
{/* Divider */} diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 0961fb37..e1d4d366 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -3,10 +3,11 @@ */ import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { formatAccountDisplayName } from '@/lib/account-identity'; +import { getAccountIdentityPresentation } from '@/lib/account-identity'; import { Select, SelectContent, @@ -45,6 +46,13 @@ export function VariantStep({ selectedProvider === 'agy' && modelName.trim().length > 0 ? isDeniedAgyModelId(modelName) : false; + const selectedAccountIdentity = selectedAccount + ? getAccountIdentityPresentation( + selectedAccount.id, + selectedAccount.email, + selectedAccount.tokenFile + ) + : null; const handleModelSelect = (value: string) => { if (value === CUSTOM_MODEL_VALUE) { @@ -59,14 +67,38 @@ export function VariantStep({ return (
{selectedAccount && ( -
+
- - {t('setupVariant.using')}{' '} - - {formatAccountDisplayName(selectedAccount.id, selectedAccount.email)} +
+ + {t('setupVariant.using')}{' '} + + {selectedAccountIdentity?.email} + - + {(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && ( +
+ {selectedAccountIdentity?.audienceLabel && ( + + {selectedAccountIdentity.audienceLabel} + + )} + {selectedAccountIdentity?.detailLabel && ( + + {selectedAccountIdentity.detailLabel} + + )} +
+ )} +
)} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index ceeb6ce0..22c00983 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -2,7 +2,7 @@ * React Query hook for CLIProxyAPI stats */ -import { useQuery } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import type { ModelQuota, QuotaResult, @@ -334,6 +334,21 @@ async function fetchGhcpQuotaApi(accountId: string): Promise { // Re-export unified type from utils for consumers export type { UnifiedQuotaResult } from '@/lib/utils'; +function getAccountQuotaQueryOptions(provider: string, accountId: string, enabled = true) { + const canonicalProvider = normalizeQuotaProvider(provider); + + return { + queryKey: ['account-quota', canonicalProvider ?? provider, accountId], + queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId), + enabled: enabled && !!canonicalProvider && !!accountId, + staleTime: 60000, + refetchInterval: 60000, + refetchOnWindowFocus: false, + refetchOnMount: false, + retry: 1 as const, + }; +} + /** * Fetch quota by provider (dispatcher) */ @@ -365,16 +380,16 @@ async function fetchQuotaByProvider( * Supports agy, codex, claude, gemini, and ghcp providers */ export function useAccountQuota(provider: string, accountId: string, enabled = true) { - const canonicalProvider = normalizeQuotaProvider(provider); + return useQuery(getAccountQuotaQueryOptions(provider, accountId, enabled)); +} - return useQuery({ - queryKey: ['account-quota', canonicalProvider ?? provider, accountId], - queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId), - enabled: enabled && !!canonicalProvider && !!accountId, - staleTime: 60000, // Match refetchInterval to prevent early refetching - refetchInterval: 60000, // Refresh every 1 minute - refetchOnWindowFocus: false, // Don't refetch on tab switch - refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders) - retry: 1, +export function useAccountQuotas( + accounts: Array<{ provider: string; accountId: string }>, + enabled = true +) { + return useQueries({ + queries: accounts.map((account) => + getAccountQuotaQueryOptions(account.provider, account.accountId, enabled) + ), }); } diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index d28e9a29..167e7ad3 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -1,3 +1,26 @@ +const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']); +const BUSINESS_PLAN_PARTS = new Set(['team']); + +export type AccountAudience = 'business' | 'personal' | 'unknown'; + +export interface AccountIdentityPresentation { + email: string; + audience: AccountAudience; + audienceLabel: string | null; + detailLabel: string | null; + compactDetailLabel: string | null; + inlineLabel: string | null; +} + +function normalizeVariantTokenPart(value: string): string { + return value + .trim() + .replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '') + .replace(/[^a-z0-9._-]+/gi, '-') + .replace(/-+/g, '-') + .toLowerCase(); +} + function formatVariantPart(part: string): string { const normalized = part.trim().toLowerCase(); if (!normalized) { @@ -24,7 +47,12 @@ function formatVariantPart(part: string): string { } } -export function extractAccountVariantKey(accountId: string, email?: string): string | null { +function extractCanonicalEmailFromAccountId(accountId: string): string | null { + const canonical = accountId.split('#')[0]?.trim(); + return canonical && canonical.includes('@') ? canonical : null; +} + +function extractVariantKeyFromAccountId(accountId: string, email?: string): string | null { if (!email) { return null; } @@ -33,29 +61,150 @@ export function extractAccountVariantKey(accountId: string, email?: string): str return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null; } -export function formatAccountVariantLabel(accountId: string, email?: string): string | null { - const variantKey = extractAccountVariantKey(accountId, email); - if (!variantKey) { +function extractVariantKeyFromTokenFile(tokenFile?: string, email?: string): string | null { + if (!tokenFile || !email) { return null; } + const fileName = tokenFile.split(/[\\/]/).pop() ?? tokenFile; + const baseName = fileName.replace(/\.json$/i, ''); + if (!baseName.toLowerCase().startsWith('codex-')) { + return null; + } + const firstDashIndex = baseName.indexOf('-'); + const candidate = + firstDashIndex > 0 && !baseName.slice(0, firstDashIndex).includes('@') + ? baseName.slice(firstDashIndex + 1) + : baseName; + const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase()); + + if (emailIndex === -1) { + return null; + } + + const before = normalizeVariantTokenPart(candidate.slice(0, emailIndex)); + const after = normalizeVariantTokenPart(candidate.slice(emailIndex + email.length)); + const parts = [before, after].filter(Boolean); + return parts.length > 0 ? parts.join('-') : null; +} + +function formatWorkspaceLabel(parts: string[]): { + detailLabel: string | null; + compactDetailLabel: string | null; +} { + const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part)); + if (workspaceId) { + return { + detailLabel: `Workspace ${workspaceId.toLowerCase()}`, + compactDetailLabel: workspaceId.toLowerCase(), + }; + } + + const extraLabel = parts.map(formatVariantPart).filter(Boolean).join(' · '); + return { + detailLabel: extraLabel || 'Team', + compactDetailLabel: extraLabel || 'Team', + }; +} + +export function extractAccountVariantKey( + accountId: string, + email?: string, + tokenFile?: string +): string | null { + const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || undefined; + return ( + extractVariantKeyFromTokenFile(tokenFile, resolvedEmail) ?? + extractVariantKeyFromAccountId(accountId, resolvedEmail) + ); +} + +export function getAccountIdentityPresentation( + accountId: string, + email?: string, + tokenFile?: string +): AccountIdentityPresentation { + const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || accountId; + const variantKey = extractAccountVariantKey(accountId, resolvedEmail, tokenFile); + if (!variantKey) { + return { + email: resolvedEmail, + audience: 'unknown', + audienceLabel: null, + detailLabel: null, + compactDetailLabel: null, + inlineLabel: null, + }; + } + const parts = variantKey.split('-').filter(Boolean); if (parts.length === 0) { - return null; + return { + email: resolvedEmail, + audience: 'unknown', + audienceLabel: null, + detailLabel: null, + compactDetailLabel: null, + inlineLabel: null, + }; } - const genericSuffix = parts[parts.length - 1]; - if (['team', 'free', 'plus', 'pro'].includes(genericSuffix)) { - return [formatVariantPart(genericSuffix), ...parts.slice(0, -1).map(formatVariantPart)] + const suffix = parts[parts.length - 1]?.toLowerCase(); + if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) { + const workspace = formatWorkspaceLabel(parts.slice(0, -1)); + const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · '); + return { + email: resolvedEmail, + audience: 'business', + audienceLabel: 'Business', + detailLabel: workspace.detailLabel, + compactDetailLabel: workspace.compactDetailLabel, + inlineLabel, + }; + } + + if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) { + const detailParts = [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)] .filter(Boolean) .join(' · '); + const detailLabel = detailParts || formatVariantPart(suffix); + const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); + return { + email: resolvedEmail, + audience: 'personal', + audienceLabel: 'Personal', + detailLabel, + compactDetailLabel: detailLabel, + inlineLabel, + }; } - return parts.map(formatVariantPart).filter(Boolean).join(' · '); + const fallbackLabel = parts.map(formatVariantPart).filter(Boolean).join(' · '); + return { + email: resolvedEmail, + audience: 'unknown', + audienceLabel: null, + detailLabel: fallbackLabel || null, + compactDetailLabel: fallbackLabel || null, + inlineLabel: fallbackLabel || null, + }; } -export function formatAccountDisplayName(accountId: string, email?: string): string { - const base = email || accountId; - const variantLabel = formatAccountVariantLabel(accountId, email); - return variantLabel ? `${base} (${variantLabel})` : base; +export function formatAccountVariantLabel( + accountId: string, + email?: string, + tokenFile?: string +): string | null { + return getAccountIdentityPresentation(accountId, email, tokenFile).inlineLabel; +} + +export function formatAccountDisplayName( + accountId: string, + email?: string, + tokenFile?: string +): string { + const presentation = getAccountIdentityPresentation(accountId, email, tokenFile); + return presentation.inlineLabel + ? `${presentation.email} (${presentation.inlineLabel})` + : presentation.email; } diff --git a/ui/src/lib/account-visual-groups.ts b/ui/src/lib/account-visual-groups.ts new file mode 100644 index 00000000..7e578d92 --- /dev/null +++ b/ui/src/lib/account-visual-groups.ts @@ -0,0 +1,127 @@ +import type { OAuthAccount } from '@/lib/api-client'; +import { getAccountIdentityPresentation, type AccountAudience } from '@/lib/account-identity'; +import { getAccountStats } from '@/lib/cliproxy-account-stats'; +import type { CliproxyStats } from '@/hooks/use-cliproxy-stats'; + +export interface AccountVisualVariant { + id: string; + email: string; + tokenFile: string; + isDefault: boolean; + successCount: number; + failureCount: number; + lastUsedAt?: string; + paused?: boolean; + tier?: OAuthAccount['tier']; + audience: AccountAudience; + audienceLabel: string | null; + detailLabel: string | null; +} + +export interface AccountVisualGroup { + id: string; + email: string; + tokenFile: string; + provider: OAuthAccount['provider']; + isDefault: boolean; + successCount: number; + failureCount: number; + lastUsedAt?: string; + paused?: boolean; + tier?: OAuthAccount['tier']; + projectId?: string; + memberIds?: string[]; + variants?: AccountVisualVariant[]; +} + +function getLatestTimestamp(current?: string, candidate?: string): string | undefined { + if (!candidate) return current; + if (!current) return candidate; + + return new Date(candidate).getTime() > new Date(current).getTime() ? candidate : current; +} + +function buildAccountVariant( + account: OAuthAccount, + statsData?: Pick | null +): AccountVisualVariant { + const identity = getAccountIdentityPresentation(account.id, account.email, account.tokenFile); + const runtimeStats = getAccountStats(statsData, account); + + return { + id: account.id, + email: identity.email || account.email || account.id, + tokenFile: account.tokenFile, + isDefault: account.isDefault, + successCount: runtimeStats?.successCount ?? 0, + failureCount: runtimeStats?.failureCount ?? 0, + lastUsedAt: runtimeStats?.lastUsedAt ?? account.lastUsedAt, + paused: account.paused, + tier: account.tier, + audience: identity.audience, + audienceLabel: identity.audienceLabel, + detailLabel: identity.detailLabel, + }; +} + +export function buildAccountVisualGroups( + accounts: OAuthAccount[], + statsData?: Pick | null +): AccountVisualGroup[] { + const buckets = new Map(); + const accountMeta = new Map(); + + for (const account of accounts) { + const variant = buildAccountVariant(account, statsData); + const isCodexProvider = account.provider.toLowerCase() === 'codex'; + const bucketKey = isCodexProvider ? `${account.provider}:${variant.email}` : account.id; + + if (!buckets.has(bucketKey)) { + buckets.set(bucketKey, []); + } + + buckets.get(bucketKey)?.push(variant); + accountMeta.set(account.id, account); + } + + return Array.from(buckets.entries()).map(([bucketKey, variants]) => { + if (variants.length === 1) { + const [variant] = variants; + const original = accountMeta.get(variant.id); + + return { + id: variant.id, + email: variant.email, + tokenFile: variant.tokenFile, + provider: original?.provider ?? 'codex', + isDefault: variant.isDefault, + successCount: variant.successCount, + failureCount: variant.failureCount, + lastUsedAt: variant.lastUsedAt, + paused: variant.paused, + tier: variant.tier, + projectId: original?.projectId, + }; + } + + const canonicalEmail = variants[0]?.email ?? bucketKey; + const originalProvider = accountMeta.get(variants[0]?.id ?? '')?.provider ?? 'codex'; + + return { + id: bucketKey, + email: canonicalEmail, + tokenFile: variants[0]?.tokenFile ?? '', + provider: originalProvider, + isDefault: variants.some((variant) => variant.isDefault), + successCount: variants.reduce((sum, variant) => sum + variant.successCount, 0), + failureCount: variants.reduce((sum, variant) => sum + variant.failureCount, 0), + lastUsedAt: variants.reduce( + (latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt), + undefined + ), + paused: variants.every((variant) => Boolean(variant.paused)), + memberIds: variants.map((variant) => variant.id), + variants, + }; + }); +} diff --git a/ui/tests/unit/ui/lib/account-identity.test.ts b/ui/tests/unit/ui/lib/account-identity.test.ts new file mode 100644 index 00000000..9ebc93fc --- /dev/null +++ b/ui/tests/unit/ui/lib/account-identity.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatAccountDisplayName, + formatAccountVariantLabel, + getAccountIdentityPresentation, +} from '@/lib/account-identity'; + +describe('account identity presentation', () => { + it('formats duplicate-email team accounts as business workspace labels', () => { + const presentation = getAccountIdentityPresentation( + 'kaidu.kd@gmail.com#04a0f049-team', + 'kaidu.kd@gmail.com' + ); + + expect(presentation.audience).toBe('business'); + expect(presentation.audienceLabel).toBe('Business'); + expect(presentation.detailLabel).toBe('Workspace 04a0f049'); + expect(presentation.compactDetailLabel).toBe('04a0f049'); + expect(presentation.inlineLabel).toBe('Business · Workspace 04a0f049'); + }); + + it('can derive business workspace labels from token file when account id is plain email', () => { + const presentation = getAccountIdentityPresentation( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-04a0f049-kaidu.kd@gmail.com-team.json' + ); + + expect(presentation.audience).toBe('business'); + expect(presentation.audienceLabel).toBe('Business'); + expect(presentation.detailLabel).toBe('Workspace 04a0f049'); + expect( + formatAccountVariantLabel( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-04a0f049-kaidu.kd@gmail.com-team.json' + ) + ).toBe('Business · Workspace 04a0f049'); + }); + + it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => { + expect( + formatAccountDisplayName( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-kaidu.kd@gmail.com-free.json' + ) + ).toBe('kaidu.kd@gmail.com (Personal · Free)'); + }); + + it('leaves plain accounts without inferred state untouched', () => { + const presentation = getAccountIdentityPresentation('user@example.com', 'user@example.com'); + + expect(presentation.audience).toBe('unknown'); + expect(presentation.audienceLabel).toBeNull(); + expect(presentation.detailLabel).toBeNull(); + expect(formatAccountDisplayName('user@example.com', 'user@example.com')).toBe( + 'user@example.com' + ); + }); +});