feat(ui): unify duplicate-email account surfaces

This commit is contained in:
Tam Nhu Tran
2026-03-30 15:30:11 -04:00
parent be0c597d25
commit 80341f18c3
16 changed files with 1471 additions and 792 deletions
@@ -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 ? (
<>
<div
className="inline-flex shrink-0 items-center overflow-hidden rounded-md border border-border/60 bg-muted/60 shadow-sm shadow-black/5 dark:bg-zinc-900/80"
title={groupedHeaderVariants
.map((variant) => variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
.join(' • ')}
>
{groupedVariantSummaryLabel ? (
<span className="inline-flex min-w-[2.2rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none text-foreground/80">
{groupedVariantSummaryLabel}
</span>
) : (
groupedHeaderVariants.map((variant, index) => (
<span
key={variant.id}
className={cn(
'inline-flex min-w-[1.9rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none',
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
)}
</span>
))
)}
</div>
{account.paused && (
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
Paused
</span>
)}
</>
) : 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 (
<div key={variant.id} className="space-y-0.5">
<div className="flex items-center justify-between gap-2 text-[8px]">
<span className="text-muted-foreground/80 truncate">{label}</span>
<span className="font-mono text-foreground/80 shrink-0">
{quotaQuery?.isLoading
? t('accountCard.quotaLoading')
: quotaValue !== null
? `${quotaLabel}%`
: failureInfo?.label || t('accountCard.quotaUnavailable')}
</span>
</div>
{quotaValue !== null && (
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
getCompactQuotaColor(quotaValue)
)}
style={{ width: `${quotaValue}%` }}
/>
</div>
)}
</div>
);
})
: 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 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-4 w-4 shrink-0 transition-all rounded-full',
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
)}
onClick={(e) => {
e.stopPropagation();
onPauseToggle(account.memberIds ?? [account.id], !account.paused);
}}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
) : account.paused ? (
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
) : (
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</>
);
return (
<div
@@ -216,176 +304,35 @@ export function AccountCard({
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Header row: Email + Tier | Pause button | Drag handle */}
<div className="flex items-center gap-1.5 mb-1">
{/* Email with tier badge inline */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
{variantLabel && (
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-muted text-muted-foreground">
{variantLabel}
</span>
)}
{showTierBadge && (
<span
className={cn(
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
account.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'
)}
>
{account.tier}
</span>
)}
</div>
{/* Pause/Resume button */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-4 w-4 shrink-0',
'transition-all rounded-full',
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
)}
onClick={(e) => {
e.stopPropagation();
onPauseToggle(account.id, !account.paused);
}}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
) : account.paused ? (
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
) : (
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Drag handle */}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</div>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
showDetails={showDetails}
<AccountSurfaceCard
mode="compact"
provider={account.provider}
accountId={account.id}
email={account.email}
displayEmail={cleanEmail(account.email)}
tokenFile={account.tokenFile}
tier={account.tier}
isDefault={account.isDefault}
paused={account.paused}
privacyMode={privacyMode}
showQuota={showQuota && !hasGroupedVariants}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={account.lastUsedAt}
headerEnd={headerEnd}
compactMetaBadges={compactMetaBadges}
footerSlot={
<>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
showDetails={showDetails}
/>
{groupedQuotaRows && <div className="mt-2 px-0.5 space-y-1">{groupedQuotaRows}</div>}
</>
}
/>
{/* Quota bar for CLIProxy accounts */}
{isCliproxyProvider && (
<div className="mt-2 px-0.5">
{quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>{t('accountCard.quotaLoading')}</span>
</div>
) : minQuotaValue !== null ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
{t('accountCard.quota')}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400'
: minQuotaValue > 20
? 'text-amber-500'
: 'text-red-500'
)}
>
{minQuotaLabel}%
</span>
</div>
{compactQuotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{compactQuotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</span>
))}
</div>
)}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
minQuotaValue > 50
? 'bg-emerald-500'
: minQuotaValue > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${minQuotaValue}%` }}
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.success ? (
<div className="text-[8px] text-muted-foreground/60">
{t('accountCard.quotaUnavailable')}
</div>
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-1 text-[8px]', failureTextClass)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)}
<div
className={cn(
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
+9 -1
View File
@@ -2,6 +2,8 @@
* Type definitions for Account Flow Visualization
*/
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
@@ -14,7 +16,9 @@ export interface DragOffset {
export interface AccountData {
id: string;
email: string;
tokenFile?: string;
provider: string;
isDefault?: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
@@ -22,6 +26,10 @@ export interface AccountData {
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 ProviderData {
@@ -34,7 +42,7 @@ export interface ProviderData {
export interface AccountFlowVizProps {
providerData: ProviderData;
onBack?: () => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
isPausingAccount?: boolean;
}
@@ -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 (
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
<Loader2 className="w-3 h-3 animate-spin" />
<span>{mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}</span>
</div>
);
}
if (minQuotaValue !== null) {
return (
<div className={cn(mode === 'compact' ? 'px-0.5' : '', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{mode === 'compact' ? (
<div className="space-y-0.5 cursor-help">
<div className="flex items-center justify-between">
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
{t('accountCard.quota')}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400'
: minQuotaValue > 20
? 'text-amber-500'
: 'text-red-500'
)}
>
{minQuotaLabel}%
</span>
</div>
{quotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{quotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</span>
))}
</div>
)}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
minQuotaValue > 50
? 'bg-emerald-500'
: minQuotaValue > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${minQuotaValue}%` }}
/>
</div>
</div>
) : (
<div className="space-y-1.5 cursor-help">
<div className="flex items-center gap-1.5 text-xs">
{isRecentlyUsed(runtimeLastUsed) ? (
<>
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600 dark:text-emerald-400">
Active · {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : runtimeLastUsed ? (
<>
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">
Last used {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : (
<>
<HelpCircle className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">Not used yet</span>
</>
)}
</div>
{quotaRows.length > 0 ? (
<div className="space-y-1.5">
{quotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
</span>
<Progress
value={Math.max(0, Math.min(100, row.value))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(row.value)}
/>
<span className="text-xs font-medium w-10 text-right">{row.value}%</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2">
<Progress
value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuotaValue)}
/>
<span className="text-xs font-medium w-10 text-right">{minQuotaLabel}%</span>
</div>
)}
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
if (quota?.success) {
return mode === 'compact' ? (
<div className={cn('text-[8px] text-muted-foreground/60', className)}>
{t('accountCard.quotaUnavailable')}
</div>
) : (
<div className={className}>
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
{t('accountCard.quotaUnavailable')}
</Badge>
</div>
);
}
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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{mode === 'compact' ? (
<div className={cn('flex items-center gap-1 text-[8px]', failureClass, className)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
) : (
<div className={className}>
<Badge variant="outline" className={cn('text-[10px] h-5 px-2 gap-1', failureClass)}>
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -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 && (
<span
className={cn(
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
getTierBadgeClass(tier)
)}
>
{tier}
</span>
)}
{identity.audienceLabel && (
<span
title={identity.audienceLabel}
className={cn(
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
identity.audience === 'business'
? 'bg-sky-500/15 text-sky-700 dark:bg-sky-500/25 dark:text-sky-300'
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
)}
>
{getCompactAudienceBadgeLabel(identity.audience)}
</span>
)}
{paused && (
<span className="text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
Paused
</span>
)}
</>
);
return (
<div className={cn('flex flex-col gap-2', className)}>
<div className="flex items-start justify-between gap-2">
<div className={cn('flex min-w-0 flex-1', isCompact ? 'gap-2' : 'gap-3')}>
{beforeIdentity}
{!isCompact && (
<div className="relative shrink-0">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
{showTierBadge && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded ring-1 ring-background',
tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{tier === 'ultra' ? 'U' : 'P'}
</span>
)}
</div>
)}
<div className="min-w-0 flex-1">
<div
className={cn('flex items-center min-w-0', isCompact ? 'gap-1.5' : 'gap-2 flex-wrap')}
>
<span
title={title}
className={cn(
isCompact
? 'flex-1 min-w-0 text-xs font-semibold tracking-tight truncate leading-none'
: 'font-medium text-sm truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{title}
</span>
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
{!isCompact && identity.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
getAudienceBadgeClass(identity.audience)
)}
>
{identity.audienceLabel}
</Badge>
)}
{!isCompact && identity.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{identity.detailLabel}
</Badge>
)}
{!isCompact && isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
)}
{!isCompact && paused && (
<Badge
variant="outline"
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
Paused
</Badge>
)}
</div>
{bodySlot && <div className="mt-1">{bodySlot}</div>}
</div>
</div>
{headerEnd && (
<div className={cn('flex items-center shrink-0', isCompact ? 'gap-0.5' : 'gap-1')}>
{headerEnd}
</div>
)}
</div>
{footerSlot}
{showQuota && (
<AccountQuotaPanel
provider={provider}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={runtimeLastUsed}
mode={mode}
className={quotaInsetClassName}
/>
)}
</div>
);
}
@@ -221,7 +221,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
{providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
{privacyMode
? '••••••'
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)}
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
</option>
))}
@@ -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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<FolderCode className="w-3 h-3" aria-hidden="true" />
<span
className={cn(
'font-mono max-w-[180px] truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
title={projectId}
>
{projectId}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
}
/**
* 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>This may cause errors. Remove the account and re-add it to fetch the project ID.</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
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 ? (
<div className="flex items-center gap-2 shrink-0">
{selectable && (
<button
type="button"
onClick={() => onSelectChange?.(!selected)}
className={cn(
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/30 hover:border-primary/50'
)}
aria-label={selected ? 'Deselect account' : 'Select account'}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : account.paused ? (
<Play className="w-4 h-4 text-emerald-500" />
) : (
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
) : undefined;
const headerEnd = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<div
className={cn(
'flex flex-col gap-2 p-3 rounded-lg border transition-colors overflow-hidden',
'rounded-lg border p-3 transition-colors overflow-hidden',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
account.paused && 'opacity-75',
selected && 'ring-2 ring-primary/50 bg-primary/5'
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Selection checkbox for bulk actions */}
{selectable && (
<button
type="button"
onClick={() => onSelectChange?.(!selected)}
className={cn(
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/30 hover:border-primary/50'
)}
aria-label={selected ? 'Deselect account' : 'Select account'}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
{/* Pause/Resume toggle button - visible left of avatar */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : account.paused ? (
<Play className="w-4 h-4 text-emerald-500" />
) : (
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Avatar with tier badge overlay */}
<div className="relative shrink-0">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
{/* Tier badge - fixed position on avatar */}
{account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded',
'ring-1 ring-background',
account.tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{account.tier === 'ultra' ? 'U' : 'P'}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn('font-medium text-sm truncate', privacyMode && PRIVACY_BLUR_CLASS)}
>
{account.email || account.id}
</span>
{variantLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{variantLabel}
</Badge>
)}
{account.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
<Star className="w-2.5 h-2.5 fill-current" />
Default
</Badge>
)}
{account.paused && (
<Badge
variant="outline"
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
Paused
</Badge>
)}
</div>
{/* Project ID for Antigravity accounts - read-only */}
{account.provider === 'agy' && (
<div className="flex items-center gap-1.5 mt-1">
{account.projectId ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FolderCode className="w-3 h-3" aria-hidden="true" />
<span
className={cn(
'font-mono max-w-[180px] truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
title={account.projectId}
>
{account.projectId}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>
This may cause errors. Remove the account and re-add it to fetch the
project ID.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
{account.lastUsedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Clock className="w-3 h-3" />
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
</div>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!account.isDefault && (
<DropdownMenuItem onClick={onSetDefault}>
<Star className="w-4 h-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 mr-2" />
{isRemoving ? 'Removing...' : 'Remove account'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Quota bar - supports all providers with quota API */}
{showQuota && (
<div className="pl-11">
{quotaLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Loading quota...</span>
</div>
) : minQuotaValue !== null ? (
<div className="space-y-1.5">
{/* Status indicator based on runtime usage, not file state */}
<div className="flex items-center gap-1.5 text-xs">
{wasRecentlyUsed ? (
<>
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600 dark:text-emerald-400">
Active · {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : runtimeLastUsed ? (
<>
<Clock className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">
Last used {formatRelativeTime(runtimeLastUsed)}
</span>
</>
) : (
<>
<HelpCircle className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">Not used yet</span>
</>
)}
</div>
{/* Quota bar */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{dualWindowQuotaRows.length > 0 ? (
<div className="space-y-1.5">
{dualWindowQuotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
</span>
<Progress
value={Math.max(0, Math.min(100, row.value))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(row.value)}
/>
<span className="text-xs font-medium w-10 text-right">
{row.value}%
</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2">
<Progress
value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuotaValue)}
/>
<span className="text-xs font-medium w-10 text-right">
{minQuotaLabel}%
</span>
</div>
)}
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : quota?.success ? (
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
{t('accountCard.quotaUnavailable')}
</Badge>
</div>
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className={cn('text-[10px] h-5 px-2 gap-1', failureBadgeClass)}
>
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)}
<AccountSurfaceCard
mode="detailed"
provider={account.provider}
accountId={account.id}
email={account.email}
displayEmail={account.email || account.id}
tokenFile={account.tokenFile}
tier={account.tier}
isDefault={account.isDefault}
paused={account.paused}
privacyMode={privacyMode}
showQuota={showQuota}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={runtimeLastUsed}
beforeIdentity={beforeIdentity}
headerEnd={headerEnd}
bodySlot={
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
}
quotaInsetClassName="pl-11"
/>
</div>
);
}
@@ -113,7 +113,11 @@ export function ProviderCard({
<div
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
title={
privacyMode
? '••••••'
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)
}
/>
{isMissingProjectId && (
<TooltipProvider>
@@ -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);
@@ -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
}
/>
) : (
<div className="p-6">
@@ -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 {
@@ -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 */}
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
{accounts.map((acc) => (
<button
key={acc.id}
type="button"
onClick={() => onSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{formatAccountDisplayName(acc.id, acc.email)}
{accounts.map((acc) => {
const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile);
return (
<button
key={acc.id}
type="button"
onClick={() => onSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div className="space-y-1">
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{identity.email}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{identity.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
identity.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
)}
>
{identity.audienceLabel}
</Badge>
)}
{identity.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{identity.detailLabel}
</Badge>
)}
{acc.isDefault && (
<span className="text-xs text-muted-foreground">Default account</span>
)}
</div>
</div>
{acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
{/* Divider */}
@@ -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 (
<div className="space-y-4">
{selectedAccount && (
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<div className="flex items-start gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" />
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
<div className="space-y-1">
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccountIdentity?.email}
</span>
</span>
</span>
{(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && (
<div className="flex items-center gap-1.5 flex-wrap">
{selectedAccountIdentity?.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
selectedAccountIdentity.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
)}
>
{selectedAccountIdentity.audienceLabel}
</Badge>
)}
{selectedAccountIdentity?.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{selectedAccountIdentity.detailLabel}
</Badge>
)}
</div>
)}
</div>
</div>
)}
+26 -11
View File
@@ -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<GhcpQuotaResult> {
// 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)
),
});
}
+162 -13
View File
@@ -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;
}
+127
View File
@@ -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<CliproxyStats, 'accountStats'> | 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<CliproxyStats, 'accountStats'> | null
): AccountVisualGroup[] {
const buckets = new Map<string, AccountVisualVariant[]>();
const accountMeta = new Map<string, OAuthAccount>();
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<string | undefined>(
(latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt),
undefined
),
paused: variants.every((variant) => Boolean(variant.paused)),
memberIds: variants.map((variant) => variant.id),
variants,
};
});
}
@@ -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'
);
});
});