diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 4554bb22..25edae1b 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -37,6 +37,23 @@ function formatPlanLabel(planType: string | null | undefined): string | null { return normalized.length > 0 ? normalized.join(' ') : planType; } +function formatAbsoluteResetTime(resetTime: string | null): string | null { + if (!resetTime) return null; + try { + const parsed = new Date(resetTime); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toLocaleString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } catch { + return null; + } +} + function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string { switch (rateLimitType) { case 'five_hour': @@ -235,19 +252,45 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro // Gemini provider tooltip if (isGeminiQuotaResult(quota)) { + const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); + return (
+ {quota.tierLabel && ( +
+ Tier + {quota.tierLabel} +
+ )} + {quota.creditBalance !== null && quota.creditBalance !== undefined && ( +
+ Credits + {quota.creditBalance.toLocaleString()} +
+ )}

Buckets:

{quota.buckets.map((b) => ( -
- - {b.label} - {b.tokenType ? ` (${b.tokenType})` : ''} - - {b.remainingPercent}% +
+
+ + {b.label} + {b.tokenType ? ` (${b.tokenType})` : ''} + + {b.remainingPercent}% +
+ {((b.remainingAmount !== null && b.remainingAmount !== undefined) || b.resetTime) && ( +
+ + {b.remainingAmount !== null && b.remainingAmount !== undefined + ? `${b.remainingAmount.toLocaleString()} remaining` + : ''} + + {formatAbsoluteResetTime(b.resetTime) ?? ''} +
+ )}
))} - + {!hasBucketResetTime && }
); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index b092142a..24876be9 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -611,6 +611,8 @@ export interface GeminiCliBucket { remainingFraction: number; /** Remaining quota as percentage (0-100) */ remainingPercent: number; + /** Remaining quota count when the upstream API provides it */ + remainingAmount?: number | null; /** ISO timestamp when quota resets, null if unknown */ resetTime: string | null; /** Model IDs in this bucket */ @@ -625,6 +627,12 @@ export interface GeminiCliQuotaResult { buckets: GeminiCliBucket[]; /** GCP project ID for this account */ projectId: string | null; + /** Human-readable Gemini tier label when available */ + tierLabel?: string | null; + /** Stable Gemini tier identifier when available */ + tierId?: string | null; + /** Available Google One AI credits when reported by the API */ + creditBalance?: number | null; /** Timestamp of fetch */ lastUpdated: number; /** Upstream HTTP status when available */ diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx new file mode 100644 index 00000000..73ab0923 --- /dev/null +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@tests/setup/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; +import type { GeminiCliQuotaResult } from '@/lib/api-client'; + +function createGeminiQuotaResult( + overrides: Partial = {} +): GeminiCliQuotaResult { + return { + success: true, + buckets: [ + { + id: 'gemini-flash-lite-series::combined', + label: 'Gemini Flash Lite Series', + tokenType: null, + remainingFraction: 1, + remainingPercent: 100, + remainingAmount: 100, + resetTime: '2026-01-30T09:00:00Z', + modelIds: ['gemini-2.5-flash-lite'], + }, + { + id: 'gemini-flash-series::combined', + label: 'Gemini Flash Series', + tokenType: null, + remainingFraction: 0.82, + remainingPercent: 82, + remainingAmount: 82, + resetTime: '2026-01-30T14:00:00Z', + modelIds: ['gemini-3-flash-preview'], + }, + ], + projectId: 'cloudaicompanion-test-123', + tierLabel: 'Pro', + tierId: 'g1-pro-tier', + creditBalance: 12, + lastUpdated: Date.now(), + ...overrides, + }; +} + +describe('QuotaTooltipContent', () => { + it('renders Gemini tier, credits, remaining amount, and bucket reset timestamps', () => { + const quota = createGeminiQuotaResult(); + const expectedReset = new Date('2026-01-30T14:00:00Z').toLocaleString(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + + render(); + + expect(screen.getByText('Tier')).toBeInTheDocument(); + expect(screen.getByText('Pro')).toBeInTheDocument(); + expect(screen.getByText('Credits')).toBeInTheDocument(); + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument(); + expect(screen.getByText('100 remaining')).toBeInTheDocument(); + expect(screen.getByText('82 remaining')).toBeInTheDocument(); + expect(screen.getByText(expectedReset)).toBeInTheDocument(); + }); + + it('falls back to the shared reset indicator when Gemini buckets omit reset timestamps', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-29T00:00:00Z')); + + const quota = createGeminiQuotaResult({ + buckets: [ + { + id: 'gemini-flash-series::combined', + label: 'Gemini Flash Series', + tokenType: null, + remainingFraction: 0.75, + remainingPercent: 75, + remainingAmount: 75, + resetTime: null, + modelIds: ['gemini-3-flash-preview'], + }, + ], + creditBalance: null, + tierLabel: null, + tierId: null, + }); + + render(); + + expect(screen.getByText(/Resets/i)).toBeInTheDocument(); + + vi.useRealTimers(); + }); +});