From e7aa04dfec2777307073e56821a0046cb5dafb1a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 18:59:59 -0700 Subject: [PATCH] fix(web): usage page no longer jumps around while devices report in Co-Authored-By: Claude Fable 5 --- apps/web/src/components/usage/UsagePage.tsx | 173 +++++++++++++++++--- 1 file changed, 147 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 91e659ad5dd..1a5b53904d9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,10 +1,10 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; -import { ArrowLeftIcon, RefreshCwIcon } from "lucide-react"; +import { ArrowLeftIcon, CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "../../lib/utils"; -import { useUsage } from "../../state/usage"; +import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { enumerateDays, formatCount, @@ -37,6 +37,11 @@ export function UsagePage() { const window = useMemo(() => makeWindow(windowDays), [windowDays]); const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + // Hold the content until every environment is terminal. Rendering merged + // totals while devices are still answering makes every number on the page + // jump as each one lands. + const settling = isPending || isPartial; + const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], @@ -112,19 +117,19 @@ export function UsagePage() { - - - {isPending ? ( -

- Scanning provider transcripts… -

+ {settling ? ( + <> + {environments.length > 1 ? : null} + + ) : ( <> + + {/* Cost first: the financial answer, then the provider split. */}
{/* The summary follows the chart toggle, so the headline and the @@ -391,37 +396,30 @@ function Metric({ } /** - * Says plainly when the totals are incomplete: an environment still answering, - * one that failed, or one whose transcripts another environment already - * reported. + * Says plainly when the totals are incomplete: an environment that failed, or + * one whose transcripts another environment already reported. Environments + * that are still answering never reach this notice; the page shows the + * loading skeleton until every one is terminal. */ function UsageCoverageNotice({ environments, duplicateSources, staleEnvironments, - isPartial, }: { - readonly environments: readonly { - environmentId: string; - label: string; - error: string | null; - isPending: boolean; - }[]; + readonly environments: readonly EnvironmentUsageStatus[]; readonly duplicateSources: readonly string[]; readonly staleEnvironments: readonly string[]; - readonly isPartial: boolean; }) { const failed = environments.filter((environment) => environment.error !== null); const stale = environments.filter((environment) => staleEnvironments.includes(environment.environmentId), ); - if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0 && !isPartial) { + if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { return null; } return (
- {isPartial ? Some environments are still reporting. Totals are partial. : null} {failed.map((environment) => ( {environment.label} could not report usage. ))} @@ -439,3 +437,126 @@ function UsageCoverageNotice({
); } + +/** + * Per-device progress while the page waits for every environment to answer. + * Only rendered with two or more devices; a lone device has nothing to + * enumerate. + */ +function UsageDeviceStrip({ + environments, +}: { + readonly environments: readonly EnvironmentUsageStatus[]; +}) { + const scanning = environments.filter( + (environment) => environment.summary === null && environment.error === null, + ); + return ( +
+ {environments.map((environment) => { + if (environment.summary !== null) { + return ( + + + {environment.label} + + ); + } + if (environment.error !== null) { + return ( + + + {environment.label} + + ); + } + return ( + + {environment.label}… + + ); + })} + + {scanning.length === 1 + ? "1 device still scanning" + : `${scanning.length} devices still scanning`} + +
+ ); +} + +/** Deterministic bar heights (each unique: they double as keys). */ +const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; + +/** + * Static stand-in with the loaded page's shape: headline, provider split, + * chart and metrics strip. No shimmer; blocks fill in exactly once when the + * last device answers. + */ +function UsageSkeleton() { + return ( + <> +
+
+
+ + Raw token cost + +
+
+
+ + {PROVIDER_ORDER.map((provider) => ( +
+
+ + + {PROVIDER_LABEL[provider]} + +
+
+
+
+
+ ))} +
+ +
+

Daily cost

+ {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a + relayout when the real chart swaps in. */} +
+ {SKELETON_BAR_HEIGHTS.map((height) => ( +
+ ))} +
+
+
+ +
+ {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( + (label) => ( +
+ {label} +
+
+
+ ), + )} +
+ + ); +}