mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(accounts): refine codex plan badges
This commit is contained in:
@@ -162,7 +162,9 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st
|
||||
}
|
||||
|
||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||
return ['Personal', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||
return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
|
||||
@@ -624,11 +624,12 @@ export async function fetchCodexQuota(
|
||||
|
||||
// Extract plan type
|
||||
const planTypeRaw = data.plan_type || data.planType;
|
||||
let planType: 'free' | 'plus' | 'team' | null = null;
|
||||
let planType: 'free' | 'plus' | 'pro' | 'team' | null = null;
|
||||
if (planTypeRaw) {
|
||||
const normalized = planTypeRaw.toLowerCase();
|
||||
if (normalized === 'free') planType = 'free';
|
||||
else if (normalized === 'plus') planType = 'plus';
|
||||
else if (normalized === 'pro') planType = 'pro';
|
||||
else if (normalized === 'team') planType = 'team';
|
||||
}
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata {
|
||||
windows: CodexQuotaWindow[];
|
||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||
coreUsage?: CodexCoreUsageSummary;
|
||||
/** Plan type: free, plus, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'team' | null;
|
||||
/** Plan type: free, plus, pro, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'pro' | 'team' | null;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Error message if fetch failed */
|
||||
|
||||
@@ -100,24 +100,53 @@ function getCodexPlanAudience(quota: unknown): AccountAudience {
|
||||
|
||||
if (planType === 'team') return 'business';
|
||||
if (planType === 'free') return 'free';
|
||||
if (planType === 'plus') return 'personal';
|
||||
if (planType === 'plus' || planType === 'pro') return 'personal';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getVariantDetailLabel(variant: {
|
||||
audience: AccountAudience;
|
||||
detailLabel?: string | null;
|
||||
compactDetailLabel?: string | null;
|
||||
}) {
|
||||
return variant.detailLabel ?? variant.compactDetailLabel ?? null;
|
||||
function getCodexPlanDetailLabel(quota: unknown): string | null {
|
||||
if (!quota || typeof quota !== 'object' || !('planType' in quota)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const planType = (quota as { planType?: unknown }).planType;
|
||||
if (typeof planType !== 'string' || planType.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (planType === 'free' || planType === 'team') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return planType === 'plus' || planType === 'pro'
|
||||
? planType[0].toUpperCase() + planType.slice(1)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getVariantCompactDetailLabel(variant: {
|
||||
audience: AccountAudience;
|
||||
compactDetailLabel?: string | null;
|
||||
detailLabel?: string | null;
|
||||
}) {
|
||||
return variant.compactDetailLabel ?? variant.detailLabel ?? null;
|
||||
function getVariantDetailLabel(
|
||||
variant: {
|
||||
audience: AccountAudience;
|
||||
detailLabel?: string | null;
|
||||
compactDetailLabel?: string | null;
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
return (
|
||||
variant.detailLabel ?? variant.compactDetailLabel ?? getCodexPlanDetailLabel(quota) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getVariantCompactDetailLabel(
|
||||
variant: {
|
||||
audience: AccountAudience;
|
||||
compactDetailLabel?: string | null;
|
||||
detailLabel?: string | null;
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
return (
|
||||
variant.compactDetailLabel ?? variant.detailLabel ?? getCodexPlanDetailLabel(quota) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getDetailedAudienceLabel(audience: AccountAudience): string | null {
|
||||
@@ -146,7 +175,7 @@ function getVariantInlineLabel(
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
const detailLabel = getVariantDetailLabel(variant);
|
||||
const detailLabel = getVariantDetailLabel(variant, quota);
|
||||
const audienceLabel =
|
||||
variant.audienceLabel ?? getDetailedAudienceLabel(getVariantAudience(variant, quota));
|
||||
const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · ');
|
||||
@@ -164,7 +193,7 @@ function getVariantMarkerLabel(
|
||||
quota?: unknown
|
||||
) {
|
||||
const audience = getVariantAudience(variant, quota);
|
||||
const compactDetailLabel = getVariantCompactDetailLabel(variant);
|
||||
const compactDetailLabel = getVariantCompactDetailLabel(variant, quota);
|
||||
if (audience === 'business') {
|
||||
const businessVariantCount = audienceCounts.get('business') ?? 0;
|
||||
return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz';
|
||||
@@ -197,7 +226,7 @@ function getGroupedVariantSummaryLabel(
|
||||
const audiences = new Set(variants.map((variant) => variant.audience));
|
||||
const hasDistinctDetails = variants.some((variant, index) =>
|
||||
Boolean(
|
||||
getVariantCompactDetailLabel(variant) ||
|
||||
getVariantCompactDetailLabel(variant, quotas[index]) ||
|
||||
getDetailedAudienceLabel(getVariantAudience(variant, quotas[index]))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pause, Star, User } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -109,10 +109,22 @@ function getCodexPlanAudience(quota: UnifiedQuotaResult | undefined): AccountAud
|
||||
|
||||
if (quota.planType === 'team') return 'business';
|
||||
if (quota.planType === 'free') return 'free';
|
||||
if (quota.planType === 'plus') return 'personal';
|
||||
if (quota.planType === 'plus' || quota.planType === 'pro') return 'personal';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null {
|
||||
if (!quota || !('planType' in quota) || !quota.planType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (quota.planType === 'free' || quota.planType === 'team') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatAccountVariantPart(quota.planType);
|
||||
}
|
||||
|
||||
export function AccountSurfaceCard({
|
||||
mode,
|
||||
provider,
|
||||
@@ -143,11 +155,13 @@ export function AccountSurfaceCard({
|
||||
const effectiveTier = resolveEffectiveTier(tier, quota);
|
||||
const codexPlanAudience =
|
||||
normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown';
|
||||
const codexPlanDetailLabel =
|
||||
normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null;
|
||||
const effectiveAudience = identity.audience !== 'unknown' ? identity.audience : codexPlanAudience;
|
||||
const effectiveAudienceLabel =
|
||||
identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience);
|
||||
const resolvedDetailLabel = identity.detailLabel;
|
||||
const resolvedCompactDetailLabel = identity.compactDetailLabel;
|
||||
const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel;
|
||||
const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel;
|
||||
const showTierBadge =
|
||||
(normalizedProvider === 'agy' ||
|
||||
normalizedProvider === 'antigravity' ||
|
||||
|
||||
@@ -186,14 +186,16 @@ export function getAccountIdentityPresentation(
|
||||
}
|
||||
|
||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||
const detailLabel = formatAudienceDetail(parts.slice(0, -1));
|
||||
const detailLabel = [formatAccountVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Personal
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'personal',
|
||||
audienceLabel: 'Personal',
|
||||
detailLabel,
|
||||
compactDetailLabel: detailLabel,
|
||||
detailLabel: detailLabel || formatAccountVariantPart(suffix),
|
||||
compactDetailLabel: detailLabel || formatAccountVariantPart(suffix),
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -610,8 +610,8 @@ export interface CodexQuotaResult {
|
||||
windows: CodexQuotaWindow[];
|
||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||
coreUsage?: CodexCoreUsageSummary;
|
||||
/** Plan type: free, plus, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'team' | null;
|
||||
/** Plan type: free, plus, pro, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'pro' | 'team' | null;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Upstream HTTP status when available */
|
||||
|
||||
@@ -20,7 +20,11 @@ vi.mock('@/hooks/use-cliproxy-stats', async () => {
|
||||
const mockedUseAccountQuota = vi.mocked(useAccountQuota);
|
||||
const mockedUseAccountQuotas = vi.mocked(useAccountQuotas);
|
||||
|
||||
function makeCodexQuota(planType: 'free' | 'plus' | 'team', fiveHour: number, weekly: number) {
|
||||
function makeCodexQuota(
|
||||
planType: 'free' | 'plus' | 'pro' | 'team',
|
||||
fiveHour: number,
|
||||
weekly: number
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
planType,
|
||||
@@ -201,5 +205,6 @@ describe('AccountCard grouped quota tooltip', () => {
|
||||
expect(screen.getByTitle('Business · Workspace 04a0f049 • Personal · Pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('Personal · Pro')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('AccountSurfaceCard', () => {
|
||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the simplified personal audience when live quota planType is coarser', () => {
|
||||
it('keeps token-derived personal detail when live quota planType is coarser', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
@@ -90,6 +90,7 @@ describe('AccountSurfaceCard', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('Pers')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -109,4 +110,21 @@ describe('AccountSurfaceCard', () => {
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to plus or pro detail when Codex quota exposes a paid plan', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider="codex"
|
||||
accountId="user@example.com"
|
||||
email="user@example.com"
|
||||
displayEmail="user@example.com"
|
||||
quota={createCodexQuotaResult({ planType: 'pro' })}
|
||||
showQuota={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Pers')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,14 +58,21 @@ describe('account identity presentation', () => {
|
||||
).toBe('kaidu.kd@gmail.com (Free)');
|
||||
});
|
||||
|
||||
it('collapses paid codex personal plans into a single personal audience label', () => {
|
||||
it('keeps plus and pro codex personal plans distinct', () => {
|
||||
expect(
|
||||
formatAccountDisplayName(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-kaidu.kd@gmail.com-plus.json'
|
||||
)
|
||||
).toBe('kaidu.kd@gmail.com (Personal)');
|
||||
).toBe('kaidu.kd@gmail.com (Personal · Plus)');
|
||||
expect(
|
||||
formatAccountDisplayName(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-kaidu.kd@gmail.com-pro.json'
|
||||
)
|
||||
).toBe('kaidu.kd@gmail.com (Personal · Pro)');
|
||||
});
|
||||
|
||||
it('leaves plain accounts without inferred state untouched', () => {
|
||||
|
||||
Reference in New Issue
Block a user