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
426426 * Countdowns anchor to render time rather than ticking: a live clock would
427427 * repaint the page every minute for no decision-changing gain.
428428 */
429export function UsageLimitsSection() {
429export function UsageLimitsSection({
430 selectedEnvironmentIds,
431}: {
432 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
433}) {
430434 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
431 const groups = collectLimitsGroups(presentations);
432 const sources = collectLimitSources(presentations);
435 const selected =
436 selectedEnvironmentIds === null
437 ? presentations
438 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
439 const groups = collectLimitsGroups(selected);
440 const sources = collectLimitSources(selected);
433441 // Anchored once per mount on purpose: countdowns must not tick (see below).
434442 const [now] = useState(() => Date.now());
435443
......@@ -437,7 +445,7 @@ export function UsageLimitsSection() {
437445 <div className="flex flex-col gap-8">
438446 {groups.length === 0 && sources.length === 0 ? (
439447 <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.
441449 </p>
442450 ) : null}
443451 {sources.map((source) => (
apps/web/src/components/usage/UsagePage.test.tsx+23-2
......@@ -1,4 +1,4 @@
1import { USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
1import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
22import { mergeUsage } from "@t3tools/shared/usageMerge";
33import { renderToStaticMarkup } from "react-dom/server";
44import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
......@@ -104,6 +104,26 @@ const modelTotals = Object.freeze([
104104 },
105105]);
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
107127beforeEach(() => {
108128 testState.metric = "cost";
109129 testState.breakdown = "time";
......@@ -128,7 +148,8 @@ beforeEach(() => {
128148 },
129149 ],
130150 },
131 environments: [],
151 environments,
152 selectedEnvironments: environments,
132153 isPending: false,
133154 isPartial: false,
134155 refresh: vi.fn(),
apps/web/src/components/usage/UsagePage.tsx+189-89
......@@ -1,9 +1,17 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { UsageProviderKind } from "@t3tools/contracts";
3import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
2import {
3 USAGE_CONTRACT_VERSION,
4 type EnvironmentId,
5 type UsageProviderKind,
6} from "@t3tools/contracts";
7import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, RefreshCwIcon } from "lucide-react";
48import { 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
816import { isElectron } from "../../env";
917import { cn } from "../../lib/utils";
......@@ -24,6 +32,7 @@ import {
2432 makeWindow,
2533} from "@t3tools/shared/usageFormat";
2634import { Button } from "../ui/button";
35import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
2736import { ScrollArea } from "../ui/scroll-area";
2837import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2938import { SidebarInset } from "../ui/sidebar";
......@@ -67,19 +76,19 @@ export function UsagePage() {
6776 const [metric, setMetric] = useState<UsageMetric>("cost");
6877 const showingLimits = metric === "limits";
6978 const [breakdown, setBreakdown] = useState<"model" | "time">("model");
79 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
80 useState<ReadonlySet<EnvironmentId> | null>(null);
7081 const { days: windowDays, window } = windowSelection;
7182 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 );
7387 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
7488 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
7589 reportFailure: false,
7690 });
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
8392 const days = useMemo(
8493 () => enumerateDays(window.sinceDay, window.untilDay),
8594 [window.sinceDay, window.untilDay],
......@@ -118,6 +127,7 @@ export function UsagePage() {
118127 const refreshWindow = () => {
119128 if (showingLimits) {
120129 for (const [environmentId, presentation] of presentations) {
130 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue;
121131 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
122132 void refreshProviders({ environmentId, input: {} });
123133 }
......@@ -141,21 +151,31 @@ export function UsagePage() {
141151 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
142152 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
143153 const topbarContent = (
144 <div className="flex w-full min-w-0 items-center gap-3">
145 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
146 <WorkspaceBreadcrumbItem current>
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">
155 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="col-span-2 min-w-0">
156 <WorkspaceBreadcrumbItem>
147157 <h1>Usage</h1>
148158 </WorkspaceBreadcrumbItem>
149 {showingLimits ? null : (
150 <>
151 <WorkspaceBreadcrumbSeparator className="hidden md:flex" />
152 <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">
153 <span className="truncate">{windowLabel}</span>
154 </WorkspaceBreadcrumbItem>
155 </>
156 )}
159 <WorkspaceBreadcrumbSeparator />
160 <WorkspaceBreadcrumbItem current className="min-w-10">
161 <UsageEnvironmentFilter
162 environments={environments}
163 selectedEnvironments={selectedEnvironments}
164 selectedEnvironmentIds={selectedEnvironmentIds}
165 onSelectionChange={setSelectedEnvironmentIds}
166 showUsageStatus={!showingLimits}
167 isPartial={isPartial}
168 duplicateSources={merged.duplicateSources}
169 staleEnvironments={merged.staleEnvironments}
170 />
171 </WorkspaceBreadcrumbItem>
157172 </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">
159179 <ToggleGroup
160180 aria-label="Usage metric"
161181 variant="segmented"
......@@ -198,7 +218,7 @@ export function UsagePage() {
198218 <RefreshCwIcon className="size-3.5" />
199219 </Button>
200220 </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">
202222 <Select
203223 value={metric}
204224 onValueChange={(value) => {
......@@ -261,7 +281,9 @@ export function UsagePage() {
261281 return (
262282 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
263283 <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
266288 <ScrollArea className="min-h-0 flex-1">
267289 <WorkspacePageContainer width="wide">
......@@ -270,21 +292,18 @@ export function UsagePage() {
270292 <UsagePriceOverrides usage={environments} />
271293 </div>
272294 ) : null}
273 {showingLimits ? (
274 <UsageLimitsSection />
275 ) : settling ? (
276 <>
277 {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
278 <UsageSkeleton />
279 </>
295 {selectedEnvironments.length === 0 ? (
296 <p className="text-sm text-muted-foreground">
297 {environments.length === 0
298 ? `Connect an environment to see ${showingLimits ? "limits" : "usage"}.`
299 : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
300 </p>
301 ) : showingLimits ? (
302 <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} />
303 ) : isPending ? (
304 <UsageSkeleton />
280305 ) : (
281306 <>
282 <UsageCoverageNotice
283 environments={environments}
284 duplicateSources={merged.duplicateSources}
285 staleEnvironments={merged.staleEnvironments}
286 />
287
288307 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
289308 <div className="flex min-w-0 flex-col gap-5">
290309 <div className="flex flex-col gap-1">
......@@ -550,10 +569,8 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri
550569}
551570
552571/**
553 * Says plainly when the totals are incomplete: an environment that failed, or
554 * one whose transcripts another environment already reported. Environments
555 * that are still answering never reach this notice; the page shows the
556 * loading skeleton until every one is terminal.
572 * Explains failed or incompatible environments and deduplicated transcripts.
573 * Shown inside the environment filter so arriving results do not move the page.
557574 */
558575function UsageCoverageNotice({
559576 environments,
......@@ -573,7 +590,7 @@ function UsageCoverageNotice({
573590 }
574591
575592 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">
577594 {failed.map((environment) => (
578595 <span key={environment.label}>{environment.label} could not report usage.</span>
579596 ))}
......@@ -592,66 +609,149 @@ function UsageCoverageNotice({
592609 );
593610}
594611
595/**
596 * Per-device progress while the page waits for every environment to answer.
597 * Only rendered with two or more devices; a lone device has nothing to
598 * enumerate.
599 */
600function UsageDeviceStrip({
612/** Environment selection and scan progress share a permanent header control. */
613function UsageEnvironmentFilter({
601614 environments,
615 selectedEnvironments,
616 selectedEnvironmentIds,
617 onSelectionChange,
618 showUsageStatus,
619 isPartial,
620 duplicateSources,
621 staleEnvironments,
602622}: {
603623 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[];
604631}) {
605 const scanning = environments.filter(
606 (environment) => environment.summary === null && environment.error === null,
607 );
632 const allSelected = selectedEnvironmentIds === null;
633 const label = allSelected
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
608646 return (
609 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border border-border px-3 py-2 text-xs">
610 {environments.map((environment) => {
611 if (environment.summary !== null) {
612 return (
613 <span
614 key={environment.environmentId}
615 className="flex items-center gap-1 text-foreground"
616 >
617 <CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden />
618 {environment.label}
619 </span>
620 );
621 }
622 if (environment.error !== null) {
623 return (
624 <span
625 key={environment.environmentId}
626 className="flex items-center gap-1 text-destructive"
627 >
628 <XIcon className="size-3" aria-hidden />
629 {environment.label}
630 </span>
631 );
632 }
633 return (
634 <span
635 key={environment.environmentId}
636 className="animate-status-pulse text-muted-foreground"
637 >
638 {environment.label}…
647 <>
648 <Menu>
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">
650 <span className="min-w-0 truncate">{label}</span>
651 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
652 {showUsageStatus && pendingCount > 0 ? (
653 <>
654 <CircleDashedIcon className="size-3.5" aria-hidden />
655 <span className="sr-only">
656 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still
657 scanning
658 {isPartial ? "; totals are partial" : ""}
659 </span>
660 </>
661 ) : showUsageStatus && hasIssue ? (
662 <CircleAlertIcon
663 className="size-3.5 text-amber-600 dark:text-amber-400"
664 aria-label="Some environments could not report usage"
665 />
666 ) : (
667 <ChevronDownIcon
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"
669 aria-hidden
670 />
671 )}
639672 </span>
640 );
641 })}
642 <span className="ms-auto text-muted-foreground">
643 {scanning.length === 1
644 ? "1 device still scanning"
645 : `${scanning.length} devices still scanning`}
646 </span>
647 </div>
673 </MenuTrigger>
674 <MenuPopup align="start" className="w-80 max-w-[calc(100vw-2rem)]">
675 <MenuCheckboxItem
676 checked={allSelected}
677 closeOnClick={false}
678 onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())}
679 >
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 </>
648748 );
649749}
650750
651751/**
652752 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
653753 * 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.
655755 */
656756function UsageSkeleton() {
657757 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) =>
6161export interface UsageView {
6262 readonly merged: MergedUsage;
6363 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. */
6566 readonly isPending: boolean;
6667 /**
6768 * True while environments that have not failed are still answering. Failed
......@@ -72,7 +73,10 @@ export interface UsageView {
7273 readonly refresh: () => void;
7374}
7475
75export function useUsage(input: UsageSummaryInput): UsageView {
76export function useUsage(
77 input: UsageSummaryInput,
78 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
79): UsageView {
7680 const windowKey = useMemo(
7781 () =>
7882 JSON.stringify({
......@@ -94,6 +98,15 @@ export function useUsage(input: UsageSummaryInput): UsageView {
9498 );
9599 const atom = usageByWindowAtom(windowKey);
96100 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
98111 // Refreshing only the derived atom would re-read the per-environment SWR
99112 // queries within their stale window and change nothing. Refresh each
......@@ -104,7 +117,7 @@ export function useUsage(input: UsageSummaryInput): UsageView {
104117 // not the refetch succeeds: an offline environment still recounts tokens.
105118 const refresh = useCallback(() => {
106119 const input = JSON.parse(windowKey) as UsageSummaryInput;
107 for (const environment of environments) {
120 for (const environment of selectedEnvironments) {
108121 const { environmentId } = environment;
109122 const query = serverEnvironment.usageSummary({ environmentId, input });
110123 void runAtomCommand(
......@@ -114,10 +127,10 @@ export function useUsage(input: UsageSummaryInput): UsageView {
114127 { reportFailure: false },
115128 ).finally(() => appAtomRegistry.refresh(query));
116129 }
117 }, [environments, windowKey]);
130 }, [selectedEnvironments, windowKey]);
118131
119132 const merged = useMemo(() => {
120 const answered: EnvironmentUsage[] = environments.flatMap((environment) =>
133 const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
121134 environment.summary === null
122135 ? []
123136 : [
......@@ -129,16 +142,19 @@ export function useUsage(input: UsageSummaryInput): UsageView {
129142 ],
130143 );
131144 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
132 }, [environments]);
145 }, [selectedEnvironments]);
133146
134 const answeredCount = environments.filter((environment) => environment.summary !== null).length;
135 const stillReporting = environments.filter(
147 const answeredCount = selectedEnvironments.filter(
148 (environment) => environment.summary !== null,
149 ).length;
150 const stillReporting = selectedEnvironments.filter(
136151 (environment) => environment.summary === null && environment.error === null,
137152 ).length;
138153
139154 return {
140155 merged,
141156 environments,
157 selectedEnvironments,
142158 isPending: answeredCount === 0 && stillReporting > 0,
143159 isPartial: answeredCount > 0 && stillReporting > 0,
144160 refresh,
docs/user/usage.md+4
......@@ -9,6 +9,10 @@ cost. These estimates are not your subscription bill.
99Totals depend on the history available on each server. Grok turns without a saved completed-turn
1010record 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
1216If recent work is missing or a new model shows no cost, refresh to rescan session history and
1317update model pricing.
1418
packages/shared/src/usageMerge.ts+4-2
......@@ -173,7 +173,7 @@ function bucketTokens(bucket: UsageBucket): number {
173173 );
174174}
175175
176function isCompatibleContractVersion(version: number, expected: number): boolean {
176export function isCompatibleUsageContractVersion(version: number, expected: number): boolean {
177177 return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected;
178178}
179179
......@@ -220,7 +220,9 @@ export function mergeUsage(
220220 const current: EnvironmentUsage[] = [];
221221 const staleEnvironments: EnvironmentId[] = [];
222222 for (const environment of environments) {
223 if (isCompatibleContractVersion(environment.summary.contractVersion, expectedContractVersion)) {
223 if (
224 isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion)
225 ) {
224226 current.push(environment);
225227 } else {
226228 staleEnvironments.push(environment.environmentId);