mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user