Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bd75784
fix(web): usage page loads without flashing partial totals
flamboh Oct 26, 2026
29f364f
fix(web): restart the usage settle window per load and delay chart di…
flamboh Oct 26, 2026
4264203
fix(web): dim usage figures only after loading outlasts the settle wi…
flamboh Oct 26, 2026
ea1a94c
fix(web): dim partial usage at once when the page already waited
flamboh Oct 26, 2026
0c90361
fix(web): fade usage dimming in instead of mounting pale
flamboh Oct 26, 2026
3903aaf
fix(web): dim usage through a CSS delay and reset the settle window p…
flamboh Oct 26, 2026
668ac6d
fix(web): time the usage settle window with an atom instead of an effect
flamboh Oct 26, 2026
290f378
fix(web): restart the usage settle window when the environment set ch…
flamboh Oct 26, 2026
de9e157
fix(web): show the usage skeleton right away
flamboh Oct 26, 2026
38df178
chore(web): merge main into usage loading fix
maria-rcks Oct 27, 2026
b6c6855
chore(web): merge main into usage loading fix
maria-rcks Oct 30, 2026
f1cfa12
fix(web): dim partial usage while connections authenticate
maria-rcks Oct 30, 2026
30f3551
Merge remote-tracking branch 'github/main' into fix/usage-loading-smooth
maria-rcks Nov 7, 2026
f0eb8d6
Merge origin/main into fix/usage-loading-smooth
juliusmarminge Nov 14, 2026
d3bce7e
fix(web): keep usage environment menu as-is, show partial note only w…
juliusmarminge Nov 14, 2026
b660018
fix(client-runtime): keep unreachable environments out of usage loadi…
juliusmarminge Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export function UsagePage() {
selectedEnvironments,
shown,
isPartial,
isWaitingLong,
refresh,
} = useUsage(window, selectedEnvironmentIds, hiddenProviders);
const merged = shown?.merged ?? answeredUsage;
Expand Down Expand Up @@ -545,6 +546,7 @@ export function UsagePage() {
selectedEnvironments={selectedEnvironments}
hiddenProviders={hiddenProviders}
refreshingUsage={refreshingUsage}
isWaitingLong={isWaitingLong}
showChatGptSharing={showChatGptSharing}
onSelectModel={selectUsageModel}
/>
Expand Down Expand Up @@ -593,6 +595,7 @@ function UsageOverview({
selectedEnvironments,
hiddenProviders,
refreshingUsage,
isWaitingLong,
showChatGptSharing,
onSelectModel,
}: {
Expand All @@ -606,6 +609,7 @@ function UsageOverview({
readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
readonly refreshingUsage: boolean;
readonly isWaitingLong: boolean;
readonly showChatGptSharing: boolean;
readonly onSelectModel: (key: string) => void;
}) {
Expand Down Expand Up @@ -649,7 +653,13 @@ function UsageOverview({
const rankedProviders = rankProvidersByMetric(activeProviders, merged.providers, metric);

return (
<div aria-busy={loadingPartial} className="flex flex-col gap-6">
<div
aria-busy={loadingPartial}
className={cn(
"flex flex-col gap-6",
isWaitingLong ? "[--usage-dim-delay:0ms]" : "[--usage-dim-delay:300ms]",
)}
>
{sourceMessages.map((message) => (
<p key={message} className="mb-4 text-sm text-muted-foreground">
{message}
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -1313,7 +1326,7 @@ function UsageSkeleton() {
<Skeleton className="h-10 w-36" />
<Skeleton className="h-4 w-32" />
</div>
{PROVIDER_ORDER.map((provider) => (
{PROVIDER_ORDER.slice(0, 3).map((provider) => (
<div key={provider} className="flex flex-col gap-1">
<div className="flex min-h-5 items-center justify-between gap-4">
<span className="flex items-center gap-2">
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/usage/UsageProviderChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 26 additions & 6 deletions apps/web/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<void>;
}

Expand All @@ -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<boolean>());

function useSettleWindow(active: boolean, loadKey: string) {
const elapsed = useAtomValue(active ? settleWindowAtom(loadKey) : idleSettleWindowAtom);
return active && AsyncResult.isSuccess(elapsed);
}

const NO_HIDDEN_PROVIDERS: ReadonlySet<UsageProviderKind> = new Set();

function withoutProviders(
Expand Down Expand Up @@ -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(",");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const isWaitingLong = useSettleWindow(stillReporting > 0, `${windowKey}|${selectionKey}`);
const settled = answeredCount > 0 && (stillReporting === 0 || isWaitingLong);

const [lastAnswered, setLastAnswered] = useState<
| (NonNullable<UsageView["shown"]> & {
Expand All @@ -212,7 +232,7 @@ export function useUsage(
| null
>(null);
if (
answeredCount > 0 &&
settled &&
(lastAnswered?.merged !== merged ||
lastAnswered.window !== input ||
lastAnswered.selection !== selectedEnvironmentIds ||
Expand All @@ -229,18 +249,18 @@ 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,
environments,
selectedEnvironments,
isPending,
shown,
isPartial: answeredCount > 0 && stillReporting > 0,
isPartial: settled && stillReporting > 0,
isWaitingLong,
refresh,
};
}
Loading