diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx
index b5b762aea73..b7989b0f3cd 100644
--- a/apps/web/src/components/usage/UsagePage.tsx
+++ b/apps/web/src/components/usage/UsagePage.tsx
@@ -449,29 +449,29 @@ function UsageDeviceStrip({
readonly environments: readonly EnvironmentUsageStatus[];
}) {
const scanning = environments.filter(
- (environment) => environment.summary === null && environment.error === null,
+ (environment) => environment.isPending && environment.error === null,
);
return (
{environments.map((environment) => {
- if (environment.summary !== null) {
+ if (environment.error !== null) {
return (
-
+
{environment.label}
);
}
- if (environment.error !== null) {
+ if (!environment.isPending && environment.summary !== null) {
return (
-
+
{environment.label}
);
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts
index 9d65b6ad600..3bee01bcac5 100644
--- a/apps/web/src/state/usage.ts
+++ b/apps/web/src/state/usage.ts
@@ -7,28 +7,21 @@
* @module state/usage
*/
import { useAtomValue } from "@effect/atom-react";
-import {
- USAGE_CONTRACT_VERSION,
- type EnvironmentId,
- type UsageSummary,
- type UsageSummaryInput,
-} from "@t3tools/contracts";
-import * as Option from "effect/Option";
-import { AsyncResult, Atom } from "effect/unstable/reactivity";
+import { USAGE_CONTRACT_VERSION, type UsageSummaryInput } from "@t3tools/contracts";
+import { Atom } from "effect/unstable/reactivity";
import { useCallback, useMemo } from "react";
import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentPresentations } from "./presentation";
import { serverEnvironment } from "./server";
+import {
+ deriveEnvironmentUsageStatus,
+ deriveUsageSettlingState,
+ type EnvironmentUsageStatus,
+} from "./usageStatus";
-export interface EnvironmentUsageStatus {
- readonly environmentId: EnvironmentId;
- readonly label: string;
- readonly isPending: boolean;
- readonly error: string | null;
- readonly summary: UsageSummary | null;
-}
+export type { EnvironmentUsageStatus } from "./usageStatus";
/**
* Reads every environment's summary for one window.
@@ -45,13 +38,14 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
const statuses: EnvironmentUsageStatus[] = [];
for (const [environmentId, presentation] of presentations) {
const result = get(serverEnvironment.usageSummary({ environmentId, input }));
- statuses.push({
- environmentId,
- label: presentation.entry.target.label,
- isPending: result.waiting,
- error: result._tag === "Failure" ? "This environment could not report usage." : null,
- summary: Option.getOrNull(AsyncResult.value(result)),
- });
+ statuses.push(
+ deriveEnvironmentUsageStatus({
+ connectionPhase: presentation.connection.phase,
+ result,
+ environmentId,
+ label: presentation.entry.target.label,
+ }),
+ );
}
return statuses;
}).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)),
@@ -111,16 +105,13 @@ export function useUsage(input: UsageSummaryInput): UsageView {
return mergeUsage(answered, USAGE_CONTRACT_VERSION);
}, [environments]);
- const answeredCount = environments.filter((environment) => environment.summary !== null).length;
- const stillReporting = environments.filter(
- (environment) => environment.summary === null && environment.error === null,
- ).length;
+ const settling = deriveUsageSettlingState(environments);
return {
merged,
environments,
- isPending: answeredCount === 0 && stillReporting > 0,
- isPartial: answeredCount > 0 && stillReporting > 0,
+ isPending: settling.isPending,
+ isPartial: settling.isPartial,
refresh,
};
}
diff --git a/apps/web/src/state/usageStatus.test.ts b/apps/web/src/state/usageStatus.test.ts
new file mode 100644
index 00000000000..50300024703
--- /dev/null
+++ b/apps/web/src/state/usageStatus.test.ts
@@ -0,0 +1,157 @@
+import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+import {
+ USAGE_CONTRACT_VERSION,
+ type EnvironmentId,
+ type UsageDay,
+ type UsageSummary,
+} from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+import { describe, expect, it } from "vite-plus/test";
+
+import { deriveEnvironmentUsageStatus, deriveUsageSettlingState } from "./usageStatus";
+
+const summary: UsageSummary = {
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: "2026-08-09T00:00:00.000Z",
+ timeZone: "UTC",
+ sinceDay: "2026-08-01" as UsageDay,
+ untilDay: "2026-08-09" as UsageDay,
+ buckets: [],
+ sources: [],
+ pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 0 },
+ scanDurationMs: 1,
+};
+
+const freshSummary: UsageSummary = {
+ ...summary,
+ readAt: "2026-08-09T00:01:00.000Z",
+ scanDurationMs: 2,
+};
+
+function status(
+ environmentId: string,
+ connectionPhase: EnvironmentConnectionPhase,
+ result: AsyncResult.AsyncResult,
+) {
+ return deriveEnvironmentUsageStatus({
+ environmentId: environmentId as EnvironmentId,
+ label: environmentId,
+ connectionPhase,
+ result,
+ });
+}
+
+describe("usage status", () => {
+ it("settles a refresh only after every environment answers the new request", () => {
+ const macRefreshing = status(
+ "mac",
+ "connected",
+ AsyncResult.success(summary, { waiting: true }),
+ );
+ const linuxRefreshing = status(
+ "linux",
+ "connected",
+ AsyncResult.success(summary, { waiting: true }),
+ );
+
+ expect(macRefreshing.summary).toBe(summary);
+ expect(deriveUsageSettlingState([macRefreshing, linuxRefreshing])).toEqual({
+ isPending: true,
+ isPartial: false,
+ });
+
+ const macFinished = status("mac", "connected", AsyncResult.success(summary));
+ expect(deriveUsageSettlingState([macFinished, linuxRefreshing])).toEqual({
+ isPending: false,
+ isPartial: true,
+ });
+
+ const linuxFinished = status("linux", "connected", AsyncResult.success(summary));
+ expect(deriveUsageSettlingState([macFinished, linuxFinished])).toEqual({
+ isPending: false,
+ isPartial: false,
+ });
+ });
+
+ it("waits while an environment makes its initial connection", () => {
+ expect(
+ status("connecting", "connecting", AsyncResult.initial(true)),
+ ).toEqual(expect.objectContaining({ isPending: true, error: null, summary: null }));
+ });
+
+ it("waits for a connected environment's first usage result", () => {
+ expect(status("connected", "connected", AsyncResult.initial())).toEqual(
+ expect.objectContaining({ isPending: true, error: null, summary: null }),
+ );
+ });
+
+ it("waits through reconnect and a fresh scan before completing", () => {
+ const reconnecting = status("laptop", "reconnecting", AsyncResult.success(summary));
+ const refreshing = status(
+ "laptop",
+ "connected",
+ AsyncResult.success(summary, { waiting: true }),
+ );
+ const refreshed = status("laptop", "connected", AsyncResult.success(freshSummary));
+
+ expect(reconnecting).toEqual(
+ expect.objectContaining({ isPending: true, error: null, summary }),
+ );
+ expect(refreshing).toEqual(expect.objectContaining({ isPending: true, error: null, summary }));
+ expect(refreshed).toEqual(
+ expect.objectContaining({ isPending: false, error: null, summary: freshSummary }),
+ );
+ });
+
+ it("settles terminal connection phases without a completed summary as failures", () => {
+ for (const connectionPhase of ["available", "offline", "error"] as const) {
+ expect(
+ status("offline", connectionPhase, AsyncResult.initial(true)),
+ ).toEqual(
+ expect.objectContaining({
+ isPending: false,
+ error: "This environment could not report usage.",
+ summary: null,
+ }),
+ );
+ }
+ });
+
+ it("keeps a completed summary when its environment later disconnects", () => {
+ const result = AsyncResult.success(summary);
+ const connected = status("laptop", "connected", result);
+ const disconnected = status("laptop", "offline", result);
+
+ expect(connected).toEqual(expect.objectContaining({ isPending: false, error: null, summary }));
+ expect(disconnected).toEqual(connected);
+ });
+
+ it("drops a retained previous summary when its refresh fails", () => {
+ const previous = AsyncResult.success(summary);
+ const retrying = AsyncResult.failWithPrevious("scan failed", {
+ previous: Option.some(previous),
+ waiting: true,
+ });
+ const failed = AsyncResult.failWithPrevious("scan failed", {
+ previous: Option.some(previous),
+ });
+
+ expect(status("desktop", "connected", retrying)).toEqual(
+ expect.objectContaining({ isPending: true, error: null, summary }),
+ );
+
+ const failedStatus = status("desktop", "connected", failed);
+ expect(failedStatus).toEqual(
+ expect.objectContaining({
+ isPending: false,
+ error: "This environment could not report usage.",
+ summary: null,
+ }),
+ );
+ expect(deriveUsageSettlingState([failedStatus])).toEqual({
+ isPending: false,
+ isPartial: false,
+ });
+ });
+});
diff --git a/apps/web/src/state/usageStatus.ts b/apps/web/src/state/usageStatus.ts
new file mode 100644
index 00000000000..6e0d898d971
--- /dev/null
+++ b/apps/web/src/state/usageStatus.ts
@@ -0,0 +1,81 @@
+import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
+import type { EnvironmentId, UsageSummary } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
+
+const USAGE_REPORT_ERROR = "This environment could not report usage.";
+
+export interface EnvironmentUsageStatus {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly isPending: boolean;
+ readonly error: string | null;
+ readonly summary: UsageSummary | null;
+}
+
+type UsageConnectionState = "connected" | "transitioning" | "terminal";
+
+/**
+ * Connection phases decide whether a usage request can still make progress.
+ * Keeping this exhaustive makes a newly added phase an explicit product
+ * decision instead of silently treating it as a failure.
+ */
+function classifyUsageConnection(phase: EnvironmentConnectionPhase): UsageConnectionState {
+ switch (phase) {
+ case "connected":
+ return "connected";
+ case "connecting":
+ case "reconnecting":
+ return "transitioning";
+ case "available":
+ case "offline":
+ case "error":
+ return "terminal";
+ }
+}
+
+/** Projects transport and SWR state into the status rendered for one environment. */
+export function deriveEnvironmentUsageStatus(input: {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly connectionPhase: EnvironmentConnectionPhase;
+ readonly result: AsyncResult.AsyncResult;
+}): EnvironmentUsageStatus {
+ const connection = classifyUsageConnection(input.connectionPhase);
+ const isPending =
+ connection === "transitioning" ||
+ (connection === "connected" && (input.result.waiting || input.result._tag === "Initial"));
+ const summary = Option.getOrNull(AsyncResult.value(input.result));
+ const hasTerminalQueryFailure = input.result._tag === "Failure" && !isPending;
+ return {
+ environmentId: input.environmentId,
+ label: input.label,
+ isPending,
+ error:
+ hasTerminalQueryFailure || (connection === "terminal" && summary === null)
+ ? USAGE_REPORT_ERROR
+ : null,
+ summary: hasTerminalQueryFailure ? null : summary,
+ };
+}
+
+/** Derives the page gate for the current request generation. */
+export function deriveUsageSettlingState(environments: readonly EnvironmentUsageStatus[]): {
+ readonly isPending: boolean;
+ readonly isPartial: boolean;
+} {
+ // SWR preserves the previous summary while a refresh is in flight. A
+ // retained value belongs to the previous request, so it does not count as
+ // this request having answered until `waiting` clears.
+ const answeredCount = environments.filter(
+ (environment) => environment.summary !== null && !environment.isPending,
+ ).length;
+ const stillReporting = environments.filter(
+ (environment) => environment.isPending && environment.error === null,
+ ).length;
+
+ return {
+ isPending: answeredCount === 0 && stillReporting > 0,
+ isPartial: answeredCount > 0 && stillReporting > 0,
+ };
+}