# Files changed · PR \#9860 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/9860/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): render and filter usage as environments respond](https://git.cafe/pingdotgg/t3code/pull/9860.md)

Version: 5

Head commit: ed79de694090076e4b038081658b0b5d9e709411

Base commit: d7fe47fd0957a34fc2e8bb05f12db19bac3403b6

Comparison base: d7fe47fd0957a34fc2e8bb05f12db19bac3403b6

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/src/components/usage/UsageLimits.tsx

Status: modified; +12; −4

```diff
@@ -426,10 +426,18 @@
  * Countdowns anchor to render time rather than ticking: a live clock would
  * repaint the page every minute for no decision-changing gain.
  */
-export function UsageLimitsSection() {
+export function UsageLimitsSection({
+  selectedEnvironmentIds,
+}: {
+  readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
+}) {
   const presentations = useAtomValue(environmentPresentations.presentationsAtom);
-  const groups = collectLimitsGroups(presentations);
-  const sources = collectLimitSources(presentations);
+  const selected =
+    selectedEnvironmentIds === null
+      ? presentations
+      : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
+  const groups = collectLimitsGroups(selected);
+  const sources = collectLimitSources(selected);
   // Anchored once per mount on purpose: countdowns must not tick (see below).
   const [now] = useState(() => Date.now());
 
@@ -437,7 +445,7 @@
     <div className="flex flex-col gap-8">
       {groups.length === 0 && sources.length === 0 ? (
         <p className="text-sm text-muted-foreground">
-          No provider on a connected environment reports subscription limits.
+          No provider on the selected environments reports subscription limits.
         </p>
       ) : null}
       {sources.map((source) => (
```

## apps/web/src/components/usage/UsagePage.test.tsx

Status: modified; +23; −2

```diff
@@ -1,4 +1,4 @@
-import { USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
+import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
 import { mergeUsage } from "@t3tools/shared/usageMerge";
 import { renderToStaticMarkup } from "react-dom/server";
 import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
@@ -104,6 +104,26 @@
   },
 ]);
 
+const environments = [
+  {
+    environmentId: EnvironmentId.make("test-environment"),
+    label: "Test environment",
+    isPending: false,
+    error: null,
+    summary: {
+      contractVersion: USAGE_CONTRACT_VERSION,
+      readAt: "2026-08-11T12:37:00.000Z",
+      sinceDay: UsageDay.make("2026-08-10"),
+      untilDay: UsageDay.make("2026-08-11"),
+      timeZone: "UTC",
+      buckets: [],
+      sources: [],
+      pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
+      scanDurationMs: 1,
+    },
+  },
+];
+
 beforeEach(() => {
   testState.metric = "cost";
   testState.breakdown = "time";
@@ -128,7 +148,8 @@
         },
       ],
     },
+    environments,
+    selectedEnvironments: environments,
-    environments: [],
     isPending: false,
     isPartial: false,
     refresh: vi.fn(),
```

## apps/web/src/components/usage/UsagePage.tsx

Status: modified; +189; −89

```diff
@@ -1,9 +1,17 @@
 import { useAtomValue } from "@effect/atom-react";
+import {
+  USAGE_CONTRACT_VERSION,
+  type EnvironmentId,
+  type UsageProviderKind,
+} from "@t3tools/contracts";
-import type { UsageProviderKind } from "@t3tools/contracts";
-import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
+import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, RefreshCwIcon } from "lucide-react";
 import { useMemo, useState } from "react";
 
-import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
+import {
+  isCompatibleUsageContractVersion,
+  type DailyTotals,
+  type HourlyTotals,
+} from "@t3tools/shared/usageMerge";
 
 import { isElectron } from "../../env";
 import { cn } from "../../lib/utils";
@@ -24,6 +32,7 @@
   makeWindow,
 } from "@t3tools/shared/usageFormat";
 import { Button } from "../ui/button";
+import { Menu, MenuCheckboxItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
 import { ScrollArea } from "../ui/scroll-area";
 import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
 import { SidebarInset } from "../ui/sidebar";
@@ -67,19 +76,19 @@
   const [metric, setMetric] = useState<UsageMetric>("cost");
   const showingLimits = metric === "limits";
   const [breakdown, setBreakdown] = useState<"model" | "time">("model");
+  const [selectedEnvironmentIds, setSelectedEnvironmentIds] =
+    useState<ReadonlySet<EnvironmentId> | null>(null);
   const { days: windowDays, window } = windowSelection;
   const isPast24Hours = windowDays === 1;
+  const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
+    window,
+    selectedEnvironmentIds,
+  );
-  const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
   const presentations = useAtomValue(environmentPresentations.presentationsAtom);
   const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
     reportFailure: false,
   });
 
-  // Hold the content until every environment is terminal. Rendering merged
-  // totals while devices are still answering makes every number on the page
-  // jump as each one lands.
-  const settling = isPending || isPartial;
-
   const days = useMemo(
     () => enumerateDays(window.sinceDay, window.untilDay),
     [window.sinceDay, window.untilDay],
@@ -118,6 +127,7 @@
   const refreshWindow = () => {
     if (showingLimits) {
       for (const [environmentId, presentation] of presentations) {
+        if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue;
         if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
           void refreshProviders({ environmentId, input: {} });
         }
@@ -141,21 +151,31 @@
       ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`
       : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;
   const topbarContent = (
-    <div className="flex w-full min-w-0 items-center gap-3">
-      <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
-        <WorkspaceBreadcrumbItem current>
+    <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">
+      <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="col-span-2 min-w-0">
+        <WorkspaceBreadcrumbItem>
           <h1>Usage</h1>
         </WorkspaceBreadcrumbItem>
-        {showingLimits ? null : (
+        <WorkspaceBreadcrumbSeparator />
+        <WorkspaceBreadcrumbItem current className="min-w-10">
+          <UsageEnvironmentFilter
-          <>
+            environments={environments}
-            <WorkspaceBreadcrumbSeparator className="hidden md:flex" />
-            <WorkspaceBreadcrumbItem className="hidden min-w-0 shrink md:flex">
-              <span className="truncate">{windowLabel}</span>
-            </WorkspaceBreadcrumbItem>
+            selectedEnvironments={selectedEnvironments}
+            selectedEnvironmentIds={selectedEnvironmentIds}
+            onSelectionChange={setSelectedEnvironmentIds}
+            showUsageStatus={!showingLimits}
+            isPartial={isPartial}
+            duplicateSources={merged.duplicateSources}
+            staleEnvironments={merged.staleEnvironments}
+          />
+        </WorkspaceBreadcrumbItem>
-          </>
-        )}
       </WorkspaceBreadcrumb>
+      {!showingLimits ? (
+        <span className="hidden min-w-0 truncate text-xs text-muted-foreground 2xl:block">
+          {windowLabel}
-      <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 lg:flex">
+        </span>
+      ) : null}
+      <div className="ms-auto hidden min-w-0 items-center justify-end gap-2 xl:flex">
         <ToggleGroup
           aria-label="Usage metric"
           variant="segmented"
@@ -198,7 +218,7 @@
           <RefreshCwIcon className="size-3.5" />
         </Button>
       </div>
-      <div className="ms-auto flex min-w-0 items-center justify-end gap-1 lg:hidden">
+      <div className="col-span-2 ms-auto flex min-w-0 items-center justify-end gap-1 xl:hidden">
         <Select
           value={metric}
           onValueChange={(value) => {
@@ -261,7 +281,9 @@
   return (
     <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
       <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
-        <WorkspacePageHeader electron={isElectron}>{topbarContent}</WorkspacePageHeader>
+        <WorkspacePageHeader electron={isElectron} className="h-auto">
+          {topbarContent}
+        </WorkspacePageHeader>
 
         <ScrollArea className="min-h-0 flex-1">
           <WorkspacePageContainer width="wide">
@@ -270,21 +292,18 @@
                 <UsagePriceOverrides usage={environments} />
               </div>
             ) : null}
+            {selectedEnvironments.length === 0 ? (
+              <p className="text-sm text-muted-foreground">
+                {environments.length === 0
+                  ? `Connect an environment to see ${showingLimits ? "limits" : "usage"}.`
-            {showingLimits ? (
-              <UsageLimitsSection />
-            ) : settling ? (
-              <>
-                {environments.length > 1 ? <UsageDeviceStrip environments={environments} /> : null}
-                <UsageSkeleton />
-              </>
+                  : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
+              </p>
+            ) : showingLimits ? (
+              <UsageLimitsSection selectedEnvironmentIds={selectedEnvironmentIds} />
+            ) : isPending ? (
+              <UsageSkeleton />
             ) : (
               <>
-                <UsageCoverageNotice
-                  environments={environments}
-                  duplicateSources={merged.duplicateSources}
-                  staleEnvironments={merged.staleEnvironments}
-                />
-
                 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
                   <div className="flex min-w-0 flex-col gap-5">
                     <div className="flex flex-col gap-1">
@@ -550,10 +569,8 @@
 }
 
 /**
+ * Explains failed or incompatible environments and deduplicated transcripts.
+ * Shown inside the environment filter so arriving results do not move the page.
- * Says plainly when the totals are incomplete: an environment that failed, or
- * one whose transcripts another environment already reported. Environments
- * that are still answering never reach this notice; the page shows the
- * loading skeleton until every one is terminal.
  */
 function UsageCoverageNotice({
   environments,
@@ -573,7 +590,7 @@
   }
 
   return (
-    <div className="flex flex-col gap-1 border border-border px-3 py-2 text-xs text-muted-foreground">
+    <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground">
       {failed.map((environment) => (
         <span key={environment.label}>{environment.label} could not report usage.</span>
       ))}
@@ -592,66 +609,149 @@
   );
 }
 
-/**
- * Per-device progress while the page waits for every environment to answer.
- * Only rendered with two or more devices; a lone device has nothing to
- * enumerate.
+/** Environment selection and scan progress share a permanent header control. */
+function UsageEnvironmentFilter({
- */
-function UsageDeviceStrip({
   environments,
+  selectedEnvironments,
+  selectedEnvironmentIds,
+  onSelectionChange,
+  showUsageStatus,
+  isPartial,
+  duplicateSources,
+  staleEnvironments,
 }: {
   readonly environments: readonly EnvironmentUsageStatus[];
+  readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
+  readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
+  readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;
+  readonly showUsageStatus: boolean;
+  readonly isPartial: boolean;
+  readonly duplicateSources: readonly string[];
+  readonly staleEnvironments: readonly string[];
 }) {
-  const scanning = environments.filter(
+  const allSelected = selectedEnvironmentIds === null;
+  const label = allSelected
+    ? "All environments"
+    : selectedEnvironments.length === 1
+      ? selectedEnvironments[0]!.label
+      : `${selectedEnvironments.length} environments`;
+  const pendingCount = selectedEnvironments.filter(
+    (environment) =>
+      environment.error === null && (environment.isPending || environment.summary === null),
-    (environment) => environment.summary === null && environment.error === null,
-  );
+  ).length;
+  const hasIssue =
+    selectedEnvironments.some((environment) => environment.error !== null) ||
+    staleEnvironments.length > 0;
+
   return (
-    <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border border-border px-3 py-2 text-xs">
-      {environments.map((environment) => {
-        if (environment.summary !== null) {
-          return (
-            <span
-              key={environment.environmentId}
-              className="flex items-center gap-1 text-foreground"
-            >
-              <CheckIcon className="size-3 text-emerald-600 dark:text-emerald-300/90" aria-hidden />
-              {environment.label}
+    <>
+      <Menu>
+        <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">
+          <span className="min-w-0 truncate">{label}</span>
+          <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
-            </span>
+            {showUsageStatus && pendingCount > 0 ? (
+              <>
+                <CircleDashedIcon className="size-3.5" aria-hidden />
-          );
-        }
-        if (environment.error !== null) {
-          return (
-            <span
-              key={environment.environmentId}
-              className="flex items-center gap-1 text-destructive"
-            >
-              <XIcon className="size-3" aria-hidden />
-              {environment.label}
-            </span>
-          );
-        }
-        return (
-          <span
-            key={environment.environmentId}
-            className="animate-status-pulse text-muted-foreground"
+                <span className="sr-only">
+                  {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still
+                  scanning
+                  {isPartial ? "; totals are partial" : ""}
+                </span>
+              </>
+            ) : showUsageStatus && hasIssue ? (
+              <CircleAlertIcon
+                className="size-3.5 text-amber-600 dark:text-amber-400"
+                aria-label="Some environments could not report usage"
+              />
+            ) : (
+              <ChevronDownIcon
+                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"
+                aria-hidden
+              />
+            )}
+          </span>
+        </MenuTrigger>
+        <MenuPopup align="start" className="w-80 max-w-[calc(100vw-2rem)]">
+          <MenuCheckboxItem
+            checked={allSelected}
+            closeOnClick={false}
+            onCheckedChange={(checked) => onSelectionChange(checked ? null : new Set())}
           >
+            All environments
+          </MenuCheckboxItem>
+          <MenuSeparator />
+          {environments.map((environment) => {
+            const checked =
+              selectedEnvironmentIds === null ||
+              selectedEnvironmentIds.has(environment.environmentId);
+            const status =
+              environment.error !== null
+                ? "Unavailable"
+                : environment.summary !== null &&
+                    !isCompatibleUsageContractVersion(
+                      environment.summary.contractVersion,
+                      USAGE_CONTRACT_VERSION,
+                    )
+                  ? "Update required"
+                  : environment.summary === null
+                    ? "Scanning…"
+                    : environment.isPending
+                      ? "Refreshing…"
+                      : "Ready";
+            return (
+              <MenuCheckboxItem
+                key={environment.environmentId}
+                checked={checked}
+                closeOnClick={false}
+                className="grid-cols-[1rem_minmax(0,1fr)]"
+                onCheckedChange={(nextChecked) => {
+                  const next = new Set(selectedEnvironments.map((entry) => entry.environmentId));
+                  if (nextChecked) next.add(environment.environmentId);
+                  else next.delete(environment.environmentId);
+                  onSelectionChange(next.size === environments.length ? null : next);
+                }}
+              >
+                <span className="flex min-w-0 items-center gap-3">
+                  <span className="min-w-0 flex-1 truncate">{environment.label}</span>
+                  {showUsageStatus ? (
+                    <span
+                      className={cn(
+                        "shrink-0 text-xs text-muted-foreground",
+                        environment.error !== null && "text-destructive",
+                      )}
+                    >
+                      {status}
+                    </span>
+                  ) : null}
+                </span>
+              </MenuCheckboxItem>
+            );
+          })}
+          {environments.length === 0 ? (
-            {environment.label}…
-          </span>
+            <p className="px-2 py-2 text-xs text-muted-foreground">No environments connected.</p>
+          ) : null}
-        );
-      })}
-      <span className="ms-auto text-muted-foreground">
-        {scanning.length === 1
-          ? "1 device still scanning"
-          : `${scanning.length} devices still scanning`}
-      </span>
-    </div>
+          {showUsageStatus && isPartial ? (
+            <p className="px-2 py-2 text-xs text-muted-foreground">
+              Totals are partial while selected environments scan.
+            </p>
+          ) : null}
+          {showUsageStatus ? (
+            <UsageCoverageNotice
+              environments={selectedEnvironments}
+              duplicateSources={duplicateSources}
+              staleEnvironments={staleEnvironments}
+            />
+          ) : null}
+        </MenuPopup>
+      </Menu>
+    </>
   );
 }
 
 /**
  * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it
  * breathes with the same `animate-skeleton` pulse as every other loading state.
- * Blocks fill in exactly once when the last device answers.
+ * Replaced by results as soon as the first environment answers.
  */
 function UsageSkeleton() {
   return (
```

## apps/web/src/state/usage.test.tsx

Status: added; +166; −0

```diff
@@ -1,0 +1,166 @@
+import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
+import { act, useLayoutEffect } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { useUsage, type EnvironmentUsageStatus, type UsageView } from "./usage";
+
+const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] }));
+vi.mock("@effect/atom-react", async (importOriginal) => ({
+  ...(await importOriginal<typeof import("@effect/atom-react")>()),
+  useAtomValue: () => testState.environments,
+}));
+
+const input = {
+  sinceDay: UsageDay.make("2026-09-04"),
+  untilDay: UsageDay.make("2026-09-04"),
+  timeZone: "UTC",
+};
+
+function environment(id: string, cost: number | null, hostId = id): EnvironmentUsageStatus {
+  return {
+    environmentId: EnvironmentId.make(id),
+    label: id,
+    isPending: cost === null,
+    error: null,
+    summary:
+      cost === null
+        ? null
+        : {
+            ...input,
+            contractVersion: USAGE_CONTRACT_VERSION,
+            readAt: "2026-09-04T12:00:00Z",
+            buckets: [
+              {
+                day: input.sinceDay,
+                provider: "codex",
+                model: id,
+                totals: {
+                  uncachedInputTokens: 100,
+                  cachedInputTokens: 0,
+                  cacheCreationTokens: 0,
+                  outputTokens: 50,
+                  reasoningTokens: 0,
+                },
+                costUsd: cost,
+                cacheSavingsUsd: 0,
+                costSource: "modelPriced",
+                records: 1,
+                unpricedRecords: 0,
+                sessions: 1,
+              },
+            ],
+            sources: [
+              {
+                fingerprint: {
+                  hostId,
+                  provider: "codex",
+                  resolvedHomePath: "/sessions",
+                  volumeId: hostId,
+                },
+                status: "ok",
+                scannedFiles: 1,
+                skippedFiles: 0,
+                malformedRecords: 0,
+                distinctSessions: 1,
+                message: null,
+              },
+            ],
+            pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
+            scanDurationMs: 1,
+          },
+  };
+}
+
+let renderer: ReactTestRenderer | undefined;
+let latest: UsageView;
+
+function Probe({ selected }: { selected: ReadonlySet<EnvironmentId> | null }) {
+  const usage = useUsage(input, selected);
+  useLayoutEffect(() => {
+    latest = usage;
+  }, [usage]);
+  return null;
+}
+
+async function select(...ids: string[]) {
+  await act(() => {
+    renderer?.update(<Probe selected={new Set(ids.map((id) => EnvironmentId.make(id)))} />);
+  });
+}
+
+beforeEach(async () => {
+  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+  testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)];
+  await act(() => {
+    renderer = create(<Probe selected={null} />);
+  });
+});
+
+afterEach(async () => {
+  await act(() => renderer?.unmount());
+  vi.unstubAllGlobals();
+});
+
+describe("usage environment selection", () => {
+  it("starts with all environments and adds results as they arrive", async () => {
+    expect(latest.merged.costUsd).toBe(30);
+    expect(latest.isPending).toBe(false);
+    expect(latest.isPartial).toBe(true);
+
+    testState.environments = [...testState.environments.slice(0, 2), environment("slow", 40)];
+    await act(() => renderer?.update(<Probe selected={null} />));
+    expect(latest.merged.costUsd).toBe(70);
+    expect(latest.isPartial).toBe(false);
+  });
+
+  it("excludes unselected usage and pending environments, then restores all", async () => {
+    await select("b");
+    expect(latest.merged.costUsd).toBe(20);
+    expect(latest.merged.models.map((model) => model.model)).toEqual(["b"]);
+    expect(latest.isPending).toBe(false);
+    expect(latest.isPartial).toBe(false);
+    expect(latest.environments).toHaveLength(3);
+
+    await act(() => renderer?.update(<Probe selected={null} />));
+    expect(latest.merged.costUsd).toBe(30);
+    expect(latest.isPartial).toBe(true);
+  });
+
+  it("distinguishes a pending selection from an empty or failed selection", async () => {
+    await select("slow");
+    expect(latest.isPending).toBe(true);
+    expect(latest.merged.costUsd).toBe(0);
+
+    await select();
+    expect(latest.selectedEnvironments).toHaveLength(0);
+    expect(latest.isPending).toBe(false);
+    expect(latest.isPartial).toBe(false);
+
+    testState.environments = [{ ...environment("slow", null), isPending: false, error: "Offline" }];
+    await select("slow");
+    expect(latest.isPending).toBe(false);
+    expect(latest.isPartial).toBe(false);
+  });
+
+  it("deduplicates within the selection so an excluded owner cannot hide usage", async () => {
+    testState.environments = [environment("a", 10, "shared"), environment("b", 20, "shared")];
+    await act(() => renderer?.update(<Probe selected={null} />));
+    expect(latest.merged.costUsd).toBe(10);
+
+    await select("b");
+    expect(latest.merged.costUsd).toBe(20);
+    expect(latest.merged.duplicateSources).toEqual([]);
+  });
+
+  it("keeps selected cached results visible during a refresh", async () => {
+    testState.environments = [
+      { ...environment("a", 10), isPending: true },
+      environment("slow", null),
+    ];
+    await select("a");
+    expect(latest.merged.costUsd).toBe(10);
+    expect(latest.isPending).toBe(false);
+    expect(latest.isPartial).toBe(false);
+  });
+});
```

## apps/web/src/state/usage.ts

Status: modified; +24; −8

```diff
@@ -61,7 +61,8 @@
 export interface UsageView {
   readonly merged: MergedUsage;
   readonly environments: readonly EnvironmentUsageStatus[];
-  /** True until at least one environment has answered. */
+  readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
+  /** True until at least one selected environment has answered. */
   readonly isPending: boolean;
   /**
    * True while environments that have not failed are still answering. Failed
@@ -72,7 +73,10 @@
   readonly refresh: () => void;
 }
 
-export function useUsage(input: UsageSummaryInput): UsageView {
+export function useUsage(
+  input: UsageSummaryInput,
+  selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
+): UsageView {
   const windowKey = useMemo(
     () =>
       JSON.stringify({
@@ -94,6 +98,15 @@
   );
   const atom = usageByWindowAtom(windowKey);
   const environments = useAtomValue(atom);
+  const selectedEnvironments = useMemo(
+    () =>
+      selectedEnvironmentIds === null
+        ? environments
+        : environments.filter((environment) =>
+            selectedEnvironmentIds.has(environment.environmentId),
+          ),
+    [environments, selectedEnvironmentIds],
+  );
 
   // Refreshing only the derived atom would re-read the per-environment SWR
   // queries within their stale window and change nothing. Refresh each
@@ -104,7 +117,7 @@
   // not the refetch succeeds: an offline environment still recounts tokens.
   const refresh = useCallback(() => {
     const input = JSON.parse(windowKey) as UsageSummaryInput;
-    for (const environment of environments) {
+    for (const environment of selectedEnvironments) {
       const { environmentId } = environment;
       const query = serverEnvironment.usageSummary({ environmentId, input });
       void runAtomCommand(
@@ -114,10 +127,10 @@
         { reportFailure: false },
       ).finally(() => appAtomRegistry.refresh(query));
     }
-  }, [environments, windowKey]);
+  }, [selectedEnvironments, windowKey]);
 
   const merged = useMemo(() => {
-    const answered: EnvironmentUsage[] = environments.flatMap((environment) =>
+    const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
       environment.summary === null
         ? []
         : [
@@ -129,16 +142,19 @@
           ],
     );
     return mergeUsage(answered, USAGE_CONTRACT_VERSION);
-  }, [environments]);
+  }, [selectedEnvironments]);
 
-  const answeredCount = environments.filter((environment) => environment.summary !== null).length;
-  const stillReporting = environments.filter(
+  const answeredCount = selectedEnvironments.filter(
+    (environment) => environment.summary !== null,
+  ).length;
+  const stillReporting = selectedEnvironments.filter(
     (environment) => environment.summary === null && environment.error === null,
   ).length;
 
   return {
     merged,
     environments,
+    selectedEnvironments,
     isPending: answeredCount === 0 && stillReporting > 0,
     isPartial: answeredCount > 0 && stillReporting > 0,
     refresh,
```

## docs/user/usage.md

Status: modified; +4; −0

```diff
@@ -9,6 +9,10 @@
 Totals depend on the history available on each server. Grok turns without a saved completed-turn
 record are missing from the totals.
 
+On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
+environments are selected by default. The dropdown shows which environments are still scanning;
+results appear as each one responds.
+
 If recent work is missing or a new model shows no cost, refresh to rescan session history and
 update model pricing.
 
```

## packages/shared/src/usageMerge.ts

Status: modified; +4; −2

```diff
@@ -173,7 +173,7 @@
   );
 }
 
-function isCompatibleContractVersion(version: number, expected: number): boolean {
+export function isCompatibleUsageContractVersion(version: number, expected: number): boolean {
   return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected;
 }
 
@@ -220,7 +220,9 @@
   const current: EnvironmentUsage[] = [];
   const staleEnvironments: EnvironmentId[] = [];
   for (const environment of environments) {
-    if (isCompatibleContractVersion(environment.summary.contractVersion, expectedContractVersion)) {
+    if (
+      isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion)
+    ) {
       current.push(environment);
     } else {
       staleEnvironments.push(environment.environmentId);
```
