| author | |
| committer | |
| log | 13c5328dd02ab0f5863f7d46455efc8dc243d4ce |
| tree | 53f6e2ce46358b8d934535813c843abc649e7f91 |
| parent | 10e29a26c289140b8f2312045c1ce0c278c7fe1a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | */ |
| 341 | export function UsageLimitsSection({ | 341 | export 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 === null | 354 | [...hiddenProviders].map((provider) => PROVIDER_PRESENTATION[provider].driverKind), |
| 353 | ? presentations | 355 | ); |
| 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)}%`; |
| 219 | 227 | ||
| 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 | <UsageEnvironmentFilter | 347 | <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 | <UsageLimitsSection | 497 | <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> |
| 550 | 567 | ||
| 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 | } |
| 1234 | 1252 | ||
| 1253 | /** Provider visibility shared by every tab. Stored as the hidden set. */ | ||
| 1254 | function 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 it | 1314 | * 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 | }); |
| 37 | 37 | ||
| 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 @@ |
| 1 | import { UsageProviderKind } from "@t3tools/contracts"; | ||
| 1 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | ||
| 3 | import { getLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage"; | 4 | import { 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"; |
| 6 | const UsagePagePreferencesSchema = Schema.Struct({ | 7 | const 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 | }); |
| 10 | export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; | 13 | export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; |
| 11 | 14 |
apps/web/src/state/usage.test.tsx+36-6| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentId, | ||
| 3 | UsageDay, | ||
| 4 | USAGE_CONTRACT_VERSION, | ||
| 5 | type UsageProviderKind, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { act, useLayoutEffect } from "react"; | 7 | import { act, useLayoutEffect } from "react"; |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 8 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 9 | import { 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 | }; |
| 19 | 24 | ||
| 20 | function environment(id: string, cost: number | null, hostId = id): EnvironmentUsageStatus { | 25 | function 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 |
| 77 | let renderer: ReactTestRenderer | undefined; | 87 | let renderer: ReactTestRenderer | undefined; |
| 78 | let latest: UsageView; | 88 | let latest: UsageView; |
| 79 | 89 | ||
| 80 | function Probe({ selected }: { selected: ReadonlySet<EnvironmentId> | null }) { | 90 | function 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 | |||
| 186 | describe("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"; |
| 18 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; | 19 | import { 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 | } |
| 130 | 131 | ||
| 132 | const 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 | */ | ||
| 138 | function 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 | |||
| 131 | export function useUsage( | 161 | export 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 | ); |
| 185 | 216 | ||
| 186 | const merged = useMemo(() => mergeAnsweredUsage(selectedEnvironments), [selectedEnvironments]); | 217 | const merged = useMemo( |
| 218 | () => mergeAnsweredUsage(withoutProviders(selectedEnvironments, hiddenProviders)), | ||
| 219 | [selectedEnvironments, hiddenProviders], | ||
| 220 | ); | ||
| 187 | 221 | ||
| 188 | const answeredCount = selectedEnvironments.filter( | 222 | const answeredCount = selectedEnvironments.filter( |
| 189 | (environment) => environment.summary !== null, | 223 | (environment) => environment.summary !== null, |