Files
openchamber/packages/ui/src/components/sections/usage/UsagePage.tsx
T
Bohdan Triapitsyn 079e3a9bd6 feat(settings): add item search (#1592)
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.
2026-06-10 12:15:15 +03:00

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>
);
};