authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-08 02:24:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 02:24:35-07:00
log13c5328dd02ab0f5863f7d46455efc8dc243d4ce
tree53f6e2ce46358b8d934535813c843abc649e7f91
parent10e29a26c289140b8f2312045c1ce0c278c7fe1a
signature Signed by PGP key B5690EEEBB952194

feat(web): filter the Usage page to just the providers you want (#16970)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

7 files changed, 204 insertions(+), 20 deletions(-)

apps/web/src/components/usage/UsageLimits.tsx+34-4
......@@ -340,17 +340,47 @@ export function ResetCredits({
340340 */
341341export function UsageLimitsSection({
342342 selectedEnvironmentIds,
343 hiddenProviders,
343344 now,
344345 cursorPrompt,
345346}: {
346347 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
348 readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
347349 readonly now: number;
348350 readonly cursorPrompt?: ReactNode;
349351}) {
350352 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
351 const selected =
352 selectedEnvironmentIds === null
353 ? presentations
354 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
353 const hiddenDrivers = new Set<ServerProvider["driver"]>(
354 [...hiddenProviders].map((provider) => PROVIDER_PRESENTATION[provider].driverKind),
355 );
356 const isVisible = (driver: ServerProvider["driver"]) => !hiddenDrivers.has(driver);
357 // Hidden drivers leave both native accounts and hub accounts, so their
358 // bars, notices, and external links all drop out together.
359 const selected = new Map(
360 [...presentations].flatMap(([id, presentation]) => {
361 if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(id)) return [];
362 const config = presentation.serverConfig;
363 if (hiddenDrivers.size === 0 || config === null) return [[id, presentation] as const];
364 return [
365 [
366 id,
367 {
368 ...presentation,
369 serverConfig: {
370 ...config,
371 providers: config.providers.filter((provider) => isVisible(provider.driver)),
372 // A hub left with no visible accounts is dropped, not reported as empty.
373 usageLimitSources: config.usageLimitSources?.flatMap((source) => {
374 const accounts = source.accounts.filter((account) => isVisible(account.driver));
375 return accounts.length === 0 && source.accounts.length > 0
376 ? []
377 : [{ ...source, accounts }];
378 }),
379 },
380 },
381 ] as const,
382 ];
383 }),
384 );
355385 return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;
356386}
apps/web/src/components/usage/UsagePage.test.tsx+1
......@@ -39,6 +39,7 @@ vi.mock("./usageProviders", async (importOriginal) => {
3939 const actual = await importOriginal<typeof import("./usageProviders")>();
4040 return {
4141 ...actual,
42 PROVIDER_ORDER: ["codex", "claude"],
4243 PROVIDER_PRESENTATION: {
4344 codex: { color: "white", label: "Codex", mark: "span" },
4445 claude: { color: "orange", label: "Claude Code", mark: "span" },
apps/web/src/components/usage/UsagePage.tsx+87-9
......@@ -139,14 +139,21 @@ export function UsagePage() {
139139 const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
140140 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
141141 useState<ReadonlySet<EnvironmentId> | null>(null);
142 const hiddenProviders = useMemo(
143 () => new Set(preferences.hiddenProviders),
144 [preferences.hiddenProviders],
145 );
142146 const { days: windowDays, window } = windowSelection;
143147 const isPast24Hours = windowDays === 1;
144148 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
145149 window,
146150 selectedEnvironmentIds,
151 hiddenProviders,
147152 );
148153 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
149 const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);
154 const cursorAccessEnvironments = hiddenProviders.has("cursor")
155 ? []
156 : cursorKeychainAccessEnvironments(selectedEnvironments);
150157 const sourceMessages = [
151158 ...new Set(
152159 selectedEnvironments.flatMap(
......@@ -154,6 +161,7 @@ export function UsagePage() {
154161 environment.summary?.sources.flatMap((source) =>
155162 source.message &&
156163 !source.action &&
164 !hiddenProviders.has(source.fingerprint.provider) &&
157165 (source.status === "partial" ||
158166 source.status === "failed" ||
159167 source.fingerprint.provider === "cursor")
......@@ -217,11 +225,14 @@ export function UsagePage() {
217225 );
218226 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
219227
220 const selectWindow = (days: number) => {
221 if (!isUsageWindowDays(days)) return;
222 const nextPreferences = { metric, windowDays: days };
228 const updatePreferences = (patch: Partial<UsagePagePreferences>) => {
229 const nextPreferences = { ...preferences, ...patch };
223230 setPreferences(nextPreferences);
224231 saveUsagePagePreferences(nextPreferences);
232 };
233 const selectWindow = (days: number) => {
234 if (!isUsageWindowDays(days)) return;
235 updatePreferences({ windowDays: days });
225236 setWindowSelection({
226237 days,
227238 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
......@@ -229,9 +240,7 @@ export function UsagePage() {
229240 };
230241 const selectMetric = (nextMetric: UsageMetric) => {
231242 if (nextMetric === "limits") setLimitsNow(Date.now());
232 const nextPreferences = { metric: nextMetric, windowDays };
233 setPreferences(nextPreferences);
234 saveUsagePagePreferences(nextPreferences);
243 updatePreferences({ metric: nextMetric });
235244 };
236245 const refreshLimits = async (automatic = false, afterPending = false) => {
237246 try {
......@@ -334,7 +343,7 @@ export function UsagePage() {
334343 <h1>Usage</h1>
335344 </WorkspaceBreadcrumbItem>
336345 <WorkspaceBreadcrumbSeparator />
337 <WorkspaceBreadcrumbItem current className="min-w-10">
346 <WorkspaceBreadcrumbItem className="min-w-10 shrink">
338347 <UsageEnvironmentFilter
339348 environments={environments}
340349 selectedEnvironments={selectedEnvironments}
......@@ -347,6 +356,13 @@ export function UsagePage() {
347356 onOpenModelPrices={() => setPriceDialog({})}
348357 />
349358 </WorkspaceBreadcrumbItem>
359 <WorkspaceBreadcrumbSeparator />
360 <WorkspaceBreadcrumbItem current className="min-w-10">
361 <UsageProviderFilter
362 hiddenProviders={hiddenProviders}
363 onChange={(next) => updatePreferences({ hiddenProviders: next })}
364 />
365 </WorkspaceBreadcrumbItem>
350366 </WorkspaceBreadcrumb>
351367 {!showingLimits ? (
352368 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
......@@ -480,6 +496,7 @@ export function UsagePage() {
480496 ) : showingLimits ? (
481497 <UsageLimitsSection
482498 selectedEnvironmentIds={selectedEnvironmentIds}
499 hiddenProviders={hiddenProviders}
483500 now={limitsNow}
484501 cursorPrompt={
485502 cursorAccessEnvironments.length > 0 ? (
......@@ -548,7 +565,8 @@ export function UsagePage() {
548565 </span>
549566 </div>
550567
551 {[...presentations].some(
568 {!hiddenProviders.has("codex") &&
569 [...presentations].some(
552570 ([id, presentation]) =>
553571 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) &&
554572 presentation.serverConfig?.providers.some(usesChatGptSharing),
......@@ -1232,6 +1250,66 @@ function UsageEnvironmentFilter({
12321250 );
12331251}
12341252
1253/** Provider visibility shared by every tab. Stored as the hidden set. */
1254function UsageProviderFilter({
1255 hiddenProviders,
1256 onChange,
1257}: {
1258 readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
1259 readonly onChange: (hiddenProviders: readonly UsageProviderKind[]) => void;
1260}) {
1261 const visible = PROVIDER_ORDER.filter((provider) => !hiddenProviders.has(provider));
1262 const label =
1263 visible.length === PROVIDER_ORDER.length
1264 ? "All providers"
1265 : visible.length === 0
1266 ? "No providers"
1267 : visible.length === 1
1268 ? PROVIDER_PRESENTATION[visible[0]!].label
1269 : `${visible.length} providers`;
1270
1271 return (
1272 <Menu>
1273 <MenuTrigger render={<InlineButton />} className="group/usage-provider min-w-0 max-w-full">
1274 <span className="min-w-0 truncate">{label}</span>
1275 <ChevronDownIcon
1276 className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/usage-provider:opacity-100 group-focus-visible/usage-provider:opacity-100 group-data-popup-open/usage-provider:opacity-100"
1277 aria-hidden
1278 />
1279 </MenuTrigger>
1280 <MenuPopup align="start">
1281 <MenuCheckboxItem
1282 checked={hiddenProviders.size === 0}
1283 closeOnClick={false}
1284 onCheckedChange={(checked) => onChange(checked ? [] : PROVIDER_ORDER)}
1285 >
1286 All providers
1287 </MenuCheckboxItem>
1288 <MenuSeparator />
1289 {PROVIDER_ORDER.map((provider) => (
1290 <MenuCheckboxItem
1291 key={provider}
1292 checked={!hiddenProviders.has(provider)}
1293 closeOnClick={false}
1294 onCheckedChange={(checked) =>
1295 onChange(
1296 PROVIDER_ORDER.filter((entry) =>
1297 entry === provider ? !checked : hiddenProviders.has(entry),
1298 ),
1299 )
1300 }
1301 >
1302 <span className="flex min-w-0 items-center gap-2">
1303 <ProviderMark provider={provider} className="size-3.5" />
1304 <span className="truncate">{PROVIDER_PRESENTATION[provider].label}</span>
1305 </span>
1306 </MenuCheckboxItem>
1307 ))}
1308 </MenuPopup>
1309 </Menu>
1310 );
1311}
1312
12351313/**
12361314 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
12371315 * breathes with the same `animate-skeleton` pulse as every other loading state.
apps/web/src/components/usage/usagePagePreferences.test.ts+8
......@@ -35,6 +35,14 @@ describe("Usage page preferences", () => {
3535 }
3636 });
3737
38 it("round-trips hidden providers and reads older saves without them", () => {
39 saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] });
40 expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]);
41
42 values.set(key, '{"metric":"tokens","windowDays":30}');
43 expect(readUsagePagePreferences()).toEqual({ metric: "tokens", windowDays: 30 });
44 });
45
3846 it.each([
3947 "not-json",
4048 '{"metric":"unknown","windowDays":7}',
apps/web/src/components/usage/usagePagePreferences.ts+3
......@@ -1,3 +1,4 @@
1import { UsageProviderKind } from "@t3tools/contracts";
12import * as Schema from "effect/Schema";
23
34import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage";
......@@ -6,6 +7,8 @@ const STORAGE_KEY = "t3code:usage-page-preferences:v1";
67const UsagePagePreferencesSchema = Schema.Struct({
78 metric: Schema.Literals(["cost", "tokens", "limits"]),
89 windowDays: Schema.Literals([1, 7, 30, 90]),
10 /** Providers filtered out of the page. Stored as hidden so new providers show by default. */
11 hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)),
912});
1013export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;
1114
apps/web/src/state/usage.test.tsx+36-6
......@@ -1,4 +1,9 @@
1import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 UsageDay,
4 USAGE_CONTRACT_VERSION,
5 type UsageProviderKind,
6} from "@t3tools/contracts";
27import { act, useLayoutEffect } from "react";
38import { create, type ReactTestRenderer } from "react-test-renderer";
49import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
......@@ -17,7 +22,12 @@ const input = {
1722 timeZone: "UTC",
1823};
1924
20function environment(id: string, cost: number | null, hostId = id): EnvironmentUsageStatus {
25function environment(
26 id: string,
27 cost: number | null,
28 hostId = id,
29 provider: UsageProviderKind = "codex",
30): EnvironmentUsageStatus {
2131 return {
2232 environmentId: EnvironmentId.make(id),
2333 label: id,
......@@ -35,7 +45,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
3545 buckets: [
3646 {
3747 day: input.sinceDay,
38 provider: "codex",
48 provider,
3949 model: id,
4050 totals: {
4151 uncachedInputTokens: 100,
......@@ -56,7 +66,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
5666 {
5767 fingerprint: {
5868 hostId,
59 provider: "codex",
69 provider,
6070 resolvedHomePath: "/sessions",
6171 volumeId: hostId,
6272 },
......@@ -77,8 +87,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
7787let renderer: ReactTestRenderer | undefined;
7888let latest: UsageView;
7989
80function Probe({ selected }: { selected: ReadonlySet<EnvironmentId> | null }) {
81 const usage = useUsage(input, selected);
90function Probe({
91 selected,
92 hidden,
93}: {
94 selected: ReadonlySet<EnvironmentId> | null;
95 hidden?: ReadonlySet<UsageProviderKind>;
96}) {
97 const usage = useUsage(input, selected, hidden);
8298 useLayoutEffect(() => {
8399 latest = usage;
84100 }, [usage]);
......@@ -166,3 +182,17 @@ describe("usage environment selection", () => {
166182 expect(latest.isPartial).toBe(false);
167183 });
168184});
185
186describe("usage provider filter", () => {
187 it("drops hidden providers from totals and sessions, then restores them", async () => {
188 testState.environments = [environment("a", 10), environment("c", 5, "c", "claude")];
189 await act(() => renderer?.update(<Probe selected={null} hidden={new Set(["codex"])} />));
190 expect(latest.merged.costUsd).toBe(5);
191 expect(latest.merged.sessions).toBe(1);
192 expect(latest.merged.providers.map((entry) => entry.provider)).toEqual(["claude"]);
193
194 await act(() => renderer?.update(<Probe selected={null} />));
195 expect(latest.merged.costUsd).toBe(15);
196 expect(latest.merged.sessions).toBe(2);
197 });
198});
apps/web/src/state/usage.ts+35-1
......@@ -13,6 +13,7 @@ import {
1313 type EnvironmentId,
1414 type UsageBucket,
1515 type UsageSummary,
16 type UsageProviderKind,
1617 type UsageSummaryInput,
1718} from "@t3tools/contracts";
1819import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
......@@ -128,9 +129,39 @@ export function mergeAnsweredUsage(
128129 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
129130}
130131
132const NO_HIDDEN_PROVIDERS: ReadonlySet<UsageProviderKind> = new Set();
133
134/**
135 * Drops hidden providers' buckets and sources before merging, so totals,
136 * shares, and session counts all describe only the visible providers.
137 */
138function withoutProviders(
139 environments: readonly EnvironmentUsageStatus[],
140 hiddenProviders: ReadonlySet<UsageProviderKind>,
141): readonly EnvironmentUsageStatus[] {
142 if (hiddenProviders.size === 0) return environments;
143 return environments.map((environment) =>
144 environment.summary === null
145 ? environment
146 : {
147 ...environment,
148 summary: {
149 ...environment.summary,
150 buckets: environment.summary.buckets.filter(
151 (bucket) => !hiddenProviders.has(bucket.provider),
152 ),
153 sources: environment.summary.sources.filter(
154 (source) => !hiddenProviders.has(source.fingerprint.provider),
155 ),
156 },
157 },
158 );
159}
160
131161export function useUsage(
132162 input: UsageSummaryInput,
133163 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
164 hiddenProviders: ReadonlySet<UsageProviderKind> = NO_HIDDEN_PROVIDERS,
134165): UsageView {
135166 const windowKey = useMemo(
136167 () =>
......@@ -183,7 +214,10 @@ export function useUsage(
183214 [selectedEnvironments, windowKey],
184215 );
185216
186 const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]);
217 const merged = useMemo(
218 () => mergeAnsweredUsage(withoutProviders(selectedEnvironments, hiddenProviders)),
219 [selectedEnvironments, hiddenProviders],
220 );
187221
188222 const answeredCount = selectedEnvironments.filter(
189223 (environment) => environment.summary !== null,