Adds item-level search inside Settings so users can find concrete settings like provider auth, agent mode, terminal font size, tunnel options, notification events, and similar controls instead of only filtering top-level pages. Groups search results by Settings page and shows localized labels plus optional descriptions where useful. Supports keyboard navigation with Arrow Up/Down, Enter, and Escape, matching the existing autocomplete interaction style. Opens the correct Settings page or split-page draft state before scrolling to the matching control. Highlights the matched setting with a subtle token-based background so users can see where they landed without an aggressive outline. Adds explicit data-settings-item anchors across Settings pages and a centralized search registry with runtime/mobile availability guards. Updates Settings UI skill guidance so future Settings changes keep search registry entries, anchors, localization, and availability guards in sync.
371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
import React from 'react';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { UsageCard } from './UsageCard';
|
|
import { QUOTA_PROVIDERS } from '@/lib/quota';
|
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
|
import { updateDesktopSettings } from '@/lib/persistence';
|
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import type { UsageWindows, QuotaProviderId } from '@/types';
|
|
import { getAllModelFamilies, getDisplayModelName, sortModelFamilies, groupModelsByFamilyWithGetter } from '@/lib/quota/model-families';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { formatTimeForPreference } from '@/lib/timeFormat';
|
|
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
|
|
|
const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference) => {
|
|
if (!timestamp) return '-';
|
|
try {
|
|
return formatTimeForPreference(timestamp, timeFormatPreference, { fallback: '-' });
|
|
} catch {
|
|
return '-';
|
|
}
|
|
};
|
|
|
|
interface ModelInfo {
|
|
name: string;
|
|
windows: UsageWindows;
|
|
}
|
|
|
|
export const UsagePage: React.FC = () => {
|
|
const { t } = useI18n();
|
|
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
|
const results = useQuotaStore((state) => state.results);
|
|
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
|
|
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
|
|
const loadSettings = useQuotaStore((state) => state.loadSettings);
|
|
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
|
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);
|
|
const selectedModels = useQuotaStore((state) => state.selectedModels);
|
|
const toggleModelSelected = useQuotaStore((state) => state.toggleModelSelected);
|
|
const applyDefaultSelections = useQuotaStore((state) => state.applyDefaultSelections);
|
|
|
|
useQuotaAutoRefresh();
|
|
|
|
React.useEffect(() => {
|
|
void loadSettings();
|
|
void fetchAllQuotas();
|
|
}, [loadSettings, fetchAllQuotas]);
|
|
|
|
React.useEffect(() => {
|
|
if (selectedProviderId) {
|
|
return;
|
|
}
|
|
if (results.length === 0) {
|
|
return;
|
|
}
|
|
const firstConfigured = results.find((entry) => entry.configured)?.providerId;
|
|
setSelectedProvider(firstConfigured ?? QUOTA_PROVIDERS[0]?.id ?? null);
|
|
}, [results, selectedProviderId, setSelectedProvider]);
|
|
|
|
const selectedResult = results.find((entry) => entry.providerId === selectedProviderId) ?? null;
|
|
|
|
const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId);
|
|
const providerName = providerMeta?.name ?? selectedProviderId ?? t('settings.usage.sidebar.title');
|
|
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]);
|
|
|
|
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]);
|
|
|
|
React.useEffect(() => {
|
|
if (selectedProviderId && providerModels.length > 0) {
|
|
applyDefaultSelections(selectedProviderId, providerModels.map((m) => m.name));
|
|
}
|
|
}, [selectedProviderId, providerModels, applyDefaultSelections]);
|
|
|
|
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]);
|
|
|
|
const sortedFamilies = React.useMemo(() => {
|
|
if (!selectedProviderId) return [];
|
|
const families = getAllModelFamilies(selectedProviderId as QuotaProviderId);
|
|
return sortModelFamilies(families);
|
|
}, [selectedProviderId]);
|
|
|
|
const [collapsedFamilies, setCollapsedFamilies] = React.useState<Record<string, boolean>>(() => {
|
|
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);
|
|
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]);
|
|
|
|
const providerSelectedModels = selectedProviderId ? (selectedModels[selectedProviderId] ?? []) : [];
|
|
|
|
if (!selectedProviderId) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<p className="typography-body">{t('settings.usage.page.empty.selectProvider')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollableOverlay outerClassName="h-full" className="w-full">
|
|
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
|
|
|
|
{/* Header */}
|
|
<div className="mb-4 flex items-center gap-3">
|
|
<ProviderLogo providerId={selectedProviderId} className="h-5 w-5 shrink-0" />
|
|
<div className="min-w-0">
|
|
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
|
{t('settings.usage.page.header.providerUsage', { provider: providerName })}
|
|
</h2>
|
|
<p className="typography-meta text-muted-foreground truncate">
|
|
{isLoading ? (
|
|
<span className="animate-pulse">{t('settings.usage.page.header.refreshing')}</span>
|
|
) : (
|
|
t('settings.usage.page.header.lastUpdated', { time: formatTime(lastUpdated, timeFormatPreference) })
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div data-settings-item="usage.header-menu" className="mb-8 px-2">
|
|
<div
|
|
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={showInDropdown}
|
|
onClick={() => handleDropdownToggle(!showInDropdown)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === ' ' || event.key === 'Enter') {
|
|
event.preventDefault();
|
|
handleDropdownToggle(!showInDropdown);
|
|
}
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={showInDropdown}
|
|
onChange={handleDropdownToggle}
|
|
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
|
|
/>
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="typography-ui-label text-foreground">{t('settings.usage.page.options.showInHeader')}</span>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={8} className="max-w-xs">
|
|
{t('settings.usage.page.options.showInHeaderTooltip')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* State Messages */}
|
|
{!selectedResult && (
|
|
<div className="mb-8 px-2">
|
|
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noData')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mb-8 rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] px-4 py-3">
|
|
<p className="typography-ui-label font-medium text-[var(--status-error)]">{t('settings.usage.page.state.refreshFailedTitle')}</p>
|
|
<p className="typography-meta text-[var(--status-error)]/80 mt-1">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{selectedResult && !selectedResult.configured && (
|
|
<div className="mb-8 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] px-4 py-3">
|
|
<p className="typography-ui-label font-medium text-[var(--status-warning)]">{t('settings.usage.page.state.providerNotConfiguredTitle')}</p>
|
|
<p className="typography-meta text-[var(--status-warning)]/80 mt-1">
|
|
{t('settings.usage.page.state.providerNotConfiguredDescription')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overall Usage Windows */}
|
|
{usage?.windows && Object.keys(usage.windows).length > 0 && (
|
|
<div data-settings-item="usage.model-quotas" className="mb-8">
|
|
<section className="px-2 pb-2 pt-0">
|
|
<div className="divide-y divide-[var(--surface-subtle)]">
|
|
{Object.entries(usage.windows).map(([label, window]) => (
|
|
<UsageCard key={label} title={label} window={window} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{/* Models Section */}
|
|
{providerModels.length > 0 && (
|
|
<div className="mb-8">
|
|
<div className="mb-1 px-1">
|
|
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.usage.page.section.modelQuotas')}</h3>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{/* Predefined families */}
|
|
{sortedFamilies.map((family) => {
|
|
const familyModels = modelsByFamily.get(family.id) ?? [];
|
|
if (familyModels.length === 0) return null;
|
|
|
|
const isCollapsed = collapsedFamilies[family.id] ?? false;
|
|
|
|
return (
|
|
<section key={family.id} className="p-2">
|
|
<Collapsible
|
|
open={!isCollapsed}
|
|
onOpenChange={() => toggleFamilyCollapsed(family.id)}
|
|
>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
|
|
<div className="flex items-center gap-1.5 text-left">
|
|
<span className="typography-ui-label font-normal text-foreground">{family.label}</span>
|
|
<span className="typography-micro text-muted-foreground">
|
|
({familyModels.length})
|
|
</span>
|
|
</div>
|
|
{isCollapsed ? (
|
|
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
|
) : (
|
|
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
|
)}
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
|
|
{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={getDisplayModelName(model.name)}
|
|
window={window}
|
|
showToggle
|
|
toggleEnabled={isSelected}
|
|
onToggle={() => handleModelToggle(model.name)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</section>
|
|
);
|
|
})}
|
|
|
|
{/* Other family */}
|
|
{(() => {
|
|
const otherModels = modelsByFamily.get(null) ?? [];
|
|
if (otherModels.length === 0) return null;
|
|
|
|
const isCollapsed = collapsedFamilies['other'] ?? false;
|
|
|
|
return (
|
|
<section className="p-2">
|
|
<Collapsible
|
|
open={!isCollapsed}
|
|
onOpenChange={() => toggleFamilyCollapsed('other')}
|
|
>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between py-0.5 group">
|
|
<div className="flex items-center gap-1.5 text-left">
|
|
<span className="typography-ui-label font-normal text-foreground">{t('settings.usage.page.section.otherModels')}</span>
|
|
<span className="typography-micro text-muted-foreground">
|
|
({otherModels.length})
|
|
</span>
|
|
</div>
|
|
{isCollapsed ? (
|
|
<Icon name="arrow-right-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
|
) : (
|
|
<Icon name="arrow-down-s" className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
|
)}
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="divide-y divide-[var(--surface-subtle)] mt-1">
|
|
{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={getDisplayModelName(model.name)}
|
|
window={window}
|
|
showToggle
|
|
toggleEnabled={isSelected}
|
|
onToggle={() => handleModelToggle(model.name)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</section>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
|
|
providerModels.length === 0 && (
|
|
<div className="mb-8 px-2">
|
|
<p className="typography-ui-label text-foreground">{t('settings.usage.page.state.noQuotaWindowsTitle')}</p>
|
|
<p className="typography-meta text-muted-foreground mt-1">{t('settings.usage.page.state.noQuotaWindowsDescription')}</p>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</ScrollableOverlay>
|
|
);
|
|
};
|