Add per-model quotas with collapsible model groups in header (#355)

* feat(ui): show per-model quota groups in header with collapsible families

Add per-model quota groups under each provider in the header
Introduce collapsible sections for model families to reveal models
Show all models when none are explicitly selected or respect explicit selections

* feat: add toggle to UsageCard for dropdown visibility

Introduce a switch in UsageCard to control inclusion in the dropdown
Hide the percent label when the toggle is visible

* feat(usage): enhance UsagePage with model grouping and collapsibles

Add model lists grouped by family for the selected provider
Enable collapsible sections per family to toggle visibility
Apply and persist default model selections on provider change

* feat(quota): add model family helpers

Add utilities to categorize models into families by provider
Enable grouping of models by family for header and usage pages
Define default models for Gemini 3.x and Claude families

* feat(desktop): extend settings with model grouping and selection

Track per-provider selected models for usage
Allow collapsing and expanding families in the usage page
Support per-provider custom model groups with labels and assignments

* feat: persist usage preferences in persistence

Persist usageSelectedModels per provider
Persist usageCollapsedFamilies and usageExpandedFamilies states
Support custom usageModelGroups with groups and assignments

* feat: track selected quota models and expanded families per provider

Initialize selectedModels and expandedFamilies state for quota providers
Add actions to set, toggle, and apply default model selections and expanded families
Persist selections to desktop settings and apply defaults on load

* feat(server): sanitize usage model configuration in settings update

Validate and sanitize usage selections per provider
Persist only valid usageCollapsedFamilies and usageExpandedFamilies in settings
Enforce limits on custom model groups and model assignments

* feat: add collapsible model families in header

Add collapsible sections for model families within each provider in the header
Show per-model usage with percent display and a progress bar
Toggle expansion via arrow icons and preserve expanded state per provider

* fix(ui): treat explicit per-provider model selections correctly in header

Enable showing all models by default when a provider has no explicit selection
Recognize an explicit per-provider selection when a provider key exists in selectedModels
Filter to selected models only if an explicit selection is present for the provider
This commit is contained in:
Nelson Pires
2026-02-08 15:42:15 +02:00
committed by GitHub
parent a630b8860e
commit 7af81c2436
8 changed files with 910 additions and 69 deletions
+273 -52
View File
@@ -32,6 +32,18 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { updateDesktopSettings } from '@/lib/persistence';
import {
getAllModelFamilies,
groupModelsByFamily,
sortModelFamilies,
} from '@/lib/quota/model-families';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import type { UsageWindow } from '@/types';
import type { GitHubAuthStatus } from '@/lib/api/types';
import { DesktopHostSwitcherButton } from '@/components/desktop/DesktopHostSwitcher';
@@ -184,12 +196,23 @@ export const Header: React.FC = () => {
const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false);
useQuotaAutoRefresh();
const selectedModels = useQuotaStore((state) => state.selectedModels);
const expandedFamilies = useQuotaStore((state) => state.expandedFamilies);
const toggleFamilyExpanded = useQuotaStore((state) => state.toggleFamilyExpanded);
interface RateLimitGroup {
providerId: string;
providerName: string;
entries: Array<[string, UsageWindow]>;
modelFamilies?: Array<{
familyId: string | null;
familyLabel: string;
models: Array<[string, UsageWindow]>;
}>;
}
const rateLimitGroups = React.useMemo(() => {
const groups: Array<{
providerId: string;
providerName: string;
entries: Array<[string, UsageWindow]>;
}> = [];
const groups: RateLimitGroup[] = [];
for (const provider of QUOTA_PROVIDERS) {
if (!dropdownProviderIds.includes(provider.id)) {
@@ -197,14 +220,91 @@ export const Header: React.FC = () => {
}
const result = quotaResults.find((entry) => entry.providerId === provider.id);
const windows = (result?.usage?.windows ?? {}) as Record<string, UsageWindow>;
const models = result?.usage?.models;
const entries = Object.entries(windows);
if (entries.length > 0) {
groups.push({ providerId: provider.id, providerName: provider.name, entries });
const group: RateLimitGroup = {
providerId: provider.id,
providerName: provider.name,
entries,
};
// Add model families if provider has per-model quotas
if (models && Object.keys(models).length > 0) {
const providerSelectedModels = selectedModels[provider.id] ?? [];
// hasExplicitSelection = true means user touched the selection (even if empty)
// hasExplicitSelection = false means no preference (key missing) → show all by default
const hasExplicitSelection = provider.id in selectedModels;
const modelGroups = groupModelsByFamily(models, provider.id);
const families = getAllModelFamilies(provider.id);
const sortedFamilies = sortModelFamilies(families);
group.modelFamilies = [];
// Add predefined families first
for (const family of sortedFamilies) {
const modelNames = modelGroups.get(family.id) ?? [];
if (modelNames.length === 0) continue;
// Filter to selected models only, OR show all if nothing selected
const selectedModelNames = hasExplicitSelection
? modelNames.filter((m: string) => providerSelectedModels.includes(m))
: modelNames;
if (selectedModelNames.length === 0) continue;
const familyModels: Array<[string, UsageWindow]> = [];
for (const modelName of selectedModelNames) {
const modelUsage = models[modelName] as { windows?: Record<string, UsageWindow> } | undefined;
if (modelUsage?.windows) {
const windowEntries = Object.entries(modelUsage.windows);
if (windowEntries.length > 0) {
familyModels.push([modelName, windowEntries[0][1]]);
}
}
}
if (familyModels.length > 0) {
group.modelFamilies.push({
familyId: family.id,
familyLabel: family.label,
models: familyModels,
});
}
}
// Add "Other" family for remaining models
const otherModelNames = modelGroups.get(null) ?? [];
const selectedOtherModels = hasExplicitSelection
? otherModelNames.filter((m: string) => providerSelectedModels.includes(m))
: otherModelNames;
if (selectedOtherModels.length > 0) {
const otherModels: Array<[string, UsageWindow]> = [];
for (const modelName of selectedOtherModels) {
const modelUsage = models[modelName] as { windows?: Record<string, UsageWindow> } | undefined;
if (modelUsage?.windows) {
const windowEntries = Object.entries(modelUsage.windows);
if (windowEntries.length > 0) {
otherModels.push([modelName, windowEntries[0][1]]);
}
}
}
if (otherModels.length > 0) {
group.modelFamilies.push({
familyId: null,
familyLabel: 'Other',
models: otherModels,
});
}
}
}
if (entries.length > 0 || (group.modelFamilies && group.modelFamilies.length > 0)) {
groups.push(group);
}
}
return groups;
}, [dropdownProviderIds, quotaResults]);
}, [dropdownProviderIds, quotaResults, selectedModels]);
const hasRateLimits = rateLimitGroups.length > 0;
React.useEffect(() => {
void loadQuotaSettings();
@@ -747,54 +847,125 @@ export const Header: React.FC = () => {
<span className="typography-ui-label text-muted-foreground">No rate limits available.</span>
</DropdownMenuItem>
)}
{rateLimitGroups.map((group, index) => (
<React.Fragment key={group.providerId}>
<DropdownMenuLabel className="sticky top-[60px] z-10 flex items-center gap-2 bg-[var(--surface-elevated)] typography-ui-label text-foreground">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
{group.providerName}
</DropdownMenuLabel>
{group.entries.length === 0 ? (
<DropdownMenuItem
key={`${group.providerId}-empty`}
className="cursor-default"
onSelect={(event) => event.preventDefault()}
>
<span className="typography-ui-label text-muted-foreground">No rate limits reported.</span>
</DropdownMenuItem>
) : (
group.entries.map(([label, window]) => (
{rateLimitGroups.map((group, index) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
return (
<React.Fragment key={group.providerId}>
<DropdownMenuLabel className="sticky top-[60px] z-10 flex items-center gap-2 bg-[var(--surface-elevated)] typography-ui-label text-foreground">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
{group.providerName}
</DropdownMenuLabel>
{/* Provider-level entries */}
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<DropdownMenuItem
key={`${group.providerId}-${label}`}
className="cursor-default items-start"
key={`${group.providerId}-empty`}
className="cursor-default"
onSelect={(event) => event.preventDefault()}
>
<span className="flex min-w-0 flex-1 flex-col gap-2">
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{formatWindowLabel(label)}</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</span>
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1" />
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
<span>{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}</span>
</span>
</>
);
})()}
</span>
<span className="typography-ui-label text-muted-foreground">No rate limits reported.</span>
</DropdownMenuItem>
))
)}
{index < rateLimitGroups.length - 1 && <DropdownMenuSeparator />}
</React.Fragment>
))}
) : (
<>
{/* Provider-level windows */}
{group.entries.map(([label, window]) => (
<DropdownMenuItem
key={`${group.providerId}-${label}`}
className="cursor-default items-start"
onSelect={(event) => event.preventDefault()}
>
<span className="flex min-w-0 flex-1 flex-col gap-2">
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{formatWindowLabel(label)}</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</span>
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1" />
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
<span>{window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}</span>
</span>
</>
);
})()}
</span>
</DropdownMenuItem>
))}
{/* Model families with collapsible sections - default COLLAPSED */}
{group.modelFamilies && group.modelFamilies.length > 0 && (
<div className="px-2 py-1">
{group.modelFamilies.map((family) => {
const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other');
return (
<Collapsible
key={family.familyId ?? 'other'}
open={isExpanded}
onOpenChange={() => toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-1.5 text-left">
<span className="typography-ui-label font-medium text-foreground">
{family.familyLabel}
</span>
{isExpanded ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-1 pl-2">
{family.models.map(([modelName, window]) => (
<div
key={`${group.providerId}-${modelName}`}
className="py-1.5"
>
<div className="flex min-w-0 flex-col gap-1.5">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">{modelName}</span>
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
);
})()}
</span>
{(() => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="h-1" />
);
})()}
</div>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</>
)}
{index < rateLimitGroups.length - 1 && <DropdownMenuSeparator />}
</React.Fragment>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<McpDropdown headerIconButtonClass={headerIconButtonClass} />
@@ -1131,6 +1302,56 @@ export const Header: React.FC = () => {
</div>
);
})}
{/* Model families with collapsible sections */}
{group.modelFamilies && group.modelFamilies.length > 0 && (
<div className="px-2 py-1">
{group.modelFamilies.map((family) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other');
return (
<Collapsible
key={family.familyId ?? 'other'}
open={isExpanded}
onOpenChange={() => toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left">
<span className="typography-ui-label font-medium text-foreground">
{family.familyLabel}
</span>
{isExpanded ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2 pl-2">
{family.models.map(([modelName, window]) => {
const displayPercent = quotaDisplayMode === 'remaining'
? window.remainingPercent
: window.usedPercent;
return (
<div key={`${group.providerId}-${modelName}`} className="py-1.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate typography-micro text-muted-foreground">
{modelName}
</span>
<span className="typography-ui-label text-foreground tabular-nums">
{formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)}
</span>
</div>
<UsageProgressBar percent={displayPercent} tonePercent={window.usedPercent} className="mt-1.5 h-1" />
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</React.Fragment>
))}
</div>
@@ -3,14 +3,25 @@ import type { UsageWindow } from '@/types';
import { formatPercent, formatWindowLabel } from '@/lib/quota';
import { UsageProgressBar } from './UsageProgressBar';
import { useQuotaStore } from '@/stores/useQuotaStore';
import { Switch } from '@/components/ui/switch';
interface UsageCardProps {
title: string;
window: UsageWindow;
subtitle?: string | null;
showToggle?: boolean;
toggleEnabled?: boolean;
onToggle?: (enabled: boolean) => void;
}
export const UsageCard: React.FC<UsageCardProps> = ({ title, window, subtitle }) => {
export const UsageCard: React.FC<UsageCardProps> = ({
title,
window,
subtitle,
showToggle = false,
toggleEnabled = false,
onToggle,
}) => {
const displayMode = useQuotaStore((state) => state.displayMode);
const displayPercent = displayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
const barLabel = displayMode === 'remaining' ? 'remaining' : 'used';
@@ -21,13 +32,23 @@ export const UsageCard: React.FC<UsageCardProps> = ({ title, window, subtitle })
return (
<div className="rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="typography-ui-label text-foreground truncate">{windowLabel}</div>
{subtitle && (
<div className="typography-micro text-muted-foreground truncate">{subtitle}</div>
)}
</div>
<div className="typography-ui-label text-foreground tabular-nums">{percentLabel === '-' ? '' : percentLabel}</div>
{showToggle ? (
<Switch
checked={toggleEnabled}
onCheckedChange={onToggle}
aria-label="Show in dropdown"
/>
) : (
<div className="typography-ui-label text-foreground tabular-nums">
{percentLabel === '-' ? '' : percentLabel}
</div>
)}
</div>
<div className="mt-3">
@@ -6,6 +6,14 @@ import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { Switch } from '@/components/ui/switch';
import { updateDesktopSettings } from '@/lib/persistence';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import type { UsageWindows, QuotaProviderId } from '@/types';
import { getAllModelFamilies, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families';
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-';
@@ -19,6 +27,11 @@ const formatTime = (timestamp: number | null) => {
}
};
interface ModelInfo {
name: string;
windows: UsageWindows;
}
export const UsagePage: React.FC = () => {
const results = useQuotaStore((state) => state.results);
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
@@ -30,6 +43,9 @@ export const UsagePage: React.FC = () => {
const error = useQuotaStore((state) => state.error);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const setDropdownProviderIds = useQuotaStore((state) => state.setDropdownProviderIds);
const selectedModels = useQuotaStore((state) => state.selectedModels);
const toggleModelSelected = useQuotaStore((state) => state.toggleModelSelected);
const applyDefaultSelections = useQuotaStore((state) => state.applyDefaultSelections);
useQuotaAutoRefresh();
@@ -67,6 +83,69 @@ export const UsagePage: React.FC = () => {
void updateDesktopSettings({ usageDropdownProviders: next });
}, [dropdownProviderIds, selectedProviderId, setDropdownProviderIds]);
// Get models for the selected provider
const providerModels = React.useMemo((): ModelInfo[] => {
if (!usage?.models) return [];
return Object.entries(usage.models)
.map(([name, modelUsage]) => ({ name, windows: modelUsage }))
.filter((model) => Object.keys(model.windows.windows).length > 0);
}, [usage?.models]);
// Apply default selections on mount if no prior selections exist
React.useEffect(() => {
if (selectedProviderId && providerModels.length > 0) {
applyDefaultSelections(selectedProviderId, providerModels.map((m) => m.name));
}
}, [selectedProviderId, providerModels, applyDefaultSelections]);
// Group models by family
const modelsByFamily = React.useMemo(() => {
if (!selectedProviderId || providerModels.length === 0) {
return new Map<string | null, ModelInfo[]>();
}
return groupModelsByFamilyWithGetter(
providerModels,
(model) => model.name,
selectedProviderId as QuotaProviderId
);
}, [providerModels, selectedProviderId]);
// Get sorted families
const sortedFamilies = React.useMemo(() => {
if (!selectedProviderId) return [];
const families = getAllModelFamilies(selectedProviderId as QuotaProviderId);
return sortModelFamilies(families);
}, [selectedProviderId]);
// Collapsible state for family sections (persist per provider)
const [collapsedFamilies, setCollapsedFamilies] = React.useState<Record<string, boolean>>(() => {
// Default: all families start expanded (not collapsed)
return {};
});
const toggleFamilyCollapsed = React.useCallback((familyId: string) => {
setCollapsedFamilies((prev) => ({
...prev,
[familyId]: !prev[familyId],
}));
}, []);
const handleModelToggle = React.useCallback((modelName: string) => {
if (!selectedProviderId) return;
toggleModelSelected(selectedProviderId, modelName);
// Also update settings to persist
const currentSelected = selectedModels[selectedProviderId] ?? [];
const isSelected = currentSelected.includes(modelName);
const nextSelected = isSelected
? currentSelected.filter((m) => m !== modelName)
: [...currentSelected, modelName];
const nextSettings: Record<string, string[]> = { ...selectedModels, [selectedProviderId]: nextSelected };
void updateDesktopSettings({ usageSelectedModels: nextSettings });
}, [selectedProviderId, selectedModels, toggleModelSelected]);
// Get selected models for this provider
const providerSelectedModels = selectedProviderId ? (selectedModels[selectedProviderId] ?? []) : [];
if (!selectedProviderId) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
@@ -131,22 +210,119 @@ export const UsagePage: React.FC = () => {
</div>
)}
{usage?.models && Object.keys(usage.models).length > 0 && (
<div className="space-y-3">
<div className="typography-ui-header font-semibold text-foreground">Model Quotas</div>
{Object.entries(usage.models).map(([modelName, modelUsage]) => {
const entries = Object.entries(modelUsage.windows);
if (entries.length === 0) {
return null;
}
const [label, window] = entries[0];
return <UsageCard key={modelName} title={label} subtitle={modelName} window={window} />;
{/* Models Section - Grouped by Family */}
{providerModels.length > 0 && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2>
<p className="typography-meta text-muted-foreground">
Toggle on to show in header dropdown
</p>
</div>
{/* Predefined families (Gemini, Claude) */}
{sortedFamilies.map((family) => {
const familyModels = modelsByFamily.get(family.id) ?? [];
if (familyModels.length === 0) return null;
const isCollapsed = collapsedFamilies[family.id] ?? false;
return (
<Collapsible
key={family.id}
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed(family.id)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left group">
<div className="space-y-0.5">
<div className="typography-ui-label font-semibold text-foreground">{family.label}</div>
<p className="typography-meta text-muted-foreground">
{familyModels.length} model{familyModels.length !== 1 ? 's' : ''}
</p>
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<RiArrowDownSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-3">
{familyModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<UsageCard
key={model.name}
title={label}
subtitle={model.name}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</CollapsibleContent>
</Collapsible>
);
})}
{/* Other family */}
{(() => {
const otherModels = modelsByFamily.get(null) ?? [];
if (otherModels.length === 0) return null;
const isCollapsed = collapsedFamilies['other'] ?? false;
return (
<Collapsible
open={!isCollapsed}
onOpenChange={() => toggleFamilyCollapsed('other')}
>
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-left group">
<div className="space-y-0.5">
<div className="typography-ui-label font-semibold text-foreground">Other</div>
<p className="typography-meta text-muted-foreground">
{otherModels.length} model{otherModels.length !== 1 ? 's' : ''}
</p>
</div>
{isCollapsed ? (
<RiArrowRightSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
<RiArrowDownSLine className="h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-3">
{otherModels.map((model) => {
const entries = Object.entries(model.windows.windows);
if (entries.length === 0) return null;
const [label, window] = entries[0];
const isSelected = providerSelectedModels.includes(model.name);
return (
<UsageCard
key={model.name}
title={label}
subtitle={model.name}
window={window}
showToggle
toggleEnabled={isSelected}
onToggle={() => handleModelToggle(model.name)}
/>
);
})}
</CollapsibleContent>
</Collapsible>
);
})()}
</div>
)}
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
Object.keys(usage.models ?? {}).length === 0 && (
providerModels.length === 0 && (
<div className="rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/60 p-4 text-muted-foreground">
<p className="typography-body">No quota windows reported for this provider.</p>
</div>
+8
View File
@@ -53,6 +53,14 @@ export type DesktopSettings = {
usageRefreshIntervalMs?: number;
usageDisplayMode?: 'usage' | 'remaining';
usageDropdownProviders?: string[];
usageSelectedModels?: Record<string, string[]>; // Map of providerId -> selected model names
usageCollapsedFamilies?: Record<string, string[]>; // Map of providerId -> collapsed family IDs (UsagePage)
usageExpandedFamilies?: Record<string, string[]>; // Map of providerId -> EXPANDED family IDs (header dropdown - inverted)
usageModelGroups?: Record<string, {
customGroups?: Array<{id: string; label: string; models: string[]; order: number}>;
modelAssignments?: Record<string, string>; // modelName -> groupId
renamedGroups?: Record<string, string>; // groupId -> custom label
}>; // Per-provider custom model groups configuration
autoDeleteEnabled?: boolean;
autoDeleteAfterDays?: number;
defaultModel?: string; // format: "provider/model"
+98
View File
@@ -421,6 +421,104 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
(entry): entry is string => typeof entry === 'string' && entry.length > 0
);
}
// Parse usageSelectedModels (Record<string, string[]>)
if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') {
const selectedModels: Record<string, string[]> = {};
for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) {
if (Array.isArray(models)) {
selectedModels[providerId] = models.filter((m): m is string => typeof m === 'string');
}
}
if (Object.keys(selectedModels).length > 0) {
result.usageSelectedModels = selectedModels;
}
}
// Parse usageCollapsedFamilies (Record<string, string[]>)
if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') {
const collapsedFamilies: Record<string, string[]> = {};
for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) {
if (Array.isArray(families)) {
collapsedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
}
}
if (Object.keys(collapsedFamilies).length > 0) {
result.usageCollapsedFamilies = collapsedFamilies;
}
}
// Parse usageExpandedFamilies (Record<string, string[]>) - inverted collapsed logic for header dropdown
if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') {
const expandedFamilies: Record<string, string[]> = {};
for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) {
if (Array.isArray(families)) {
expandedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
}
}
if (Object.keys(expandedFamilies).length > 0) {
result.usageExpandedFamilies = expandedFamilies;
}
}
// Parse usageModelGroups - custom model groups configuration per provider
if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') {
const modelGroups: Record<string, {
customGroups?: Array<{id: string; label: string; models: string[]; order: number}>;
modelAssignments?: Record<string, string>;
renamedGroups?: Record<string, string>;
}> = {};
for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
if (config && typeof config === 'object') {
const typedConfig = config as Record<string, unknown>;
const providerConfig: {
customGroups?: Array<{id: string; label: string; models: string[]; order: number}>;
modelAssignments?: Record<string, string>;
renamedGroups?: Record<string, string>;
} = {};
// Parse customGroups
if (Array.isArray(typedConfig.customGroups)) {
providerConfig.customGroups = typedConfig.customGroups
.filter((g): g is Record<string, unknown> => g && typeof g === 'object')
.map((g) => ({
id: String(g.id ?? ''),
label: String(g.label ?? ''),
models: Array.isArray(g.models)
? g.models.filter((m): m is string => typeof m === 'string')
: [],
order: typeof g.order === 'number' ? g.order : 0,
}));
}
// Parse modelAssignments
if (typedConfig.modelAssignments && typeof typedConfig.modelAssignments === 'object') {
providerConfig.modelAssignments = Object.fromEntries(
Object.entries(typedConfig.modelAssignments as Record<string, unknown>)
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, String(v)])
);
}
// Parse renamedGroups
if (typedConfig.renamedGroups && typeof typedConfig.renamedGroups === 'object') {
providerConfig.renamedGroups = Object.fromEntries(
Object.entries(typedConfig.renamedGroups as Record<string, unknown>)
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, String(v)])
);
}
if (Object.keys(providerConfig).length > 0) {
modelGroups[providerId] = providerConfig;
}
}
}
if (Object.keys(modelGroups).length > 0) {
result.usageModelGroups = modelGroups;
}
}
if (
typeof candidate.toolCallExpansion === 'string'
&& (candidate.toolCallExpansion === 'collapsed'
+110
View File
@@ -0,0 +1,110 @@
import type { QuotaProviderId } from '@/types';
export interface ModelFamily {
id: string;
label: string;
matcher: (modelName: string) => boolean;
order: number;
}
const GOOGLE_MODEL_FAMILIES: ModelFamily[] = [
{
id: 'gemini',
label: 'Gemini',
matcher: (modelName) => modelName.toLowerCase().startsWith('gemini-'),
order: 1,
},
{
id: 'claude',
label: 'Claude',
matcher: (modelName) => modelName.toLowerCase().startsWith('claude-'),
order: 2,
},
];
export const PROVIDER_MODEL_FAMILIES: Record<string, ModelFamily[]> = {
google: GOOGLE_MODEL_FAMILIES,
};
export function getModelFamily(modelName: string, providerId: QuotaProviderId): ModelFamily | null {
const families = PROVIDER_MODEL_FAMILIES[providerId] ?? [];
for (const family of families) {
if (family.matcher(modelName)) {
return family;
}
}
return null;
}
export function getAllModelFamilies(providerId: QuotaProviderId): ModelFamily[] {
return PROVIDER_MODEL_FAMILIES[providerId] ?? [];
}
export function sortModelFamilies(families: ModelFamily[]): ModelFamily[] {
return [...families].sort((a, b) => a.order - b.order);
}
/**
* Group model names by family (for backward compatibility with Header.tsx)
*/
export function groupModelsByFamily(
models: Record<string, unknown>,
providerId: QuotaProviderId
): Map<string | null, string[]> {
const groups = new Map<string | null, string[]>();
for (const modelName of Object.keys(models)) {
const family = getModelFamily(modelName, providerId);
const familyId = family?.id ?? null;
if (!groups.has(familyId)) {
groups.set(familyId, []);
}
groups.get(familyId)!.push(modelName);
}
return groups;
}
/**
* Group models by family with custom getter function (for UsagePage.tsx)
*/
export function groupModelsByFamilyWithGetter<T>(
models: T[],
getModelName: (model: T) => string,
providerId: QuotaProviderId
): Map<string | null, T[]> {
const groups = new Map<string | null, T[]>();
for (const model of models) {
const modelName = getModelName(model);
const family = getModelFamily(modelName, providerId);
const familyId = family?.id ?? null;
if (!groups.has(familyId)) {
groups.set(familyId, []);
}
groups.get(familyId)!.push(model);
}
return groups;
}
/**
* Get default models for a provider based on simple patterns.
* - Gemini 3.x models (starting with gemini-3-)
* - All Claude models
*/
export function getDefaultModels(
providerId: QuotaProviderId,
availableModels: string[]
): string[] {
return availableModels.filter((model) => {
const lower = model.toLowerCase();
// Gemini 3.x
if (lower.startsWith('gemini-3-')) return true;
// All Claude models
if (lower.startsWith('claude-')) return true;
return false;
});
}
+102 -3
View File
@@ -5,6 +5,8 @@ import type { ProviderResult, QuotaProviderId } from '@/types';
import { QUOTA_PROVIDERS } from '@/lib/quota';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDefaultModels } from '@/lib/quota/model-families';
import { updateDesktopSettings } from '@/lib/persistence';
const DEFAULT_REFRESH_INTERVAL_MS = 60000;
@@ -13,6 +15,8 @@ interface QuotaSettingsState {
refreshIntervalMs: number;
displayMode: 'usage' | 'remaining';
dropdownProviderIds: QuotaProviderId[];
selectedModels: Record<string, string[]>; // Map of providerId -> selected model names
expandedFamilies: Record<string, string[]>; // Map of providerId -> EXPANDED family IDs (header dropdown - inverted)
}
interface QuotaStore extends QuotaSettingsState {
@@ -31,6 +35,11 @@ interface QuotaStore extends QuotaSettingsState {
setRefreshInterval: (intervalMs: number) => void;
setDisplayMode: (mode: 'usage' | 'remaining') => void;
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
setSelectedModels: (providerId: string, modelNames: string[]) => void;
toggleModelSelected: (providerId: string, modelName: string) => void;
setExpandedFamilies: (providerId: string, familyIds: string[]) => void;
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
applyDefaultSelections: (providerId: string, availableModels: string[]) => void;
}
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
@@ -53,7 +62,36 @@ const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState
)
: allProviderIds;
return { autoRefresh, refreshIntervalMs, displayMode, dropdownProviderIds };
// Parse selected models (providerId -> array of model names)
const selectedModels: Record<string, string[]> = {};
const rawSelectedModels = data?.usageSelectedModels;
if (rawSelectedModels && typeof rawSelectedModels === 'object') {
for (const [providerId, models] of Object.entries(rawSelectedModels)) {
if (Array.isArray(models)) {
selectedModels[providerId] = models.filter((m): m is string => typeof m === 'string');
}
}
}
// Parse expanded families (inverted collapsed logic for header dropdown)
const expandedFamilies: Record<string, string[]> = {};
const rawExpandedFamilies = data?.usageExpandedFamilies;
if (rawExpandedFamilies && typeof rawExpandedFamilies === 'object') {
for (const [providerId, families] of Object.entries(rawExpandedFamilies)) {
if (Array.isArray(families)) {
expandedFamilies[providerId] = families.filter((f): f is string => typeof f === 'string');
}
}
}
return {
autoRefresh,
refreshIntervalMs,
displayMode,
dropdownProviderIds,
selectedModels,
expandedFamilies,
};
};
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
@@ -83,7 +121,9 @@ const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
autoRefresh: false,
refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
displayMode: 'usage',
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id)
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
selectedModels: {},
expandedFamilies: {},
};
};
@@ -100,6 +140,8 @@ export const useQuotaStore = create<QuotaStore>()(
refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
displayMode: 'usage',
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
selectedModels: {},
expandedFamilies: {},
loadSettings: async () => {
try {
@@ -174,7 +216,64 @@ export const useQuotaStore = create<QuotaStore>()(
set({ refreshIntervalMs: clamped });
},
setDisplayMode: (mode) => set({ displayMode: mode }),
setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds })
setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }),
setSelectedModels: (providerId, modelNames) => {
set((state) => ({
selectedModels: { ...state.selectedModels, [providerId]: modelNames }
}));
},
toggleModelSelected: (providerId, modelName) => {
set((state) => {
const currentSelected = state.selectedModels[providerId] ?? [];
const isSelected = currentSelected.includes(modelName);
const nextSelected = isSelected
? currentSelected.filter((m) => m !== modelName)
: [...currentSelected, modelName];
return {
selectedModels: { ...state.selectedModels, [providerId]: nextSelected }
};
});
},
setExpandedFamilies: (providerId, familyIds) => {
set((state) => ({
expandedFamilies: { ...state.expandedFamilies, [providerId]: familyIds }
}));
// Persist
void updateDesktopSettings({ usageExpandedFamilies: get().expandedFamilies });
},
toggleFamilyExpanded: (providerId, familyId) => {
set((state) => {
const currentExpanded = state.expandedFamilies[providerId] ?? [];
const isExpanded = currentExpanded.includes(familyId);
const nextExpanded = isExpanded
? currentExpanded.filter((id) => id !== familyId)
: [...currentExpanded, familyId];
return {
expandedFamilies: { ...state.expandedFamilies, [providerId]: nextExpanded }
};
});
// Persist
void updateDesktopSettings({ usageExpandedFamilies: get().expandedFamilies });
},
applyDefaultSelections: (providerId, availableModels) => {
const state = get();
// Only apply if no prior selections exist
if ((state.selectedModels[providerId]?.length ?? 0) > 0) return;
const defaults = getDefaultModels(providerId as QuotaProviderId, availableModels);
if (defaults.length === 0) return;
set((s) => ({
selectedModels: { ...s.selectedModels, [providerId]: defaults },
}));
// Persist
void updateDesktopSettings({ usageSelectedModels: get().selectedModels });
},
}),
{ name: 'quota-store' }
)