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({...@@ -340,17 +340,47 @@ export function ResetCredits({
340 */340 */
341export function UsageLimitsSection({341export function UsageLimitsSection({
342 selectedEnvironmentIds,342 selectedEnvironmentIds,
343 hiddenProviders,
343 now,344 now,
344 cursorPrompt,345 cursorPrompt,
345}: {346}: {
346 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;347 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
348 readonly hiddenProviders: ReadonlySet<UsageProviderKind>;
347 readonly now: number;349 readonly now: number;
348 readonly cursorPrompt?: ReactNode;350 readonly cursorPrompt?: ReactNode;
349}) {351}) {
350 const presentations = useAtomValue(environmentPresentations.presentationsAtom);352 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
351 const selected =353 const hiddenDrivers = new Set<ServerProvider["driver"]>(
352 selectedEnvironmentIds === null354 [...hiddenProviders].map((provider) => PROVIDER_PRESENTATION[provider].driverKind),
353 ? presentations355 );
354 : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));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 );
355 return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;385 return <UsageLimitsPooled presentations={selected} now={now} cursorPrompt={cursorPrompt} />;
356}386}
apps/web/src/components/usage/UsagePage.test.tsx+1
...@@ -39,6 +39,7 @@ vi.mock("./usageProviders", async (importOriginal) => {...@@ -39,6 +39,7 @@ vi.mock("./usageProviders", async (importOriginal) => {
39 const actual = await importOriginal<typeof import("./usageProviders")>();39 const actual = await importOriginal<typeof import("./usageProviders")>();
40 return {40 return {
41 ...actual,41 ...actual,
42 PROVIDER_ORDER: ["codex", "claude"],
42 PROVIDER_PRESENTATION: {43 PROVIDER_PRESENTATION: {
43 codex: { color: "white", label: "Codex", mark: "span" },44 codex: { color: "white", label: "Codex", mark: "span" },
44 claude: { color: "orange", label: "Claude Code", mark: "span" },45 claude: { color: "orange", label: "Claude Code", mark: "span" },
apps/web/src/components/usage/UsagePage.tsx+87-9
...@@ -139,14 +139,21 @@ export function UsagePage() {...@@ -139,14 +139,21 @@ export function UsagePage() {
139 const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);139 const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null);
140 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =140 const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
141 useState<ReadonlySet<EnvironmentId> | null>(null);141 useState<ReadonlySet<EnvironmentId> | null>(null);
142 const hiddenProviders = useMemo(
143 () => new Set(preferences.hiddenProviders),
144 [preferences.hiddenProviders],
145 );
142 const { days: windowDays, window } = windowSelection;146 const { days: windowDays, window } = windowSelection;
143 const isPast24Hours = windowDays === 1;147 const isPast24Hours = windowDays === 1;
144 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(148 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
145 window,149 window,
146 selectedEnvironmentIds,150 selectedEnvironmentIds,
151 hiddenProviders,
147 );152 );
148 const presentations = useAtomValue(environmentPresentations.presentationsAtom);153 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
149 const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);154 const cursorAccessEnvironments = hiddenProviders.has("cursor")
155 ? []
156 : cursorKeychainAccessEnvironments(selectedEnvironments);
150 const sourceMessages = [157 const sourceMessages = [
151 ...new Set(158 ...new Set(
152 selectedEnvironments.flatMap(159 selectedEnvironments.flatMap(
...@@ -154,6 +161,7 @@ export function UsagePage() {...@@ -154,6 +161,7 @@ export function UsagePage() {
154 environment.summary?.sources.flatMap((source) =>161 environment.summary?.sources.flatMap((source) =>
155 source.message &&162 source.message &&
156 !source.action &&163 !source.action &&
164 !hiddenProviders.has(source.fingerprint.provider) &&
157 (source.status === "partial" ||165 (source.status === "partial" ||
158 source.status === "failed" ||166 source.status === "failed" ||
159 source.fingerprint.provider === "cursor")167 source.fingerprint.provider === "cursor")
...@@ -217,11 +225,14 @@ export function UsagePage() {...@@ -217,11 +225,14 @@ export function UsagePage() {
217 );225 );
218 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;226 const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`;
219227
220 const selectWindow = (days: number) => {228 const updatePreferences = (patch: Partial<UsagePagePreferences>) => {
221 if (!isUsageWindowDays(days)) return;229 const nextPreferences = { ...preferences, ...patch };
222 const nextPreferences = { metric, windowDays: days };
223 setPreferences(nextPreferences);230 setPreferences(nextPreferences);
224 saveUsagePagePreferences(nextPreferences);231 saveUsagePagePreferences(nextPreferences);
232 };
233 const selectWindow = (days: number) => {
234 if (!isUsageWindowDays(days)) return;
235 updatePreferences({ windowDays: days });
225 setWindowSelection({236 setWindowSelection({
226 days,237 days,
227 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),238 window: makeWindow(days, undefined, days === 1 ? "hour" : "day"),
...@@ -229,9 +240,7 @@ export function UsagePage() {...@@ -229,9 +240,7 @@ export function UsagePage() {
229 };240 };
230 const selectMetric = (nextMetric: UsageMetric) => {241 const selectMetric = (nextMetric: UsageMetric) => {
231 if (nextMetric === "limits") setLimitsNow(Date.now());242 if (nextMetric === "limits") setLimitsNow(Date.now());
232 const nextPreferences = { metric: nextMetric, windowDays };243 updatePreferences({ metric: nextMetric });
233 setPreferences(nextPreferences);
234 saveUsagePagePreferences(nextPreferences);
235 };244 };
236 const refreshLimits = async (automatic = false, afterPending = false) => {245 const refreshLimits = async (automatic = false, afterPending = false) => {
237 try {246 try {
...@@ -334,7 +343,7 @@ export function UsagePage() {...@@ -334,7 +343,7 @@ export function UsagePage() {
334 <h1>Usage</h1>343 <h1>Usage</h1>
335 </WorkspaceBreadcrumbItem>344 </WorkspaceBreadcrumbItem>
336 <WorkspaceBreadcrumbSeparator />345 <WorkspaceBreadcrumbSeparator />
337 <WorkspaceBreadcrumbItem current className="min-w-10">346 <WorkspaceBreadcrumbItem className="min-w-10 shrink">
338 <UsageEnvironmentFilter347 <UsageEnvironmentFilter
339 environments={environments}348 environments={environments}
340 selectedEnvironments={selectedEnvironments}349 selectedEnvironments={selectedEnvironments}
...@@ -347,6 +356,13 @@ export function UsagePage() {...@@ -347,6 +356,13 @@ export function UsagePage() {
347 onOpenModelPrices={() => setPriceDialog({})}356 onOpenModelPrices={() => setPriceDialog({})}
348 />357 />
349 </WorkspaceBreadcrumbItem>358 </WorkspaceBreadcrumbItem>
359 <WorkspaceBreadcrumbSeparator />
360 <WorkspaceBreadcrumbItem current className="min-w-10">
361 <UsageProviderFilter
362 hiddenProviders={hiddenProviders}
363 onChange={(next) => updatePreferences({ hiddenProviders: next })}
364 />
365 </WorkspaceBreadcrumbItem>
350 </WorkspaceBreadcrumb>366 </WorkspaceBreadcrumb>
351 {!showingLimits ? (367 {!showingLimits ? (
352 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">368 <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
...@@ -480,6 +496,7 @@ export function UsagePage() {...@@ -480,6 +496,7 @@ export function UsagePage() {
480 ) : showingLimits ? (496 ) : showingLimits ? (
481 <UsageLimitsSection497 <UsageLimitsSection
482 selectedEnvironmentIds={selectedEnvironmentIds}498 selectedEnvironmentIds={selectedEnvironmentIds}
499 hiddenProviders={hiddenProviders}
483 now={limitsNow}500 now={limitsNow}
484 cursorPrompt={501 cursorPrompt={
485 cursorAccessEnvironments.length > 0 ? (502 cursorAccessEnvironments.length > 0 ? (
...@@ -548,7 +565,8 @@ export function UsagePage() {...@@ -548,7 +565,8 @@ export function UsagePage() {
548 </span>565 </span>
549 </div>566 </div>
550567
551 {[...presentations].some(568 {!hiddenProviders.has("codex") &&
569 [...presentations].some(
552 ([id, presentation]) =>570 ([id, presentation]) =>
553 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) &&571 (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) &&
554 presentation.serverConfig?.providers.some(usesChatGptSharing),572 presentation.serverConfig?.providers.some(usesChatGptSharing),
...@@ -1232,6 +1250,66 @@ function UsageEnvironmentFilter({...@@ -1232,6 +1250,66 @@ function UsageEnvironmentFilter({
1232 );1250 );
1233}1251}
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
1235/**1313/**
1236 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it1314 * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
1237 * breathes with the same `animate-skeleton` pulse as every other loading state.1315 * 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", () => {...@@ -35,6 +35,14 @@ describe("Usage page preferences", () => {
35 }35 }
36 });36 });
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
38 it.each([46 it.each([
39 "not-json",47 "not-json",
40 '{"metric":"unknown","windowDays":7}',48 '{"metric":"unknown","windowDays":7}',
apps/web/src/components/usage/usagePagePreferences.ts+3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { UsageProviderKind } from "@t3tools/contracts";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
23
3import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage";4import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage";
...@@ -6,6 +7,8 @@ const STORAGE_KEY = "t3code:usage-page-preferences:v1";...@@ -6,6 +7,8 @@ const STORAGE_KEY = "t3code:usage-page-preferences:v1";
6const UsagePagePreferencesSchema = Schema.Struct({7const UsagePagePreferencesSchema = Schema.Struct({
7 metric: Schema.Literals(["cost", "tokens", "limits"]),8 metric: Schema.Literals(["cost", "tokens", "limits"]),
8 windowDays: Schema.Literals([1, 7, 30, 90]),9 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)),
9});12});
10export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;13export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type;
1114
apps/web/src/state/usage.test.tsx+36-6
...@@ -1,4 +1,9 @@...@@ -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";
2import { act, useLayoutEffect } from "react";7import { act, useLayoutEffect } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";8import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
...@@ -17,7 +22,12 @@ const input = {...@@ -17,7 +22,12 @@ const input = {
17 timeZone: "UTC",22 timeZone: "UTC",
18};23};
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 {
21 return {31 return {
22 environmentId: EnvironmentId.make(id),32 environmentId: EnvironmentId.make(id),
23 label: id,33 label: id,
...@@ -35,7 +45,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU...@@ -35,7 +45,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
35 buckets: [45 buckets: [
36 {46 {
37 day: input.sinceDay,47 day: input.sinceDay,
38 provider: "codex",48 provider,
39 model: id,49 model: id,
40 totals: {50 totals: {
41 uncachedInputTokens: 100,51 uncachedInputTokens: 100,
...@@ -56,7 +66,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU...@@ -56,7 +66,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
56 {66 {
57 fingerprint: {67 fingerprint: {
58 hostId,68 hostId,
59 provider: "codex",69 provider,
60 resolvedHomePath: "/sessions",70 resolvedHomePath: "/sessions",
61 volumeId: hostId,71 volumeId: hostId,
62 },72 },
...@@ -77,8 +87,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU...@@ -77,8 +87,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
77let renderer: ReactTestRenderer | undefined;87let renderer: ReactTestRenderer | undefined;
78let latest: UsageView;88let latest: UsageView;
7989
80function Probe({ selected }: { selected: ReadonlySet<EnvironmentId> | null }) {90function Probe({
81 const usage = useUsage(input, selected);91 selected,
92 hidden,
93}: {
94 selected: ReadonlySet<EnvironmentId> | null;
95 hidden?: ReadonlySet<UsageProviderKind>;
96}) {
97 const usage = useUsage(input, selected, hidden);
82 useLayoutEffect(() => {98 useLayoutEffect(() => {
83 latest = usage;99 latest = usage;
84 }, [usage]);100 }, [usage]);
...@@ -166,3 +182,17 @@ describe("usage environment selection", () => {...@@ -166,3 +182,17 @@ describe("usage environment selection", () => {
166 expect(latest.isPartial).toBe(false);182 expect(latest.isPartial).toBe(false);
167 });183 });
168});184});
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 {...@@ -13,6 +13,7 @@ import {
13 type EnvironmentId,13 type EnvironmentId,
14 type UsageBucket,14 type UsageBucket,
15 type UsageSummary,15 type UsageSummary,
16 type UsageProviderKind,
16 type UsageSummaryInput,17 type UsageSummaryInput,
17} from "@t3tools/contracts";18} from "@t3tools/contracts";
18import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";19import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
...@@ -128,9 +129,39 @@ export function mergeAnsweredUsage(...@@ -128,9 +129,39 @@ export function mergeAnsweredUsage(
128 return mergeUsage(answered, USAGE_CONTRACT_VERSION);129 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
129}130}
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
131export function useUsage(161export function useUsage(
132 input: UsageSummaryInput,162 input: UsageSummaryInput,
133 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,163 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
164 hiddenProviders: ReadonlySet<UsageProviderKind> = NO_HIDDEN_PROVIDERS,
134): UsageView {165): UsageView {
135 const windowKey = useMemo(166 const windowKey = useMemo(
136 () =>167 () =>
...@@ -183,7 +214,10 @@ export function useUsage(...@@ -183,7 +214,10 @@ export function useUsage(
183 [selectedEnvironments, windowKey],214 [selectedEnvironments, windowKey],
184 );215 );
185216
186 const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]);217 const merged = useMemo(
218 () => mergeAnsweredUsage(withoutProviders(selectedEnvironments, hiddenProviders)),
219 [selectedEnvironments, hiddenProviders],
220 );
187221
188 const answeredCount = selectedEnvironments.filter(222 const answeredCount = selectedEnvironments.filter(
189 (environment) => environment.summary !== null,223 (environment) => environment.summary !== null,