fix(usage): refresh work status quotas automatically
This commit is contained in:
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Usage: quota limits enabled for display now refresh every three minutes on desktop, mobile, and VS Code, with a manual refresh action available at any time.
|
||||
|
||||
## [1.18.2] - 2026-08-10
|
||||
|
||||
- **Observability panel:** a new panel near to the chat brings the active goal, tasks, subagents, pinned context, MCP servers, and context usage into one live view. The session list also shows how long an agent has been working.
|
||||
|
||||
@@ -82,7 +82,8 @@ and therefore displaces nothing.
|
||||
## Data sources
|
||||
|
||||
Everything is read from already-warm caches. The panel adds no aggregated
|
||||
endpoint and no polling of its own.
|
||||
endpoint; quota data refreshes through the shared fixed three-minute quota timer,
|
||||
which requests only providers enabled for this panel.
|
||||
|
||||
| Block | Source | Notes |
|
||||
|---|---|---|
|
||||
@@ -320,8 +321,8 @@ Two readouts had no loader of their own and appeared only after the user opened
|
||||
the matching header dropdown:
|
||||
|
||||
- **MCP** — `McpDropdown` was the only mount-time caller of `refresh()`.
|
||||
- **Usage** — `useQuotaAutoRefresh` merely schedules an interval; the *first*
|
||||
fetch was performed by the dropdown's open handler.
|
||||
- **Usage** — `useQuotaAutoRefresh` schedules the shared fixed three-minute
|
||||
refresh; the *first* fetch was performed by the dropdown's open handler.
|
||||
- **Skills** — `loadSkills()` ran only when the composer's slash autocomplete
|
||||
opened, so the context-sources count was whatever happened to be cached. The
|
||||
section loads them itself, keyed on the directory, since skills are
|
||||
@@ -330,7 +331,8 @@ the matching header dropdown:
|
||||
|
||||
The panel now performs these itself, silently and through the
|
||||
background-network gate, so it cannot compete with chat bootstrap traffic for
|
||||
sockets. A panel that reports a subsystem's state cannot depend on an unrelated
|
||||
sockets. Usage additionally provides an explicit refresh action in its section
|
||||
header. A panel that reports a subsystem's state cannot depend on an unrelated
|
||||
component having been mounted or opened.
|
||||
|
||||
The repository section follows the same ownership rule. It subscribes directly
|
||||
|
||||
@@ -63,9 +63,11 @@ export const WorkStatusCollapsibleSection: React.FC<{
|
||||
iconColor?: string;
|
||||
/** Shown on the header while collapsed and expanded alike. */
|
||||
summary?: React.ReactNode;
|
||||
/** An independent header action, such as refreshing this section's data. */
|
||||
action?: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, title, icon, iconNode, iconColor, summary, defaultExpanded = false, children }) => {
|
||||
}> = ({ id, title, icon, iconNode, iconColor, summary, action, defaultExpanded = false, children }) => {
|
||||
const stored = useUIStore(
|
||||
React.useCallback((state) => state.workStatusExpandedSections[id], [id]),
|
||||
);
|
||||
@@ -73,35 +75,38 @@ export const WorkStatusCollapsibleSection: React.FC<{
|
||||
const expanded = stored ?? defaultExpanded;
|
||||
return (
|
||||
<section className={SECTION_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedInStore(id, !expanded)}
|
||||
className={cn(
|
||||
'group/section mb-0.5 flex h-6 items-center gap-1.5 rounded-md px-1 text-left',
|
||||
// No hover fill anywhere in the panel: at this row density the blocks
|
||||
// of colour read as selection, not as affordance. Interactivity shows
|
||||
// through the text instead.
|
||||
'transition-colors hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{iconNode ?? (icon ? (
|
||||
<div className="mb-0.5 flex h-6 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedInStore(id, !expanded)}
|
||||
className={cn(
|
||||
'group/section flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 text-left',
|
||||
// No hover fill anywhere in the panel: at this row density the blocks
|
||||
// of colour read as selection, not as affordance. Interactivity shows
|
||||
// through the text instead.
|
||||
'transition-colors hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{iconNode ?? (icon ? (
|
||||
<Icon
|
||||
name={icon}
|
||||
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
/>
|
||||
) : null)}
|
||||
<span className={cn(HEADING_CLASS, 'min-w-0 truncate')}>{title}</span>
|
||||
<Icon
|
||||
name={icon}
|
||||
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null)}
|
||||
<span className={cn(HEADING_CLASS, 'min-w-0 truncate')}>{title}</span>
|
||||
<Icon
|
||||
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="flex-1" />
|
||||
{summary !== undefined && summary !== null ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
{summary !== undefined && summary !== null ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
|
||||
) : null}
|
||||
</button>
|
||||
{action}
|
||||
</div>
|
||||
{expanded ? children : null}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
|
||||
@@ -43,7 +45,7 @@ export const WorkStatusUsageSection: React.FC = () => {
|
||||
const isLoading = useQuotaStore((state) => state.isLoading);
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const fetchQuotas = useQuotaStore((state) => state.fetchQuotas);
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
|
||||
@@ -61,8 +63,8 @@ export const WorkStatusUsageSection: React.FC = () => {
|
||||
(providerId) => !quotaResults.some((result) => result.providerId === providerId),
|
||||
);
|
||||
if (!missingProvider) return;
|
||||
void runBackgroundNetworkTask(() => fetchAllQuotas());
|
||||
}, [dropdownProviderIds, fetchAllQuotas, isLoading, quotaResults]);
|
||||
void runBackgroundNetworkTask(() => fetchQuotas(dropdownProviderIds));
|
||||
}, [dropdownProviderIds, fetchQuotas, isLoading, quotaResults]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (groups.length === 0) return;
|
||||
@@ -96,7 +98,6 @@ export const WorkStatusUsageSection: React.FC = () => {
|
||||
icon="timer"
|
||||
summary={(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isLoading ? <Icon name="refresh" className="size-3 animate-spin" /> : null}
|
||||
{headline && headlineMetric && headlineMetric !== '-' ? (
|
||||
<>
|
||||
<span className="truncate">{headline.row.label}</span>
|
||||
@@ -105,6 +106,19 @@ export const WorkStatusUsageSection: React.FC = () => {
|
||||
) : modeLabel}
|
||||
</span>
|
||||
)}
|
||||
action={(
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 shrink-0 text-muted-foreground"
|
||||
onClick={() => void fetchQuotas(dropdownProviderIds)}
|
||||
aria-label={t('settings.usage.sidebar.actions.refreshAria')}
|
||||
title={t('settings.usage.sidebar.actions.refreshTitle')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Icon name="refresh" className={cn('size-3.5', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<React.Fragment key={group.providerId}>
|
||||
|
||||
@@ -41,9 +41,8 @@ import { cn, hasModifier } from '@/lib/utils';
|
||||
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
|
||||
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
||||
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
@@ -549,7 +548,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
||||
const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated);
|
||||
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
|
||||
const showPredValues = useQuotaStore((state) => state.showPredValues);
|
||||
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||
const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode);
|
||||
@@ -2448,12 +2446,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const displayPercent = quotaDisplayMode === 'remaining'
|
||||
? window.remainingPercent
|
||||
: window.usedPercent;
|
||||
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
|
||||
const expectedMarker = paceInfo?.dailyAllocationPercent != null
|
||||
? (quotaDisplayMode === 'remaining'
|
||||
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
|
||||
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
|
||||
: null;
|
||||
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
|
||||
const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference);
|
||||
return (
|
||||
@@ -2475,11 +2467,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
percent={displayPercent}
|
||||
tonePercent={window.usedPercent}
|
||||
className="h-1.5"
|
||||
expectedMarkerPercent={expectedMarker}
|
||||
/>
|
||||
{paceInfo && showPredValues ? (
|
||||
<PaceIndicator paceInfo={paceInfo} compact />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -2513,12 +2501,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const displayPercent = quotaDisplayMode === 'remaining'
|
||||
? window.remainingPercent
|
||||
: window.usedPercent;
|
||||
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds);
|
||||
const expectedMarker = paceInfo?.dailyAllocationPercent != null
|
||||
? (quotaDisplayMode === 'remaining'
|
||||
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
|
||||
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
|
||||
: null;
|
||||
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
|
||||
return (
|
||||
<div key={`${group.providerId}-${modelName}`} className="flex flex-col gap-1.5">
|
||||
@@ -2532,11 +2514,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
percent={displayPercent}
|
||||
tonePercent={window.usedPercent}
|
||||
className="h-1.5"
|
||||
expectedMarkerPercent={expectedMarker}
|
||||
/>
|
||||
{paceInfo && showPredValues ? (
|
||||
<PaceIndicator paceInfo={paceInfo} compact />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -30,9 +30,8 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { toast } from '@/components/ui';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
||||
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
@@ -673,7 +672,6 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
||||
const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated);
|
||||
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
|
||||
const showPredValues = useQuotaStore((state) => state.showPredValues);
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||
@@ -975,12 +973,6 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
const displayPercent = quotaDisplayMode === 'remaining'
|
||||
? window.remainingPercent
|
||||
: window.usedPercent;
|
||||
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
|
||||
const expectedMarker = paceInfo?.dailyAllocationPercent != null
|
||||
? (quotaDisplayMode === 'remaining'
|
||||
? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio)
|
||||
: calculateExpectedUsagePercent(paceInfo.elapsedRatio))
|
||||
: null;
|
||||
const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -999,13 +991,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
percent={displayPercent}
|
||||
tonePercent={window.usedPercent}
|
||||
className="h-1"
|
||||
expectedMarkerPercent={expectedMarker}
|
||||
/>
|
||||
{paceInfo && showPredValues && (
|
||||
<div className="mt-0.5">
|
||||
<PaceIndicator paceInfo={paceInfo} compact />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center justify-between typography-micro text-muted-foreground text-[10px]">
|
||||
<span>{formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference)}</span>
|
||||
</span>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PaceInfo } from '@/lib/quota';
|
||||
import { getPaceStatusColor, formatRemainingTime } from '@/lib/quota';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface PaceIndicatorProps {
|
||||
paceInfo: PaceInfo;
|
||||
className?: string;
|
||||
/** Compact mode shows just the status dot and prediction */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual indicator showing whether usage is on track, slightly fast, or too fast.
|
||||
* Inspired by opencode-bar's pace visualization.
|
||||
*/
|
||||
export const PaceIndicator: React.FC<PaceIndicatorProps> = ({
|
||||
paceInfo,
|
||||
className,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const statusColor = getPaceStatusColor(paceInfo.status);
|
||||
|
||||
const statusLabel = React.useMemo(() => {
|
||||
switch (paceInfo.status) {
|
||||
case 'on-track':
|
||||
return t('settings.usage.pace.status.onTrack');
|
||||
case 'slightly-fast':
|
||||
return t('settings.usage.pace.status.slightlyFast');
|
||||
case 'too-fast':
|
||||
return t('settings.usage.pace.status.tooFast');
|
||||
case 'exhausted':
|
||||
return t('settings.usage.pace.status.usedUp');
|
||||
}
|
||||
}, [paceInfo.status, t]);
|
||||
|
||||
const predictionTooltip = t('settings.usage.pace.predictionTooltip', { prediction: paceInfo.predictText });
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5', className)}>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={statusLabel}
|
||||
/>
|
||||
<span
|
||||
className="typography-micro tabular-nums"
|
||||
style={{ color: statusColor }}
|
||||
title={paceInfo.isExhausted ? undefined : predictionTooltip}
|
||||
>
|
||||
{paceInfo.isExhausted ? (
|
||||
<>{t('settings.usage.pace.wait', { duration: formatRemainingTime(paceInfo.remainingSeconds) })}</>
|
||||
) : (
|
||||
<>{t('settings.usage.pace.prediction', { prediction: paceInfo.predictText })}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-2', className)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!paceInfo.isExhausted && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('settings.usage.pace.rate', { rate: paceInfo.paceRateText })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="typography-micro tabular-nums"
|
||||
style={{ color: statusColor }}
|
||||
>
|
||||
{paceInfo.isExhausted ? (
|
||||
<>
|
||||
<span className="font-medium">{statusLabel}</span>
|
||||
<span className="text-muted-foreground">{t('settings.usage.pace.waitSeparator')}</span>
|
||||
<span className="font-medium">{formatRemainingTime(paceInfo.remainingSeconds)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span title={predictionTooltip}>
|
||||
<span className="text-muted-foreground">{t('settings.usage.pace.predictionLabel')}</span>
|
||||
<span className="font-medium">{paceInfo.predictText}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={statusLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { UsageWindow } from '@/types';
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||
import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel } from '@/lib/quota';
|
||||
import { UsageProgressBar } from './UsageProgressBar';
|
||||
import { PaceIndicator } from './PaceIndicator';
|
||||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -25,7 +23,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
onToggle,
|
||||
}) => {
|
||||
const displayMode = useQuotaStore((state) => state.displayMode);
|
||||
const showPredValues = useQuotaStore((state) => state.showPredValues);
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
const displayPercent = displayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
|
||||
const barLabel = displayMode === 'remaining' ? 'remaining' : 'used';
|
||||
@@ -33,18 +30,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference);
|
||||
const windowLabel = formatWindowLabel(title);
|
||||
|
||||
const paceInfo = React.useMemo(() => {
|
||||
return calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, title);
|
||||
}, [window.usedPercent, window.resetAt, window.windowSeconds, title]);
|
||||
|
||||
const expectedMarkerPercent = React.useMemo(() => {
|
||||
if (!paceInfo || paceInfo.dailyAllocationPercent === null) {
|
||||
return null;
|
||||
}
|
||||
const expectedUsed = calculateExpectedUsagePercent(paceInfo.elapsedRatio);
|
||||
return displayMode === 'remaining' ? 100 - expectedUsed : expectedUsed;
|
||||
}, [paceInfo, displayMode]);
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -72,7 +57,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
<UsageProgressBar
|
||||
percent={displayPercent}
|
||||
tonePercent={window.usedPercent}
|
||||
expectedMarkerPercent={expectedMarkerPercent}
|
||||
className="h-1.5"
|
||||
/>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
@@ -85,11 +69,6 @@ export const UsageCard: React.FC<UsageCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paceInfo && showPredValues && (
|
||||
<div className="mt-1.5">
|
||||
<PaceIndicator paceInfo={paceInfo} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -168,13 +168,13 @@ export const UsagePage: React.FC = () => {
|
||||
}
|
||||
showSaveStatus
|
||||
>
|
||||
<SettingsSection divider={false} settingsItem="usage.header-menu">
|
||||
<SettingsSection divider={false} settingsItem="usage.work-status-panel">
|
||||
<SettingsCheckboxRow
|
||||
checked={showInDropdown}
|
||||
onChange={handleDropdownToggle}
|
||||
label={t('settings.usage.page.options.showInHeader')}
|
||||
ariaLabel={t('settings.usage.page.options.showInHeaderAria')}
|
||||
info={t('settings.usage.page.options.showInHeaderTooltip')}
|
||||
label={t('settings.usage.page.options.showInWorkStatus')}
|
||||
ariaLabel={t('settings.usage.page.options.showInWorkStatusAria')}
|
||||
info={t('settings.usage.page.options.showInWorkStatusTooltip')}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -6,24 +6,15 @@ interface UsageProgressBarProps {
|
||||
percent: number | null;
|
||||
tonePercent?: number | null;
|
||||
className?: string;
|
||||
/**
|
||||
* Position (0-100) to show a marker indicating expected usage based on time elapsed.
|
||||
* Used for weekly/monthly quotas to show where usage "should" be if evenly distributed.
|
||||
*/
|
||||
expectedMarkerPercent?: number | null;
|
||||
}
|
||||
|
||||
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({
|
||||
percent,
|
||||
tonePercent,
|
||||
className,
|
||||
expectedMarkerPercent,
|
||||
}) => {
|
||||
const clamped = clampPercent(percent) ?? 0;
|
||||
const tone = resolveUsageTone(tonePercent ?? percent);
|
||||
const markerClamped = expectedMarkerPercent != null
|
||||
? Math.max(0, Math.min(100, expectedMarkerPercent))
|
||||
: null;
|
||||
|
||||
const fillStyle = tone === 'critical'
|
||||
? { backgroundColor: 'var(--status-error)' }
|
||||
@@ -41,14 +32,6 @@ export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
{markerClamped != null && markerClamped > 0 && markerClamped < 100 && (
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground"
|
||||
style={{ left: `${markerClamped}%` }}
|
||||
title={`Expected usage if spread evenly: ${Math.round(markerClamped)}% of quota`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -35,21 +33,15 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
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 showPredValues = useQuotaStore((state) => state.showPredValues);
|
||||
const setShowPredValues = useQuotaStore((state) => state.setShowPredValues);
|
||||
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadUsageSettings();
|
||||
}, [loadUsageSettings]);
|
||||
|
||||
const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number; usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[]; usageShowPredValues?: boolean }) => {
|
||||
const persistUsageSettings = React.useCallback(async (changes: { usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[] }) => {
|
||||
try {
|
||||
await updateDesktopSettings(changes);
|
||||
} catch (error) {
|
||||
@@ -57,20 +49,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsageAutoRefreshChange = React.useCallback((enabled: boolean) => {
|
||||
setUsageAutoRefresh(enabled);
|
||||
void persistUsageSettings({ usageAutoRefresh: enabled });
|
||||
}, [persistUsageSettings, setUsageAutoRefresh]);
|
||||
|
||||
const handleUsageRefreshIntervalChange = React.useCallback((value: string) => {
|
||||
const next = Number(value);
|
||||
if (!Number.isFinite(next)) {
|
||||
return;
|
||||
}
|
||||
setUsageRefreshInterval(next);
|
||||
void persistUsageSettings({ usageRefreshIntervalMs: next });
|
||||
}, [persistUsageSettings, setUsageRefreshInterval]);
|
||||
|
||||
const handleUsageDisplayModeChange = React.useCallback((value: string) => {
|
||||
if (value !== 'usage' && value !== 'remaining') {
|
||||
return;
|
||||
@@ -79,11 +57,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
void persistUsageSettings({ usageDisplayMode: value });
|
||||
}, [persistUsageSettings, setUsageDisplayMode]);
|
||||
|
||||
const handleShowPredValuesChange = React.useCallback((enabled: boolean) => {
|
||||
setShowPredValues(enabled);
|
||||
void persistUsageSettings({ usageShowPredValues: enabled });
|
||||
}, [persistUsageSettings, setShowPredValues]);
|
||||
|
||||
const bgClass = 'bg-background';
|
||||
|
||||
return (
|
||||
@@ -93,34 +66,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.usage.sidebar.total', { count: QUOTA_PROVIDERS.length })}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Checkbox
|
||||
checked={usageAutoRefresh}
|
||||
onChange={handleUsageAutoRefreshChange}
|
||||
ariaLabel={t('settings.usage.sidebar.actions.toggleAutoRefreshAria')}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('settings.usage.sidebar.tooltip.autoRefresh')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Select
|
||||
value={String(usageRefreshIntervalMs)}
|
||||
onValueChange={handleUsageRefreshIntervalChange}
|
||||
disabled={!usageAutoRefresh}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder={t('settings.usage.sidebar.field.intervalPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30000">30s</SelectItem>
|
||||
<SelectItem value="60000">1m</SelectItem>
|
||||
<SelectItem value="300000">5m</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 text-muted-foreground"
|
||||
@@ -145,16 +90,6 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('settings.usage.sidebar.field.showPredictions')}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={showPredValues}
|
||||
onChange={handleShowPredValuesChange}
|
||||
ariaLabel={t('settings.usage.sidebar.field.showPredictions')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
||||
|
||||
@@ -554,14 +554,8 @@ export const useTraySync = (): void => {
|
||||
const { dropdownProviderIds, results } = useQuotaStore.getState();
|
||||
const needsFetch = dropdownProviderIds.length > 0
|
||||
&& dropdownProviderIds.some((id) => !results.some((r) => r.providerId === id));
|
||||
if (needsFetch) void useQuotaStore.getState().fetchAllQuotas();
|
||||
if (needsFetch) void useQuotaStore.getState().fetchQuotas(dropdownProviderIds);
|
||||
});
|
||||
// Keep the Usage submenu current per the user's auto-refresh setting
|
||||
// (desktop-only; checked each tick so toggling it mid-session applies).
|
||||
const usageRefreshTick = window.setInterval(() => {
|
||||
const quota = useQuotaStore.getState();
|
||||
if (quota.autoRefresh && quota.dropdownProviderIds.length > 0) void quota.fetchAllQuotas();
|
||||
}, Math.max(30000, useQuotaStore.getState().refreshIntervalMs || 60000));
|
||||
|
||||
// Safety net: catches anything the event subscriptions miss (e.g. a store
|
||||
// that existed before the registry subscription was attached).
|
||||
@@ -575,7 +569,6 @@ export const useTraySync = (): void => {
|
||||
window.clearInterval(interval);
|
||||
window.clearInterval(refreshInterval);
|
||||
window.clearInterval(globalStatusInterval);
|
||||
window.clearInterval(usageRefreshTick);
|
||||
unsubscribeNotif();
|
||||
unsubscribeGlobal();
|
||||
unsubscribeProjects();
|
||||
|
||||
@@ -97,10 +97,7 @@ export type DesktopSettings = {
|
||||
summaryLength?: number;
|
||||
maxLastMessageLength?: number;
|
||||
|
||||
usageAutoRefresh?: boolean;
|
||||
usageRefreshIntervalMs?: number;
|
||||
usageDisplayMode?: 'usage' | 'remaining';
|
||||
usageShowPredValues?: boolean;
|
||||
usageDropdownProviders?: string[];
|
||||
usageSelectedModels?: Record<string, string[]>; // Map of providerId -> selected model names
|
||||
usageCollapsedFamilies?: Record<string, string[]>; // Map of providerId -> collapsed family IDs (UsagePage)
|
||||
|
||||
@@ -1117,15 +1117,14 @@ export const settingsDict = {
|
||||
'settings.usage.sidebar.field.displayModePlaceholder': 'Anzeigemodus',
|
||||
'settings.usage.sidebar.field.displayModeUsage': 'Nutzung',
|
||||
'settings.usage.sidebar.field.displayModeRemaining': 'Verbleibendes Kontingent',
|
||||
'settings.usage.sidebar.field.showPredictions': 'Vorhersagen anzeigen',
|
||||
'settings.usage.sidebar.status.notSet': 'Nicht festgelegt',
|
||||
'settings.usage.page.empty.selectProvider': 'Wählen Sie einen Anbieter aus, um Nutzungsdetails anzuzeigen.',
|
||||
'settings.usage.page.header.providerUsage': '{provider} Nutzung',
|
||||
'settings.usage.page.header.refreshing': 'Aktualisiere Nutzung...',
|
||||
'settings.usage.page.header.lastUpdated': 'Zuletzt aktualisiert: {time}',
|
||||
'settings.usage.page.options.showInHeaderAria': 'Im Kopfmenü anzeigen',
|
||||
'settings.usage.page.options.showInHeader': 'Im Kopfmenü anzeigen',
|
||||
'settings.usage.page.options.showInHeaderTooltip': 'Wenn aktiviert, ist die Nutzung dieses Anbieters im Schnellzugriff-Menü in der App-Kopfzeile sichtbar.',
|
||||
'settings.usage.page.options.showInWorkStatusAria': 'Im Arbeitsstatusbereich anzeigen',
|
||||
'settings.usage.page.options.showInWorkStatus': 'Im Arbeitsstatusbereich anzeigen',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': 'Wenn aktiviert, ist die Nutzung dieses Anbieters im Arbeitsstatusbereich sichtbar.',
|
||||
'settings.usage.page.state.noData': 'Noch keine Nutzungsdaten verfügbar.',
|
||||
'settings.usage.page.state.refreshFailedTitle': 'Aktualisierung der Nutzungsdaten fehlgeschlagen',
|
||||
'settings.usage.page.state.providerNotConfiguredTitle': 'Anbieter nicht konfiguriert',
|
||||
|
||||
@@ -1182,15 +1182,14 @@ export const settingsDict = {
|
||||
'settings.usage.sidebar.field.displayModePlaceholder': 'Display mode',
|
||||
'settings.usage.sidebar.field.displayModeUsage': 'Usage',
|
||||
'settings.usage.sidebar.field.displayModeRemaining': 'Quota remaining',
|
||||
'settings.usage.sidebar.field.showPredictions': 'Show predictions',
|
||||
'settings.usage.sidebar.status.notSet': 'Not set',
|
||||
'settings.usage.page.empty.selectProvider': 'Select a provider to view usage details.',
|
||||
'settings.usage.page.header.providerUsage': '{provider} Usage',
|
||||
'settings.usage.page.header.refreshing': 'Refreshing usage...',
|
||||
'settings.usage.page.header.lastUpdated': 'Last updated: {time}',
|
||||
'settings.usage.page.options.showInHeaderAria': 'Show in header menu',
|
||||
'settings.usage.page.options.showInHeader': 'Show in Header Menu',
|
||||
'settings.usage.page.options.showInHeaderTooltip': 'When enabled, this provider\'s usage will be visible in the quick access dropdown menu in the app header.',
|
||||
'settings.usage.page.options.showInWorkStatusAria': 'Show in work status panel',
|
||||
'settings.usage.page.options.showInWorkStatus': 'Show in Work Status Panel',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': 'When enabled, this provider\'s usage will be visible in the work status panel.',
|
||||
'settings.usage.page.state.noData': 'No usage data available yet.',
|
||||
'settings.usage.page.state.refreshFailedTitle': 'Failed to refresh usage data',
|
||||
'settings.usage.page.state.providerNotConfiguredTitle': 'Provider not configured',
|
||||
|
||||
@@ -1150,15 +1150,14 @@ export const settingsDict = {
|
||||
"settings.usage.sidebar.field.displayModePlaceholder": "Modo de visualización",
|
||||
"settings.usage.sidebar.field.displayModeUsage": "Uso",
|
||||
"settings.usage.sidebar.field.displayModeRemaining": "Cuota restante",
|
||||
"settings.usage.sidebar.field.showPredictions": "Mostrar predicciones",
|
||||
"settings.usage.sidebar.status.notSet": "No establecido",
|
||||
"settings.usage.page.empty.selectProvider": "Selecciona un proveedor para ver detalles de uso.",
|
||||
"settings.usage.page.header.providerUsage": "Uso de {provider}",
|
||||
"settings.usage.page.header.refreshing": "Actualizando uso...",
|
||||
"settings.usage.page.header.lastUpdated": "Última actualización: {time}",
|
||||
"settings.usage.page.options.showInHeaderAria": "Mostrar en menú de encabezado",
|
||||
"settings.usage.page.options.showInHeader": "Mostrar en el menú del encabezado",
|
||||
"settings.usage.page.options.showInHeaderTooltip": "Cuando esté habilitado, el uso de este proveedor será visible en el menú de acceso rápido del encabezado de la aplicación.",
|
||||
"settings.usage.page.options.showInWorkStatusAria": "Mostrar en el panel de estado del trabajo",
|
||||
"settings.usage.page.options.showInWorkStatus": "Mostrar en el panel de estado del trabajo",
|
||||
"settings.usage.page.options.showInWorkStatusTooltip": "Cuando esté habilitado, el uso de este proveedor será visible en el panel de estado del trabajo.",
|
||||
"settings.usage.page.state.noData": "No hay datos de uso disponibles aún.",
|
||||
"settings.usage.page.state.refreshFailedTitle": "No se pudo actualizar los datos de uso",
|
||||
"settings.usage.page.state.providerNotConfiguredTitle": "Proveedor no configurado",
|
||||
|
||||
@@ -1068,15 +1068,14 @@ export const settingsDict = {
|
||||
'settings.usage.sidebar.field.displayModePlaceholder': 'Mode d\'affichage',
|
||||
'settings.usage.sidebar.field.displayModeUsage': 'Usage',
|
||||
'settings.usage.sidebar.field.displayModeRemaining': 'Quota restant',
|
||||
'settings.usage.sidebar.field.showPredictions': 'Afficher les prédictions',
|
||||
'settings.usage.sidebar.status.notSet': 'Non défini',
|
||||
'settings.usage.page.empty.selectProvider': 'Sélectionnez un fournisseur pour afficher les détails d\'utilisation.',
|
||||
'settings.usage.page.header.providerUsage': 'Utilisation de {provider}',
|
||||
'settings.usage.page.header.refreshing': 'Utilisation rafraîchissante...',
|
||||
'settings.usage.page.header.lastUpdated': 'Dernière mise à jour : {time}',
|
||||
'settings.usage.page.options.showInHeaderAria': 'Afficher dans le menu d\'en-tête',
|
||||
'settings.usage.page.options.showInHeader': 'Afficher dans le menu d\'en-tête',
|
||||
'settings.usage.page.options.showInHeaderTooltip': 'Lorsqu\'elle est activée, l\'utilisation de ce fournisseur sera visible dans le menu déroulant d\'accès rapide dans l\'en-tête de l\'application.',
|
||||
'settings.usage.page.options.showInWorkStatusAria': 'Afficher dans le panneau d’état du travail',
|
||||
'settings.usage.page.options.showInWorkStatus': 'Afficher dans le panneau d’état du travail',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': 'Lorsqu’elle est activée, l’utilisation de ce fournisseur sera visible dans le panneau d’état du travail.',
|
||||
'settings.usage.page.state.noData': 'Aucune donnée d\'utilisation disponible pour l\'instant.',
|
||||
'settings.usage.page.state.refreshFailedTitle': 'Échec de l\'actualisation des données d\'utilisation',
|
||||
'settings.usage.page.state.providerNotConfiguredTitle': 'Fournisseur non configuré',
|
||||
|
||||
@@ -1183,15 +1183,14 @@ export const settingsDict = {
|
||||
'settings.usage.sidebar.field.displayModePlaceholder': '表示モード',
|
||||
'settings.usage.sidebar.field.displayModeUsage': '使用量',
|
||||
'settings.usage.sidebar.field.displayModeRemaining': '残り割り当て',
|
||||
'settings.usage.sidebar.field.showPredictions': '予測を表示',
|
||||
'settings.usage.sidebar.status.notSet': '未設定',
|
||||
'settings.usage.page.empty.selectProvider': '使用量の詳細を表示する Provider を選択してください。',
|
||||
'settings.usage.page.header.providerUsage': '{provider} の使用量',
|
||||
'settings.usage.page.header.refreshing': '使用量を更新中...',
|
||||
'settings.usage.page.header.lastUpdated': '最終更新: {time}',
|
||||
'settings.usage.page.options.showInHeaderAria': 'ヘッダーメニューに表示',
|
||||
'settings.usage.page.options.showInHeader': 'ヘッダーメニューに表示',
|
||||
'settings.usage.page.options.showInHeaderTooltip': '有効にすると、アプリヘッダーのクイックアクセスドロップダウンメニューにこの Provider の使用量が表示されます。',
|
||||
'settings.usage.page.options.showInWorkStatusAria': '作業ステータスパネルに表示',
|
||||
'settings.usage.page.options.showInWorkStatus': '作業ステータスパネルに表示',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': '有効にすると、このプロバイダーの使用量が作業ステータスパネルに表示されます。',
|
||||
'settings.usage.page.state.noData': 'まだ使用量データがありません。',
|
||||
'settings.usage.page.state.refreshFailedTitle': '使用量データの更新に失敗しました',
|
||||
'settings.usage.page.state.providerNotConfiguredTitle': 'Provider が設定されていません',
|
||||
|
||||
@@ -1150,15 +1150,14 @@ export const settingsDict = {
|
||||
'settings.usage.sidebar.field.displayModePlaceholder': '표시 모드',
|
||||
'settings.usage.sidebar.field.displayModeUsage': '사용량',
|
||||
'settings.usage.sidebar.field.displayModeRemaining': '남은 할당량',
|
||||
'settings.usage.sidebar.field.showPredictions': '예측 표시',
|
||||
'settings.usage.sidebar.status.notSet': '설정 안 됨',
|
||||
'settings.usage.page.empty.selectProvider': '사용량 세부 정보를 보려면 프로바이더를 선택하세요.',
|
||||
'settings.usage.page.header.providerUsage': '{provider} 사용량',
|
||||
'settings.usage.page.header.refreshing': '사용량 새로고침 중...',
|
||||
'settings.usage.page.header.lastUpdated': '마지막 업데이트: {time}',
|
||||
'settings.usage.page.options.showInHeaderAria': '헤더 메뉴에 표시',
|
||||
'settings.usage.page.options.showInHeader': '헤더 메뉴에 표시',
|
||||
'settings.usage.page.options.showInHeaderTooltip': '활성화하면 이 프로바이더의 사용량이 앱 헤더의 빠른 접근 메뉴에 표시됩니다.',
|
||||
'settings.usage.page.options.showInWorkStatusAria': '작업 상태 패널에 표시',
|
||||
'settings.usage.page.options.showInWorkStatus': '작업 상태 패널에 표시',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': '활성화하면 이 제공업체의 사용량이 작업 상태 패널에 표시됩니다.',
|
||||
'settings.usage.page.state.noData': '아직 사용량 데이터가 없습니다.',
|
||||
'settings.usage.page.state.refreshFailedTitle': '사용량 데이터를 새로고침하지 못했습니다',
|
||||
'settings.usage.page.state.providerNotConfiguredTitle': '프로바이더가 설정되지 않았습니다',
|
||||
|
||||
@@ -1959,9 +1959,9 @@ export const settingsDict = {
|
||||
'settings.usage.page.header.lastUpdated': 'Ostatnio aktualizowano: {time}',
|
||||
'settings.usage.page.header.providerUsage': 'Użycie {provider}',
|
||||
'settings.usage.page.header.refreshing': 'Odświeżanie użycia...',
|
||||
'settings.usage.page.options.showInHeader': 'Pokaż w menu nagłówka',
|
||||
'settings.usage.page.options.showInHeaderAria': 'Pokaż w menu nagłówka',
|
||||
'settings.usage.page.options.showInHeaderTooltip': 'Jeśli jest włączone, ten dostawca',
|
||||
'settings.usage.page.options.showInWorkStatus': 'Pokaż w panelu statusu pracy',
|
||||
'settings.usage.page.options.showInWorkStatusAria': 'Pokaż w panelu statusu pracy',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': 'Po włączeniu użycie tego dostawcy będzie widoczne w panelu statusu pracy.',
|
||||
'settings.usage.page.section.modelQuotas': 'Limity modeli',
|
||||
'settings.usage.page.section.otherModels': 'Inne modele',
|
||||
'settings.usage.page.state.noData': 'Brak dostępnych danych o użyciu.',
|
||||
@@ -1976,7 +1976,6 @@ export const settingsDict = {
|
||||
'settings.usage.sidebar.field.display': 'Wyświetlanie',
|
||||
'settings.usage.sidebar.field.displayModePlaceholder': 'Tryb wyświetlania',
|
||||
'settings.usage.sidebar.field.displayModeRemaining': 'Pozostała kwota',
|
||||
'settings.usage.sidebar.field.showPredictions': 'Pokaż prognozy',
|
||||
'settings.usage.sidebar.field.displayModeUsage': 'Użycie',
|
||||
'settings.usage.sidebar.field.intervalPlaceholder': 'Interwał',
|
||||
'settings.usage.sidebar.status.notSet': 'Nie ustawiono',
|
||||
|
||||
@@ -1150,15 +1150,14 @@ export const settingsDict = {
|
||||
"settings.usage.sidebar.field.displayModePlaceholder": "Modo de visualização",
|
||||
"settings.usage.sidebar.field.displayModeUsage": "Uso",
|
||||
"settings.usage.sidebar.field.displayModeRemaining": "Cota restante",
|
||||
"settings.usage.sidebar.field.showPredictions": "Mostrar previsões",
|
||||
"settings.usage.sidebar.status.notSet": "Não definido",
|
||||
"settings.usage.page.empty.selectProvider": "Selecione um provedor para ver detalhes de uso.",
|
||||
"settings.usage.page.header.providerUsage": "Uso de {provider}",
|
||||
"settings.usage.page.header.refreshing": "Atualizando uso...",
|
||||
"settings.usage.page.header.lastUpdated": "Última atualização: {time}",
|
||||
"settings.usage.page.options.showInHeaderAria": "Mostrar em menu de cabeçalho",
|
||||
"settings.usage.page.options.showInHeader": "Mostrar no menu do cabeçalho",
|
||||
"settings.usage.page.options.showInHeaderTooltip": "Quando habilitado, o uso deste provedor ficará visível no menu de acesso rápido do cabeçalho do aplicativo.",
|
||||
"settings.usage.page.options.showInWorkStatusAria": "Mostrar no painel de status do trabalho",
|
||||
"settings.usage.page.options.showInWorkStatus": "Mostrar no painel de status do trabalho",
|
||||
"settings.usage.page.options.showInWorkStatusTooltip": "Quando habilitado, o uso deste provedor ficará visível no painel de status do trabalho.",
|
||||
"settings.usage.page.state.noData": "Não há dados de uso disponíveis ainda.",
|
||||
"settings.usage.page.state.refreshFailedTitle": "Não foi possível atualizar os dados de uso",
|
||||
"settings.usage.page.state.providerNotConfiguredTitle": "Provedor não configurado",
|
||||
|
||||
@@ -1150,15 +1150,14 @@ export const settingsDict = {
|
||||
"settings.usage.sidebar.field.displayModePlaceholder": "Режим відображення",
|
||||
"settings.usage.sidebar.field.displayModeUsage": "Використання",
|
||||
"settings.usage.sidebar.field.displayModeRemaining": "Залишок квоти",
|
||||
"settings.usage.sidebar.field.showPredictions": "Показувати прогнози",
|
||||
"settings.usage.sidebar.status.notSet": "Не встановлено",
|
||||
"settings.usage.page.empty.selectProvider": "Виберіть провайдера, щоб переглянути деталі використання.",
|
||||
"settings.usage.page.header.providerUsage": "Використання {provider}",
|
||||
"settings.usage.page.header.refreshing": "Оновлення використання...",
|
||||
"settings.usage.page.header.lastUpdated": "Останнє оновлення: {time}",
|
||||
"settings.usage.page.options.showInHeaderAria": "Показати в меню заголовка",
|
||||
"settings.usage.page.options.showInHeader": "Показати в меню заголовка",
|
||||
"settings.usage.page.options.showInHeaderTooltip": "Якщо ввімкнути, використання цього провайдера буде видно в спадному меню швидкого доступу в заголовку програми.",
|
||||
"settings.usage.page.options.showInWorkStatusAria": "Показувати в панелі статусу роботи",
|
||||
"settings.usage.page.options.showInWorkStatus": "Показувати в панелі статусу роботи",
|
||||
"settings.usage.page.options.showInWorkStatusTooltip": "Якщо ввімкнути, використання цього провайдера буде видно в панелі статусу роботи.",
|
||||
"settings.usage.page.state.noData": "Даних про використання ще немає.",
|
||||
"settings.usage.page.state.refreshFailedTitle": "Не вдалося оновити дані про використання",
|
||||
"settings.usage.page.state.providerNotConfiguredTitle": "Провайдер не налаштований",
|
||||
|
||||
@@ -1150,15 +1150,14 @@ export const settingsDict = {
|
||||
'settings.usage.sidebar.field.displayModePlaceholder': '显示模式',
|
||||
'settings.usage.sidebar.field.displayModeUsage': '用量',
|
||||
'settings.usage.sidebar.field.displayModeRemaining': '剩余配额',
|
||||
'settings.usage.sidebar.field.showPredictions': '显示预测',
|
||||
'settings.usage.sidebar.status.notSet': '未设置',
|
||||
'settings.usage.page.empty.selectProvider': '选择一个提供商以查看用量详情。',
|
||||
'settings.usage.page.header.providerUsage': '{provider} 用量',
|
||||
'settings.usage.page.header.refreshing': '正在刷新用量...',
|
||||
'settings.usage.page.header.lastUpdated': '最后更新:{time}',
|
||||
'settings.usage.page.options.showInHeaderAria': '在页眉菜单显示',
|
||||
'settings.usage.page.options.showInHeader': '在页眉菜单显示',
|
||||
'settings.usage.page.options.showInHeaderTooltip': '启用后,该提供商的用量会显示在应用页眉的快速访问下拉菜单中。',
|
||||
'settings.usage.page.options.showInWorkStatusAria': '在工作状态面板中显示',
|
||||
'settings.usage.page.options.showInWorkStatus': '在工作状态面板中显示',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': '启用后,该提供商的用量会显示在工作状态面板中。',
|
||||
'settings.usage.page.state.noData': '暂无用量数据。',
|
||||
'settings.usage.page.state.refreshFailedTitle': '刷新用量数据失败',
|
||||
'settings.usage.page.state.providerNotConfiguredTitle': '提供商未配置',
|
||||
|
||||
@@ -1062,9 +1062,9 @@
|
||||
'settings.usage.page.header.providerUsage': '{provider} 用量',
|
||||
'settings.usage.page.header.refreshing': '正在重新整理用量...',
|
||||
'settings.usage.page.header.lastUpdated': '最後更新:{time}',
|
||||
'settings.usage.page.options.showInHeaderAria': '在頁首選單顯示',
|
||||
'settings.usage.page.options.showInHeader': '在頁首選單顯示',
|
||||
'settings.usage.page.options.showInHeaderTooltip': '啟用後,該供應商的用量會顯示在應用程式頁首的快速存取下拉選單中。',
|
||||
'settings.usage.page.options.showInWorkStatusAria': '在工作狀態面板中顯示',
|
||||
'settings.usage.page.options.showInWorkStatus': '在工作狀態面板中顯示',
|
||||
'settings.usage.page.options.showInWorkStatusTooltip': '啟用後,該供應商的用量會顯示在工作狀態面板中。',
|
||||
'settings.usage.page.state.noData': '暫無用量資料。',
|
||||
'settings.usage.page.state.refreshFailedTitle': '重新整理用量資料失敗',
|
||||
'settings.usage.page.state.providerNotConfiguredTitle': '供應商未設定',
|
||||
@@ -2120,7 +2120,6 @@
|
||||
'settings.plugins.toast.updatedToLatest': '外掛已更新到 {version}',
|
||||
'settings.plugins.validation.fileName': '檔案名稱必須為小寫,並以 .js / .ts / .mjs / .cjs 結尾',
|
||||
'settings.plugins.validation.specRequired': '請輸入套件或路徑',
|
||||
'settings.usage.sidebar.field.showPredictions': '顯示預測',
|
||||
'settings.view.home.cards.plugins.description': '管理 opencode 外掛',
|
||||
'settings.view.home.cards.plugins.title': '外掛',
|
||||
'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior',
|
||||
|
||||
@@ -1260,18 +1260,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
|
||||
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
|
||||
}
|
||||
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
||||
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
||||
}
|
||||
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
|
||||
result.usageRefreshIntervalMs = candidate.usageRefreshIntervalMs;
|
||||
}
|
||||
if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
|
||||
result.usageDisplayMode = candidate.usageDisplayMode;
|
||||
}
|
||||
if (typeof candidate.usageShowPredValues === 'boolean') {
|
||||
result.usageShowPredValues = candidate.usageShowPredValues;
|
||||
}
|
||||
if (Array.isArray(candidate.usageDropdownProviders)) {
|
||||
result.usageDropdownProviders = candidate.usageDropdownProviders.filter(
|
||||
(entry): entry is string => typeof entry === 'string' && entry.length > 0
|
||||
|
||||
@@ -5,9 +5,4 @@ export {
|
||||
formatQuotaResetLabel,
|
||||
resolveUsageTone,
|
||||
formatWindowLabel,
|
||||
calculatePace,
|
||||
getPaceStatusColor,
|
||||
formatRemainingTime,
|
||||
calculateExpectedUsagePercent,
|
||||
} from './utils';
|
||||
export type { PaceInfo } from './utils';
|
||||
|
||||
@@ -95,203 +95,3 @@ export const formatWindowLabel = (label: string): string => {
|
||||
if (label === 'premium_interactions') return t('quota.window.premiumInteractions');
|
||||
return label;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pace status indicating whether usage is on track, slightly fast, or too fast
|
||||
*/
|
||||
export type PaceStatus = 'on-track' | 'slightly-fast' | 'too-fast' | 'exhausted';
|
||||
|
||||
/**
|
||||
* Information about the current pace of usage
|
||||
*/
|
||||
export interface PaceInfo {
|
||||
/** Ratio of time elapsed in the window (0-1) */
|
||||
elapsedRatio: number;
|
||||
/** Ratio of quota used (0-1) */
|
||||
usageRatio: number;
|
||||
/** Predicted final usage percentage at end of window */
|
||||
predictedFinalPercent: number;
|
||||
/** Seconds remaining until reset */
|
||||
remainingSeconds: number;
|
||||
/** Whether usage is exhausted (100% used with time remaining) */
|
||||
isExhausted: boolean;
|
||||
/** Elapsed seconds in the window */
|
||||
elapsedSeconds: number;
|
||||
/** Total window duration in seconds */
|
||||
totalSeconds: number;
|
||||
/** Current pace status */
|
||||
status: PaceStatus;
|
||||
/** Per-unit pace rate (e.g., "2.5%/h" or "15%/d") */
|
||||
paceRateText: string;
|
||||
/** Prediction text (e.g., "85%" or "+120%") */
|
||||
predictText: string;
|
||||
/** For weekly quotas: the per-day allocation percentage */
|
||||
dailyAllocationPercent: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer window duration in seconds from a window label.
|
||||
* Used when the API doesn't provide windowSeconds directly.
|
||||
*/
|
||||
const inferWindowSeconds = (label: string): number | null => {
|
||||
const normalized = label.toLowerCase().trim();
|
||||
|
||||
// Exact matches
|
||||
if (normalized === '5h') return 5 * 3600;
|
||||
if (normalized === '7d' || normalized === 'weekly' || normalized === '7d-sonnet' || normalized === '7d-opus') return 7 * 86400;
|
||||
if (normalized === 'monthly') return 30 * 86400;
|
||||
if (normalized === '24h' || normalized === 'daily') return 86400;
|
||||
if (normalized === '1h') return 3600;
|
||||
|
||||
// Pattern matches
|
||||
const hourMatch = normalized.match(/^(\d+)h$/);
|
||||
if (hourMatch) return parseInt(hourMatch[1], 10) * 3600;
|
||||
|
||||
const dayMatch = normalized.match(/^(\d+)d$/);
|
||||
if (dayMatch) return parseInt(dayMatch[1], 10) * 86400;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate pace information for a usage window.
|
||||
*
|
||||
* @param usedPercent - Current usage percentage (0-100)
|
||||
* @param resetAt - Timestamp (ms) when the window resets
|
||||
* @param windowSeconds - Total window duration in seconds (can be null, will be inferred from label if possible)
|
||||
* @param windowLabel - Optional label to infer window duration from
|
||||
* @returns PaceInfo object with pace calculations
|
||||
*/
|
||||
export const calculatePace = (
|
||||
usedPercent: number | null,
|
||||
resetAt: number | null,
|
||||
windowSeconds: number | null,
|
||||
windowLabel?: string
|
||||
): PaceInfo | null => {
|
||||
// Try to infer windowSeconds from label if not provided
|
||||
let effectiveWindowSeconds = windowSeconds;
|
||||
if (effectiveWindowSeconds === null && windowLabel) {
|
||||
effectiveWindowSeconds = inferWindowSeconds(windowLabel);
|
||||
}
|
||||
|
||||
if (usedPercent === null || resetAt === null || effectiveWindowSeconds === null || effectiveWindowSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const remainingSeconds = Math.max(0, (resetAt - now) / 1000);
|
||||
const elapsedSeconds = Math.max(0, Math.min(effectiveWindowSeconds, effectiveWindowSeconds - remainingSeconds));
|
||||
const elapsedRatio = Math.max(0, Math.min(1, elapsedSeconds / effectiveWindowSeconds));
|
||||
const usageRatio = usedPercent / 100;
|
||||
const isExhausted = usedPercent >= 100 && remainingSeconds > 0;
|
||||
|
||||
// Calculate predicted final usage
|
||||
let predictedFinalPercent: number;
|
||||
if (elapsedRatio > 0.01) {
|
||||
predictedFinalPercent = Math.min(999, (usageRatio / elapsedRatio) * 100);
|
||||
} else {
|
||||
predictedFinalPercent = usedPercent;
|
||||
}
|
||||
|
||||
// Determine pace status
|
||||
let status: PaceStatus;
|
||||
if (isExhausted) {
|
||||
status = 'exhausted';
|
||||
} else if (usageRatio <= elapsedRatio) {
|
||||
status = 'on-track';
|
||||
} else if (predictedFinalPercent <= 130) {
|
||||
status = 'slightly-fast';
|
||||
} else {
|
||||
status = 'too-fast';
|
||||
}
|
||||
|
||||
// Calculate pace rate text (per hour for < 5 days, per day otherwise)
|
||||
const usePerDay = effectiveWindowSeconds >= 5 * 24 * 3600;
|
||||
const unitSeconds = usePerDay ? 86400 : 3600;
|
||||
const unitSuffix = usePerDay ? 'd' : 'h';
|
||||
const totalUnits = effectiveWindowSeconds / unitSeconds;
|
||||
const elapsedUnits = Math.max(elapsedSeconds / unitSeconds, totalUnits * 0.01);
|
||||
const pacePercentPerUnit = (usedPercent / elapsedUnits);
|
||||
const paceRateText = Number.isFinite(pacePercentPerUnit)
|
||||
? `${Math.min(999.9, Math.max(0, pacePercentPerUnit)).toFixed(1)}%/${unitSuffix}`
|
||||
: '-';
|
||||
|
||||
// Calculate predict text
|
||||
const predictText = predictedFinalPercent > 100
|
||||
? `+${Math.round(predictedFinalPercent)}%`
|
||||
: `${Math.round(predictedFinalPercent)}%`;
|
||||
|
||||
// Calculate daily allocation for weekly quotas (7 days = 604800 seconds)
|
||||
// Also include monthly quotas (roughly 30 days)
|
||||
let dailyAllocationPercent: number | null = null;
|
||||
const windowDays = effectiveWindowSeconds / 86400;
|
||||
if (windowDays >= 7) {
|
||||
// For a 7-day window, each day should use ~14.3% (100/7)
|
||||
// For monthly, each day should use ~3.3% (100/30)
|
||||
dailyAllocationPercent = 100 / windowDays;
|
||||
}
|
||||
|
||||
return {
|
||||
elapsedRatio,
|
||||
usageRatio,
|
||||
predictedFinalPercent,
|
||||
remainingSeconds,
|
||||
isExhausted,
|
||||
elapsedSeconds,
|
||||
totalSeconds: effectiveWindowSeconds,
|
||||
status,
|
||||
paceRateText,
|
||||
predictText,
|
||||
dailyAllocationPercent,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the color for a pace status (returns CSS variable names)
|
||||
*/
|
||||
export const getPaceStatusColor = (status: PaceStatus): string => {
|
||||
switch (status) {
|
||||
case 'exhausted':
|
||||
case 'too-fast':
|
||||
return 'var(--status-error)';
|
||||
case 'slightly-fast':
|
||||
return 'var(--status-warning)';
|
||||
case 'on-track':
|
||||
return 'var(--status-success)';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format remaining time as a human-readable string
|
||||
*/
|
||||
export const formatRemainingTime = (seconds: number): string => {
|
||||
const totalSeconds = Math.max(0, Math.floor(seconds));
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const days = Math.floor(totalHours / 24);
|
||||
const hours = totalHours % 24;
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
if (totalHours > 0) {
|
||||
return `${totalHours}h`;
|
||||
}
|
||||
if (totalMinutes === 0) {
|
||||
return '<1m';
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the marker position for daily allocation on weekly quotas.
|
||||
* Returns a percentage (0-100) representing where the "expected" usage should be
|
||||
* based on how much time has elapsed.
|
||||
*
|
||||
* @param elapsedRatio - Ratio of time elapsed (0-1)
|
||||
* @returns Expected usage percentage based on time elapsed
|
||||
*/
|
||||
export const calculateExpectedUsagePercent = (elapsedRatio: number): number => {
|
||||
return Math.min(100, Math.max(0, elapsedRatio * 100));
|
||||
};
|
||||
|
||||
@@ -513,11 +513,11 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
keywords: ['ignored', 'files', 'gitignore'],
|
||||
},
|
||||
{
|
||||
id: 'usage.header-menu',
|
||||
id: 'usage.work-status-panel',
|
||||
page: 'usage',
|
||||
titleKey: 'settings.usage.page.options.showInHeader',
|
||||
descriptionKey: 'settings.usage.page.options.showInHeaderTooltip',
|
||||
keywords: ['quota', 'header', 'dropdown'],
|
||||
titleKey: 'settings.usage.page.options.showInWorkStatus',
|
||||
descriptionKey: 'settings.usage.page.options.showInWorkStatusTooltip',
|
||||
keywords: ['quota', 'work', 'status', 'panel'],
|
||||
},
|
||||
{
|
||||
id: 'usage.model-quotas',
|
||||
|
||||
@@ -9,13 +9,12 @@ import { getDefaultModels } from '@/lib/quota/model-families';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_MS = 60000;
|
||||
const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
|
||||
let quotaAutoRefreshConsumers = 0;
|
||||
let quotaAutoRefreshInterval: number | null = null;
|
||||
|
||||
interface QuotaSettingsState {
|
||||
autoRefresh: boolean;
|
||||
refreshIntervalMs: number;
|
||||
displayMode: 'usage' | 'remaining';
|
||||
showPredValues: boolean;
|
||||
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)
|
||||
@@ -31,12 +30,10 @@ interface QuotaStore extends QuotaSettingsState {
|
||||
|
||||
loadSettings: () => Promise<void>;
|
||||
fetchAllQuotas: () => Promise<void>;
|
||||
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<void>;
|
||||
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<void>;
|
||||
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
|
||||
setAutoRefresh: (enabled: boolean) => void;
|
||||
setRefreshInterval: (intervalMs: number) => void;
|
||||
setDisplayMode: (mode: 'usage' | 'remaining') => void;
|
||||
setShowPredValues: (enabled: boolean) => void;
|
||||
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
|
||||
setSelectedModels: (providerId: string, modelNames: string[]) => void;
|
||||
toggleModelSelected: (providerId: string, modelName: string) => void;
|
||||
@@ -47,18 +44,7 @@ interface QuotaStore extends QuotaSettingsState {
|
||||
|
||||
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
|
||||
const allProviderIds = QUOTA_PROVIDERS.map((provider) => provider.id);
|
||||
const autoRefresh = typeof data?.usageAutoRefresh === 'boolean'
|
||||
? data.usageAutoRefresh
|
||||
: false;
|
||||
const refreshIntervalMs =
|
||||
typeof data?.usageRefreshIntervalMs === 'number' && Number.isFinite(data.usageRefreshIntervalMs)
|
||||
? Math.max(30000, Math.min(300000, Math.round(data.usageRefreshIntervalMs)))
|
||||
: DEFAULT_REFRESH_INTERVAL_MS;
|
||||
|
||||
const displayMode = data?.usageDisplayMode === 'remaining' ? 'remaining' : 'usage';
|
||||
const showPredValues = typeof data?.usageShowPredValues === 'boolean'
|
||||
? data.usageShowPredValues
|
||||
: false;
|
||||
const rawDropdownProviders = Array.isArray(data?.usageDropdownProviders)
|
||||
? data?.usageDropdownProviders
|
||||
: null;
|
||||
@@ -91,10 +77,7 @@ const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState
|
||||
}
|
||||
|
||||
return {
|
||||
autoRefresh,
|
||||
refreshIntervalMs,
|
||||
displayMode,
|
||||
showPredValues,
|
||||
dropdownProviderIds,
|
||||
selectedModels,
|
||||
expandedFamilies,
|
||||
@@ -125,10 +108,7 @@ const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
||||
}
|
||||
|
||||
return {
|
||||
autoRefresh: false,
|
||||
refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
|
||||
displayMode: 'usage',
|
||||
showPredValues: false,
|
||||
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
||||
selectedModels: {},
|
||||
expandedFamilies: {},
|
||||
@@ -144,10 +124,7 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
isFetchingProvider: {},
|
||||
lastUpdated: null,
|
||||
error: null,
|
||||
autoRefresh: false,
|
||||
refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
|
||||
displayMode: 'usage',
|
||||
showPredValues: false,
|
||||
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
||||
selectedModels: {},
|
||||
expandedFamilies: {},
|
||||
@@ -161,9 +138,8 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
fetchAllQuotas: async () => {
|
||||
fetchQuotas: async (providerIds) => {
|
||||
set({ isLoading: true, error: null });
|
||||
const providerIds = QUOTA_PROVIDERS.map((provider) => provider.id);
|
||||
try {
|
||||
await Promise.all(
|
||||
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
|
||||
@@ -178,6 +154,10 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
fetchAllQuotas: async () => {
|
||||
await get().fetchQuotas(QUOTA_PROVIDERS.map((provider) => provider.id));
|
||||
},
|
||||
|
||||
fetchProviderQuota: async (providerId) => {
|
||||
set((state) => ({
|
||||
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
|
||||
@@ -219,13 +199,7 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
},
|
||||
|
||||
setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }),
|
||||
setAutoRefresh: (enabled) => set({ autoRefresh: enabled }),
|
||||
setRefreshInterval: (intervalMs) => {
|
||||
const clamped = Math.max(30000, Math.min(300000, Math.round(intervalMs)));
|
||||
set({ refreshIntervalMs: clamped });
|
||||
},
|
||||
setDisplayMode: (mode) => set({ displayMode: mode }),
|
||||
setShowPredValues: (enabled) => set({ showPredValues: enabled }),
|
||||
setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }),
|
||||
|
||||
setSelectedModels: (providerId, modelNames) => {
|
||||
@@ -290,19 +264,23 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
);
|
||||
|
||||
export const useQuotaAutoRefresh = () => {
|
||||
const autoRefresh = useQuotaStore((state) => state.autoRefresh);
|
||||
const refreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoRefresh) {
|
||||
return;
|
||||
quotaAutoRefreshConsumers += 1;
|
||||
if (quotaAutoRefreshInterval === null) {
|
||||
quotaAutoRefreshInterval = window.setInterval(() => {
|
||||
const { dropdownProviderIds, fetchQuotas } = useQuotaStore.getState();
|
||||
if (dropdownProviderIds.length > 0) {
|
||||
void fetchQuotas(dropdownProviderIds);
|
||||
}
|
||||
}, QUOTA_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
fetchAllQuotas();
|
||||
}, refreshIntervalMs);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [autoRefresh, refreshIntervalMs, fetchAllQuotas]);
|
||||
return () => {
|
||||
quotaAutoRefreshConsumers -= 1;
|
||||
if (quotaAutoRefreshConsumers === 0 && quotaAutoRefreshInterval !== null) {
|
||||
window.clearInterval(quotaAutoRefreshInterval);
|
||||
quotaAutoRefreshInterval = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -326,20 +326,6 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageAutoRefresh !== 'boolean') {
|
||||
delete restChanges.usageAutoRefresh;
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageShowPredValues !== 'boolean') {
|
||||
delete restChanges.usageShowPredValues;
|
||||
}
|
||||
|
||||
if (typeof restChanges.usageRefreshIntervalMs === 'number' && Number.isFinite(restChanges.usageRefreshIntervalMs)) {
|
||||
restChanges.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(restChanges.usageRefreshIntervalMs)));
|
||||
} else {
|
||||
delete restChanges.usageRefreshIntervalMs;
|
||||
}
|
||||
|
||||
if (typeof restChanges.opencodeBinary === 'string') {
|
||||
restChanges.opencodeBinary = restChanges.opencodeBinary.trim();
|
||||
}
|
||||
|
||||
@@ -362,18 +362,9 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
|
||||
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
|
||||
}
|
||||
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
||||
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
||||
}
|
||||
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
|
||||
result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs)));
|
||||
}
|
||||
if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
|
||||
result.usageDisplayMode = candidate.usageDisplayMode;
|
||||
}
|
||||
if (typeof candidate.usageShowPredValues === 'boolean') {
|
||||
result.usageShowPredValues = candidate.usageShowPredValues;
|
||||
}
|
||||
if (Array.isArray(candidate.usageDropdownProviders)) {
|
||||
result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user