mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 18:24:54 +00:00
feat(ui): show split Claude quota bars
This commit is contained in:
@@ -26,6 +26,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
type AccountSurfaceMode = 'compact' | 'detailed';
|
type AccountSurfaceMode = 'compact' | 'detailed';
|
||||||
|
|
||||||
|
interface QuotaRow {
|
||||||
|
id: 'five-hour' | 'weekly';
|
||||||
|
label: string;
|
||||||
|
compactLabel: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AccountQuotaPanelProps {
|
interface AccountQuotaPanelProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
quota?: UnifiedQuotaResult;
|
quota?: UnifiedQuotaResult;
|
||||||
@@ -101,18 +108,27 @@ export function AccountQuotaPanel({
|
|||||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||||
? getCodexQuotaBreakdown(quota.windows)
|
? getCodexQuotaBreakdown(quota.windows)
|
||||||
: null;
|
: 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,
|
value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null,
|
||||||
},
|
},
|
||||||
]
|
].filter((row): row is QuotaRow => row.value !== null)
|
||||||
: isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
: isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: '5h',
|
id: 'five-hour',
|
||||||
|
label: t('quotaTooltip.fiveHourLimit'),
|
||||||
|
compactLabel: '5h',
|
||||||
value:
|
value:
|
||||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
quota.coreUsage?.fiveHour?.remainingPercent ??
|
||||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
||||||
@@ -120,7 +136,9 @@ export function AccountQuotaPanel({
|
|||||||
null,
|
null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: mode === 'compact' ? 'Wk' : 'Weekly',
|
id: 'weekly',
|
||||||
|
label: t('quotaTooltip.weeklyLimit'),
|
||||||
|
compactLabel: 'Week',
|
||||||
value:
|
value:
|
||||||
quota.coreUsage?.weekly?.remainingPercent ??
|
quota.coreUsage?.weekly?.remainingPercent ??
|
||||||
quota.windows.find((window) =>
|
quota.windows.find((window) =>
|
||||||
@@ -134,11 +152,9 @@ export function AccountQuotaPanel({
|
|||||||
)?.remainingPercent ??
|
)?.remainingPercent ??
|
||||||
null,
|
null,
|
||||||
},
|
},
|
||||||
]
|
].filter((row): row is QuotaRow => row.value !== null)
|
||||||
: [];
|
: [];
|
||||||
const quotaRows = compactQuotaRows.filter(
|
const quotaRows = compactQuotaRows;
|
||||||
(row): row is { label: string; value: number } => row.value !== null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (quotaLoading) {
|
if (quotaLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -176,28 +192,42 @@ export function AccountQuotaPanel({
|
|||||||
{minQuotaLabel}%
|
{minQuotaLabel}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{quotaRows.length > 0 && (
|
{quotaRows.length > 0 ? (
|
||||||
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
|
<div className="space-y-0.5">
|
||||||
{quotaRows.map((row) => (
|
{quotaRows.map((row) => (
|
||||||
<span key={row.label}>
|
<div
|
||||||
{row.label} {row.value}%
|
key={row.id}
|
||||||
</span>
|
className="grid grid-cols-[2rem_minmax(0,1fr)_2.25rem] items-center gap-1 text-[7px]"
|
||||||
|
>
|
||||||
|
<span className="font-semibold uppercase text-muted-foreground/75">
|
||||||
|
{row.compactLabel}
|
||||||
|
</span>
|
||||||
|
<Progress
|
||||||
|
value={Math.max(0, Math.min(100, row.value))}
|
||||||
|
aria-label={`${row.compactLabel} quota`}
|
||||||
|
className="h-1"
|
||||||
|
indicatorClassName={getQuotaColor(row.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-right font-mono font-semibold text-foreground/80">
|
||||||
|
{formatQuotaPercent(row.value)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
<Progress
|
||||||
<div
|
value={minQuotaValue}
|
||||||
className={cn(
|
aria-label="Quota"
|
||||||
'h-full rounded-full transition-all',
|
className="h-1"
|
||||||
|
indicatorClassName={
|
||||||
minQuotaValue > 50
|
minQuotaValue > 50
|
||||||
? 'bg-emerald-500'
|
? 'bg-emerald-500'
|
||||||
: minQuotaValue > 20
|
: minQuotaValue > 20
|
||||||
? 'bg-amber-500'
|
? 'bg-amber-500'
|
||||||
: 'bg-red-500'
|
: 'bg-red-500'
|
||||||
)}
|
}
|
||||||
style={{ width: `${minQuotaValue}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5 cursor-help">
|
<div className="space-y-1.5 cursor-help">
|
||||||
@@ -228,18 +258,23 @@ export function AccountQuotaPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{quotaRows.length > 0 ? (
|
{quotaRows.length > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{quotaRows.map((row) => (
|
{quotaRows.map((row) => (
|
||||||
<div key={row.label} className="flex items-center gap-2">
|
<div key={row.id} className="space-y-1">
|
||||||
<span className="w-10 text-[10px] text-muted-foreground">
|
<div className="flex items-center justify-between gap-2 text-[10px]">
|
||||||
{row.label}
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
</span>
|
{row.label}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono font-semibold text-foreground/80">
|
||||||
|
{formatQuotaPercent(row.value)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
value={Math.max(0, Math.min(100, row.value))}
|
value={Math.max(0, Math.min(100, row.value))}
|
||||||
className="h-2 flex-1"
|
aria-label={`${row.label} quota`}
|
||||||
|
className="h-2"
|
||||||
indicatorClassName={getQuotaColor(row.value)}
|
indicatorClassName={getQuotaColor(row.value)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-medium w-10 text-right">{row.value}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +282,7 @@ export function AccountQuotaPanel({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress
|
<Progress
|
||||||
value={Math.max(0, Math.min(100, minQuotaValue))}
|
value={Math.max(0, Math.min(100, minQuotaValue))}
|
||||||
|
aria-label="Quota"
|
||||||
className="h-2 flex-1"
|
className="h-2 flex-1"
|
||||||
indicatorClassName={getQuotaColor(minQuotaValue)}
|
indicatorClassName={getQuotaColor(minQuotaValue)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { AccountQuotaPanel } from '@/components/account/shared/account-quota-panel';
|
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';
|
||||||
|
|
||||||
function createGeminiFailureQuota(): GeminiCliQuotaResult {
|
function createGeminiFailureQuota(): GeminiCliQuotaResult {
|
||||||
return {
|
return {
|
||||||
@@ -19,6 +19,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: 0.6,
|
||||||
|
usedPercent: 60,
|
||||||
|
remainingPercent: 40,
|
||||||
|
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: 40,
|
||||||
|
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(
|
||||||
|
<AccountQuotaPanel
|
||||||
|
provider="claude"
|
||||||
|
quota={createClaudeQuota()}
|
||||||
|
quotaLoading={false}
|
||||||
|
mode="compact"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('5h')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Week')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('43%')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('40%').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByRole('progressbar', { name: '5h quota' })).toHaveAttribute(
|
||||||
|
'aria-valuenow',
|
||||||
|
'43'
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('progressbar', { name: 'Week quota' })).toHaveAttribute(
|
||||||
|
'aria-valuenow',
|
||||||
|
'40'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows separate compact Codex 5h and weekly quota bars', () => {
|
||||||
|
render(
|
||||||
|
<AccountQuotaPanel
|
||||||
|
provider="codex"
|
||||||
|
quota={createCodexQuota()}
|
||||||
|
quotaLoading={false}
|
||||||
|
mode="compact"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AccountQuotaPanel
|
||||||
|
provider="gemini"
|
||||||
|
quota={createGeminiSuccessQuota()}
|
||||||
|
quotaLoading={false}
|
||||||
|
mode="compact"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AccountQuotaPanel
|
||||||
|
provider="claude"
|
||||||
|
quota={createClaudeQuota()}
|
||||||
|
quotaLoading={false}
|
||||||
|
mode="detailed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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', () => {
|
describe('AccountQuotaPanel failure tooltip', () => {
|
||||||
it('renders the shared failure tooltip content with viewport-safe shell classes', async () => {
|
it('renders the shared failure tooltip content with viewport-safe shell classes', async () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
Reference in New Issue
Block a user