mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 20:17:20 +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 {
|
import {
|
||||||
cn,
|
cn,
|
||||||
sortModelsByPriority,
|
|
||||||
formatResetTime,
|
formatResetTime,
|
||||||
getClaudeResetTime,
|
getClaudeResetTime,
|
||||||
getMinClaudeQuota,
|
getMinClaudeQuota,
|
||||||
getModelsWithExhaustedIndicator,
|
getModelsWithTiers,
|
||||||
|
groupModelsByTier,
|
||||||
|
getTierLabel,
|
||||||
|
type ModelTier,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react';
|
import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react';
|
||||||
@@ -218,34 +220,39 @@ export function AccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
<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>
|
<p className="font-medium">Model Quotas:</p>
|
||||||
{sortModelsByPriority(getModelsWithExhaustedIndicator(quota?.models || [])).map(
|
{(() => {
|
||||||
(m) => (
|
const tiered = getModelsWithTiers(quota?.models || []);
|
||||||
<div key={m.name} className="flex justify-between gap-4">
|
const groups = groupModelsByTier(tiered);
|
||||||
<span
|
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||||
className={cn(
|
return tierOrder.map((tier) => {
|
||||||
'truncate',
|
const models = groups.get(tier);
|
||||||
m.name === 'claude-exhausted' && 'text-red-500'
|
if (!models || models.length === 0) return null;
|
||||||
)}
|
return (
|
||||||
>
|
<div key={tier} className="space-y-0.5">
|
||||||
{m.displayName || m.name}
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/70 font-semibold border-b border-border/30 pb-0.5">
|
||||||
</span>
|
{getTierLabel(tier)}
|
||||||
<span
|
</div>
|
||||||
className={cn(
|
{models.map((m) => (
|
||||||
'font-mono',
|
<div key={m.name} className="flex justify-between gap-4 pl-1">
|
||||||
m.name === 'claude-exhausted' && 'text-red-500'
|
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||||
)}
|
{m.displayName}
|
||||||
>
|
{m.exhausted && ' (Exhausted)'}
|
||||||
{m.percentage}%
|
</span>
|
||||||
</span>
|
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||||
</div>
|
{m.percentage}%
|
||||||
)
|
</span>
|
||||||
)}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
{(() => {
|
{(() => {
|
||||||
const resetTime = getClaudeResetTime(quota?.models || []);
|
const resetTime = getClaudeResetTime(quota?.models || []);
|
||||||
return resetTime ? (
|
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" />
|
<Clock className="w-3 h-3 text-blue-400" />
|
||||||
<span className="text-blue-400 font-medium">
|
<span className="text-blue-400 font-medium">
|
||||||
Resets {formatResetTime(resetTime)}
|
Resets {formatResetTime(resetTime)}
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
sortModelsByPriority,
|
|
||||||
formatResetTime,
|
formatResetTime,
|
||||||
getClaudeResetTime,
|
getClaudeResetTime,
|
||||||
getMinClaudeQuota,
|
getMinClaudeQuota,
|
||||||
getModelsWithExhaustedIndicator,
|
getModelsWithTiers,
|
||||||
|
groupModelsByTier,
|
||||||
|
getTierLabel,
|
||||||
|
type ModelTier,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||||
@@ -348,32 +350,37 @@ export function AccountItem({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
<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>
|
<p className="font-medium">Model Quotas:</p>
|
||||||
{sortModelsByPriority(
|
{(() => {
|
||||||
getModelsWithExhaustedIndicator(quota?.models || [])
|
const tiered = getModelsWithTiers(quota?.models || []);
|
||||||
).map((m) => (
|
const groups = groupModelsByTier(tiered);
|
||||||
<div key={m.name} className="flex justify-between gap-4">
|
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||||
<span
|
return tierOrder.map((tier) => {
|
||||||
className={cn(
|
const models = groups.get(tier);
|
||||||
'truncate',
|
if (!models || models.length === 0) return null;
|
||||||
m.name === 'claude-exhausted' && 'text-red-500'
|
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">
|
||||||
{m.displayName || m.name}
|
{getTierLabel(tier)}
|
||||||
</span>
|
</div>
|
||||||
<span
|
{models.map((m) => (
|
||||||
className={cn(
|
<div key={m.name} className="flex justify-between gap-4 pl-1">
|
||||||
'font-mono',
|
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||||
m.name === 'claude-exhausted' && 'text-red-500'
|
{m.displayName}
|
||||||
)}
|
{m.exhausted && ' (Exhausted)'}
|
||||||
>
|
</span>
|
||||||
{m.percentage}%
|
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||||
</span>
|
{m.percentage}%
|
||||||
</div>
|
</span>
|
||||||
))}
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
{nextReset && (
|
{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" />
|
<Clock className="w-3 h-3 text-blue-400" />
|
||||||
<span className="text-blue-400 font-medium">
|
<span className="text-blue-400 font-medium">
|
||||||
Resets {formatResetTime(nextReset)}
|
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.
|
* Convert models to tiered format with exhausted primary models injected.
|
||||||
* Used for tooltip display to show user that primary models are exhausted (0%).
|
* Groups models by tier for visual display in tooltip.
|
||||||
*/
|
*/
|
||||||
export function getModelsWithExhaustedIndicator<
|
export function getModelsWithTiers<
|
||||||
T extends { name: string; displayName?: string; percentage: number },
|
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 [];
|
if (models.length === 0) return [];
|
||||||
|
|
||||||
const primaryModels = filterPrimaryModels(models);
|
const primaryModels = filterPrimaryModels(models);
|
||||||
|
const result: TieredModel[] = [];
|
||||||
|
|
||||||
// If primary models exist, return as-is
|
// If primary models exhausted, add known ones with 0%
|
||||||
if (primaryModels.length > 0) return models;
|
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
|
// Add all models with tier info
|
||||||
return [
|
for (const m of models) {
|
||||||
{ name: 'claude-exhausted', displayName: 'Claude/GPT (Exhausted)', percentage: 0 },
|
const displayName = m.displayName || m.name;
|
||||||
...models,
|
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