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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user