From 25aa8bdb16e04f9f81e5b54487716c7cb85728ea Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 21:12:41 -0400 Subject: [PATCH] fix(accounts): refine codex plan badges --- .../accounts/email-account-identity.ts | 4 +- src/cliproxy/quota-fetcher-codex.ts | 3 +- src/cliproxy/quota-types.ts | 4 +- .../account/flow-viz/account-card.tsx | 61 ++++++++++++++----- .../account/shared/account-surface-card.tsx | 22 +++++-- ui/src/lib/account-identity.ts | 8 ++- ui/src/lib/api-client.ts | 4 +- .../account/flow-viz/account-card.test.tsx | 7 ++- .../shared/account-surface-card.test.tsx | 20 +++++- ui/tests/unit/ui/lib/account-identity.test.ts | 11 +++- 10 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts index 7d6c73a1..d03f2337 100644 --- a/src/cliproxy/accounts/email-account-identity.ts +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -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(' · '); diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 43fdd606..811be609 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -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'; } diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index a1f0b313..e768ec9b 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -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 */ diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 7b6f399f..22e8cea8 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -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])) ) ); diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 21657220..063676a1 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -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' || diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index 7227f3a2..9d16a37d 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -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, }; } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a4fe2ed8..a20a764a 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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 */ diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 5d746854..d2b06cac 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -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(); }); }); diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx index 3d0bcb68..5f4909f7 100644 --- a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -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( { ); 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( + + ); + + expect(screen.getByText('Pers')).toBeInTheDocument(); + expect(screen.getByText('Pro')).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/ui/lib/account-identity.test.ts b/ui/tests/unit/ui/lib/account-identity.test.ts index cce62502..0b675d00 100644 --- a/ui/tests/unit/ui/lib/account-identity.test.ts +++ b/ui/tests/unit/ui/lib/account-identity.test.ts @@ -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', () => {