feat: extend quota providers and add usage dropdown (#254) (#284)

* feat: extend quota providers and add usage dropdown

Add new quota providers:
- Claude (Anthropic API)
- Codex (OpenAI/ChatGPT)
- GitHub Copilot (base + add-on)
- Kimi for Coding
- OpenRouter

UI enhancements:
- Add rate limits dropdown in header with timer icon
- Desktop: sticky header with Used/Remaining toggle, refresh button
- Mobile: full-screen dropdown with same controls
- Per-provider sticky headers with provider logos
- Auto-refresh on open if no data exists

Usage settings page:
- Provider icon next to header
- Show in dropdown toggle per provider
- Global display selector (Usage vs Quota remaining)
- Auto-refresh controls in sidebar

Settings persistence:
- Add usageDisplayMode and usageDropdownProviders settings
- Server-side sanitization for new settings

Provider logo aliases:
- codex -> openai, claude -> anthropic

Bug fixes:
- Fix React error #310 by calling hooks before early returns
- Add aria-describedby to SettingsWindow dialog
- Remove horizontal scroll from dropdown

* fix: Remove duplicate github copilot declarations
This commit is contained in:
gsxdsm
2026-02-04 11:13:23 +02:00
committed by GitHub
parent 2785f4d9ad
commit a264393f1b
16 changed files with 1600 additions and 319 deletions
@@ -2,6 +2,7 @@ import React from 'react';
import type { UsageWindow } from '@/types';
import { formatPercent, formatWindowLabel } from '@/lib/quota';
import { UsageProgressBar } from './UsageProgressBar';
import { useQuotaStore } from '@/stores/useQuotaStore';
interface UsageCardProps {
title: string;
@@ -10,8 +11,11 @@ interface UsageCardProps {
}
export const UsageCard: React.FC<UsageCardProps> = ({ title, window, subtitle }) => {
const percentLabel = formatPercent(window.usedPercent);
const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? '-';
const displayMode = useQuotaStore((state) => state.displayMode);
const displayPercent = displayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
const barLabel = displayMode === 'remaining' ? 'remaining' : 'used';
const percentLabel = window.valueLabel ?? formatPercent(displayPercent);
const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? '';
const windowLabel = formatWindowLabel(title);
return (
@@ -23,15 +27,18 @@ export const UsageCard: React.FC<UsageCardProps> = ({ title, window, subtitle })
<div className="typography-micro text-muted-foreground truncate">{subtitle}</div>
)}
</div>
<div className="typography-ui-label text-foreground tabular-nums">{percentLabel}</div>
<div className="typography-ui-label text-foreground tabular-nums">{percentLabel === '-' ? '' : percentLabel}</div>
</div>
<div className="mt-3">
<UsageProgressBar percent={window.usedPercent} />
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} />
<div className="mt-1 text-right typography-micro text-muted-foreground text-[10px]">
{barLabel}
</div>
</div>
<div className="mt-3 flex items-center justify-between text-muted-foreground">
<span className="typography-micro">Resets in</span>
<span className="typography-micro">Resets</span>
<span className="typography-micro tabular-nums">{resetLabel}</span>
</div>
</div>
@@ -3,6 +3,9 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { UsageCard } from './UsageCard';
import { QUOTA_PROVIDERS } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { Switch } from '@/components/ui/switch';
import { updateDesktopSettings } from '@/lib/persistence';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-';
@@ -25,6 +28,8 @@ export const UsagePage: React.FC = () => {
const isLoading = useQuotaStore((state) => state.isLoading);
const lastUpdated = useQuotaStore((state) => state.lastUpdated);
const error = useQuotaStore((state) => state.error);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const setDropdownProviderIds = useQuotaStore((state) => state.setDropdownProviderIds);
useQuotaAutoRefresh();
@@ -33,6 +38,7 @@ export const UsagePage: React.FC = () => {
void fetchAllQuotas();
}, [loadSettings, fetchAllQuotas]);
React.useEffect(() => {
if (selectedProviderId) {
return;
@@ -46,6 +52,21 @@ export const UsagePage: React.FC = () => {
const selectedResult = results.find((entry) => entry.providerId === selectedProviderId) ?? null;
const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId);
const providerName = providerMeta?.name ?? selectedProviderId ?? 'Usage';
const usage = selectedResult?.usage;
const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false;
const handleDropdownToggle = React.useCallback((enabled: boolean) => {
if (!selectedProviderId) {
return;
}
const next = enabled
? Array.from(new Set([...dropdownProviderIds, selectedProviderId]))
: dropdownProviderIds.filter((id) => id !== selectedProviderId);
setDropdownProviderIds(next);
void updateDesktopSettings({ usageDropdownProviders: next });
}, [dropdownProviderIds, selectedProviderId, setDropdownProviderIds]);
if (!selectedProviderId) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
@@ -54,18 +75,30 @@ export const UsagePage: React.FC = () => {
);
}
const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId);
const providerName = providerMeta?.name ?? selectedProviderId;
const usage = selectedResult?.usage;
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">{providerName} Usage</h1>
<p className="typography-meta text-muted-foreground">
{isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`}
</p>
<div className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5" />
<h1 className="typography-ui-header font-semibold text-lg">{providerName} Usage</h1>
</div>
<p className="typography-meta text-muted-foreground">
{isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground">Show in dropdown</span>
<Switch
checked={showInDropdown}
onCheckedChange={handleDropdownToggle}
aria-label={`Show ${providerName} in usage dropdown`}
className="data-[state=checked]:bg-[var(--status-info)]"
/>
</div>
</div>
{!selectedResult && (
@@ -4,12 +4,13 @@ import { clampPercent, resolveUsageTone } from '@/lib/quota';
interface UsageProgressBarProps {
percent: number | null;
tonePercent?: number | null;
className?: string;
}
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({ percent, className }) => {
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({ percent, tonePercent, className }) => {
const clamped = clampPercent(percent) ?? 0;
const tone = resolveUsageTone(percent);
const tone = resolveUsageTone(tonePercent ?? percent);
const fillStyle = tone === 'critical'
? { backgroundColor: 'var(--status-error)' }
@@ -36,8 +36,10 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
const isLoading = useQuotaStore((state) => state.isLoading);
const usageAutoRefresh = useQuotaStore((state) => state.autoRefresh);
const usageRefreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
const usageDisplayMode = useQuotaStore((state) => state.displayMode);
const setUsageAutoRefresh = useQuotaStore((state) => state.setAutoRefresh);
const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval);
const setUsageDisplayMode = useQuotaStore((state) => state.setDisplayMode);
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
@@ -57,7 +59,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
void loadUsageSettings();
}, [loadUsageSettings]);
const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number }) => {
const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number; usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[] }) => {
try {
await updateDesktopSettings(changes);
} catch (error) {
@@ -79,6 +81,17 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
void persistUsageSettings({ usageRefreshIntervalMs: next });
}, [persistUsageSettings, setUsageRefreshInterval]);
const handleUsageDisplayModeChange = React.useCallback((value: string) => {
if (value !== 'usage' && value !== 'remaining') {
return;
}
setUsageDisplayMode(value);
void persistUsageSettings({ usageDisplayMode: value });
}, [persistUsageSettings, setUsageDisplayMode]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
@@ -134,6 +147,22 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
</Button>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="typography-micro text-muted-foreground">Display</span>
<Select value={usageDisplayMode} onValueChange={handleUsageDisplayModeChange}>
<SelectTrigger size="sm" className="min-w-[140px]">
<SelectValue placeholder="Display mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="usage" className="pr-2 [&>span:first-child]:hidden">
Usage
</SelectItem>
<SelectItem value="remaining" className="pr-2 [&>span:first-child]:hidden">
Quota remaining
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
@@ -173,11 +202,11 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
{provider.name}
</span>
{!configured && (
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">Not set</span>
)}
</button>
</div>
{!configured && (
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">Not set</span>
)}
</button>
</div>
);
})}
</ScrollableOverlay>