diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index 09c23ffb..244fc649 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -26,6 +26,13 @@ import { useTranslation } from 'react-i18next'; type AccountSurfaceMode = 'compact' | 'detailed'; +interface QuotaRow { + id: 'five-hour' | 'weekly'; + label: string; + compactLabel: string; + value: number; +} + interface AccountQuotaPanelProps { provider: string; quota?: UnifiedQuotaResult; @@ -42,6 +49,17 @@ function getQuotaColor(percentage: number): string { return 'bg-green-500'; } +function getQuotaTextColor(percentage: number): string { + const clamped = Math.max(0, Math.min(100, percentage)); + if (clamped <= 20) return 'text-red-600 dark:text-red-400'; + if (clamped <= 50) return 'text-amber-600 dark:text-amber-400'; + return 'text-emerald-600 dark:text-emerald-400'; +} + +function getDisplayQuotaValue(value: number): number { + return Number(formatQuotaPercent(value)); +} + function formatRelativeTime(dateStr: string | undefined): string { if (!dateStr) return ''; @@ -101,18 +119,27 @@ export function AccountQuotaPanel({ isCodexProvider && quota && isCodexQuotaResult(quota) ? getCodexQuotaBreakdown(quota.windows) : null; - const compactQuotaRows = isCodexProvider + const compactQuotaRows: QuotaRow[] = isCodexProvider ? [ - { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, { - label: mode === 'compact' ? 'Wk' : 'Weekly', + id: 'five-hour', + label: t('quotaTooltip.fiveHourLimit'), + compactLabel: '5h', + value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null, + }, + { + id: 'weekly', + label: t('quotaTooltip.weeklyLimit'), + compactLabel: 'Week', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null, }, - ] + ].filter((row): row is QuotaRow => row.value !== null) : isClaudeProvider && quota && isClaudeQuotaResult(quota) ? [ { - label: '5h', + id: 'five-hour', + label: t('quotaTooltip.fiveHourLimit'), + compactLabel: '5h', value: quota.coreUsage?.fiveHour?.remainingPercent ?? quota.windows.find((window) => window.rateLimitType === 'five_hour') @@ -120,7 +147,9 @@ export function AccountQuotaPanel({ null, }, { - label: mode === 'compact' ? 'Wk' : 'Weekly', + id: 'weekly', + label: t('quotaTooltip.weeklyLimit'), + compactLabel: 'Week', value: quota.coreUsage?.weekly?.remainingPercent ?? quota.windows.find((window) => @@ -134,11 +163,9 @@ export function AccountQuotaPanel({ )?.remainingPercent ?? null, }, - ] + ].filter((row): row is QuotaRow => row.value !== null) : []; - const quotaRows = compactQuotaRows.filter( - (row): row is { label: string; value: number } => row.value !== null - ); + const quotaRows = compactQuotaRows; if (quotaLoading) { return ( @@ -158,46 +185,67 @@ export function AccountQuotaPanel({ {mode === 'compact' ? ( -
-
- - {t('accountCard.quota')} - - 50 - ? 'text-emerald-600 dark:text-emerald-400' - : minQuotaValue > 20 - ? 'text-amber-500' - : 'text-red-500' - )} - > - {minQuotaLabel}% - -
- {quotaRows.length > 0 && ( -
+
+ {quotaRows.length > 0 ? ( +
{quotaRows.map((row) => ( - - {row.label} {row.value}% - +
+ + {row.compactLabel} + + + + {formatQuotaPercent(row.value)}% + +
))}
+ ) : ( +
+
+ + {t('accountCard.quota')} + + 50 + ? 'text-emerald-600 dark:text-emerald-400' + : minQuotaValue > 20 + ? 'text-amber-500' + : 'text-red-500' + )} + > + {minQuotaLabel}% + +
+ 50 + ? 'bg-emerald-500' + : minQuotaValue > 20 + ? 'bg-amber-500' + : 'bg-red-500' + } + /> +
)} -
-
50 - ? 'bg-emerald-500' - : minQuotaValue > 20 - ? 'bg-amber-500' - : 'bg-red-500' - )} - style={{ width: `${minQuotaValue}%` }} - /> -
) : (
@@ -228,18 +276,28 @@ export function AccountQuotaPanel({ )}
{quotaRows.length > 0 ? ( -
+
{quotaRows.map((row) => ( -
- - {row.label} - +
+
+ + {row.label} + + + {formatQuotaPercent(row.value)}% + +
- {row.value}%
))}
@@ -247,6 +305,7 @@ export function AccountQuotaPanel({
diff --git a/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx index 4263945f..be182aa7 100644 --- a/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx +++ b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx @@ -1,7 +1,10 @@ import { render, screen, userEvent } from '@tests/setup/test-utils'; import { describe, expect, it } from 'vitest'; import { AccountQuotaPanel } from '@/components/account/shared/account-quota-panel'; -import type { GeminiCliQuotaResult } from '@/lib/api-client'; +import type { ClaudeQuotaResult, CodexQuotaResult, GeminiCliQuotaResult } from '@/lib/api-client'; + +const NOISY_WEEKLY_REMAINING_PERCENT = 45 - 1e-14; +const NOISY_WEEKLY_USED_PERCENT = 100 - NOISY_WEEKLY_REMAINING_PERCENT; function createGeminiFailureQuota(): GeminiCliQuotaResult { return { @@ -19,6 +22,189 @@ function createGeminiFailureQuota(): GeminiCliQuotaResult { }; } +function createClaudeQuota(): ClaudeQuotaResult { + return { + success: true, + lastUpdated: Date.now(), + windows: [ + { + rateLimitType: 'five_hour', + label: '5-hour usage', + status: 'allowed', + utilization: 0.57, + usedPercent: 57, + remainingPercent: 43, + resetAt: '2026-05-01T01:00:00.000Z', + }, + { + rateLimitType: 'seven_day_sonnet', + label: 'Weekly usage', + status: 'allowed', + utilization: NOISY_WEEKLY_USED_PERCENT / 100, + usedPercent: NOISY_WEEKLY_USED_PERCENT, + remainingPercent: NOISY_WEEKLY_REMAINING_PERCENT, + resetAt: '2026-05-07T01:00:00.000Z', + }, + ], + coreUsage: { + fiveHour: { + rateLimitType: 'five_hour', + label: '5-hour usage', + remainingPercent: 43, + resetAt: '2026-05-01T01:00:00.000Z', + status: 'allowed', + }, + weekly: { + rateLimitType: 'seven_day_sonnet', + label: 'Weekly usage', + remainingPercent: NOISY_WEEKLY_REMAINING_PERCENT, + resetAt: '2026-05-07T01:00:00.000Z', + status: 'allowed', + }, + }, + }; +} + +function createCodexQuota(): CodexQuotaResult { + return { + success: true, + planType: 'pro', + lastUpdated: Date.now(), + windows: [ + { + label: 'Primary', + category: 'usage', + cadence: '5h', + usedPercent: 38, + remainingPercent: 62, + resetAfterSeconds: 60 * 60, + resetAt: '2026-05-01T01:00:00.000Z', + }, + { + label: 'Secondary', + category: 'usage', + cadence: 'weekly', + usedPercent: 61, + remainingPercent: 39, + resetAfterSeconds: 7 * 24 * 60 * 60, + resetAt: '2026-05-07T01:00:00.000Z', + }, + ], + coreUsage: { + fiveHour: { + label: 'Primary', + remainingPercent: 62, + resetAfterSeconds: 60 * 60, + resetAt: '2026-05-01T01:00:00.000Z', + }, + weekly: { + label: 'Secondary', + remainingPercent: 39, + resetAfterSeconds: 7 * 24 * 60 * 60, + resetAt: '2026-05-07T01:00:00.000Z', + }, + }, + }; +} + +function createGeminiSuccessQuota(): GeminiCliQuotaResult { + return { + success: true, + buckets: [ + { + id: 'flash::input', + label: 'Gemini Flash', + tokenType: 'input', + remainingFraction: 0.6, + remainingPercent: 60, + resetTime: '2026-05-01T01:00:00.000Z', + modelIds: ['gemini-2.5-flash'], + }, + ], + projectId: 'test-project', + lastUpdated: Date.now(), + }; +} + +describe('AccountQuotaPanel quota bars', () => { + it('shows separate compact Claude 5h and weekly quota bars', () => { + render( + + ); + + expect(screen.getByText('5h')).toBeInTheDocument(); + expect(screen.getByText('Week')).toBeInTheDocument(); + expect(screen.getByText('43%')).toBeInTheDocument(); + expect(screen.getAllByText('45%').length).toBeGreaterThan(0); + expect(screen.getByRole('progressbar', { name: '5h quota' })).toHaveAttribute( + 'aria-valuenow', + '43' + ); + expect(screen.getByRole('progressbar', { name: 'Week quota' })).toHaveAttribute( + 'aria-valuenow', + '45' + ); + }); + + it('shows separate compact Codex 5h and weekly quota bars', () => { + render( + + ); + + expect(screen.getByRole('progressbar', { name: '5h quota' })).toHaveAttribute( + 'aria-valuenow', + '62' + ); + expect(screen.getByRole('progressbar', { name: 'Week quota' })).toHaveAttribute( + 'aria-valuenow', + '39' + ); + }); + + it('keeps the compact single-bar fallback for providers without split quota rows', () => { + render( + + ); + + expect(screen.getByRole('progressbar', { name: 'Quota' })).toBeInTheDocument(); + expect(screen.queryByRole('progressbar', { name: '5h quota' })).not.toBeInTheDocument(); + expect(screen.queryByRole('progressbar', { name: 'Week quota' })).not.toBeInTheDocument(); + }); + + it('shows explicit detailed Claude quota labels', () => { + render( + + ); + + expect(screen.getByText('5h usage limit')).toBeInTheDocument(); + expect(screen.getByText('Weekly usage limit')).toBeInTheDocument(); + expect(screen.getByRole('progressbar', { name: '5h usage limit quota' })).toBeInTheDocument(); + expect( + screen.getByRole('progressbar', { name: 'Weekly usage limit quota' }) + ).toBeInTheDocument(); + }); +}); + describe('AccountQuotaPanel failure tooltip', () => { it('renders the shared failure tooltip content with viewport-safe shell classes', async () => { render(