feat(ui): add tiered visual grouping to quota tooltip

- Replace generic "Claude/GPT (Exhausted)" with actual model names
- Group models by tier with visual headers:
  - Claude & GPT (primary, weekly limits)
  - Gemini 3 (high capability)
  - Gemini 2.5 (mid tier)
  - Other
- When primary models exhausted, show known models with "(Exhausted)" label
- Add border separators between tier groups for visual clarity
This commit is contained in:
kaitranntt
2026-01-25 17:06:06 -05:00
parent de7138166c
commit ebfc554f5f
3 changed files with 156 additions and 63 deletions
@@ -4,11 +4,13 @@
import {
cn,
sortModelsByPriority,
formatResetTime,
getClaudeResetTime,
getMinClaudeQuota,
getModelsWithExhaustedIndicator,
getModelsWithTiers,
groupModelsByTier,
getTierLabel,
type ModelTier,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react';
@@ -218,34 +220,39 @@ export function AccountCard({
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs space-y-1">
<div className="text-xs space-y-2">
<p className="font-medium">Model Quotas:</p>
{sortModelsByPriority(getModelsWithExhaustedIndicator(quota?.models || [])).map(
(m) => (
<div key={m.name} className="flex justify-between gap-4">
<span
className={cn(
'truncate',
m.name === 'claude-exhausted' && 'text-red-500'
)}
>
{m.displayName || m.name}
</span>
<span
className={cn(
'font-mono',
m.name === 'claude-exhausted' && 'text-red-500'
)}
>
{m.percentage}%
</span>
</div>
)
)}
{(() => {
const tiered = getModelsWithTiers(quota?.models || []);
const groups = groupModelsByTier(tiered);
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return tierOrder.map((tier) => {
const models = groups.get(tier);
if (!models || models.length === 0) return null;
return (
<div key={tier} className="space-y-0.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/70 font-semibold border-b border-border/30 pb-0.5">
{getTierLabel(tier)}
</div>
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4 pl-1">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
{m.exhausted && ' (Exhausted)'}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
})()}
{(() => {
const resetTime = getClaudeResetTime(quota?.models || []);
return resetTime ? (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(resetTime)}
@@ -31,11 +31,13 @@ import {
} from 'lucide-react';
import {
cn,
sortModelsByPriority,
formatResetTime,
getClaudeResetTime,
getMinClaudeQuota,
getModelsWithExhaustedIndicator,
getModelsWithTiers,
groupModelsByTier,
getTierLabel,
type ModelTier,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
@@ -348,32 +350,37 @@ export function AccountItem({
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-1">
<div className="text-xs space-y-2">
<p className="font-medium">Model Quotas:</p>
{sortModelsByPriority(
getModelsWithExhaustedIndicator(quota?.models || [])
).map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span
className={cn(
'truncate',
m.name === 'claude-exhausted' && 'text-red-500'
)}
>
{m.displayName || m.name}
</span>
<span
className={cn(
'font-mono',
m.name === 'claude-exhausted' && 'text-red-500'
)}
>
{m.percentage}%
</span>
</div>
))}
{(() => {
const tiered = getModelsWithTiers(quota?.models || []);
const groups = groupModelsByTier(tiered);
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return tierOrder.map((tier) => {
const models = groups.get(tier);
if (!models || models.length === 0) return null;
return (
<div key={tier} className="space-y-0.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/70 font-semibold border-b border-border/30 pb-0.5">
{getTierLabel(tier)}
</div>
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4 pl-1">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
{m.exhausted && ' (Exhausted)'}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
})()}
{nextReset && (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
+90 -11
View File
@@ -196,23 +196,102 @@ export function getClaudeResetTime<
);
}
// Known primary models to show when exhausted (removed from API response)
const KNOWN_PRIMARY_MODELS = [
{ name: 'claude-opus-4-5', displayName: 'Claude Opus 4.5' },
{ name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' },
{ name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B' },
];
/** Model tier for visual grouping */
export type ModelTier = 'primary' | 'gemini-3' | 'gemini-2' | 'other';
/** Model with tier info for grouped display */
export interface TieredModel {
name: string;
displayName: string;
percentage: number;
tier: ModelTier;
exhausted?: boolean;
}
/** Get tier label for display */
export function getTierLabel(tier: ModelTier): string {
switch (tier) {
case 'primary':
return 'Claude & GPT';
case 'gemini-3':
return 'Gemini 3';
case 'gemini-2':
return 'Gemini 2.5';
case 'other':
return 'Other';
}
}
/** Determine tier for a model */
function getModelTier(name: string): ModelTier {
const lower = name.toLowerCase();
if (lower.includes('claude') || lower.includes('gpt')) return 'primary';
if (lower.includes('gemini 3') || lower.includes('gemini-3')) return 'gemini-3';
if (lower.includes('gemini 2') || lower.includes('gemini-2')) return 'gemini-2';
return 'other';
}
/**
* Augment models list with synthetic "Exhausted" entry when Claude/GPT models are missing.
* Used for tooltip display to show user that primary models are exhausted (0%).
* Convert models to tiered format with exhausted primary models injected.
* Groups models by tier for visual display in tooltip.
*/
export function getModelsWithExhaustedIndicator<
export function getModelsWithTiers<
T extends { name: string; displayName?: string; percentage: number },
>(models: T[]): (T | { name: string; displayName: string; percentage: number })[] {
>(models: T[]): TieredModel[] {
if (models.length === 0) return [];
const primaryModels = filterPrimaryModels(models);
const result: TieredModel[] = [];
// If primary models exist, return as-is
if (primaryModels.length > 0) return models;
// If primary models exhausted, add known ones with 0%
if (primaryModels.length === 0) {
for (const known of KNOWN_PRIMARY_MODELS) {
result.push({
name: known.name,
displayName: known.displayName,
percentage: 0,
tier: 'primary',
exhausted: true,
});
}
}
// Primary models exhausted - prepend synthetic entry
return [
{ name: 'claude-exhausted', displayName: 'Claude/GPT (Exhausted)', percentage: 0 },
...models,
];
// Add all models with tier info
for (const m of models) {
const displayName = m.displayName || m.name;
result.push({
name: m.name,
displayName,
percentage: m.percentage,
tier: getModelTier(displayName),
});
}
// Sort by tier priority, then alphabetically within tier
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return result.sort((a, b) => {
const tierDiff = tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier);
if (tierDiff !== 0) return tierDiff;
return a.displayName.localeCompare(b.displayName);
});
}
/**
* Group tiered models by tier for sectioned display
*/
export function groupModelsByTier(models: TieredModel[]): Map<ModelTier, TieredModel[]> {
const groups = new Map<ModelTier, TieredModel[]>();
for (const m of models) {
const existing = groups.get(m.tier) || [];
existing.push(m);
groups.set(m.tier, existing);
}
return groups;
}