mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-19 04:19:51 +00:00
refactor(ui): extract shared QuotaTooltipContent component
Address PR review feedback: - Create QuotaTooltipContent component in shared/ for DRY principle - Use component in account-item.tsx and account-card.tsx - Use QUOTA_SUPPORTED_PROVIDERS constant in model-config-tab.tsx - Reduces ~200 lines of duplicated tooltip rendering code
This commit is contained in:
@@ -2,24 +2,14 @@
|
|||||||
* Account Card Component for Flow Visualization
|
* Account Card Component for Flow Visualization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils';
|
||||||
cn,
|
|
||||||
formatResetTime,
|
|
||||||
getModelsWithTiers,
|
|
||||||
groupModelsByTier,
|
|
||||||
getProviderMinQuota,
|
|
||||||
getProviderResetTime,
|
|
||||||
isAgyQuotaResult,
|
|
||||||
isCodexQuotaResult,
|
|
||||||
isGeminiQuotaResult,
|
|
||||||
type ModelTier,
|
|
||||||
} 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, Pause, Play } from 'lucide-react';
|
||||||
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
|
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
|
||||||
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||||
|
|
||||||
import type { AccountData, DragOffset } from './types';
|
import type { AccountData, DragOffset } from './types';
|
||||||
import { cleanEmail } from './utils';
|
import { cleanEmail } from './utils';
|
||||||
@@ -105,6 +95,7 @@ export function AccountCard({
|
|||||||
|
|
||||||
// Use shared helper for provider-specific minimum quota
|
// Use shared helper for provider-specific minimum quota
|
||||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||||
|
const resetTime = getProviderResetTime(account.provider, quota);
|
||||||
|
|
||||||
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
||||||
const showTierBadge =
|
const showTierBadge =
|
||||||
@@ -254,95 +245,7 @@ export function AccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
{quota && isAgyQuotaResult(quota) ? (
|
{quota && <QuotaTooltipContent quota={quota} resetTime={resetTime} />}
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<p className="font-medium">Model Quotas:</p>
|
|
||||||
{(() => {
|
|
||||||
const tiered = getModelsWithTiers(quota.models || []);
|
|
||||||
const groups = groupModelsByTier(tiered);
|
|
||||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
|
||||||
return tierOrder.map((tier, idx) => {
|
|
||||||
const models = groups.get(tier);
|
|
||||||
if (!models || models.length === 0) return null;
|
|
||||||
const isFirst = tierOrder
|
|
||||||
.slice(0, idx)
|
|
||||||
.every((t) => !groups.get(t)?.length);
|
|
||||||
return (
|
|
||||||
<div key={tier}>
|
|
||||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
|
||||||
{models.map((m) => (
|
|
||||||
<div key={m.name} className="flex justify-between gap-4">
|
|
||||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
|
||||||
{m.displayName}
|
|
||||||
</span>
|
|
||||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
|
||||||
{m.percentage}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const resetTime = getProviderResetTime('agy', quota);
|
|
||||||
return resetTime ? (
|
|
||||||
<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)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
) : quota && isCodexQuotaResult(quota) ? (
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<p className="font-medium">Rate Limits:</p>
|
|
||||||
{quota.windows.map((w) => (
|
|
||||||
<div key={w.label} className="flex justify-between gap-4">
|
|
||||||
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>
|
|
||||||
{w.label}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono">{w.remainingPercent}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(() => {
|
|
||||||
const resetTime = getProviderResetTime('codex', quota);
|
|
||||||
return resetTime ? (
|
|
||||||
<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)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
) : quota && isGeminiQuotaResult(quota) ? (
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<p className="font-medium">Buckets:</p>
|
|
||||||
{quota.buckets.map((b) => (
|
|
||||||
<div key={b.id} className="flex justify-between gap-4">
|
|
||||||
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
|
||||||
{b.label}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono">{b.remainingPercent}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(() => {
|
|
||||||
const resetTime = getProviderResetTime('gemini', quota);
|
|
||||||
return resetTime ? (
|
|
||||||
<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)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -29,20 +29,10 @@ import {
|
|||||||
FolderCode,
|
FolderCode,
|
||||||
Check,
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils';
|
||||||
cn,
|
|
||||||
formatResetTime,
|
|
||||||
getModelsWithTiers,
|
|
||||||
groupModelsByTier,
|
|
||||||
getProviderMinQuota,
|
|
||||||
getProviderResetTime,
|
|
||||||
isAgyQuotaResult,
|
|
||||||
isCodexQuotaResult,
|
|
||||||
isGeminiQuotaResult,
|
|
||||||
type ModelTier,
|
|
||||||
} 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';
|
||||||
|
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||||
import type { AccountItemProps } from './types';
|
import type { AccountItemProps } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,97 +115,6 @@ export function AccountItem({
|
|||||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||||
const nextReset = getProviderResetTime(account.provider, quota);
|
const nextReset = getProviderResetTime(account.provider, quota);
|
||||||
|
|
||||||
// Provider-specific tooltip content renderer using type guards
|
|
||||||
const renderQuotaTooltip = () => {
|
|
||||||
if (!quota?.success) return null;
|
|
||||||
|
|
||||||
// Antigravity (agy) provider tooltip
|
|
||||||
if (isAgyQuotaResult(quota)) {
|
|
||||||
const tiered = getModelsWithTiers(quota.models || []);
|
|
||||||
const groups = groupModelsByTier(tiered);
|
|
||||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
|
||||||
return (
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<p className="font-medium">Model Quotas:</p>
|
|
||||||
{tierOrder.map((tier, idx) => {
|
|
||||||
const models = groups.get(tier);
|
|
||||||
if (!models || models.length === 0) return null;
|
|
||||||
const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length);
|
|
||||||
return (
|
|
||||||
<div key={tier}>
|
|
||||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
|
||||||
{models.map((m) => (
|
|
||||||
<div key={m.name} className="flex justify-between gap-4">
|
|
||||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
|
||||||
{m.displayName}
|
|
||||||
</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 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)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex provider tooltip
|
|
||||||
if (isCodexQuotaResult(quota)) {
|
|
||||||
return (
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<p className="font-medium">Rate Limit Windows:</p>
|
|
||||||
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
|
|
||||||
{quota.windows.map((w) => (
|
|
||||||
<div key={w.label} className="flex justify-between gap-4">
|
|
||||||
<span>{w.label}</span>
|
|
||||||
<span className="font-mono">{w.remainingPercent}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{nextReset && (
|
|
||||||
<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)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini provider tooltip
|
|
||||||
if (isGeminiQuotaResult(quota)) {
|
|
||||||
return (
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<p className="font-medium">Model Buckets:</p>
|
|
||||||
{quota.buckets.map((b) => (
|
|
||||||
<div key={b.id} className="flex justify-between gap-4">
|
|
||||||
<span>
|
|
||||||
{b.label}
|
|
||||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono">{b.remainingPercent}%</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{nextReset && (
|
|
||||||
<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)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -447,7 +346,7 @@ export function AccountItem({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="max-w-xs">
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
{renderQuotaTooltip()}
|
{quota && <QuotaTooltipContent quota={quota} resetTime={nextReset} />}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AccountsSection } from './accounts-section';
|
|||||||
import { api } from '@/lib/api-client';
|
import { api } from '@/lib/api-client';
|
||||||
import type { ProviderCatalog } from '../provider-model-selector';
|
import type { ProviderCatalog } from '../provider-model-selector';
|
||||||
import type { OAuthAccount } from '@/lib/api-client';
|
import type { OAuthAccount } from '@/lib/api-client';
|
||||||
|
import { QUOTA_SUPPORTED_PROVIDERS, type QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||||
|
|
||||||
interface ModelConfigTabProps {
|
interface ModelConfigTabProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -167,7 +168,9 @@ export function ModelConfigTab({
|
|||||||
isBulkPausing={isBulkPausing}
|
isBulkPausing={isBulkPausing}
|
||||||
isBulkResuming={isBulkResuming}
|
isBulkResuming={isBulkResuming}
|
||||||
privacyMode={privacyMode}
|
privacyMode={privacyMode}
|
||||||
showQuota={['agy', 'codex', 'gemini'].includes(provider) && !isRemoteMode}
|
showQuota={
|
||||||
|
QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode
|
||||||
|
}
|
||||||
isKiro={isKiro}
|
isKiro={isKiro}
|
||||||
kiroNoIncognito={kiroNoIncognito}
|
kiroNoIncognito={kiroNoIncognito}
|
||||||
onKiroNoIncognitoChange={saveKiroNoIncognito}
|
onKiroNoIncognitoChange={saveKiroNoIncognito}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export { PrivacyToggle } from './privacy-toggle';
|
|||||||
export { ProjectSelectionDialog } from './project-selection-dialog';
|
export { ProjectSelectionDialog } from './project-selection-dialog';
|
||||||
export { ProviderIcon } from './provider-icon';
|
export { ProviderIcon } from './provider-icon';
|
||||||
export { QuickCommands } from './quick-commands';
|
export { QuickCommands } from './quick-commands';
|
||||||
|
export { QuotaTooltipContent } from './quota-tooltip-content';
|
||||||
export { SettingsDialog } from './settings-dialog';
|
export { SettingsDialog } from './settings-dialog';
|
||||||
export { SponsorButton } from './sponsor-button';
|
export { SponsorButton } from './sponsor-button';
|
||||||
export { StatCard } from './stat-card';
|
export { StatCard } from './stat-card';
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Shared Quota Tooltip Content Component
|
||||||
|
* Displays provider-specific quota information in tooltips
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
formatResetTime,
|
||||||
|
getModelsWithTiers,
|
||||||
|
groupModelsByTier,
|
||||||
|
isAgyQuotaResult,
|
||||||
|
isCodexQuotaResult,
|
||||||
|
isGeminiQuotaResult,
|
||||||
|
type ModelTier,
|
||||||
|
type UnifiedQuotaResult,
|
||||||
|
} from '@/lib/utils';
|
||||||
|
|
||||||
|
interface QuotaTooltipContentProps {
|
||||||
|
quota: UnifiedQuotaResult;
|
||||||
|
resetTime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders provider-specific quota tooltip content
|
||||||
|
* Uses type guards for proper TypeScript narrowing
|
||||||
|
*/
|
||||||
|
export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) {
|
||||||
|
if (!quota?.success) return null;
|
||||||
|
|
||||||
|
// Antigravity (agy) provider tooltip
|
||||||
|
if (isAgyQuotaResult(quota)) {
|
||||||
|
const tiered = getModelsWithTiers(quota.models || []);
|
||||||
|
const groups = groupModelsByTier(tiered);
|
||||||
|
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<p className="font-medium">Model Quotas:</p>
|
||||||
|
{tierOrder.map((tier, idx) => {
|
||||||
|
const models = groups.get(tier);
|
||||||
|
if (!models || models.length === 0) return null;
|
||||||
|
const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length);
|
||||||
|
return (
|
||||||
|
<div key={tier}>
|
||||||
|
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||||
|
{models.map((m) => (
|
||||||
|
<div key={m.name} className="flex justify-between gap-4">
|
||||||
|
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||||
|
{m.displayName}
|
||||||
|
</span>
|
||||||
|
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||||
|
{m.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ResetTimeIndicator resetTime={resetTime} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codex provider tooltip
|
||||||
|
if (isCodexQuotaResult(quota)) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<p className="font-medium">Rate Limits:</p>
|
||||||
|
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
|
||||||
|
{quota.windows.map((w) => (
|
||||||
|
<div key={w.label} className="flex justify-between gap-4">
|
||||||
|
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>{w.label}</span>
|
||||||
|
<span className="font-mono">{w.remainingPercent}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<ResetTimeIndicator resetTime={resetTime} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini provider tooltip
|
||||||
|
if (isGeminiQuotaResult(quota)) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<p className="font-medium">Buckets:</p>
|
||||||
|
{quota.buckets.map((b) => (
|
||||||
|
<div key={b.id} className="flex justify-between gap-4">
|
||||||
|
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
||||||
|
{b.label}
|
||||||
|
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">{b.remainingPercent}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<ResetTimeIndicator resetTime={resetTime} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset time indicator shown at bottom of tooltip
|
||||||
|
*/
|
||||||
|
function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) {
|
||||||
|
if (!resetTime) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user