authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 19:45:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:45:13-07:00
log7ee52b077399463323e859f9451a65d31b96a27f
treed8465c4f36a40249d4f184aabbca46ad902327c6
parentde1b798c6ed4223cabd64f56eadcc8f512963043
signature Signed by PGP key B5690EEEBB952194

fix(web): render and filter usage as environments respond (#9860)


7 files changed, 422 insertions(+), 105 deletions(-)

apps/web/src/components/usage/UsageLimits.tsx+12-4
...@@ -426,10 +426,18 @@ function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly...@@ -426,10 +426,18 @@ function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly
426 * Countdowns anchor to render time rather than ticking: a live clock would426 * Countdowns anchor to render time rather than ticking: a live clock would
427 * repaint the page every minute for no decision-changing gain.427 * repaint the page every minute for no decision-changing gain.
428 */428 */
429export function UsageLimitsSection() {429export function UsageLimitsSection({
430 selectedEnvironmentIds,
431}: {
432 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
433}) {
430 const presentations = useAtomValue(environmentPresentations.presentationsAtom);434 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
431 const groups = collectLimitsGroups(presentations);435 const selected =
432 const sources = collectLimitSources(presentations);436 selectedEnvironmentIds === null
437 ? presentations
438 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
439 const groups = collectLimitsGroups(selected);
440 const sources = collectLimitSources(selected);
433 // Anchored once per mount on purpose: countdowns must not tick (see below).441 // Anchored once per mount on purpose: countdowns must not tick (see below).
434 const [now] = useState(() => Date.now());442 const [now] = useState(() => Date.now());
435443
...@@ -437,7 +445,7 @@ export function UsageLimitsSection() {...@@ -437,7 +445,7 @@ export function UsageLimitsSection() {
437 <div className="flex flex-col gap-8">445 <div className="flex flex-col gap-8">
438 {groups.length === 0 && sources.length === 0 ? (446 {groups.length === 0 && sources.length === 0 ? (
439 <p className="text-sm text-muted-foreground">447 <p className="text-sm text-muted-foreground">
440 No provider on a connected environment reports subscription limits.448 No provider on the selected environments reports subscription limits.
441 </p>449 </p>
442 ) : null}450 ) : null}
443 {sources.map((source) => (451 {sources.map((source) => (
apps/web/src/components/usage/UsagePage.test.tsx+23-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { USAGE_CONTRACT_VERSION } from "@t3tools/contracts";1import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { mergeUsage } from "@t3tools/shared/usageMerge";2import { mergeUsage } from "@t3tools/shared/usageMerge";
3import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";4import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
...@@ -104,6 +104,26 @@ const modelTotals = Object.freeze([...@@ -104,6 +104,26 @@ const modelTotals = Object.freeze([
104 },104 },
105]);105]);
106106
107const environments = [
108 {
109 environmentId: EnvironmentId.make("test-environment"),
110 label: "Test environment",
111 isPending: false,
112 error: null,
113 summary: {
114 contractVersion: USAGE_CONTRACT_VERSION,
115 readAt: "2026-08-11T12:37:00.000Z",
116 sinceDay: UsageDay.make("2026-08-10"),
117 untilDay: UsageDay.make("2026-08-11"),
118 timeZone: "UTC",
119 buckets: [],
120 sources: [],
121 pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
122 scanDurationMs: 1,
123 },
124 },
125];
126
107beforeEach(() => {127beforeEach(() => {
108 testState.metric = "cost";128 testState.metric = "cost";
109 testState.breakdown = "time";129 testState.breakdown = "time";
...@@ -128,7 +148,8 @@ beforeEach(() => {...@@ -128,7 +148,8 @@ beforeEach(() => {
128 },148 },
129 ],149 ],
130 },150 },
131 environments: [],151 environments,
152 selectedEnvironments: environments,
132 isPending: false,153 isPending: false,
133 isPartial: false,154 isPartial: false,
134 refresh: vi.fn(),155 refresh: vi.fn(),
apps/web/src/components/usage/UsagePage.tsx+189-89
...@@ -1,9 +1,17 @@...@@ -1,9 +1,17 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { UsageProviderKind } from "@t3tools/contracts";2import {
3import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";3 USAGE_CONTRACT_VERSION,
4 type EnvironmentId,
5 type UsageProviderKind,
6} from "@t3tools/contracts";
7import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, RefreshCwIcon } from "lucide-react";
4import { useMemo, useState } from "react";8import { useMemo, useState } from "react";
59
6import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";10import {
11 isCompatibleUsageContractVersion,
12 type DailyTotals,
13 type HourlyTotals,
14} from "@t3tools/shared/usageMerge";
715
8import { isElectron } from "../../env";16import { isElectron } from "../../env";
9import { cn } from "../../lib/utils";17import { cn } from "../../lib/utils";
...@@ -24,6 +32,7 @@ import {...@@ -24,6 +32,7 @@ import {
24 makeWindow,32 makeWindow,
25} from "@t3tools/shared/usageFormat";33} from "@t3tools/shared/usageFormat";
26import { Button } from "../ui/button";34import { Button } from "../ui/button";
35import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
27import { ScrollArea } from "../ui/scroll-area";36import { ScrollArea } from "../ui/scroll-area";
28import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";37import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
29import { SidebarInset } from "../ui/sidebar";38import { SidebarInset } from "../ui/sidebar";
...@@ -67,19 +76,19 @@ export function UsagePage() {...@@ -67,19 +76,19 @@ export function UsagePage() {
67 const [metric, setMetric] = useState<UsageMetric>("cost");76 const [metric, setMetric] = useState<UsageMetric>("cost");
68 const showingLimits = metric === "limits";77 const showingLimits = metric === "limits";
69 const [breakdown, setBreakdown] = useState<"model" | "time">("model");78 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
79 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
80 useState<ReadonlySet<EnvironmentId> | null>(null);
70 const { days: windowDays, window } = windowSelection;81 const { days: windowDays, window } = windowSelection;
71 const isPast24Hours = windowDays === 1;82 const isPast24Hours = windowDays === 1;
72 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);83 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
84 window,
85 selectedEnvironmentIds,
86 );
73 const presentations = useAtomValue(environmentPresentations.presentationsAtom);87 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
74 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {88 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
75 reportFailure: false,89 reportFailure: false,
76 });90 });
7791
78 // Hold the content until every environment is terminal. Rendering merged
79 // totals while devices are still answering makes every number on the page
80 // jump as each one lands.
81 const settling = isPending || isPartial;
82
83 const days = useMemo(92 const days = useMemo(
84 () => enumerateDays(window.sinceDay, window.untilDay),93 () => enumerateDays(window.sinceDay, window.untilDay),
85 [window.sinceDay, window.untilDay],94 [window.sinceDay, window.untilDay],
...@@ -118,6 +127,7 @@ export function UsagePage() {...@@ -118,6 +127,7 @@ export function UsagePage() {
118 const refreshWindow = () => {127 const refreshWindow = () => {
119 if (showingLimits) {128 if (showingLimits) {
120 for (const [environmentId, presentation] of presentations) {129 for (const [environmentId, presentation] of presentations) {
130 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue;
121 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {131 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
122 void refreshProviders({ environmentId, input: {} });132 void refreshProviders({ environmentId, input: {} });
123 }133 }
...@@ -141,21 +151,31 @@ export function UsagePage() {...@@ -141,21 +151,31 @@ export function UsagePage() {
141 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`151 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
142 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;152 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
143 const topbarContent = (153 const topbarContent = (
144 <div className="flex w-full min-w-0 items-center gap-3">154 <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 py-2 xl:flex">
145 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">155 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="col-span-2 min-w-0">
146 <WorkspaceBreadcrumbItem current>156 <WorkspaceBreadcrumbItem>
147 <h1>Usage</h1>157 <h1>Usage</h1>
148 </WorkspaceBreadcrumbItem>158 </WorkspaceBreadcrumbItem>
149 {showingLimits ? null : (159 <WorkspaceBreadcrumbSeparator />
150 <>160 <WorkspaceBreadcrumbItem current className="min-w-10">
151 <WorkspaceBreadcrumbSeparator className="hidden md:flex" />161 <UsageEnvironmentFilter
152 <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">162 environments={environments}
153 <span className="truncate">{windowLabel}</span>163 selectedEnvironments={selectedEnvironments}
154 </WorkspaceBreadcrumbItem>164 selectedEnvironmentIds={selectedEnvironmentIds}
155 </>165 onSelectionChange={setSelectedEnvironmentIds}
156 )}166 showUsageStatus={!showingLimits}
167 isPartial={isPartial}
168 duplicateSources={merged.duplicateSources}
169 staleEnvironments={merged.staleEnvironments}
170 />
171 </WorkspaceBreadcrumbItem>
157 </WorkspaceBreadcrumb>172 </WorkspaceBreadcrumb>
158 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">173 {!showingLimits ? (
174 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
175 {windowLabel}
176 </span>
177 ) : null}
178 <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 xl:flex">
159 <ToggleGroup179 <ToggleGroup
160 aria-label="Usage metric"180 aria-label="Usage metric"
161 variant="segmented"181 variant="segmented"
...@@ -198,7 +218,7 @@ export function UsagePage() {...@@ -198,7 +218,7 @@ export function UsagePage() {
198 <RefreshCwIcon className="size-3.5" />218 <RefreshCwIcon className="size-3.5" />
199 </Button>219 </Button>
200 </div>220 </div>
201 <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden">221 <div className="col-span-2 ms-auto flex min-w-0 items-center justify-end gap-1 xl:hidden">
202 <Select222 <Select
203 value={metric}223 value={metric}
204 onValueChange={(value) => {224 onValueChange={(value) => {
...@@ -261,7 +281,9 @@ export function UsagePage() {...@@ -261,7 +281,9 @@ export function UsagePage() {
261 return (281 return (
262 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">282 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
263 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">283 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
264 <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader>284 <WorkspacePageHeader electron={isElectron} className="h-auto">
285 {topbarContent}
286 </WorkspacePageHeader>
265287
266 <ScrollArea className="min-h-0 flex-1">288 <ScrollArea className="min-h-0 flex-1">
267 <WorkspacePageContainer width="wide">289 <WorkspacePageContainer width="wide">
...@@ -270,21 +292,18 @@ export function UsagePage() {...@@ -270,21 +292,18 @@ export function UsagePage() {
270 <UsagePriceOverrides usage={environments} />292 <UsagePriceOverrides usage={environments} />
271 </div>293 </div>
272 ) : null}294 ) : null}
273 {showingLimits ? (295 {selectedEnvironments.length === 0 ? (
274 <UsageLimitsSection />296 <p className="text-sm text-muted-foreground">
275 ) : settling ? (297 {environments.length === 0
276 <>298 ? `Connect an environment to see ${showingLimits ? "limits" : "usage"}.`
277 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}299 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
278 <UsageSkeleton />300 </p>
279 </>301 ) : showingLimits ? (
302 <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} />
303 ) : isPending ? (
304 <UsageSkeleton />
280 ) : (305 ) : (
281 <>306 <>
282 <UsageCoverageNotice
283 environments={environments}
284 duplicateSources={merged.duplicateSources}
285 staleEnvironments={merged.staleEnvironments}
286 />
287
288 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">307 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
289 <div className="flex min-w-0 flex-col gap-5">308 <div className="flex min-w-0 flex-col gap-5">
290 <div className="flex flex-col gap-1">309 <div className="flex flex-col gap-1">
...@@ -550,10 +569,8 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri...@@ -550,10 +569,8 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri
550}569}
551570
552/**571/**
553 * Says plainly when the totals are incomplete: an environment that failed, or572 * Explains failed or incompatible environments and deduplicated transcripts.
554 * one whose transcripts another environment already reported. Environments573 * Shown inside the environment filter so arriving results do not move the page.
555 * that are still answering never reach this notice; the page shows the
556 * loading skeleton until every one is terminal.
557 */574 */
558function UsageCoverageNotice({575function UsageCoverageNotice({
559 environments,576 environments,
...@@ -573,7 +590,7 @@ function UsageCoverageNotice({...@@ -573,7 +590,7 @@ function UsageCoverageNotice({
573 }590 }
574591
575 return (592 return (
576 <div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground">593 <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground">
577 {failed.map((environment) => (594 {failed.map((environment) => (
578 <span key={environment.label}>{environment.label} could not report usage.</span>595 <span key={environment.label}>{environment.label} could not report usage.</span>
579 ))}596 ))}
...@@ -592,66 +609,149 @@ function UsageCoverageNotice({...@@ -592,66 +609,149 @@ function UsageCoverageNotice({
592 );609 );
593}610}
594611
595/**612/** Environment selection and scan progress share a permanent header control. */
596 * Per-device progress while the page waits for every environment to answer.613function UsageEnvironmentFilter({
597 * Only rendered with two or more devices; a lone device has nothing to
598 * enumerate.
599 */
600function UsageDeviceStrip({
601 environments,614 environments,
615 selectedEnvironments,
616 selectedEnvironmentIds,
617 onSelectionChange,
618 showUsageStatus,
619 isPartial,
620 duplicateSources,
621 staleEnvironments,
602}: {622}: {
603 readonly environments: readonly EnvironmentUsageStatus[];623 readonly environments: readonly EnvironmentUsageStatus[];
624 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
625 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
626 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;
627 readonly showUsageStatus: boolean;
628 readonly isPartial: boolean;
629 readonly duplicateSources: readonly string[];
630 readonly staleEnvironments: readonly string[];
604}) {631}) {
605 const scanning = environments.filter(632 const allSelected = selectedEnvironmentIds === null;
606 (environment) => environment.summary === null && environment.error === null,633 const label = allSelected
607 );634 ? "All environments"
635 : selectedEnvironments.length === 1
636 ? selectedEnvironments[0]!.label
637 : `${selectedEnvironments.length} environments`;
638 const pendingCount = selectedEnvironments.filter(
639 (environment) =>
640 environment.error === null && (environment.isPending || environment.summary === null),
641 ).length;
642 const hasIssue =
643 selectedEnvironments.some((environment) => environment.error !== null) ||
644 staleEnvironments.length > 0;
645
608 return (646 return (
609 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border border-border px-3 py-2 text-xs">647 <>
610 {environments.map((environment) => {648 <Menu>
611 if (environment.summary !== null) {649 <MenuTrigger className="group/usage-environment inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
612 return (650 <span className="min-w-0 truncate">{label}</span>
613 <span651 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
614 key={environment.environmentId}652 {showUsageStatus && pendingCount > 0 ? (
615 className="flex items-center gap-1 text-foreground"653 <>
616 >654 <CircleDashedIcon className="size-3.5" aria-hidden />
617 <CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden />655 <span className="sr-only">
618 {environment.label}656 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still
619 </span>657 scanning
620 );658 {isPartial ? "; totals are partial" : ""}
621 }659 </span>
622 if (environment.error !== null) {660 </>
623 return (661 ) : showUsageStatus && hasIssue ? (
624 <span662 <CircleAlertIcon
625 key={environment.environmentId}663 className="size-3.5 text-amber-600 dark:text-amber-400"
626 className="flex items-center gap-1 text-destructive"664 aria-label="Some environments could not report usage"
627 >665 />
628 <XIcon className="size-3" aria-hidden />666 ) : (
629 {environment.label}667 <ChevronDownIcon
630 </span>668 className="size-3.5 opacity-0 transition-opacity group-hover/usage-environment:opacity-100 group-focus-visible/usage-environment:opacity-100 group-data-popup-open/usage-environment:opacity-100"
631 );669 aria-hidden
632 }670 />
633 return (671 )}
634 <span
635 key={environment.environmentId}
636 className="animate-status-pulse text-muted-foreground"
637 >
638 {environment.label}…
639 </span>672 </span>
640 );673 </MenuTrigger>
641 })}674 <MenuPopup align="start" className="w-80 max-w-[calc(100vw-2rem)]">
642 <span className="ms-auto text-muted-foreground">675 <MenuCheckboxItem
643 {scanning.length === 1676 checked={allSelected}
644 ? "1 device still scanning"677 closeOnClick={false}
645 : `${scanning.length} devices still scanning`}678 onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())}
646 </span>679 >
647 </div>680 All environments
681 </MenuCheckboxItem>
682 <MenuSeparator />
683 {environments.map((environment) => {
684 const checked =
685 selectedEnvironmentIds === null ||
686 selectedEnvironmentIds.has(environment.environmentId);
687 const status =
688 environment.error !== null
689 ? "Unavailable"
690 : environment.summary !== null &&
691 !isCompatibleUsageContractVersion(
692 environment.summary.contractVersion,
693 USAGE_CONTRACT_VERSION,
694 )
695 ? "Update required"
696 : environment.summary === null
697 ? "Scanning…"
698 : environment.isPending
699 ? "Refreshing…"
700 : "Ready";
701 return (
702 <MenuCheckboxItem
703 key={environment.environmentId}
704 checked={checked}
705 closeOnClick={false}
706 className="grid-cols-[1rem_minmax(0,1fr)]"
707 onCheckedChange={(nextChecked) => {
708 const next = new Set(selectedEnvironments.map((entry) => entry.environmentId));
709 if (nextChecked) next.add(environment.environmentId);
710 else next.delete(environment.environmentId);
711 onSelectionChange(next.size === environments.length ? null : next);
712 }}
713 >
714 <span className="flex min-w-0 items-center gap-3">
715 <span className="min-w-0 flex-1 truncate">{environment.label}</span>
716 {showUsageStatus ? (
717 <span
718 className={cn(
719 "shrink-0 text-xs text-muted-foreground",
720 environment.error !== null && "text-destructive",
721 )}
722 >
723 {status}
724 </span>
725 ) : null}
726 </span>
727 </MenuCheckboxItem>
728 );
729 })}
730 {environments.length === 0 ? (
731 <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>
732 ) : null}
733 {showUsageStatus && isPartial ? (
734 <p className="px-2 py-2 text-xs text-muted-foreground">
735 Totals are partial while selected environments scan.
736 </p>
737 ) : null}
738 {showUsageStatus ? (
739 <UsageCoverageNotice
740 environments={selectedEnvironments}
741 duplicateSources={duplicateSources}
742 staleEnvironments={staleEnvironments}
743 />
744 ) : null}
745 </MenuPopup>
746 </Menu>
747 </>
648 );748 );
649}749}
650750
651/**751/**
652 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it752 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
653 * breathes with the same `animate-skeleton` pulse as every other loading state.753 * breathes with the same `animate-skeleton` pulse as every other loading state.
654 * Blocks fill in exactly once when the last device answers.754 * Replaced by results as soon as the first environment answers.
655 */755 */
656function UsageSkeleton() {756function UsageSkeleton() {
657 return (757 return (
apps/web/src/state/usage.test.tsx created+166
...@@ -0,0 +1,166 @@
1import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
2import { act, useLayoutEffect } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { useUsage, type EnvironmentUsageStatus, type UsageView } from "./usage";
7
8const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] }));
9vi.mock("@effect/atom-react", async (importOriginal) => ({
10 ...(await importOriginal<typeof import("@effect/atom-react")>()),
11 useAtomValue: () => testState.environments,
12}));
13
14const input = {
15 sinceDay: UsageDay.make("2026-09-04"),
16 untilDay: UsageDay.make("2026-09-04"),
17 timeZone: "UTC",
18};
19
20function environment(id: string, cost: number | null, hostId = id): EnvironmentUsageStatus {
21 return {
22 environmentId: EnvironmentId.make(id),
23 label: id,
24 isPending: cost === null,
25 error: null,
26 summary:
27 cost === null
28 ? null
29 : {
30 ...input,
31 contractVersion: USAGE_CONTRACT_VERSION,
32 readAt: "2026-09-04T12:00:00Z",
33 buckets: [
34 {
35 day: input.sinceDay,
36 provider: "codex",
37 model: id,
38 totals: {
39 uncachedInputTokens: 100,
40 cachedInputTokens: 0,
41 cacheCreationTokens: 0,
42 outputTokens: 50,
43 reasoningTokens: 0,
44 },
45 costUsd: cost,
46 cacheSavingsUsd: 0,
47 costSource: "modelPriced",
48 records: 1,
49 unpricedRecords: 0,
50 sessions: 1,
51 },
52 ],
53 sources: [
54 {
55 fingerprint: {
56 hostId,
57 provider: "codex",
58 resolvedHomePath: "/sessions",
59 volumeId: hostId,
60 },
61 status: "ok",
62 scannedFiles: 1,
63 skippedFiles: 0,
64 malformedRecords: 0,
65 distinctSessions: 1,
66 message: null,
67 },
68 ],
69 pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
70 scanDurationMs: 1,
71 },
72 };
73}
74
75let renderer: ReactTestRenderer | undefined;
76let latest: UsageView;
77
78function Probe({ selected }: { selected: ReadonlySet<EnvironmentId> | null }) {
79 const usage = useUsage(input, selected);
80 useLayoutEffect(() => {
81 latest = usage;
82 }, [usage]);
83 return null;
84}
85
86async function select(...ids: string[]) {
87 await act(() => {
88 renderer?.update(<Probe selected={new Set(ids.map((id) => EnvironmentId.make(id)))} />);
89 });
90}
91
92beforeEach(async () => {
93 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
94 testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)];
95 await act(() => {
96 renderer = create(<Probe selected={null} />);
97 });
98});
99
100afterEach(async () => {
101 await act(() => renderer?.unmount());
102 vi.unstubAllGlobals();
103});
104
105describe("usage environment selection", () => {
106 it("starts with all environments and adds results as they arrive", async () => {
107 expect(latest.merged.costUsd).toBe(30);
108 expect(latest.isPending).toBe(false);
109 expect(latest.isPartial).toBe(true);
110
111 testState.environments = [...testState.environments.slice(0, 2), environment("slow", 40)];
112 await act(() => renderer?.update(<Probe selected={null} />));
113 expect(latest.merged.costUsd).toBe(70);
114 expect(latest.isPartial).toBe(false);
115 });
116
117 it("excludes unselected usage and pending environments, then restores all", async () => {
118 await select("b");
119 expect(latest.merged.costUsd).toBe(20);
120 expect(latest.merged.models.map((model) => model.model)).toEqual(["b"]);
121 expect(latest.isPending).toBe(false);
122 expect(latest.isPartial).toBe(false);
123 expect(latest.environments).toHaveLength(3);
124
125 await act(() => renderer?.update(<Probe selected={null} />));
126 expect(latest.merged.costUsd).toBe(30);
127 expect(latest.isPartial).toBe(true);
128 });
129
130 it("distinguishes a pending selection from an empty or failed selection", async () => {
131 await select("slow");
132 expect(latest.isPending).toBe(true);
133 expect(latest.merged.costUsd).toBe(0);
134
135 await select();
136 expect(latest.selectedEnvironments).toHaveLength(0);
137 expect(latest.isPending).toBe(false);
138 expect(latest.isPartial).toBe(false);
139
140 testState.environments = [{ ...environment("slow", null), isPending: false, error: "Offline" }];
141 await select("slow");
142 expect(latest.isPending).toBe(false);
143 expect(latest.isPartial).toBe(false);
144 });
145
146 it("deduplicates within the selection so an excluded owner cannot hide usage", async () => {
147 testState.environments = [environment("a", 10, "shared"), environment("b", 20, "shared")];
148 await act(() => renderer?.update(<Probe selected={null} />));
149 expect(latest.merged.costUsd).toBe(10);
150
151 await select("b");
152 expect(latest.merged.costUsd).toBe(20);
153 expect(latest.merged.duplicateSources).toEqual([]);
154 });
155
156 it("keeps selected cached results visible during a refresh", async () => {
157 testState.environments = [
158 { ...environment("a", 10), isPending: true },
159 environment("slow", null),
160 ];
161 await select("a");
162 expect(latest.merged.costUsd).toBe(10);
163 expect(latest.isPending).toBe(false);
164 expect(latest.isPartial).toBe(false);
165 });
166});
apps/web/src/state/usage.ts+24-8
...@@ -61,7 +61,8 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -61,7 +61,8 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
61export interface UsageView {61export interface UsageView {
62 readonly merged: MergedUsage;62 readonly merged: MergedUsage;
63 readonly environments: readonly EnvironmentUsageStatus[];63 readonly environments: readonly EnvironmentUsageStatus[];
64 /** True until at least one environment has answered. */64 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
65 /** True until at least one selected environment has answered. */
65 readonly isPending: boolean;66 readonly isPending: boolean;
66 /**67 /**
67 * True while environments that have not failed are still answering. Failed68 * True while environments that have not failed are still answering. Failed
...@@ -72,7 +73,10 @@ export interface UsageView {...@@ -72,7 +73,10 @@ export interface UsageView {
72 readonly refresh: () => void;73 readonly refresh: () => void;
73}74}
7475
75export function useUsage(input: UsageSummaryInput): UsageView {76export function useUsage(
77 input: UsageSummaryInput,
78 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
79): UsageView {
76 const windowKey = useMemo(80 const windowKey = useMemo(
77 () =>81 () =>
78 JSON.stringify({82 JSON.stringify({
...@@ -94,6 +98,15 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -94,6 +98,15 @@ export function useUsage(input: UsageSummaryInput): UsageView {
94 );98 );
95 const atom = usageByWindowAtom(windowKey);99 const atom = usageByWindowAtom(windowKey);
96 const environments = useAtomValue(atom);100 const environments = useAtomValue(atom);
101 const selectedEnvironments = useMemo(
102 () =>
103 selectedEnvironmentIds === null
104 ? environments
105 : environments.filter((environment) =>
106 selectedEnvironmentIds.has(environment.environmentId),
107 ),
108 [environments, selectedEnvironmentIds],
109 );
97110
98 // Refreshing only the derived atom would re-read the per-environment SWR111 // Refreshing only the derived atom would re-read the per-environment SWR
99 // queries within their stale window and change nothing. Refresh each112 // queries within their stale window and change nothing. Refresh each
...@@ -104,7 +117,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -104,7 +117,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
104 // not the refetch succeeds: an offline environment still recounts tokens.117 // not the refetch succeeds: an offline environment still recounts tokens.
105 const refresh = useCallback(() => {118 const refresh = useCallback(() => {
106 const input = JSON.parse(windowKey) as UsageSummaryInput;119 const input = JSON.parse(windowKey) as UsageSummaryInput;
107 for (const environment of environments) {120 for (const environment of selectedEnvironments) {
108 const { environmentId } = environment;121 const { environmentId } = environment;
109 const query = serverEnvironment.usageSummary({ environmentId, input });122 const query = serverEnvironment.usageSummary({ environmentId, input });
110 void runAtomCommand(123 void runAtomCommand(
...@@ -114,10 +127,10 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -114,10 +127,10 @@ export function useUsage(input: UsageSummaryInput): UsageView {
114 { reportFailure: false },127 { reportFailure: false },
115 ).finally(() => appAtomRegistry.refresh(query));128 ).finally(() => appAtomRegistry.refresh(query));
116 }129 }
117 }, [environments, windowKey]);130 }, [selectedEnvironments, windowKey]);
118131
119 const merged = useMemo(() => {132 const merged = useMemo(() => {
120 const answered: EnvironmentUsage[] = environments.flatMap((environment) =>133 const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
121 environment.summary === null134 environment.summary === null
122 ? []135 ? []
123 : [136 : [
...@@ -129,16 +142,19 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -129,16 +142,19 @@ export function useUsage(input: UsageSummaryInput): UsageView {
129 ],142 ],
130 );143 );
131 return mergeUsage(answered, USAGE_CONTRACT_VERSION);144 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
132 }, [environments]);145 }, [selectedEnvironments]);
133146
134 const answeredCount = environments.filter((environment) => environment.summary !== null).length;147 const answeredCount = selectedEnvironments.filter(
135 const stillReporting = environments.filter(148 (environment) => environment.summary !== null,
149 ).length;
150 const stillReporting = selectedEnvironments.filter(
136 (environment) => environment.summary === null && environment.error === null,151 (environment) => environment.summary === null && environment.error === null,
137 ).length;152 ).length;
138153
139 return {154 return {
140 merged,155 merged,
141 environments,156 environments,
157 selectedEnvironments,
142 isPending: answeredCount === 0 && stillReporting > 0,158 isPending: answeredCount === 0 && stillReporting > 0,
143 isPartial: answeredCount > 0 && stillReporting > 0,159 isPartial: answeredCount > 0 && stillReporting > 0,
144 refresh,160 refresh,
docs/user/usage.md+4
...@@ -9,6 +9,10 @@ cost. These estimates are not your subscription bill....@@ -9,6 +9,10 @@ cost. These estimates are not your subscription bill.
9Totals depend on the history available on each server. Grok turns without a saved completed-turn9Totals depend on the history available on each server. Grok turns without a saved completed-turn
10record are missing from the totals.10record are missing from the totals.
1111
12On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
13environments are selected by default. The dropdown shows which environments are still scanning;
14results appear as each one responds.
15
12If recent work is missing or a new model shows no cost, refresh to rescan session history and16If recent work is missing or a new model shows no cost, refresh to rescan session history and
13update model pricing.17update model pricing.
1418
packages/shared/src/usageMerge.ts+4-2
...@@ -173,7 +173,7 @@ function bucketTokens(bucket: UsageBucket): number {...@@ -173,7 +173,7 @@ function bucketTokens(bucket: UsageBucket): number {
173 );173 );
174}174}
175175
176function isCompatibleContractVersion(version: number, expected: number): boolean {176export function isCompatibleUsageContractVersion(version: number, expected: number): boolean {
177 return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected;177 return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected;
178}178}
179179
...@@ -220,7 +220,9 @@ export function mergeUsage(...@@ -220,7 +220,9 @@ export function mergeUsage(
220 const current: EnvironmentUsage[] = [];220 const current: EnvironmentUsage[] = [];
221 const staleEnvironments: EnvironmentId[] = [];221 const staleEnvironments: EnvironmentId[] = [];
222 for (const environment of environments) {222 for (const environment of environments) {
223 if (isCompatibleContractVersion(environment.summary.contractVersion, expectedContractVersion)) {223 if (
224 isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion)
225 ) {
224 current.push(environment);226 current.push(environment);
225 } else {227 } else {
226 staleEnvironments.push(environment.environmentId);228 staleEnvironments.push(environment.environmentId);