diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 8bc8a93a43d69..a56acfa3d9410 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -211,6 +211,7 @@ export function UsagePage() { selectedEnvironments, shown, isPartial, + isWaitingLong, refresh, } = useUsage(window, selectedEnvironmentIds, hiddenProviders); const merged = shown?.merged ?? answeredUsage; @@ -545,6 +546,7 @@ export function UsagePage() { selectedEnvironments={selectedEnvironments} hiddenProviders={hiddenProviders} refreshingUsage={refreshingUsage} + isWaitingLong={isWaitingLong} showChatGptSharing={showChatGptSharing} onSelectModel={selectUsageModel} /> @@ -593,6 +595,7 @@ function UsageOverview({ selectedEnvironments, hiddenProviders, refreshingUsage, + isWaitingLong, showChatGptSharing, onSelectModel, }: { @@ -606,6 +609,7 @@ function UsageOverview({ readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; readonly hiddenProviders: ReadonlySet; readonly refreshingUsage: boolean; + readonly isWaitingLong: boolean; readonly showChatGptSharing: boolean; readonly onSelectModel: (key: string) => void; }) { @@ -649,7 +653,13 @@ function UsageOverview({ const rankedProviders = rankProvidersByMetric(activeProviders, merged.providers, metric); return ( -
+
{sourceMessages.map((message) => (

{message} @@ -1042,7 +1052,10 @@ function ProviderMark({ } function figureClass(loading: boolean) { - return cn("transition-opacity", loading && "opacity-40 delay-150"); + return cn( + "transition-opacity", + loading && "opacity-40 delay-(--usage-dim-delay) starting:opacity-100", + ); } function Metric({ @@ -1313,7 +1326,7 @@ function UsageSkeleton() {

- {PROVIDER_ORDER.map((provider) => ( + {PROVIDER_ORDER.slice(0, 3).map((provider) => (
diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 1745be4df897b..e5d873cc0d9d2 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -433,7 +433,10 @@ export function UsageProviderChart({ const toY = (value: number) => valueToY(value, scale.max); const seriesClassName = (loading: boolean) => - cn("transition-opacity", loading && "opacity-40 delay-150"); + cn( + "transition-opacity", + loading && "opacity-40 delay-(--usage-dim-delay) starting:opacity-100", + ); const format = metric === "tokens" ? formatTokens : formatUsd; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone, hour12) : formatDayShort(period); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index bf5de2e330a3f..9ee8c1f06bdf1 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { refreshUsage, usageSummaryInput } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; +import * as Effect from "effect/Effect"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -84,6 +85,7 @@ export interface UsageView { readonly isPending: boolean; readonly shown: { readonly window: UsageSummaryInput; readonly merged: MergedUsage } | null; readonly isPartial: boolean; + readonly isWaitingLong: boolean; readonly refresh: (input?: UsageSummaryInput) => Promise; } @@ -108,6 +110,18 @@ export function mergeAnsweredUsage( return mergeUsage(answered, USAGE_CONTRACT_VERSION); } +const settleWindowAtom = Atom.family((loadKey: string) => + Atom.make(Effect.sleep("300 millis").pipe(Effect.as(true))).pipe( + Atom.withLabel(`web-usage:settle:${loadKey}`), + ), +); +const idleSettleWindowAtom = Atom.make(AsyncResult.initial()); + +function useSettleWindow(active: boolean, loadKey: string) { + const elapsed = useAtomValue(active ? settleWindowAtom(loadKey) : idleSettleWindowAtom); + return active && AsyncResult.isSuccess(elapsed); +} + const NO_HIDDEN_PROVIDERS: ReadonlySet = new Set(); function withoutProviders( @@ -203,6 +217,12 @@ export function useUsage( (environment) => environment.summary === null && environment.error === null, ).length; const isPending = answeredCount === 0 && stillReporting > 0; + const selectionKey = selectedEnvironments + .map(({ environmentId }) => environmentId) + .toSorted() + .join(","); + const isWaitingLong = useSettleWindow(stillReporting > 0, `${windowKey}|${selectionKey}`); + const settled = answeredCount > 0 && (stillReporting === 0 || isWaitingLong); const [lastAnswered, setLastAnswered] = useState< | (NonNullable & { @@ -212,7 +232,7 @@ export function useUsage( | null >(null); if ( - answeredCount > 0 && + settled && (lastAnswered?.merged !== merged || lastAnswered.window !== input || lastAnswered.selection !== selectedEnvironmentIds || @@ -229,10 +249,9 @@ export function useUsage( lastAnswered?.selection === selectedEnvironmentIds && lastAnswered.hidden === hiddenProviders ? lastAnswered : null; - const shown = - answeredCount > 0 - ? { window: input, merged } - : (kept ?? (isPending ? null : { window: input, merged })); + const shown = settled + ? { window: input, merged } + : (kept ?? (stillReporting > 0 ? null : { window: input, merged })); return { merged, @@ -240,7 +259,8 @@ export function useUsage( selectedEnvironments, isPending, shown, - isPartial: answeredCount > 0 && stillReporting > 0, + isPartial: settled && stillReporting > 0, + isWaitingLong, refresh, }; }