authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-08 02:48:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 02:48:14-07:00
log85975585579188be20f46d96b4972ce46c33f4bc
tree0cbc8866c0f7716f1003d134b690d8ad0932a804
parent0ce6b9deb0c3f6b4dd9df7ed809f7acb9d237ae0
signature Signed by PGP key B5690EEEBB952194

perf(usage): the Usage page shows numbers in under a second and dims only what is still loading (#17147)

Takeover of #16376. Co-authored-by: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

22 files changed, 2131 insertions(+), 429 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+101-53
...@@ -1,8 +1,13 @@...@@ -1,8 +1,13 @@
1import { ChatGptUsageSummary } from "./ChatGptUsageSummary";1import { ChatGptUsageSummary } from "./ChatGptUsageSummary";
2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
3import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";3import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts";
4import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";4import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
5import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";5import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";
6import {
7 updatingProvidersLabel,
8 usageEnvironmentProgress,
9 usageProgress,
10} from "@t3tools/client-runtime/state/usage-progress";
6import {11import {
7 isCompatibleUsageContractVersion,12 isCompatibleUsageContractVersion,
8 isModelCostUnknown,13 isModelCostUnknown,
...@@ -21,9 +26,16 @@ import {...@@ -21,9 +26,16 @@ import {
21 formatUsd,26 formatUsd,
22 makeWindow,27 makeWindow,
23} from "@t3tools/shared/usageFormat";28} from "@t3tools/shared/usageFormat";
24import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";29import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
25import { Platform, Pressable, RefreshControl, View } from "react-native";30import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native";
26import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";31import Animated, {
32 FadeIn,
33 ReduceMotion,
34 useAnimatedStyle,
35 useSharedValue,
36 withDelay,
37 withTiming,
38} from "react-native-reanimated";
27import { useSafeAreaInsets } from "react-native-safe-area-context";39import { useSafeAreaInsets } from "react-native-safe-area-context";
2840
29import { SegmentedControl } from "../../components/SegmentedControl";41import { SegmentedControl } from "../../components/SegmentedControl";
...@@ -65,6 +77,7 @@ const METRIC_OPTIONS = [...@@ -65,6 +77,7 @@ const METRIC_OPTIONS = [
65] as const satisfies readonly { value: UsageChartMetric; label: string }[];77] as const satisfies readonly { value: UsageChartMetric; label: string }[];
6678
67const CHART_HEIGHT = 180;79const CHART_HEIGHT = 180;
80const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider];
68const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";81const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";
6982
70/**83/**
...@@ -156,6 +169,10 @@ export function UsageRouteScreen() {...@@ -156,6 +169,10 @@ export function UsageRouteScreen() {
156 const [refreshingUsage, setRefreshingUsage] = useState(false);169 const [refreshingUsage, setRefreshingUsage] = useState(false);
157 const refreshingRef = useRef(false);170 const refreshingRef = useRef(false);
158 const showingLimits = tab === "limits";171 const showingLimits = tab === "limits";
172 const progress = usageProgress(selectedEnvironments, {
173 refreshing: refreshingUsage,
174 providerLabel,
175 });
159 const selectWindow = (days: number) => {176 const selectWindow = (days: number) => {
160 setWindowSelection({177 setWindowSelection({
161 days,178 days,
...@@ -182,10 +199,6 @@ export function UsageRouteScreen() {...@@ -182,10 +199,6 @@ export function UsageRouteScreen() {
182 };199 };
183200
184 const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null;201 const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null;
185 const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading);
186 const filterAccessibilityLabel = hasLoadingEnvironments
187 ? "Filter usage environments, some environments are loading"
188 : "Filter usage environments";
189 const filterIcon =202 const filterIcon =
190 selectedEnvironmentIds === null203 selectedEnvironmentIds === null
191 ? "line.3.horizontal.decrease"204 ? "line.3.horizontal.decrease"
...@@ -201,14 +214,14 @@ export function UsageRouteScreen() {...@@ -201,14 +214,14 @@ export function UsageRouteScreen() {
201 ...environments.map((environment) => ({214 ...environments.map((environment) => ({
202 id: environment.environmentId,215 id: environment.environmentId,
203 title: environment.label,216 title: environment.label,
204 subtitle: usageEnvironmentStatus(environment),217 subtitle: usageEnvironmentStatus(environment, refreshingUsage),
205 state:218 state:
206 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId)219 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId)
207 ? ("on" as const)220 ? ("on" as const)
208 : ("off" as const),221 : ("off" as const),
209 })),222 })),
210 ],223 ],
211 [environments, selectedEnvironmentIds],224 [environments, refreshingUsage, selectedEnvironmentIds],
212 );225 );
213 const selectEnvironment = useCallback(226 const selectEnvironment = useCallback(
214 (value: string) => {227 (value: string) => {
...@@ -227,37 +240,24 @@ export function UsageRouteScreen() {...@@ -227,37 +240,24 @@ export function UsageRouteScreen() {
227 <ControlPillMenu240 <ControlPillMenu
228 accessible241 accessible
229 accessibilityRole="button"242 accessibilityRole="button"
230 accessibilityLabel={filterAccessibilityLabel}243 accessibilityLabel="Filter usage environments"
231 title="Environments"244 title="Environments"
232 actions={environmentActions}245 actions={environmentActions}
233 onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)}246 onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)}
234 >247 >
235 <Pressable248 <Pressable
236 accessibilityRole="button"249 accessibilityRole="button"
237 accessibilityLabel={filterAccessibilityLabel}250 accessibilityLabel="Filter usage environments"
238 className={cn(251 className={cn(
239 "items-center justify-center rounded-full",252 "items-center justify-center rounded-full",
240 Platform.OS === "ios" ? "size-[28px]" : "size-[44px]",253 Platform.OS === "ios" ? "size-[28px]" : "size-[44px]",
241 )}254 )}
242 >255 >
243 <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" />256 <SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" />
244 {hasLoadingEnvironments ? (
245 <View
246 pointerEvents="none"
247 className="absolute -right-[2px] -top-[2px] size-[9px] rounded-full bg-amber-500"
248 />
249 ) : null}
250 </Pressable>257 </Pressable>
251 </ControlPillMenu>258 </ControlPillMenu>
252 ) : null,259 ) : null,
253 [260 [showEnvironmentFilter, environmentActions, selectEnvironment, filterIcon],
254 showEnvironmentFilter,
255 environmentActions,
256 selectEnvironment,
257 filterAccessibilityLabel,
258 filterIcon,
259 hasLoadingEnvironments,
260 ],
261 );261 );
262262
263 useLayoutEffect(() => {263 useLayoutEffect(() => {
...@@ -361,26 +361,28 @@ export function UsageRouteScreen() {...@@ -361,26 +361,28 @@ export function UsageRouteScreen() {
361 {message}361 {message}
362 </Text>362 </Text>
363 ))}363 ))}
364 <ChartCard364 <UsageUpdating dimmed={progress.dimmed} label={progress.label}>
365 merged={merged}365 <ChartCard
366 days={chartDays}366 merged={merged}
367 daily={chartTotals}367 days={chartDays}
368 metric={metric}368 daily={chartTotals}
369 sinceDay={window.sinceDay}369 metric={metric}
370 untilDay={window.untilDay}370 sinceDay={window.sinceDay}
371 isPast24Hours={isPast24Hours}371 untilDay={window.untilDay}
372 timeZone={window.timeZone}372 isPast24Hours={isPast24Hours}
373 />373 timeZone={window.timeZone}
374 <ProviderSection374 />
375 merged={merged}375 <ProviderSection
376 metric={metric}376 merged={merged}
377 cursorAccessEnvironments={cursorAccessEnvironments}377 metric={metric}
378 showCursorEnvironment={selectedEnvironments.length > 1}378 cursorAccessEnvironments={cursorAccessEnvironments}
379 onCursorEnabled={refreshAfterCursorEnable}379 showCursorEnvironment={selectedEnvironments.length > 1}
380 />380 onCursorEnabled={refreshAfterCursorEnable}
381 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />381 />
382 <CostSection merged={merged} />382 <TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
383 <ModelsSection merged={merged} metric={metric} />383 <CostSection merged={merged} />
384 <ModelsSection merged={merged} metric={metric} />
385 </UsageUpdating>
384 </>386 </>
385 )}387 )}
386 </>388 </>
...@@ -391,6 +393,53 @@ export function UsageRouteScreen() {...@@ -391,6 +393,53 @@ export function UsageRouteScreen() {
391 );393 );
392}394}
393395
396/**
397 * Dims totals that are about to change and says what is still updating. The
398 * status overlays the first child's top-right corner, the chart card's label
399 * row, so appearing never moves anything.
400 */
401function UsageUpdating({
402 dimmed,
403 label,
404 children,
405}: {
406 readonly dimmed: boolean;
407 readonly label: string | null;
408 readonly children: ReactNode;
409}) {
410 const opacity = useSharedValue(1);
411 useLayoutEffect(() => {
412 // The delay keeps a quick cached answer from flashing the dim.
413 opacity.set(
414 dimmed
415 ? withDelay(150, withTiming(0.5, { duration: 150, reduceMotion: ReduceMotion.System }))
416 : withTiming(1, { duration: 150, reduceMotion: ReduceMotion.System }),
417 );
418 }, [opacity, dimmed]);
419 const dimStyle = useAnimatedStyle(() => ({ opacity: opacity.get() }));
420
421 return (
422 <View>
423 <Animated.View style={dimStyle} className="gap-6" accessibilityState={{ busy: dimmed }}>
424 {children}
425 </Animated.View>
426 {label !== null ? (
427 <Animated.View
428 entering={FadeIn.delay(150).duration(150).reduceMotion(ReduceMotion.System)}
429 accessibilityLiveRegion="polite"
430 pointerEvents="none"
431 className="absolute right-4 top-4 h-5 max-w-[55%] flex-row items-center gap-1.5"
432 >
433 <ActivityIndicator size="small" colorClassName="accent-adaptive-sky-600-400" />
434 <Text className="shrink text-sm text-adaptive-sky-600-400" numberOfLines={1}>
435 {label}
436 </Text>
437 </Animated.View>
438 ) : null}
439 </View>
440 );
441}
442
394function CursorEnableAction({443function CursorEnableAction({
395 environmentId,444 environmentId,
396 label,445 label,
...@@ -860,11 +909,7 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U...@@ -860,11 +909,7 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U
860 * one that failed, or one whose transcripts another environment already909 * one that failed, or one whose transcripts another environment already
861 * reported.910 * reported.
862 */911 */
863function isUsageLoading(environment: EnvironmentUsageStatus) {912function usageEnvironmentStatus(environment: EnvironmentUsageStatus, refreshing: boolean): string {
864 return environment.isPending || (environment.summary === null && environment.error === null);
865}
866
867function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
868 if (913 if (
869 environment.summary &&914 environment.summary &&
870 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)915 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
...@@ -881,7 +926,10 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {...@@ -881,7 +926,10 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
881 return environment.summary ? `${environment.error} Showing saved totals.` : environment.error;926 return environment.summary ? `${environment.error} Showing saved totals.` : environment.error;
882 if (!environment.isConnected)927 if (!environment.isConnected)
883 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";928 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
884 if (isUsageLoading(environment))929 const progress = usageEnvironmentProgress(environment, refreshing);
885 return environment.summary ? "Updating usage…" : "Loading usage…";930 if (progress.phase === "loading") return "Loading usage…";
931 if (progress.phase === "stale") return "Updating usage…";
932 if (progress.phase === "partway")
933 return updatingProvidersLabel(progress.providers, providerLabel);
886 return "Usage up to date";934 return "Usage up to date";
887}935}
apps/server/src/server.ts+5-1
...@@ -153,6 +153,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien...@@ -153,6 +153,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
153import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";153import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
154import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";154import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
155import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";155import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
156import * as CursorUsageReader from "./usage/cursorUsageReader.ts";
156import * as UsageService from "./usage/UsageService.ts";157import * as UsageService from "./usage/UsageService.ts";
157import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";158import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";
158import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";159import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
...@@ -225,7 +226,10 @@ const layerBackground = BackgroundPolicy.layer.pipe(...@@ -225,7 +226,10 @@ const layerBackground = BackgroundPolicy.layer.pipe(
225 Layer.provideMerge(layerServerSettings),226 Layer.provideMerge(layerServerSettings),
226);227);
227228
228const layerUsage = UsageService.layer.pipe(Layer.provide(layerServerSettings));229const layerUsage = UsageService.layer.pipe(
230 Layer.provide(layerServerSettings),
231 Layer.provide(CursorUsageReader.layer),
232);
229233
230const layerResourceDiagnostics = Layer.mergeAll(234const layerResourceDiagnostics = Layer.mergeAll(
231 HostResources.layer,235 HostResources.layer,
apps/server/src/usage/UsageService.test.ts+276-2
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 ProviderDriverKind,15 ProviderDriverKind,
16 ProviderInstanceId,16 ProviderInstanceId,
17 UsageDay,17 UsageDay,
18 type UsageSource,
18 type UsageSummaryInput,19 type UsageSummaryInput,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
20import * as Duration from "effect/Duration";21import * as Duration from "effect/Duration";
...@@ -31,6 +32,8 @@ import { HttpClient, HttpClientResponse } from "effect/http";...@@ -31,6 +32,8 @@ import { HttpClient, HttpClientResponse } from "effect/http";
3132
32import * as ServerConfig from "../config.ts";33import * as ServerConfig from "../config.ts";
33import * as ServerSettings from "../serverSettings.ts";34import * as ServerSettings from "../serverSettings.ts";
35import * as CursorUsageReader from "./cursorUsageReader.ts";
36import type { UsageRecord } from "./usageTranscripts.ts";
34import * as UsageService from "./UsageService.ts";37import * as UsageService from "./UsageService.ts";
3538
36const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));39const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
...@@ -90,6 +93,7 @@ const layerService = (input: {...@@ -90,6 +93,7 @@ const layerService = (input: {
90}) =>93}) =>
91 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(94 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
92 Layer.provideMerge(NodeServices.layer),95 Layer.provideMerge(NodeServices.layer),
96 Layer.provideMerge(CursorUsageReader.layer),
93 Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")),97 Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")),
94 Layer.provideMerge(ServerSettings.layerTest(input.settings)),98 Layer.provideMerge(ServerSettings.layerTest(input.settings)),
95 Layer.provideMerge(99 Layer.provideMerge(
...@@ -157,6 +161,71 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens...@@ -157,6 +161,71 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
157 return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0);161 return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0);
158}162}
159163
164/** Inside `WINDOW`: the time Cursor tests run at. */
165const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z");
166const HOUR_MS = 60 * 60 * 1000;
167/** The service's cache retention, which the Cursor account cache always covers. */
168const CURSOR_RETENTION_MS = 92 * 24 * HOUR_MS;
169
170/**
171 * Stands in for Cursor's dashboard API. Each read returns the account's events
172 * in its range, keyed like the real reader: occurrences count within one read.
173 * While `gate` is set, reads wait for it.
174 */
175function makeFakeCursor() {
176 const state = {
177 accountKey: "account-a",
178 events: [] as { readonly timestampMs: number; readonly outputTokens: number }[],
179 error: null as string | null,
180 gate: undefined as Deferred.Deferred<void> | undefined,
181 calls: [] as { readonly sinceMs: number; readonly untilMs: number }[],
182 };
183 const read = (_credential: unknown, sinceMs: number, untilMs: number) =>
184 Effect.gen(function* () {
185 state.calls.push({ sinceMs, untilMs });
186 if (state.gate !== undefined) yield* Deferred.await(state.gate);
187 const { accountKey, error } = state;
188 if (error !== null) return { accountKey, records: [], missing: false, error };
189 const occurrences = new Map<string, number>();
190 const records = state.events
191 .filter(({ timestampMs }) => timestampMs >= sinceMs && timestampMs <= untilMs)
192 .map(({ timestampMs, outputTokens }): UsageRecord => {
193 const key = `${timestampMs}:${outputTokens}`;
194 const occurrence = occurrences.get(key) ?? 0;
195 occurrences.set(key, occurrence + 1);
196 return {
197 provider: "cursor",
198 timestampMs,
199 model: "claude-fable-5",
200 sessionId: "conversation-1",
201 totals: {
202 uncachedInputTokens: 0,
203 cachedInputTokens: 0,
204 cacheCreationTokens: 0,
205 outputTokens,
206 reasoningTokens: 0,
207 },
208 reportedCostUsd: null,
209 speed: "standard",
210 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
211 };
212 });
213 return { accountKey, records, missing: false, error: null };
214 });
215 return { state, read };
216}
217
218const writeCursorLogin = (home: string) =>
219 Effect.promise(async () => {
220 const authPath = NodePath.join(home, "config", "cursor", "auth.json");
221 await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true });
222 await NodeFSP.writeFile(authPath, "{}");
223 });
224
225function cursorSource(summary: { readonly sources: readonly UsageSource[] }) {
226 return summary.sources.find((source) => source.fingerprint.provider === "cursor");
227}
228
160describe("UsageService", () => {229describe("UsageService", () => {
161 it.live.each([230 it.live.each([
162 { explicitDefault: true, label: "explicit" },231 { explicitDefault: true, label: "explicit" },
...@@ -196,6 +265,9 @@ describe("UsageService", () => {...@@ -196,6 +265,9 @@ describe("UsageService", () => {
196 const service = yield* UsageService.make;265 const service = yield* UsageService.make;
197 return yield* service.readSummary(WINDOW);266 return yield* service.readSummary(WINDOW);
198 }).pipe(267 }).pipe(
268 // Scoped inside the state directory, so pending cache writes land
269 // before it is removed.
270 Effect.scoped,
199 Effect.provide(271 Effect.provide(
200 layerService({272 layerService({
201 prefix: "usage-managed-accounts",273 prefix: "usage-managed-accounts",
...@@ -282,12 +354,201 @@ describe("UsageService", () => {...@@ -282,12 +354,201 @@ describe("UsageService", () => {
282 layerService({ prefix: "usage-service-cursor-invalid-login", home, settings }),354 layerService({ prefix: "usage-service-cursor-invalid-login", home, settings }),
283 ),355 ),
284 );356 );
285 const summary = yield* service.readSummary(WINDOW);357 const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
286 const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor");358 const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor");
287 assert.strictEqual(cursor?.message, "Cursor credentials could not be read.");359 assert.strictEqual(cursor?.message, "Cursor credentials could not be read.");
288 }).pipe(Effect.scoped),360 }).pipe(Effect.scoped),
289 );361 );
290362
363 it.live("answers Cursor from its cache while one shared refresh runs", () =>
364 Effect.gen(function* () {
365 const { settings, home } = yield* setup;
366 yield* writeCursorLogin(home);
367 yield* TestClock.setTime(CURSOR_NOW);
368 const cursor = makeFakeCursor();
369 cursor.state.events = [{ timestampMs: CURSOR_NOW - 2 * HOUR_MS, outputTokens: 5 }];
370 const gate = yield* Deferred.make<void>();
371 cursor.state.gate = gate;
372 yield* Effect.gen(function* () {
373 const service = yield* UsageService.make.pipe(
374 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
375 );
376 // Cold: nothing cached yet, so Cursor answers empty while it refreshes.
377 const cold = yield* service.readSummary(WINDOW);
378 assert.strictEqual(cursorSource(cold)?.refreshing, true);
379 assert.strictEqual(cursorSource(cold)?.status, "ok");
380 assert.strictEqual(totalOutputTokens(cold), 0);
381 // A second reader joins the refresh in flight.
382 const joined = yield* service.readSummary(WINDOW);
383 assert.strictEqual(cursorSource(joined)?.refreshing, true);
384
385 const waited = yield* service
386 .readSummary({ ...WINDOW, awaitRefresh: true })
387 .pipe(Effect.forkChild);
388 yield* Deferred.succeed(gate, undefined);
389 const refreshed = yield* Fiber.join(waited);
390 assert.isFalse(refreshed.sources.some((source) => source.refreshing));
391 assert.strictEqual(cursorSource(refreshed)?.status, "ok");
392 assert.strictEqual(totalOutputTokens(refreshed), 5);
393 assert.strictEqual(cursor.state.calls.length, 1);
394
395 // Inside the TTL a plain read answers from the cache without refreshing.
396 yield* TestClock.adjust(Duration.seconds(30));
397 const cached = yield* service.readSummary(WINDOW);
398 assert.isUndefined(cursorSource(cached)?.refreshing);
399 assert.strictEqual(totalOutputTokens(cached), 5);
400 assert.strictEqual(cursor.state.calls.length, 1);
401
402 // Past it, a plain read answers from the stale cache and refreshes.
403 yield* TestClock.adjust(Duration.seconds(31));
404 cursor.state.events.push({ timestampMs: CURSOR_NOW, outputTokens: 7 });
405 const stale = yield* service.readSummary(WINDOW);
406 assert.strictEqual(cursorSource(stale)?.refreshing, true);
407 assert.strictEqual(totalOutputTokens(stale), 5);
408 const fresh = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
409 assert.isUndefined(cursorSource(fresh)?.refreshing);
410 assert.strictEqual(totalOutputTokens(fresh), 12);
411 assert.strictEqual(cursor.state.calls.length, 2);
412 }).pipe(Effect.provide(layerService({ prefix: "usage-service-cursor-swr", home, settings })));
413 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
414 );
415
416 it.live("caches Cursor's whole retention and then refetches only the newest edge", () =>
417 Effect.gen(function* () {
418 const { settings, home } = yield* setup;
419 yield* writeCursorLogin(home);
420 yield* TestClock.setTime(CURSOR_NOW);
421 const cursor = makeFakeCursor();
422 // Two identical billed rows inside the refetched hour must both survive it, once each.
423 cursor.state.events = [
424 { timestampMs: Date.parse("2026-07-10T10:00:00Z"), outputTokens: 17 },
425 { timestampMs: CURSOR_NOW - 24 * HOUR_MS, outputTokens: 5 },
426 { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 },
427 { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 },
428 ];
429 yield* Effect.gen(function* () {
430 const service = yield* UsageService.make.pipe(
431 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
432 );
433 const read = (input: UsageSummaryInput) =>
434 service.readSummary({ ...input, awaitRefresh: true });
435 assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19);
436 assert.deepStrictEqual(cursor.state.calls, [
437 { sinceMs: CURSOR_NOW - CURSOR_RETENTION_MS, untilMs: CURSOR_NOW },
438 ]);
439
440 // A wider window is answered from the same cache.
441 const wide = { ...WINDOW, sinceDay: UsageDay.make("2026-07-01") };
442 assert.strictEqual(totalOutputTokens(yield* read(wide)), 36);
443 assert.strictEqual(cursor.state.calls.length, 1);
444
445 // An event finalized late, inside the overlap, and a new one.
446 cursor.state.events.push(
447 { timestampMs: CURSOR_NOW - HOUR_MS / 6, outputTokens: 11 },
448 { timestampMs: CURSOR_NOW + HOUR_MS, outputTokens: 13 },
449 );
450 yield* TestClock.adjust(Duration.hours(2));
451 assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 43);
452 assert.deepStrictEqual(cursor.state.calls[1], {
453 sinceMs: CURSOR_NOW - HOUR_MS,
454 untilMs: CURSOR_NOW + 2 * HOUR_MS,
455 });
456
457 // Another login replaces the cached account's history instead of adding to it.
458 cursor.state.accountKey = "account-b";
459 yield* TestClock.adjust(Duration.minutes(2));
460 assert.strictEqual(totalOutputTokens(yield* read(wide)), 60);
461 assert.strictEqual(cursor.state.calls.length, 4);
462 assert.strictEqual(cursorSource(yield* read(wide))?.fingerprint.volumeId, "account-b");
463 }).pipe(
464 Effect.provide(
465 layerService({ prefix: "usage-service-cursor-incremental", home, settings }),
466 ),
467 );
468 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
469 );
470
471 it.live("reports a failed Cursor refresh from its cache without refreshing", () =>
472 Effect.gen(function* () {
473 const { settings, home } = yield* setup;
474 yield* writeCursorLogin(home);
475 yield* TestClock.setTime(CURSOR_NOW);
476 const cursor = makeFakeCursor();
477 cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }];
478 yield* Effect.gen(function* () {
479 const service = yield* UsageService.make.pipe(
480 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }),
481 );
482 yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
483
484 yield* TestClock.adjust(Duration.minutes(2));
485 cursor.state.error = "Sign in to Cursor again to read account usage.";
486 const failed = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
487 assert.strictEqual(cursorSource(failed)?.status, "partial");
488 assert.strictEqual(cursorSource(failed)?.message, cursor.state.error);
489 assert.isUndefined(cursorSource(failed)?.refreshing);
490 assert.strictEqual(totalOutputTokens(failed), 5);
491
492 // The failure stands for the TTL; a plain read does not retry it.
493 const again = yield* service.readSummary(WINDOW);
494 assert.strictEqual(cursorSource(again)?.status, "partial");
495 assert.isUndefined(cursorSource(again)?.refreshing);
496 assert.strictEqual(cursor.state.calls.length, 2);
497
498 // A failing read of another login drops the previous account's history.
499 yield* TestClock.adjust(Duration.minutes(2));
500 cursor.state.accountKey = "account-b";
501 const switched = yield* service.readSummary({ ...WINDOW, awaitRefresh: true });
502 assert.strictEqual(cursorSource(switched)?.status, "missing");
503 assert.strictEqual(cursorSource(switched)?.message, cursor.state.error);
504 assert.strictEqual(totalOutputTokens(switched), 0);
505 }).pipe(
506 Effect.provide(layerService({ prefix: "usage-service-cursor-failure", home, settings })),
507 );
508 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
509 );
510
511 it.live("restores the Cursor account cache after a restart", () =>
512 Effect.gen(function* () {
513 const { settings, home } = yield* setup;
514 yield* writeCursorLogin(home);
515 yield* TestClock.setTime(CURSOR_NOW);
516 const before = makeFakeCursor();
517 before.state.events = [
518 { timestampMs: CURSOR_NOW - 30 * HOUR_MS, outputTokens: 5 },
519 { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 },
520 ];
521 yield* Effect.gen(function* () {
522 const first = yield* UsageService.make.pipe(
523 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: before.read }),
524 );
525 const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true });
526 yield* first.awaitPersisted;
527
528 const after = makeFakeCursor();
529 after.state.events = before.state.events;
530 const restarted = yield* UsageService.make.pipe(
531 Effect.provideService(CursorUsageReader.CursorAccountReader, { read: after.read }),
532 );
533 const restored = yield* restarted.readSummary(WINDOW);
534 assert.isUndefined(cursorSource(restored)?.refreshing);
535 assert.deepStrictEqual(restored.buckets, original.buckets);
536 assert.deepStrictEqual(cursorSource(restored), cursorSource(original));
537 assert.strictEqual(after.state.calls.length, 0);
538
539 // Once stale, the restored cache refreshes only its newest edge.
540 yield* TestClock.adjust(Duration.minutes(2));
541 const refreshed = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true });
542 assert.deepStrictEqual(refreshed.buckets, original.buckets);
543 assert.deepStrictEqual(after.state.calls, [
544 { sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 },
545 ]);
546 }).pipe(
547 Effect.provide(layerService({ prefix: "usage-service-cursor-restart", home, settings })),
548 );
549 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
550 );
551
291 it.live("does not read the macOS Cursor Keychain before account usage is enabled", () =>552 it.live("does not read the macOS Cursor Keychain before account usage is enabled", () =>
292 Effect.gen(function* () {553 Effect.gen(function* () {
293 const { settings, home } = yield* setup;554 const { settings, home } = yield* setup;
...@@ -642,6 +903,7 @@ describe("UsageService", () => {...@@ -642,6 +903,7 @@ describe("UsageService", () => {
642 environmentProjects,903 environmentProjects,
643 );904 );
644 }).pipe(905 }).pipe(
906 Effect.scoped,
645 Effect.provide(907 Effect.provide(
646 layerService({908 layerService({
647 prefix: "usage-service-home-refresh-test",909 prefix: "usage-service-home-refresh-test",
...@@ -747,6 +1009,7 @@ describe("UsageService", () => {...@@ -747,6 +1009,7 @@ describe("UsageService", () => {
747 const restored = yield* service.readSummary(WINDOW);1009 const restored = yield* service.readSummary(WINDOW);
748 assert.deepStrictEqual(restored.buckets, original.buckets);1010 assert.deepStrictEqual(restored.buckets, original.buckets);
749 }).pipe(1011 }).pipe(
1012 Effect.scoped,
750 Effect.provide(1013 Effect.provide(
751 layerService({ prefix: "usage-service-price-overrides-test", home, settings }),1014 layerService({ prefix: "usage-service-price-overrides-test", home, settings }),
752 ),1015 ),
...@@ -801,9 +1064,11 @@ describe("UsageService", () => {...@@ -801,9 +1064,11 @@ describe("UsageService", () => {
801 appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0),1064 appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0),
802 20,1065 20,
803 );1066 );
1067 yield* service.awaitPersisted;
804 const restarted = yield* UsageService.make;1068 const restarted = yield* UsageService.make;
805 const restored = yield* restarted.readSummary(WINDOW);1069 const restored = yield* restarted.readSummary(WINDOW);
806 assert.deepStrictEqual(restored.buckets, appended.buckets);1070 assert.deepStrictEqual(restored.buckets, appended.buckets);
1071 yield* restarted.awaitPersisted;
807 yield* Effect.promise(() => NodeFSP.rm(transcript));1072 yield* Effect.promise(() => NodeFSP.rm(transcript));
808 const afterCleanup = yield* UsageService.make;1073 const afterCleanup = yield* UsageService.make;
809 assert.deepStrictEqual(1074 assert.deepStrictEqual(
...@@ -811,6 +1076,7 @@ describe("UsageService", () => {...@@ -811,6 +1076,7 @@ describe("UsageService", () => {
811 appended.buckets,1076 appended.buckets,
812 );1077 );
813 }).pipe(1078 }).pipe(
1079 Effect.scoped,
814 Effect.provide(1080 Effect.provide(
815 layerService({1081 layerService({
816 prefix: "usage-service-large-record-test",1082 prefix: "usage-service-large-record-test",
...@@ -865,7 +1131,9 @@ describe("UsageService", () => {...@@ -865,7 +1131,9 @@ describe("UsageService", () => {
865 const { stateDir } = yield* ServerConfig.ServerConfig;1131 const { stateDir } = yield* ServerConfig.ServerConfig;
866 const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json");1132 const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json");
867 const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json");1133 const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json");
868 yield* (yield* UsageService.make).readSummary(WINDOW);1134 const first = yield* UsageService.make;
1135 yield* first.readSummary(WINDOW);
1136 yield* first.awaitPersisted;
8691137
870 // Rewrite the cache as a v4 server left it: every Codex record at1138 // Rewrite the cache as a v4 server left it: every Codex record at
871 // speed 0 (standard), and no tier in the reducer state.1139 // speed 0 (standard), and no tier in the reducer state.
...@@ -937,6 +1205,7 @@ describe("UsageService", () => {...@@ -937,6 +1205,7 @@ describe("UsageService", () => {
937 assert.deepStrictEqual(deleted.buckets, first.buckets);1205 assert.deepStrictEqual(deleted.buckets, first.buckets);
938 assert.deepStrictEqual(deleted.sources, first.sources);1206 assert.deepStrictEqual(deleted.sources, first.sources);
9391207
1208 yield* service.awaitPersisted;
940 const restarted = yield* UsageService.make;1209 const restarted = yield* UsageService.make;
941 const restored = yield* restarted.readSummary(WINDOW);1210 const restored = yield* restarted.readSummary(WINDOW);
942 assert.deepStrictEqual(restored.buckets, first.buckets);1211 assert.deepStrictEqual(restored.buckets, first.buckets);
...@@ -947,6 +1216,7 @@ describe("UsageService", () => {...@@ -947,6 +1216,7 @@ describe("UsageService", () => {
947 const moved = yield* restarted.readSummary(WINDOW);1216 const moved = yield* restarted.readSummary(WINDOW);
948 assert.deepStrictEqual(moved.buckets, first.buckets);1217 assert.deepStrictEqual(moved.buckets, first.buckets);
949 assert.strictEqual(moved.sources[0]?.distinctSessions, 1);1218 assert.strictEqual(moved.sources[0]?.distinctSessions, 1);
1219 yield* restarted.awaitPersisted;
9501220
951 const replacementProjects = NodePath.join(home, "replacement-projects");1221 const replacementProjects = NodePath.join(home, "replacement-projects");
952 yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects));1222 yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects));
...@@ -994,6 +1264,9 @@ describe("UsageService", () => {...@@ -994,6 +1264,9 @@ describe("UsageService", () => {
994 assert.deepStrictEqual(outsideWindow.buckets, []);1264 assert.deepStrictEqual(outsideWindow.buckets, []);
995 assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0);1265 assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0);
996 }).pipe(1266 }).pipe(
1267 // Scoped inside the state directory, so pending cache writes land
1268 // before it is removed.
1269 Effect.scoped,
997 Effect.provide(1270 Effect.provide(
998 layerService({1271 layerService({
999 prefix: "usage-service-cleanup-test",1272 prefix: "usage-service-cleanup-test",
...@@ -1039,6 +1312,7 @@ describe("UsageService", () => {...@@ -1039,6 +1312,7 @@ describe("UsageService", () => {
1039 const saved = yield* service.readSummary(WINDOW);1312 const saved = yield* service.readSummary(WINDOW);
1040 assert.deepStrictEqual(saved.buckets, live.buckets);1313 assert.deepStrictEqual(saved.buckets, live.buckets);
1041 assert.deepStrictEqual(saved.sources, live.sources);1314 assert.deepStrictEqual(saved.sources, live.sources);
1315 yield* service.awaitPersisted;
1042 const restored = yield* (yield* UsageService.make).readSummary(WINDOW);1316 const restored = yield* (yield* UsageService.make).readSummary(WINDOW);
1043 assert.deepStrictEqual(restored.buckets, live.buckets);1317 assert.deepStrictEqual(restored.buckets, live.buckets);
1044 }).pipe(1318 }).pipe(
apps/server/src/usage/UsageService.ts+467-212
...@@ -12,6 +12,10 @@...@@ -12,6 +12,10 @@
12 * remain visible. Antigravity databases are memoised in memory while the12 * remain visible. Antigravity databases are memoised in memory while the
13 * database and its WAL keep the same `(size, mtime, ctime)`.13 * database and its WAL keep the same `(size, mtime, ctime)`.
14 *14 *
15 * Cursor's account API is slow, so its source answers from a cache and marks
16 * itself `refreshing` while a background refresh runs; `awaitRefresh` waits
17 * for that refresh instead. See `cursorAccountCache`.
18 *
15 * @module UsageService19 * @module UsageService
16 */20 */
17import * as NodeOS from "node:os";21import * as NodeOS from "node:os";
...@@ -38,6 +42,8 @@ import * as Crypto from "effect/Crypto";...@@ -38,6 +42,8 @@ import * as Crypto from "effect/Crypto";
38import * as DateTime from "effect/DateTime";42import * as DateTime from "effect/DateTime";
39import * as Deferred from "effect/Deferred";43import * as Deferred from "effect/Deferred";
40import * as Effect from "effect/Effect";44import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
46import * as Fiber from "effect/Fiber";
41import * as FileSystem from "effect/FileSystem";47import * as FileSystem from "effect/FileSystem";
42import * as Layer from "effect/Layer";48import * as Layer from "effect/Layer";
43import * as Option from "effect/Option";49import * as Option from "effect/Option";
...@@ -55,7 +61,18 @@ import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAu...@@ -55,7 +61,18 @@ import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAu
55import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";61import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
56import { readOpenCodeUsage } from "./opencodeUsageReader.ts";62import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
57import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";63import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
58import { readCursorAccountUsage } from "./cursorUsageReader.ts";64import {
65 CURSOR_ACCOUNT_CACHE_FILE_NAME,
66 CURSOR_ACCOUNT_TTL_MS,
67 cursorFetchRange,
68 decodeCursorAccountCaches,
69 encodeCursorAccountCaches,
70 isCursorCacheFresh,
71 mergeCursorFetch,
72 type CursorAccountCache,
73 type CursorCredentialSource,
74} from "./cursorAccountCache.ts";
75import * as CursorUsageReader from "./cursorUsageReader.ts";
59import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";76import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
60import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";77import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
61import {78import {
...@@ -91,8 +108,13 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000;...@@ -91,8 +108,13 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000;
91const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;108const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
92const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;109const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
93110
94/** Longest window the UI offers, plus slack. Older entries are pruned. */111/**
95const CACHE_RETENTION_DAYS = 90;112 * The longest window the UI offers, 90 days, plus its `MTIME_SLACK_MS`, rounded
113 * up. Older entries are pruned.
114 */
115const CACHE_RETENTION_DAYS = 92;
116
117const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read.";
96118
97/** Transcripts parsed at once. More gains little once the disk stays busy. */119/** Transcripts parsed at once. More gains little once the disk stays busy. */
98const TRANSCRIPT_READ_CONCURRENCY = 4;120const TRANSCRIPT_READ_CONCURRENCY = 4;
...@@ -195,11 +217,26 @@ export const make = Effect.gen(function* () {...@@ -195,11 +217,26 @@ export const make = Effect.gen(function* () {
195 const httpClient = yield* HttpClient.HttpClient;217 const httpClient = yield* HttpClient.HttpClient;
196 const hostEnvironment = yield* HostProcessEnvironment;218 const hostEnvironment = yield* HostProcessEnvironment;
197 const platform = yield* HostProcessPlatform;219 const platform = yield* HostProcessPlatform;
220 const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader;
198221
199 const fileCache: ScanCache = new Map();222 const fileCache: ScanCache = new Map();
200 const antigravityCache = makeAntigravityUsageCache();223 const antigravityCache = makeAntigravityUsageCache();
201 const sourceCache = new Map<string, typeof CachedSource.Type>();224 const sourceCache = new Map<string, typeof CachedSource.Type>();
202 let cacheDirty = false;225 let cacheDirty = false;
226 /** Cursor account caches by credential source. */
227 const cursorCaches = new Map<string, CursorAccountCache>();
228 let cursorCacheDirty = false;
229 /**
230 * The last failed refresh per credential source, standing for a TTL so a
231 * client refetching a broken login does not refetch Cursor each time. A
232 * `null` error means there is no login: no source to report.
233 */
234 const cursorFailures = new Map<
235 string,
236 { readonly atMs: number; readonly error: string | null }
237 >();
238 /** The refresh in flight per credential source, which every read joins. */
239 const cursorRefreshes = new Map<string, Deferred.Deferred<void>>();
203 const isWithinDirectory = (filePath: string, dir: string) => {240 const isWithinDirectory = (filePath: string, dir: string) => {
204 const relative = path.relative(dir, filePath);241 const relative = path.relative(dir, filePath);
205 return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);242 return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative);
...@@ -208,6 +245,7 @@ export const make = Effect.gen(function* () {...@@ -208,6 +245,7 @@ export const make = Effect.gen(function* () {
208 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");245 const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
209 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);246 const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME);
210 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);247 const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME);
248 const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME);
211 const writeCacheFile = (filePath: string, contents: string) =>249 const writeCacheFile = (filePath: string, contents: string) =>
212 writeFileStringAtomically({ filePath, contents }).pipe(250 writeFileStringAtomically({ filePath, contents }).pipe(
213 Effect.provideService(FileSystem.FileSystem, fileSystem),251 Effect.provideService(FileSystem.FileSystem, fileSystem),
...@@ -398,7 +436,7 @@ export const make = Effect.gen(function* () {...@@ -398,7 +436,7 @@ export const make = Effect.gen(function* () {
398 });436 });
399437
400 /**438 /**
401 * Loads the persisted scan cache exactly once per process.439 * Loads the persisted scan and Cursor account caches exactly once per process.
402 *440 *
403 * `Effect.cached` makes concurrent first readers await the same load rather441 * `Effect.cached` makes concurrent first readers await the same load rather
404 * than each seeing a "loaded" flag set before the read finished and cold442 * than each seeing a "loaded" flag set before the read finished and cold
...@@ -411,6 +449,9 @@ export const make = Effect.gen(function* () {...@@ -411,6 +449,9 @@ export const make = Effect.gen(function* () {
411 Effect.flatMap((raw) => decodeScanCacheFile(raw)),449 Effect.flatMap((raw) => decodeScanCacheFile(raw)),
412 Effect.catchCause(() => Effect.succeed(null)),450 Effect.catchCause(() => Effect.succeed(null)),
413 );451 );
452 for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) {
453 cursorCaches.set(key, cache);
454 }
414 let document = yield* readDocument(scanCachePath);455 let document = yield* readDocument(scanCachePath);
415 if (document === null) {456 if (document === null) {
416 document = yield* readDocument(legacyScanCachePath);457 document = yield* readDocument(legacyScanCachePath);
...@@ -432,25 +473,52 @@ export const make = Effect.gen(function* () {...@@ -432,25 +473,52 @@ export const make = Effect.gen(function* () {
432 // keeps an older snapshot from landing after a newer one.473 // keeps an older snapshot from landing after a newer one.
433 const persistLock = yield* Semaphore.make(1);474 const persistLock = yield* Semaphore.make(1);
434475
435 const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () {476 // Each dirty flag is cleared before encoding, so a change while the write is
436 if (!cacheDirty) return;477 // in flight marks it dirty again. A failed write restores the flag, so the
437 // Cleared before encoding, so a scan that changes the cache while this478 // next persist retries instead of leaving disk stale. A cache we cannot
438 // write is in flight marks it dirty again. A failed write restores the479 // write is a slower next start, not a failed read.
439 // flag, so the next scan retries instead of leaving disk stale.480 const persistCaches = Effect.gen(function* () {
440 cacheDirty = false;481 if (cacheDirty) {
441 yield* Effect.sync(() =>482 cacheDirty = false;
442 writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }),483 yield* Effect.sync(() =>
443 ).pipe(484 writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }),
444 Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)),485 ).pipe(
445 // A cache we cannot write is a slower next start, not a failed read.486 Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)),
446 Effect.catchCause(() =>487 Effect.catchCause(() =>
447 Effect.sync(() => {488 Effect.sync(() => {
448 cacheDirty = true;489 cacheDirty = true;
449 }),490 }),
450 ),491 ),
451 persistLock.withPermit,492 );
452 );493 }
453 });494 if (cursorCacheDirty) {
495 cursorCacheDirty = false;
496 yield* Effect.sync(() => encodeCursorAccountCaches(cursorCaches)).pipe(
497 Effect.flatMap((contents) => writeCacheFile(cursorCachePath, contents)),
498 Effect.catchCause(() =>
499 Effect.sync(() => {
500 cursorCacheDirty = true;
501 }),
502 ),
503 );
504 }
505 }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches"));
506
507 const pendingPersists = new Set<Fiber.Fiber<void>>();
508 /** Writes dirty caches in the background, after the summary that dirtied them answers. */
509 const schedulePersist = Effect.forkDetach(persistCaches).pipe(
510 Effect.map((fiber) => {
511 pendingPersists.add(fiber);
512 fiber.addObserver(() => pendingPersists.delete(fiber));
513 }),
514 );
515 /** Waits for every write scheduled so far, as a restart would need. */
516 const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe(
517 Effect.asVoid,
518 );
519 // A write still running when the service shuts down finishes first, so the
520 // next start does not lose the last scan.
521 yield* Effect.addFinalizer(() => awaitPersisted);
454522
455 /**523 /**
456 * Parses one transcript, reusing the cached result when it is unchanged.524 * Parses one transcript, reusing the cached result when it is unchanged.
...@@ -541,58 +609,223 @@ export const make = Effect.gen(function* () {...@@ -541,58 +609,223 @@ export const make = Effect.gen(function* () {
541 readonly files:609 readonly files:
542 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]610 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
543 | null;611 | null;
612 /** Answered from a cache while a refresh runs. */
613 readonly refreshing?: true;
544 }614 }
545615
616 const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (
617 source: {
618 readonly provider: UsageProviderKind;
619 readonly dir: string;
620 readonly volumeId: string;
621 readonly fileName?: string;
622 },
623 windowStartMs: number,
624 ) {
625 const { provider, dir, volumeId, fileName } = source;
626 const exists = yield* fileSystem
627 .exists(dir)
628 .pipe(Effect.catchCause(() => Effect.succeed(false)));
629 if (!exists) return { provider, dir, volumeId, files: null } satisfies ScannedDir;
630 const files = yield* Effect.promise(() =>
631 listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
632 );
633 // A cold parse waits on disk reads, so a few files in flight read
634 // close to twice as fast. Results keep walk order.
635 const read = yield* Effect.forEach(
636 files,
637 (file) =>
638 readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe(
639 Effect.map((result) => ({ path: file.path, ...result })),
640 ),
641 { concurrency: TRANSCRIPT_READ_CONCURRENCY },
642 );
643 const parsedFiles = read.map(({ path, records, update }) => {
644 if (update === undefined) return { path, records };
645 // A scan of another window may have cached its own read of this file
646 // meanwhile. Then keep whichever read saw the later file, so a slower
647 // scan never replaces newer usage with older.
648 const current = fileCache.get(path);
649 if (
650 current === update.replaces ||
651 current === undefined ||
652 !isLaterRead(current, update.entry)
653 ) {
654 fileCache.set(path, update.entry);
655 cacheDirty = true;
656 }
657 return { path, records };
658 });
659 return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir;
660 });
661
662 /** Fetches what one account cache is missing, then persists it if anything changed. */
663 const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* (
664 credential: CursorCredentialSource,
665 credentialKey: string,
666 retentionStartMs: number,
667 ) {
668 const nowMs = yield* Clock.currentTimeMillis;
669 const fetchMissing = (cache: CursorAccountCache | undefined) => {
670 const range = cursorFetchRange(cache, retentionStartMs, nowMs);
671 return cursorAccountReader
672 .read(credential, range.sinceMs, range.untilMs)
673 .pipe(Effect.map((result) => ({ range, result })));
674 };
675 let base = cursorCaches.get(credentialKey);
676 let fetched = yield* fetchMissing(base);
677 // Another login's history replaces the cached account's, even if reading it fails.
678 if (
679 base !== undefined &&
680 fetched.result.accountKey !== null &&
681 fetched.result.accountKey !== base.accountKey
682 ) {
683 cursorCaches.delete(credentialKey);
684 cursorCacheDirty = true;
685 base = undefined;
686 fetched = yield* fetchMissing(base);
687 }
688 const { range, result } = fetched;
689 if (result.missing || result.error !== null || result.accountKey === null) {
690 cursorFailures.set(credentialKey, {
691 atMs: nowMs,
692 error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR,
693 });
694 yield* schedulePersist;
695 return;
696 }
697 const merged = mergeCursorFetch(
698 base,
699 result.accountKey,
700 range,
701 result.records,
702 nowMs,
703 retentionStartMs,
704 );
705 cursorCaches.set(credentialKey, merged.cache);
706 cursorFailures.delete(credentialKey);
707 // An unchanged edge is not worth rewriting the file for: after a restart
708 // the cache just refetches a slightly wider edge.
709 if (merged.changed) {
710 cursorCacheDirty = true;
711 yield* schedulePersist;
712 }
713 });
714
715 /** Joins the refresh in flight, or starts one. */
716 const startCursorRefresh = (
717 credential: CursorCredentialSource,
718 credentialKey: string,
719 retentionStartMs: number,
720 ) =>
721 // Enrollment and fork are atomic, so an interrupted caller cannot leave a
722 // registered refresh that nothing will finish.
723 Effect.uninterruptible(
724 Effect.gen(function* () {
725 const current = cursorRefreshes.get(credentialKey);
726 if (current !== undefined) return current;
727 const done = Deferred.makeUnsafe<void>();
728 cursorRefreshes.set(credentialKey, done);
729 // Detached: a departing client must not cancel a fetch later reads reuse.
730 yield* refreshCursorAccount(credential, credentialKey, retentionStartMs).pipe(
731 Effect.catchCause(() =>
732 Clock.currentTimeMillis.pipe(
733 Effect.map((atMs) =>
734 cursorFailures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }),
735 ),
736 ),
737 ),
738 Effect.ensuring(
739 Effect.suspend(() => {
740 cursorRefreshes.delete(credentialKey);
741 return Deferred.succeed(done, undefined);
742 }),
743 ),
744 Effect.forkDetach,
745 );
746 return done;
747 }),
748 );
749
750 /**
751 * The Cursor account source. A cache inside its TTL, or a refresh that failed
752 * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh`
753 * waits for it, and anything else answers from the cache marked `refreshing`.
754 */
755 const cursorAccountSource = Effect.fn("UsageService.cursorAccountSource")(function* (
756 credential: CursorCredentialSource,
757 authPath: string,
758 windowStartMs: number,
759 retentionStartMs: number,
760 awaitRefresh: boolean,
761 ) {
762 // No saved login means there is no account source to report, not a setup error.
763 if (
764 typeof credential === "string" &&
765 !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true)))
766 ) {
767 return null;
768 }
769 const credentialKey = typeof credential === "string" ? credential : "keychain";
770 const nowMs = yield* Clock.currentTimeMillis;
771 const recentFailure = cursorFailures.get(credentialKey);
772 let refreshing = false;
773 if (
774 (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) &&
775 !isCursorCacheFresh(cursorCaches.get(credentialKey), nowMs)
776 ) {
777 const refresh = yield* startCursorRefresh(credential, credentialKey, retentionStartMs);
778 if (awaitRefresh) yield* Deferred.await(refresh);
779 else refreshing = true;
780 }
781
782 const cache = cursorCaches.get(credentialKey);
783 const failure = refreshing ? undefined : cursorFailures.get(credentialKey);
784 const failureMessage = failure === undefined ? undefined : failure.error;
785 if (failureMessage === null) return null;
786 if (cache === undefined) {
787 return {
788 provider: "cursor",
789 dir: authPath,
790 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(authPath)),
791 // Never combine a local fallback with another server's account-wide history.
792 ...(refreshing
793 ? { files: [], refreshing: true }
794 : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }),
795 } satisfies ScannedDir;
796 }
797 // The same account includes CLI and desktop history from every machine.
798 // A stable remote fingerprint prevents connected environments counting it twice.
799 const source = `cursor-account:${cache.accountKey}`;
800 return {
801 provider: "cursor",
802 dir: source,
803 hostId: "cursor.com",
804 volumeId: cache.accountKey,
805 files: [
806 {
807 path: source,
808 records: cache.records.filter((record) => record.timestampMs >= windowStartMs),
809 },
810 ],
811 ...(failureMessage === undefined
812 ? { status: "ok" }
813 : { status: "partial", message: failureMessage }),
814 ...(refreshing ? { refreshing: true } : {}),
815 } satisfies ScannedDir;
816 });
817
546 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (818 const collectDirs = Effect.fn("UsageService.collectDirs")(function* (
547 windowStartMs: number,819 windowStartMs: number,
548 settings: ServerSettingsValue,820 settings: ServerSettingsValue,
549 retentionCutoffMs: number,821 retentionCutoffMs: number,
822 awaitRefresh: boolean,
550 ) {823 ) {
551 // The home resolvers ask for `Path` themselves; satisfy them from the824 // The home resolvers ask for `Path` themselves; satisfy them from the
552 // instance we already hold so the scan stays context-free.825 // instance we already hold so the scan stays context-free.
553 const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe(826 const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe(
554 Effect.provideService(Path.Path, path),827 Effect.provideService(Path.Path, path),
555 );828 );
556 const scanned: ScannedDir[] = [];
557 for (const { provider, dir, volumeId, fileName } of dirs) {
558 const exists = yield* fileSystem
559 .exists(dir)
560 .pipe(Effect.catchCause(() => Effect.succeed(false)));
561 if (!exists) {
562 scanned.push({ provider, dir, volumeId, files: null });
563 continue;
564 }
565 const files = yield* Effect.promise(() =>
566 listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
567 );
568 // A cold parse waits on disk reads, so a few files in flight read
569 // close to twice as fast. Results keep walk order.
570 const read = yield* Effect.forEach(
571 files,
572 (file) =>
573 readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe(
574 Effect.map((result) => ({ path: file.path, ...result })),
575 ),
576 { concurrency: TRANSCRIPT_READ_CONCURRENCY },
577 );
578 const parsedFiles = read.map(({ path, records, update }) => {
579 if (update === undefined) return { path, records };
580 // A scan of another window may have cached its own read of this file
581 // meanwhile. Then keep whichever read saw the later file, so a slower
582 // scan never replaces newer usage with older.
583 const current = fileCache.get(path);
584 if (
585 current === update.replaces ||
586 current === undefined ||
587 !isLaterRead(current, update.entry)
588 ) {
589 fileCache.set(path, update.entry);
590 cacheDirty = true;
591 }
592 return { path, records };
593 });
594 scanned.push({ provider, dir, volumeId, files: parsedFiles });
595 }
596829
597 const home = NodeOS.homedir();830 const home = NodeOS.homedir();
598 const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) {831 const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) {
...@@ -610,156 +843,168 @@ export const make = Effect.gen(function* () {...@@ -610,156 +843,168 @@ export const make = Effect.gen(function* () {
610 return [...canonical];843 return [...canonical];
611 });844 });
612 const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim();845 const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim();
613 for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [846
614 path.join(847 const openCode = Effect.gen(function* () {
615 dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"),848 const roots = yield* envRoots("OPENCODE_DATA_DIR", [
616 "opencode",849 path.join(
617 ),850 dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"),
618 ])) {851 "opencode",
619 const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs));852 ),
620 scanned.push({853 ]);
621 provider: "opencode",854 return yield* Effect.forEach(
622 dir,855 roots,
623 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),856 (dir) =>
624 files: result.missing && !result.error ? null : result.files,857 Effect.gen(function* () {
625 status: result.error ? "partial" : "ok",858 const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs));
626 ...(result.error ? { message: "Some OpenCode history could not be read." } : {}),859 return {
627 });860 provider: "opencode",
628 }861 dir,
629 const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [862 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
630 ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) =>863 files: result.missing && !result.error ? null : result.files,
631 path.join(home, ".gemini", name),864 status: result.error ? "partial" : "ok",
632 ),865 ...(result.error ? { message: "Some OpenCode history could not be read." } : {}),
633 path.join(home, ".config", "antigravity"),866 } satisfies ScannedDir;
634 ]);867 }),
635 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {868 { concurrency: "unbounded" },
636 if (instance.driver === "antigravity") {
637 const directories = yield* resolveAntigravityInstanceDirectories(
638 config.stateDir,
639 ProviderInstanceId.make(instanceId),
640 ).pipe(
641 Effect.provideService(Crypto.Crypto, crypto),
642 Effect.provideService(Path.Path, path),
643 Effect.mapError(
644 (cause) =>
645 new UsageReadError({
646 reason: "scanFailed",
647 detail: "Antigravity profile directory could not be resolved.",
648 cause,
649 }),
650 ),
651 );
652 antigravityRoots.push(path.join(directories.profile, "antigravity-acp"));
653 }
654 }
655 const antigravityDirs = new Set<string>();
656 for (const root of antigravityRoots) {
657 const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
658 const nested = path.join(resolvedRoot, "conversations");
659 const dir = (yield* fileSystem
660 .exists(nested)
661 .pipe(Effect.catchCause(() => Effect.succeed(false))))
662 ? nested
663 : resolvedRoot;
664 antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir)));
665 }
666 const antigravity = yield* Effect.promise(() =>
667 readAntigravityUsage([...antigravityDirs], windowStartMs, antigravityCache),
668 );
669 for (const dir of antigravityDirs) {
670 const exists = yield* fileSystem
671 .exists(dir)
672 .pipe(Effect.catchCause(() => Effect.succeed(false)));
673 const failed = antigravity.errors.some(
674 (error) => error === dir || error.startsWith(`${dir}${path.sep}`),
675 );869 );
676 scanned.push({870 });
677 provider: "antigravity",871
678 dir,872 const antigravity = Effect.gen(function* () {
679 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),873 const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [
680 files: !exists && !failed ? null : antigravity.files.filter((file) => file.root === dir),874 ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) =>
681 status: failed ? "partial" : "ok",875 path.join(home, ".gemini", name),
682 ...(failed ? { message: "Some Antigravity history could not be read." } : {}),876 ),
683 });877 path.join(home, ".config", "antigravity"),
684 }878 ]);
685 const cursorUserHome =879 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
686 (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;880 if (instance.driver === "antigravity") {
687 const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();881 const directories = yield* resolveAntigravityInstanceDirectories(
688 const cursorHome =882 config.stateDir,
689 platform === "darwin"883 ProviderInstanceId.make(instanceId),
690 ? path.join(cursorUserHome, "Library", "Application Support")884 ).pipe(
691 : platform === "win32"885 Effect.provideService(Crypto.Crypto, crypto),
692 ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming")886 Effect.provideService(Path.Path, path),
693 : configHome && path.isAbsolute(configHome)887 Effect.mapError(
694 ? configHome888 (cause) =>
695 : path.join(cursorUserHome, ".config");889 new UsageReadError({
696 const cursorAuthPath =890 reason: "scanFailed",
697 platform === "darwin"891 detail: "Antigravity profile directory could not be resolved.",
698 ? path.join(cursorUserHome, ".cursor", "auth.json")892 cause,
699 : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");893 }),
700 const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];894 ),
701 const loginUnavailable =895 );
702 Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) ||896 antigravityRoots.push(path.join(directories.profile, "antigravity-acp"));
703 Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) ||
704 credentialStore === "memory";
705 if (
706 platform === "darwin" &&
707 credentialStore !== "file" &&
708 !loginUnavailable &&
709 !settings.cursorKeychainUsageEnabled
710 ) {
711 scanned.push({
712 provider: "cursor",
713 dir: cursorAuthPath,
714 volumeId: "",
715 files: null,
716 message: "Cursor account usage is off on this environment.",
717 action: "enableCursorKeychain",
718 });
719 return scanned;
720 }
721 const cursorUntilMs = yield* Clock.currentTimeMillis;
722 const account = loginUnavailable
723 ? {
724 accountKey: null,
725 records: [],
726 missing: true,
727 error: "Cursor account history needs a Cursor CLI login on this server.",
728 }897 }
729 : yield* Effect.promise(() =>898 }
730 readCursorAccountUsage(899 const antigravityDirs = new Set<string>();
731 platform === "darwin" && credentialStore !== "file"900 for (const root of antigravityRoots) {
732 ? { kind: "keychain" }901 const resolvedRoot = yield* fileSystem
733 : cursorAuthPath,902 .realPath(root)
734 windowStartMs,903 .pipe(Effect.orElseSucceed(() => root));
735 cursorUntilMs,904 const nested = path.join(resolvedRoot, "conversations");
736 ),905 const dir = (yield* fileSystem
906 .exists(nested)
907 .pipe(Effect.catchCause(() => Effect.succeed(false))))
908 ? nested
909 : resolvedRoot;
910 antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir)));
911 }
912 const result = yield* Effect.promise(() =>
913 readAntigravityUsage([...antigravityDirs], windowStartMs, antigravityCache),
914 );
915 const scanned: ScannedDir[] = [];
916 for (const dir of antigravityDirs) {
917 const exists = yield* fileSystem
918 .exists(dir)
919 .pipe(Effect.catchCause(() => Effect.succeed(false)));
920 const failed = result.errors.some(
921 (error) => error === dir || error.startsWith(`${dir}${path.sep}`),
737 );922 );
738 // No saved login means there is no account source to report, not a setup error.923 scanned.push({
739 if (account.missing && account.error === null) return scanned;924 provider: "antigravity",
740 if (account.accountKey !== null && account.error === null && !account.missing) {925 dir,
741 // The same account includes CLI and desktop history from every machine.926 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)),
742 // A stable remote fingerprint prevents connected environments counting it twice.927 files: !exists && !failed ? null : result.files.filter((file) => file.root === dir),
743 const source = `cursor-account:${account.accountKey}`;928 status: failed ? "partial" : "ok",
744 scanned.push({929 ...(failed ? { message: "Some Antigravity history could not be read." } : {}),
745 provider: "cursor",930 });
746 dir: source,931 }
747 hostId: "cursor.com",
748 volumeId: account.accountKey,
749 files: [{ path: source, records: account.records }],
750 status: "ok",
751 });
752 return scanned;932 return scanned;
753 }
754 scanned.push({
755 provider: "cursor",
756 dir: cursorAuthPath,
757 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)),
758 // Never combine a local fallback with another server's account-wide history.
759 files: null,
760 message:
761 account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.",
762 });933 });
934
935 const cursor = Effect.gen(function* () {
936 const cursorUserHome =
937 (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
938 const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();
939 const cursorHome =
940 platform === "darwin"
941 ? path.join(cursorUserHome, "Library", "Application Support")
942 : platform === "win32"
943 ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming")
944 : configHome && path.isAbsolute(configHome)
945 ? configHome
946 : path.join(cursorUserHome, ".config");
947 const cursorAuthPath =
948 platform === "darwin"
949 ? path.join(cursorUserHome, ".cursor", "auth.json")
950 : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");
951 const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];
952 const loginUnavailable =
953 Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) ||
954 Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) ||
955 credentialStore === "memory";
956 const useKeychain = platform === "darwin" && credentialStore !== "file";
957 if (useKeychain && !loginUnavailable && !settings.cursorKeychainUsageEnabled) {
958 return [
959 {
960 provider: "cursor",
961 dir: cursorAuthPath,
962 volumeId: "",
963 files: null,
964 message: "Cursor account usage is off on this environment.",
965 action: "enableCursorKeychain",
966 } satisfies ScannedDir,
967 ];
968 }
969 if (loginUnavailable) {
970 return [
971 {
972 provider: "cursor",
973 dir: cursorAuthPath,
974 volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)),
975 files: null,
976 message: "Cursor account history needs a Cursor CLI login on this server.",
977 } satisfies ScannedDir,
978 ];
979 }
980 const source = yield* cursorAccountSource(
981 useKeychain ? { kind: "keychain" } : cursorAuthPath,
982 cursorAuthPath,
983 windowStartMs,
984 retentionCutoffMs,
985 awaitRefresh,
986 );
987 return source === null ? [] : [source];
988 });
989
990 // Independent sources scan together. Transcript directories go one at a
991 // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result
992 // keeps this order, since aggregation keeps the first copy of a duplicate.
993 const [transcripts, openCodeDirs, antigravityDirs, cursorDirs] = yield* Effect.all(
994 [
995 Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)),
996 openCode,
997 antigravity,
998 cursor,
999 ],
1000 { concurrency: "unbounded" },
1001 );
1002 const scanned: readonly ScannedDir[] = [
1003 ...transcripts,
1004 ...openCodeDirs,
1005 ...antigravityDirs,
1006 ...cursorDirs,
1007 ];
763 return scanned;1008 return scanned;
764 });1009 });
7651010
...@@ -818,7 +1063,10 @@ export const make = Effect.gen(function* () {...@@ -818,7 +1063,10 @@ export const make = Effect.gen(function* () {
818 // loads while transcripts stream instead of gating them: a cold rates1063 // loads while transcripts stream instead of gating them: a cold rates
819 // fetch on a slow network no longer delays the scan by its own timeout.1064 // fetch on a slow network no longer delays the scan by its own timeout.
820 const [, scannedDirs] = yield* Effect.all(1065 const [, scannedDirs] = yield* Effect.all(
821 [ensureRates(false), collectDirs(windowStartMs, settings, retentionCutoffMs)],1066 [
1067 ensureRates(false),
1068 collectDirs(windowStartMs, settings, retentionCutoffMs, input.awaitRefresh === true),
1069 ],
822 { concurrency: 2 },1070 { concurrency: 2 },
823 );1071 );
8241072
...@@ -859,7 +1107,7 @@ export const make = Effect.gen(function* () {...@@ -859,7 +1107,7 @@ export const make = Effect.gen(function* () {
8591107
860 for (const [1108 for (const [
861 index,1109 index,
862 { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId },1110 { provider, dir, volumeId, files, status, message, action, refreshing, hostId: sourceHostId },
863 ] of scannedDirs.entries()) {1111 ] of scannedDirs.entries()) {
864 let scannedFiles = 0;1112 let scannedFiles = 0;
865 let skippedFiles = 0;1113 let skippedFiles = 0;
...@@ -910,12 +1158,12 @@ export const make = Effect.gen(function* () {...@@ -910,12 +1158,12 @@ export const make = Effect.gen(function* () {
910 message:1158 message:
911 message ?? (files === null ? "No transcript directory on this environment." : null),1159 message ?? (files === null ? "No transcript directory on this environment." : null),
912 ...(action ? { action } : {}),1160 ...(action ? { action } : {}),
1161 ...(refreshing ? { refreshing } : {}),
913 });1162 });
914 }1163 }
9151164
916 const pruned = pruneScanCache(fileCache, retentionCutoffMs);1165 const pruned = pruneScanCache(fileCache, retentionCutoffMs);
917 if (pruned > 0) cacheDirty = true;1166 if (pruned > 0) cacheDirty = true;
918 yield* persistScanCache();
9191167
920 const aggregated = aggregator.finish();1168 const aggregated = aggregator.finish();
921 const readAt = yield* DateTime.now;1169 const readAt = yield* DateTime.now;
...@@ -952,6 +1200,8 @@ export const make = Effect.gen(function* () {...@@ -952,6 +1200,8 @@ export const make = Effect.gen(function* () {
952 settings.usagePriceOverrides,1200 settings.usagePriceOverrides,
953 settings.usageModelAliases,1201 settings.usageModelAliases,
954 settings.cursorKeychainUsageEnabled,1202 settings.cursorKeychainUsageEnabled,
1203 // A waiting read must never share a scan that answers with `refreshing`.
1204 input.awaitRefresh === true,
955 ]);1205 ]);
9561206
957 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {1207 const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
...@@ -968,9 +1218,12 @@ export const make = Effect.gen(function* () {...@@ -968,9 +1218,12 @@ export const make = Effect.gen(function* () {
968 inflightScans.set(key, created);1218 inflightScans.set(key, created);
969 // Detached so one departing client cannot tear the scan out from under1219 // Detached so one departing client cannot tear the scan out from under
970 // the fibers awaiting it; a finished scan warms the cache either way.1220 // the fibers awaiting it; a finished scan warms the cache either way.
1221 // The cache write is registered before the waiters resume, so they
1222 // can await it, but its fiber starts after they have the summary.
971 yield* scanSummary(input, settings).pipe(1223 yield* scanSummary(input, settings).pipe(
972 Effect.onExit((exit) =>1224 Effect.onExit((exit) =>
973 Effect.sync(() => inflightScans.delete(key)).pipe(1225 Effect.sync(() => inflightScans.delete(key)).pipe(
1226 Effect.andThen(Exit.isSuccess(exit) ? schedulePersist : Effect.void),
974 Effect.andThen(Deferred.done(created, exit)),1227 Effect.andThen(Deferred.done(created, exit)),
975 ),1228 ),
976 ),1229 ),
...@@ -984,7 +1237,9 @@ export const make = Effect.gen(function* () {...@@ -984,7 +1237,9 @@ export const make = Effect.gen(function* () {
984 return yield* Deferred.await(deferred);1237 return yield* Deferred.await(deferred);
985 });1238 });
9861239
987 return { readSummary, refreshRates } as const;1240 // `awaitPersisted` is outside the service interface: tests use it to restart
1241 // against what a previous instance wrote.
1242 return { readSummary, refreshRates, awaitPersisted } as const;
988});1243});
9891244
990export const layer = Layer.effect(UsageService, make);1245export const layer = Layer.effect(UsageService, make);
apps/server/src/usage/cursorAccountCache.test.ts created+80
...@@ -0,0 +1,80 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts";
4import type { UsageRecord } from "./usageTranscripts.ts";
5
6const DAY_MS = 24 * 60 * 60 * 1000;
7
8const record = (timestampMs: number): UsageRecord => ({
9 provider: "cursor",
10 timestampMs,
11 model: "claude-fable-5",
12 sessionId: "conversation-1",
13 totals: {
14 uncachedInputTokens: 0,
15 cachedInputTokens: 0,
16 cacheCreationTokens: 0,
17 outputTokens: 1,
18 reasoningTokens: 0,
19 },
20 reportedCostUsd: null,
21 speed: "standard",
22 dedupeKey: `cursor-account:a:${timestampMs}:0`,
23});
24
25describe("cursorAccountCache", () => {
26 it("refetches everything once the cache no longer reaches the retention start", () => {
27 const cache = {
28 accountKey: "a",
29 sinceMs: 0,
30 untilMs: 10 * DAY_MS,
31 fetchedAtMs: 10 * DAY_MS,
32 records: [record(5 * DAY_MS)],
33 };
34 const nowMs = 100 * DAY_MS;
35 const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs);
36 assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs });
37
38 const { cache: merged } = mergeCursorFetch(
39 cache,
40 "a",
41 range,
42 [record(50 * DAY_MS)],
43 nowMs,
44 10 * DAY_MS,
45 );
46 assert.deepStrictEqual(
47 merged.records.map((entry) => entry.timestampMs),
48 [50 * DAY_MS],
49 );
50 // Then only the newest edge.
51 assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), {
52 sinceMs: nowMs - 60 * 60 * 1000,
53 untilMs: nowMs + DAY_MS,
54 });
55 });
56
57 it("reports an edge that only confirms the cache as unchanged", () => {
58 const cache = {
59 accountKey: "a",
60 sinceMs: 0,
61 untilMs: 10 * DAY_MS,
62 fetchedAtMs: 10 * DAY_MS,
63 records: [record(DAY_MS), record(10 * DAY_MS)],
64 };
65 const range = cursorFetchRange(cache, 0, 11 * DAY_MS);
66 assert.isFalse(
67 mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed,
68 );
69 assert.isTrue(
70 mergeCursorFetch(
71 cache,
72 "a",
73 range,
74 [record(10 * DAY_MS), record(10.5 * DAY_MS)],
75 11 * DAY_MS,
76 0,
77 ).changed,
78 );
79 });
80});
apps/server/src/usage/cursorAccountCache.ts created+271
...@@ -0,0 +1,271 @@
1/**
2 * Cursor account usage, cached per credential source.
3 *
4 * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched
5 * records are kept for the whole retention period, whatever window was asked
6 * for. After one full fetch, a refresh fetches only the newest edge.
7 *
8 * A record's dedupe key carries its timestamp and an occurrence index counted
9 * within one fetch. A refetched range therefore replaces every cached record in
10 * that range rather than adding to it, so each timestamp's records always come
11 * from a single fetch and their occurrence indexes cannot collide.
12 *
13 * @module cursorAccountCache
14 */
15import { cursorRateModel } from "./cursorUsageReader.ts";
16import type { UsageRecord } from "./usageTranscripts.ts";
17
18export type CursorCredentialSource = string | { readonly kind: "keychain" };
19
20/** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */
21export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000;
22
23/** Dashboard events can be finalized after they were first read. */
24const REFETCH_OVERLAP_MS = 60 * 60 * 1000;
25
26export interface CursorAccountCache {
27 readonly accountKey: string;
28 /** Inclusive range of event timestamps the records are complete for. */
29 readonly sinceMs: number;
30 readonly untilMs: number;
31 /** When the newest edge was fetched. */
32 readonly fetchedAtMs: number;
33 readonly records: readonly UsageRecord[];
34}
35
36interface CursorFetchRange {
37 readonly sinceMs: number;
38 readonly untilMs: number;
39}
40
41/** Whether the cache answers without a refresh. */
42export function isCursorCacheFresh(
43 cache: CursorAccountCache | undefined,
44 nowMs: number,
45): cache is CursorAccountCache {
46 return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS;
47}
48
49/**
50 * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the
51 * newest edge, or everything when the cache does not reach back that far.
52 */
53export function cursorFetchRange(
54 cache: CursorAccountCache | undefined,
55 retentionStartMs: number,
56 nowMs: number,
57): CursorFetchRange {
58 return cache === undefined ||
59 cache.sinceMs > retentionStartMs ||
60 cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs
61 ? { sinceMs: retentionStartMs, untilMs: nowMs }
62 : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs };
63}
64
65/**
66 * Applies a fetched range to the cache, replacing the cached records it covers
67 * and dropping those before the retention start. Pass `undefined` for a cache
68 * that belongs to another account. `changed` is false when the fetch only
69 * confirmed the cached records, so there is nothing new to persist.
70 */
71export function mergeCursorFetch(
72 cache: CursorAccountCache | undefined,
73 accountKey: string,
74 range: CursorFetchRange,
75 fetched: readonly UsageRecord[],
76 nowMs: number,
77 retentionStartMs: number,
78) {
79 const inRange = (record: UsageRecord) =>
80 record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs;
81 const previous = cache?.records ?? [];
82 const added = fetched.filter(inRange);
83 const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey));
84 const changed =
85 cache === undefined ||
86 replacedKeys.size !== added.length ||
87 added.some((record) => !replacedKeys.has(record.dedupeKey));
88 return {
89 changed,
90 cache: {
91 accountKey,
92 sinceMs: retentionStartMs,
93 untilMs: range.untilMs,
94 fetchedAtMs: nowMs,
95 records: [
96 ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs),
97 ...added,
98 ],
99 } satisfies CursorAccountCache,
100 };
101}
102
103/**
104 * Each version writes its own file, as the scan cache does, so servers of
105 * different versions sharing a state directory keep their own caches.
106 */
107export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json";
108const CURSOR_ACCOUNT_CACHE_VERSION = 1;
109
110/** Positional rows with interned strings, as in the scan cache. */
111type SerializedRecord = readonly [
112 timestampMs: number,
113 modelIndex: number,
114 sessionIndex: number,
115 uncachedInputTokens: number,
116 cachedInputTokens: number,
117 cacheCreationTokens: number,
118 outputTokens: number,
119 reportedCostUsd: number | null,
120 dedupeKey: string | null,
121];
122
123interface SerializedAccount {
124 readonly accountKey: string;
125 readonly sinceMs: number;
126 readonly untilMs: number;
127 readonly fetchedAtMs: number;
128 readonly records: readonly SerializedRecord[];
129}
130
131/** Serialises the caches by credential source. */
132export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string {
133 const strings: string[] = [];
134 const indexes = new Map<string, number>();
135 const intern = (value: string) => {
136 let index = indexes.get(value);
137 if (index === undefined) {
138 index = strings.length;
139 strings.push(value);
140 indexes.set(value, index);
141 }
142 return index;
143 };
144 const accounts: Record<string, SerializedAccount> = {};
145 for (const [credentialKey, cache] of caches) {
146 accounts[credentialKey] = {
147 accountKey: cache.accountKey,
148 sinceMs: cache.sinceMs,
149 untilMs: cache.untilMs,
150 fetchedAtMs: cache.fetchedAtMs,
151 records: cache.records.map((record) => [
152 record.timestampMs,
153 intern(record.model),
154 intern(record.sessionId),
155 record.totals.uncachedInputTokens,
156 record.totals.cachedInputTokens,
157 record.totals.cacheCreationTokens,
158 record.totals.outputTokens,
159 record.reportedCostUsd,
160 record.dedupeKey,
161 ]),
162 };
163 }
164 return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts });
165}
166
167const isFiniteNumber = (value: unknown): value is number =>
168 typeof value === "number" && Number.isFinite(value);
169
170/**
171 * Rebuilds the caches from a parsed document. A malformed account is dropped
172 * whole, costing one refetch; keeping its survivors would leave a gap the
173 * covered range claims is complete.
174 */
175export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> {
176 const caches = new Map<string, CursorAccountCache>();
177 if (typeof document !== "object" || document === null) return caches;
178 const root = document as {
179 readonly version?: unknown;
180 readonly strings?: unknown;
181 readonly accounts?: unknown;
182 };
183 const strings = root.strings;
184 if (
185 root.version !== CURSOR_ACCOUNT_CACHE_VERSION ||
186 !Array.isArray(strings) ||
187 !strings.every((value): value is string => typeof value === "string") ||
188 typeof root.accounts !== "object" ||
189 root.accounts === null
190 ) {
191 return caches;
192 }
193 const stringAt = (index: unknown): string | undefined =>
194 typeof index === "number" ? strings[index] : undefined;
195
196 const decodeRecord = (row: unknown): UsageRecord | null => {
197 if (!Array.isArray(row)) return null;
198 const [
199 timestampMs,
200 modelIndex,
201 sessionIndex,
202 uncached,
203 cached,
204 creation,
205 output,
206 cost,
207 key,
208 ]: readonly unknown[] = row;
209 const model = stringAt(modelIndex);
210 const sessionId = stringAt(sessionIndex);
211 if (
212 !isFiniteNumber(timestampMs) ||
213 model === undefined ||
214 sessionId === undefined ||
215 !isFiniteNumber(uncached) ||
216 !isFiniteNumber(cached) ||
217 !isFiniteNumber(creation) ||
218 !isFiniteNumber(output) ||
219 (cost !== null && !isFiniteNumber(cost)) ||
220 (key !== null && typeof key !== "string")
221 ) {
222 return null;
223 }
224 return {
225 provider: "cursor",
226 timestampMs,
227 model,
228 rateModel: cursorRateModel(model),
229 sessionId,
230 totals: {
231 uncachedInputTokens: uncached,
232 cachedInputTokens: cached,
233 cacheCreationTokens: creation,
234 outputTokens: output,
235 reasoningTokens: 0,
236 },
237 reportedCostUsd: cost,
238 speed: "standard",
239 dedupeKey: key,
240 };
241 };
242
243 for (const [credentialKey, raw] of Object.entries(root.accounts)) {
244 if (typeof raw !== "object" || raw === null) continue;
245 const account = raw as Partial<Record<keyof SerializedAccount, unknown>>;
246 if (
247 typeof account.accountKey !== "string" ||
248 !isFiniteNumber(account.sinceMs) ||
249 !isFiniteNumber(account.untilMs) ||
250 !isFiniteNumber(account.fetchedAtMs) ||
251 !Array.isArray(account.records)
252 ) {
253 continue;
254 }
255 const records: UsageRecord[] = [];
256 for (const row of account.records) {
257 const record = decodeRecord(row);
258 if (record === null) break;
259 records.push(record);
260 }
261 if (records.length !== account.records.length) continue;
262 caches.set(credentialKey, {
263 accountKey: account.accountKey,
264 sinceMs: account.sinceMs,
265 untilMs: account.untilMs,
266 fetchedAtMs: account.fetchedAtMs,
267 records,
268 });
269 }
270 return caches;
271}
apps/server/src/usage/cursorUsageReader.ts+25
...@@ -4,6 +4,10 @@ import * as NodeFSP from "node:fs/promises";...@@ -4,6 +4,10 @@ import * as NodeFSP from "node:fs/promises";
4import * as NodeCrypto from "node:crypto";4import * as NodeCrypto from "node:crypto";
5import * as NodeTimersPromises from "node:timers/promises";5import * as NodeTimersPromises from "node:timers/promises";
66
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
7import type { UsageRecord } from "./usageTranscripts.ts";11import type { UsageRecord } from "./usageTranscripts.ts";
8import {12import {
9 CursorKeychainTimeoutError,13 CursorKeychainTimeoutError,
...@@ -289,3 +293,24 @@ export async function readCursorAccountUsage(...@@ -289,3 +293,24 @@ export async function readCursorAccountUsage(
289 cancel.abort();293 cancel.abort();
290 }294 }
291}295}
296
297/** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */
298export class CursorAccountReader extends Context.Service<
299 CursorAccountReader,
300 {
301 readonly read: (
302 credentialSource: string | { readonly kind: "keychain" },
303 sinceMs: number,
304 untilMs: number,
305 ) => Effect.Effect<CursorAccountUsageReadResult>;
306 }
307>()("t3/usage/cursorUsageReader/CursorAccountReader") {}
308
309/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */
310export const layer = Layer.succeed(
311 CursorAccountReader,
312 CursorAccountReader.of({
313 read: (credentialSource, sinceMs, untilMs) =>
314 Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)),
315 }),
316);
apps/web/src/components/usage/UsagePage.refresh.test.tsx+1
...@@ -50,6 +50,7 @@ vi.mock("../../state/usage", () => ({...@@ -50,6 +50,7 @@ vi.mock("../../state/usage", () => ({
50 },50 },
51 ],51 ],
52 isPending: false,52 isPending: false,
53 shown: null,
53 isPartial: false,54 isPartial: false,
54 refresh: async () => undefined,55 refresh: async () => undefined,
55 }),56 }),
apps/web/src/components/usage/UsagePage.test.tsx+1
...@@ -75,6 +75,7 @@ beforeEach(() => {...@@ -75,6 +75,7 @@ beforeEach(() => {
75 environments,75 environments,
76 selectedEnvironments: environments,76 selectedEnvironments: environments,
77 isPending: false,77 isPending: false,
78 shown: null,
78 isPartial: false,79 isPartial: false,
79 refresh: vi.fn(),80 refresh: vi.fn(),
80 });81 });
apps/web/src/components/usage/UsagePage.tsx+201-76
...@@ -8,18 +8,17 @@ import {...@@ -8,18 +8,17 @@ import {
8 type EnvironmentId,8 type EnvironmentId,
9 type UsageProviderKind,9 type UsageProviderKind,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import {11import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react";
12 CircleAlertIcon,
13 ChevronDownIcon,
14 CircleDashedIcon,
15 InfoIcon,
16 SlidersHorizontalIcon,
17} from "lucide-react";
18import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";12import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
19import {13import {
20 cursorKeychainAccessEnvironments,14 cursorKeychainAccessEnvironments,
21 refreshUsageLimits,15 refreshUsageLimits,
22} from "@t3tools/client-runtime/state/usage";16} from "@t3tools/client-runtime/state/usage";
17import {
18 updatingProvidersLabel,
19 usageEnvironmentProgress,
20 usageLoadingState,
21} from "@t3tools/client-runtime/state/usage-progress";
2322
24import {23import {
25 isCompatibleUsageContractVersion,24 isCompatibleUsageContractVersion,
...@@ -109,6 +108,8 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window...@@ -109,6 +108,8 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window
109 return WINDOW_OPTIONS.some((option) => option.days === value);108 return WINDOW_OPTIONS.some((option) => option.days === value);
110}109}
111110
111const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label;
112
112export function UsagePage() {113export function UsagePage() {
113 const [preferences, setPreferences] = useState(readUsagePagePreferences);114 const [preferences, setPreferences] = useState(readUsagePagePreferences);
114 useEscapeToGoBack();115 useEscapeToGoBack();
...@@ -145,11 +146,36 @@ export function UsagePage() {...@@ -145,11 +146,36 @@ export function UsagePage() {
145 );146 );
146 const { days: windowDays, window } = windowSelection;147 const { days: windowDays, window } = windowSelection;
147 const isPast24Hours = windowDays === 1;148 const isPast24Hours = windowDays === 1;
148 const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(149 const {
149 window,150 merged: answeredUsage,
150 selectedEnvironmentIds,151 environments,
151 hiddenProviders,152 selectedEnvironments,
152 );153 shown,
154 isPartial,
155 refresh,
156 } = useUsage(window, selectedEnvironmentIds, hiddenProviders);
157 // Until a new window's first answer, the previous one stays on screen, muted.
158 const merged = shown?.merged ?? answeredUsage;
159 const shownWindow = shown?.window ?? window;
160 const shownHourly = shownWindow.resolution === "hour";
161 const refreshingUsage = isRefreshing && !showingLimits;
162 // Usage kept from another window is all old, so every figure stays muted.
163 const showingKept = shown !== null && shown.window !== window;
164 const loading = useMemo(() => {
165 if (showingKept) {
166 return { partial: true, everyProvider: true, providers: new Set<UsageProviderKind>() };
167 }
168 const state = usageLoadingState(selectedEnvironments, refreshingUsage);
169 // A hidden provider still refreshing must not add its row or mute the totals.
170 const providers = new Set([...state.providers].filter((p) => !hiddenProviders.has(p)));
171 return {
172 partial: state.everyProvider || providers.size > 0,
173 everyProvider: state.everyProvider,
174 providers,
175 };
176 }, [showingKept, refreshingUsage, selectedEnvironments, hiddenProviders]);
177 const isProviderLoading = (provider: UsageProviderKind) =>
178 loading.everyProvider || loading.providers.has(provider);
153 const presentations = useAtomValue(environmentPresentations.presentationsAtom);179 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
154 const cursorAccessEnvironments = hiddenProviders.has("cursor")180 const cursorAccessEnvironments = hiddenProviders.has("cursor")
155 ? []181 ? []
...@@ -180,21 +206,21 @@ export function UsagePage() {...@@ -180,21 +206,21 @@ export function UsagePage() {
180 );206 );
181207
182 const days = useMemo(208 const days = useMemo(
183 () => enumerateDays(window.sinceDay, window.untilDay),209 () => enumerateDays(shownWindow.sinceDay, shownWindow.untilDay),
184 [window.sinceDay, window.untilDay],210 [shownWindow.sinceDay, shownWindow.untilDay],
185 );211 );
186 const hours = useMemo(212 const hours = useMemo(
187 () =>213 () =>
188 window.sinceTime === undefined || window.untilTime === undefined214 shownWindow.sinceTime === undefined || shownWindow.untilTime === undefined
189 ? []215 ? []
190 : enumerateHourStarts(window.sinceTime, window.untilTime),216 : enumerateHourStarts(shownWindow.sinceTime, shownWindow.untilTime),
191 [window.sinceTime, window.untilTime],217 [shownWindow.sinceTime, shownWindow.untilTime],
192 );218 );
193 // Newest first: the window can run 90 periods, so the interesting end219 // Newest first: the window can run 90 periods, so the interesting end
194 // belongs at the top of the table.220 // belongs at the top of the table.
195 const breakdownPeriods = useMemo<readonly (DailyTotals | HourlyTotals)[]>(221 const breakdownPeriods = useMemo<readonly (DailyTotals | HourlyTotals)[]>(
196 () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(),222 () => (shownHourly ? merged.hourly : merged.daily).toReversed(),
197 [isPast24Hours, merged.daily, merged.hourly],223 [shownHourly, merged.daily, merged.hourly],
198 );224 );
199 const breakdownModels = useMemo(225 const breakdownModels = useMemo(
200 () =>226 () =>
...@@ -203,7 +229,24 @@ export function UsagePage() {...@@ -203,7 +229,24 @@ export function UsagePage() {
203 : merged.models,229 : merged.models,
204 [breakdown, merged.models, metric],230 [breakdown, merged.models, metric],
205 );231 );
206 const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);232 const providersWithData = useMemo(() => providersWithUsage(merged.providers), [merged.providers]);
233 // A provider still refreshing keeps its row and line before its usage lands.
234 const activeProviders = useMemo(
235 () =>
236 PROVIDER_ORDER.filter(
237 (provider) => providersWithData.includes(provider) || loading.providers.has(provider),
238 ),
239 [loading.providers, providersWithData],
240 );
241 const chartLoadingProviders = useMemo(
242 () =>
243 new Set(
244 activeProviders.filter(
245 (provider) => loading.everyProvider || loading.providers.has(provider),
246 ),
247 ),
248 [activeProviders, loading.everyProvider, loading.providers],
249 );
207 const selectedModel =250 const selectedModel =
208 selectedModelKey === null251 selectedModelKey === null
209 ? undefined252 ? undefined
...@@ -332,10 +375,11 @@ export function UsagePage() {...@@ -332,10 +375,11 @@ export function UsagePage() {
332 if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits();375 if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits();
333 }, [showingLimits, connectedLimitsEnvironments]);376 }, [showingLimits, connectedLimitsEnvironments]);
334377
378 // Names the period on screen, which is the previous one until the new one answers.
335 const windowLabel =379 const windowLabel =
336 isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined380 shownHourly && shownWindow.sinceTime !== undefined && shownWindow.untilTime !== undefined
337 ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}`381 ? `${formatDateTimeShort(shownWindow.sinceTime, shownWindow.timeZone)} to ${formatDateTimeShort(shownWindow.untilTime, shownWindow.timeZone)}`
338 : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`;382 : `${formatDayShort(shownWindow.sinceDay)} to ${formatDayShort(shownWindow.untilDay)}`;
339 const topbarContent = (383 const topbarContent = (
340 <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 xl:flex">384 <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 xl:flex">
341 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">385 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb" className="min-w-0">
...@@ -350,6 +394,7 @@ export function UsagePage() {...@@ -350,6 +394,7 @@ export function UsagePage() {
350 selectedEnvironmentIds={selectedEnvironmentIds}394 selectedEnvironmentIds={selectedEnvironmentIds}
351 onSelectionChange={setSelectedEnvironmentIds}395 onSelectionChange={setSelectedEnvironmentIds}
352 showUsageStatus={!showingLimits}396 showUsageStatus={!showingLimits}
397 refreshing={refreshingUsage}
353 isPartial={isPartial}398 isPartial={isPartial}
354 duplicateSources={merged.duplicateSources}399 duplicateSources={merged.duplicateSources}
355 contractMismatches={merged.contractMismatches}400 contractMismatches={merged.contractMismatches}
...@@ -510,7 +555,7 @@ export function UsagePage() {...@@ -510,7 +555,7 @@ export function UsagePage() {
510 ) : null555 ) : null
511 }556 }
512 />557 />
513 ) : isPending ? (558 ) : shown === null ? (
514 <UsageSkeleton />559 <UsageSkeleton />
515 ) : !canReadDiagnostics ? (560 ) : !canReadDiagnostics ? (
516 <div className="space-y-2 py-12 text-center text-sm text-muted-foreground">561 <div className="space-y-2 py-12 text-center text-sm text-muted-foreground">
...@@ -522,7 +567,7 @@ export function UsagePage() {...@@ -522,7 +567,7 @@ export function UsagePage() {
522 ))}567 ))}
523 </div>568 </div>
524 ) : (569 ) : (
525 <>570 <div aria-busy={loading.partial} className="flex flex-col gap-6">
526 {sourceMessages.map((message) => (571 {sourceMessages.map((message) => (
527 <p key={message} className="mb-4 text-sm text-muted-foreground">572 <p key={message} className="mb-4 text-sm text-muted-foreground">
528 {message}573 {message}
...@@ -531,13 +576,20 @@ export function UsagePage() {...@@ -531,13 +576,20 @@ export function UsagePage() {
531 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">576 <section className="grid gap-6 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
532 <div className="flex min-w-0 flex-col gap-5">577 <div className="flex min-w-0 flex-col gap-5">
533 <div className="flex flex-col gap-1">578 <div className="flex flex-col gap-1">
534 <span className="text-4xl font-semibold text-foreground tabular-nums">579 <span
580 className={cn(
581 "text-4xl font-semibold text-foreground tabular-nums",
582 figureClass(loading.partial),
583 )}
584 >
535 {metric === "cost"585 {metric === "cost"
536 ? formatUsd(merged.costUsd)586 ? formatUsd(merged.costUsd)
537 : formatTokens(merged.totalTokens)}587 : formatTokens(merged.totalTokens)}
538 </span>588 </span>
539 <span className="text-xs text-muted-foreground">589 <span className="text-xs text-muted-foreground">
540 {formatCount(merged.sessions)} sessions590 <span className={figureClass(loading.partial)}>
591 {formatCount(merged.sessions)} sessions
592 </span>
541 {metric === "cost" && (593 {metric === "cost" && (
542 <>594 <>
543 {" · API estimate"}595 {" · API estimate"}
...@@ -600,6 +652,8 @@ export function UsagePage() {...@@ -600,6 +652,8 @@ export function UsagePage() {
600 const sessionLabel = `${formatCount(providerSessions)} ${652 const sessionLabel = `${formatCount(providerSessions)} ${
601 providerSessions === 1 ? "session" : "sessions"653 providerSessions === 1 ? "session" : "sessions"
602 }`;654 }`;
655 const providerLoading = isProviderLoading(provider);
656 const awaitingData = providerLoading && !providersWithData.includes(provider);
603 return (657 return (
604 <div key={provider} className="flex flex-col gap-1">658 <div key={provider} className="flex flex-col gap-1">
605 <div className="flex items-baseline justify-between gap-4">659 <div className="flex items-baseline justify-between gap-4">
...@@ -616,18 +670,38 @@ export function UsagePage() {...@@ -616,18 +670,38 @@ export function UsagePage() {
616 <span className="truncate">670 <span className="truncate">
617 {PROVIDER_PRESENTATION[provider].label}671 {PROVIDER_PRESENTATION[provider].label}
618 </span>672 </span>
619 <span className="shrink-0 whitespace-nowrap text-2xs text-muted-foreground tabular-nums">673 <span
674 className={cn(
675 "shrink-0 whitespace-nowrap text-2xs text-muted-foreground tabular-nums",
676 figureClass(providerLoading),
677 awaitingData && "invisible",
678 )}
679 >
620 {sessionLabel}680 {sessionLabel}
621 </span>681 </span>
622 </span>682 </span>
623 </span>683 </span>
624 <span className="shrink-0 text-sm font-medium text-foreground tabular-nums">684 <span
625 {metric === "cost"685 className={cn(
626 ? formatUsd(totals?.costUsd ?? 0)686 "shrink-0 text-sm font-medium text-foreground tabular-nums",
627 : formatTokens(totals?.totalTokens ?? 0)}687 figureClass(providerLoading),
688 )}
689 >
690 {awaitingData
691 ? "—"
692 : metric === "cost"
693 ? formatUsd(totals?.costUsd ?? 0)
694 : formatTokens(totals?.totalTokens ?? 0)}
628 </span>695 </span>
629 </div>696 </div>
630 <span className="text-xs text-muted-foreground">697 {/* Kept while awaiting data so the row does not grow when it lands. */}
698 <span
699 className={cn(
700 "text-xs text-muted-foreground",
701 figureClass(providerLoading),
702 awaitingData && "invisible",
703 )}
704 >
631 {metric === "cost"705 {metric === "cost"
632 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`706 ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens`
633 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}707 : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
...@@ -639,19 +713,20 @@ export function UsagePage() {...@@ -639,19 +713,20 @@ export function UsagePage() {
639713
640 <div className="flex min-w-0 flex-col gap-3">714 <div className="flex min-w-0 flex-col gap-3">
641 <h2 className="text-sm font-medium text-foreground">715 <h2 className="text-sm font-medium text-foreground">
642 {isPast24Hours ? "Hourly" : "Daily"}{" "}716 {shownHourly ? "Hourly" : "Daily"}{" "}
643 {metric === "tokens" ? "processed tokens" : "cost"}717 {metric === "tokens" ? "processed tokens" : "cost"}
644 </h2>718 </h2>
645 <UsageProviderChart719 <UsageProviderChart
646 providers={activeProviders}720 providers={activeProviders}
721 loadingProviders={chartLoadingProviders}
647 days={days}722 days={days}
648 daily={merged.daily}723 daily={merged.daily}
649 hours={hours}724 hours={hours}
650 hourly={merged.hourly}725 hourly={merged.hourly}
651 metric={metric}726 metric={metric}
652 referenceTime={window.untilTime}727 referenceTime={shownWindow.untilTime}
653 resolution={isPast24Hours ? "hour" : "day"}728 resolution={shownHourly ? "hour" : "day"}
654 timeZone={window.timeZone}729 timeZone={shownWindow.timeZone}
655 />730 />
656 </div>731 </div>
657 </section>732 </section>
...@@ -659,14 +734,28 @@ export function UsagePage() {...@@ -659,14 +734,28 @@ export function UsagePage() {
659 <section className="flex flex-col gap-2">734 <section className="flex flex-col gap-2">
660 <h2 className="text-sm font-medium text-foreground">Totals</h2>735 <h2 className="text-sm font-medium text-foreground">Totals</h2>
661 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">736 <div className="grid grid-cols-2 gap-x-6 gap-y-4 py-1 md:grid-cols-5">
662 <Metric label="Processed tokens" value={formatTokens(merged.totalTokens)} />
663 <Metric label="Cached input" value={formatTokens(merged.cachedInputTokens)} />
664 <Metric737 <Metric
738 loading={loading.partial}
739 label="Processed tokens"
740 value={formatTokens(merged.totalTokens)}
741 />
742 <Metric
743 loading={loading.partial}
744 label="Cached input"
745 value={formatTokens(merged.cachedInputTokens)}
746 />
747 <Metric
748 loading={loading.partial}
665 label="Uncached input"749 label="Uncached input"
666 value={formatTokens(merged.uncachedInputTokens)}750 value={formatTokens(merged.uncachedInputTokens)}
667 />751 />
668 <Metric label="Output" value={formatTokens(merged.outputTokens)} />
669 <Metric752 <Metric
753 loading={loading.partial}
754 label="Output"
755 value={formatTokens(merged.outputTokens)}
756 />
757 <Metric
758 loading={loading.partial}
670 label="Cache savings"759 label="Cache savings"
671 value={formatUsd(merged.costQuality.cacheSavingsUsd)}760 value={formatUsd(merged.costQuality.cacheSavingsUsd)}
672 />761 />
...@@ -674,7 +763,12 @@ export function UsagePage() {...@@ -674,7 +763,12 @@ export function UsagePage() {
674 </section>763 </section>
675764
676 {merged.totalTokens > 0 ? (765 {merged.totalTokens > 0 ? (
677 <section className="grid gap-x-12 gap-y-8 lg:grid-cols-2">766 <section
767 className={cn(
768 "grid gap-x-12 gap-y-8 lg:grid-cols-2",
769 figureClass(loading.partial),
770 )}
771 >
678 {metric === "tokens" ? (772 {metric === "tokens" ? (
679 <UsageShareBar773 <UsageShareBar
680 label="Tokens by type"774 label="Tokens by type"
...@@ -716,7 +810,7 @@ export function UsagePage() {...@@ -716,7 +810,7 @@ export function UsagePage() {
716 {(810 {(
717 [811 [
718 { value: "model", label: "Model" },812 { value: "model", label: "Model" },
719 { value: "time", label: isPast24Hours ? "Hour" : "Day" },813 { value: "time", label: shownHourly ? "Hour" : "Day" },
720 ] as const814 ] as const
721 ).map((option) => (815 ).map((option) => (
722 <Toggle key={option.value} value={option.value}>816 <Toggle key={option.value} value={option.value}>
...@@ -752,6 +846,7 @@ export function UsagePage() {...@@ -752,6 +846,7 @@ export function UsagePage() {
752 model,846 model,
753 metric === "tokens" ? "tokens" : "cost",847 metric === "tokens" ? "tokens" : "cost",
754 );848 );
849 const rowFigures = figureClass(isProviderLoading(model.provider));
755 return (850 return (
756 <tr851 <tr
757 key={key}852 key={key}
...@@ -769,7 +864,10 @@ export function UsagePage() {...@@ -769,7 +864,10 @@ export function UsagePage() {
769 <ProviderMark provider={model.provider} className="size-3.5" />864 <ProviderMark provider={model.provider} className="size-3.5" />
770 {model.model}865 {model.model}
771 </button>866 </button>
772 <div aria-hidden className="mt-1.5 h-0.5 max-w-48">867 <div
868 aria-hidden
869 className={cn("mt-1.5 h-0.5 max-w-48", rowFigures)}
870 >
773 <div871 <div
774 className="h-full rounded-full"872 className="h-full rounded-full"
775 style={{873 style={{
...@@ -784,17 +882,19 @@ export function UsagePage() {...@@ -784,17 +882,19 @@ export function UsagePage() {
784 />882 />
785 </div>883 </div>
786 </td>884 </td>
787 <td className="py-2.5 pl-6 text-foreground">885 <td className={cn("py-2.5 pl-6 text-foreground", rowFigures)}>
788 {isModelCostUnknown(model) ? (886 {isModelCostUnknown(model) ? (
789 <span className="text-muted-foreground">Unpriced</span>887 <span className="text-muted-foreground">Unpriced</span>
790 ) : (888 ) : (
791 formatUsd(model.costUsd)889 formatUsd(model.costUsd)
792 )}890 )}
793 </td>891 </td>
794 <td className="hidden py-2.5 pl-6 sm:table-cell">892 <td className={cn("hidden py-2.5 pl-6 sm:table-cell", rowFigures)}>
795 {share === null ? "" : formatPercent(share)}893 {share === null ? "" : formatPercent(share)}
796 </td>894 </td>
797 <td className="py-2.5 pl-6">{formatTokens(model.totalTokens)}</td>895 <td className={cn("py-2.5 pl-6", rowFigures)}>
896 {formatTokens(model.totalTokens)}
897 </td>
798 </tr>898 </tr>
799 );899 );
800 })900 })
...@@ -813,7 +913,7 @@ export function UsagePage() {...@@ -813,7 +913,7 @@ export function UsagePage() {
813 </colgroup>913 </colgroup>
814 <thead>914 <thead>
815 <tr className="border-b border-border text-left text-xs text-muted-foreground">915 <tr className="border-b border-border text-left text-xs text-muted-foreground">
816 <th className="py-2 font-normal">{isPast24Hours ? "Hour" : "Day"}</th>916 <th className="py-2 font-normal">{shownHourly ? "Hour" : "Day"}</th>
817 {activeProviders.map((provider) => (917 {activeProviders.map((provider) => (
818 <th key={provider} className="py-2 text-right font-normal">918 <th key={provider} className="py-2 text-right font-normal">
819 {PROVIDER_PRESENTATION[provider].label}919 {PROVIDER_PRESENTATION[provider].label}
...@@ -841,21 +941,34 @@ export function UsagePage() {...@@ -841,21 +941,34 @@ export function UsagePage() {
841 >941 >
842 <td className="py-2 text-foreground">942 <td className="py-2 text-foreground">
843 {"hourStart" in period943 {"hourStart" in period
844 ? formatHourShort(period.hourStart, window.timeZone)944 ? formatHourShort(period.hourStart, shownWindow.timeZone)
845 : formatDayShort(period.day)}945 : formatDayShort(period.day)}
846 </td>946 </td>
847 {activeProviders.map((provider) => (947 {activeProviders.map((provider) => (
848 <td948 <td
849 key={provider}949 key={provider}
850 className="py-2 text-right text-muted-foreground tabular-nums"950 className={cn(
951 "py-2 text-right text-muted-foreground tabular-nums",
952 figureClass(isProviderLoading(provider)),
953 )}
851 >954 >
852 {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)}955 {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)}
853 </td>956 </td>
854 ))}957 ))}
855 <td className="py-2 text-right text-foreground tabular-nums">958 <td
959 className={cn(
960 "py-2 text-right text-foreground tabular-nums",
961 figureClass(loading.partial),
962 )}
963 >
856 {formatUsd(period.costUsd)}964 {formatUsd(period.costUsd)}
857 </td>965 </td>
858 <td className="py-2 text-right text-muted-foreground tabular-nums">966 <td
967 className={cn(
968 "py-2 text-right text-muted-foreground tabular-nums",
969 figureClass(loading.partial),
970 )}
971 >
859 {formatTokens(period.totalTokens)}972 {formatTokens(period.totalTokens)}
860 </td>973 </td>
861 </tr>974 </tr>
...@@ -865,7 +978,7 @@ export function UsagePage() {...@@ -865,7 +978,7 @@ export function UsagePage() {
865 </table>978 </table>
866 )}979 )}
867 </section>980 </section>
868 </>981 </div>
869 )}982 )}
870 </WorkspacePageContainer>983 </WorkspacePageContainer>
871 </ScrollArea>984 </ScrollArea>
...@@ -878,9 +991,9 @@ export function UsagePage() {...@@ -878,9 +991,9 @@ export function UsagePage() {
878 chartWindow={{991 chartWindow={{
879 days,992 days,
880 hours,993 hours,
881 resolution: isPast24Hours ? "hour" : "day",994 resolution: shownHourly ? "hour" : "day",
882 timeZone: window.timeZone,995 timeZone: shownWindow.timeZone,
883 referenceTime: window.untilTime,996 referenceTime: shownWindow.untilTime,
884 }}997 }}
885 onSetPrice={() => {998 onSetPrice={() => {
886 setSelectedModelKey(null);999 setSelectedModelKey(null);
...@@ -1052,11 +1165,28 @@ function ProviderMark({...@@ -1052,11 +1165,28 @@ function ProviderMark({
1052 );1165 );
1053}1166}
10541167
1055function Metric({ label, value }: { readonly label: string; readonly value: string }) {1168/** Mutes a figure that is still coming in. The delay keeps a quick answer from flashing. */
1169function figureClass(loading: boolean) {
1170 return cn("transition-opacity", loading && "opacity-40 delay-150");
1171}
1172
1173function Metric({
1174 label,
1175 value,
1176 loading,
1177}: {
1178 readonly label: string;
1179 readonly value: string;
1180 readonly loading: boolean;
1181}) {
1056 return (1182 return (
1057 <div className="flex min-w-0 flex-col gap-0.5">1183 <div className="flex min-w-0 flex-col gap-0.5">
1058 <span className="text-xs text-muted-foreground">{label}</span>1184 <span className="text-xs text-muted-foreground">{label}</span>
1059 <span className="text-base font-medium text-foreground tabular-nums">{value}</span>1185 <span
1186 className={cn("text-base font-medium text-foreground tabular-nums", figureClass(loading))}
1187 >
1188 {value}
1189 </span>
1060 </div>1190 </div>
1061 );1191 );
1062}1192}
...@@ -1108,13 +1238,14 @@ function UsageCoverageNotice({...@@ -1108,13 +1238,14 @@ function UsageCoverageNotice({
1108 );1238 );
1109}1239}
11101240
1111/** Environment selection and scan progress share a permanent header control. */1241/** Environment selection, with each environment's scan status in the menu. */
1112function UsageEnvironmentFilter({1242function UsageEnvironmentFilter({
1113 environments,1243 environments,
1114 selectedEnvironments,1244 selectedEnvironments,
1115 selectedEnvironmentIds,1245 selectedEnvironmentIds,
1116 onSelectionChange,1246 onSelectionChange,
1117 showUsageStatus,1247 showUsageStatus,
1248 refreshing,
1118 isPartial,1249 isPartial,
1119 duplicateSources,1250 duplicateSources,
1120 contractMismatches,1251 contractMismatches,
...@@ -1125,6 +1256,7 @@ function UsageEnvironmentFilter({...@@ -1125,6 +1256,7 @@ function UsageEnvironmentFilter({
1125 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;1256 readonly selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null;
1126 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;1257 readonly onSelectionChange: (ids: ReadonlySet<EnvironmentId> | null) => void;
1127 readonly showUsageStatus: boolean;1258 readonly showUsageStatus: boolean;
1259 readonly refreshing: boolean;
1128 readonly isPartial: boolean;1260 readonly isPartial: boolean;
1129 readonly duplicateSources: readonly string[];1261 readonly duplicateSources: readonly string[];
1130 readonly contractMismatches: MergedUsage["contractMismatches"];1262 readonly contractMismatches: MergedUsage["contractMismatches"];
...@@ -1136,10 +1268,6 @@ function UsageEnvironmentFilter({...@@ -1136,10 +1268,6 @@ function UsageEnvironmentFilter({
1136 : selectedEnvironments.length === 11268 : selectedEnvironments.length === 1
1137 ? selectedEnvironments[0]!.label1269 ? selectedEnvironments[0]!.label
1138 : `${selectedEnvironments.length} environments`;1270 : `${selectedEnvironments.length} environments`;
1139 const pendingCount = selectedEnvironments.filter(
1140 (environment) =>
1141 environment.error === null && (environment.isPending || environment.summary === null),
1142 ).length;
1143 const hasIssue =1271 const hasIssue =
1144 selectedEnvironments.some((environment) => environment.error !== null) ||1272 selectedEnvironments.some((environment) => environment.error !== null) ||
1145 contractMismatches.length > 0;1273 contractMismatches.length > 0;
...@@ -1149,15 +1277,7 @@ function UsageEnvironmentFilter({...@@ -1149,15 +1277,7 @@ function UsageEnvironmentFilter({
1149 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">1277 <MenuTrigger render={<InlineButton />} className="group/usage-environment min-w-0 max-w-full">
1150 <span className="min-w-0 truncate">{label}</span>1278 <span className="min-w-0 truncate">{label}</span>
1151 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">1279 <span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">
1152 {showUsageStatus && pendingCount > 0 ? (1280 {showUsageStatus && hasIssue ? (
1153 <>
1154 <CircleDashedIcon className="size-3.5" aria-hidden />
1155 <span className="sr-only">
1156 {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning
1157 {isPartial ? "; totals are partial" : ""}
1158 </span>
1159 </>
1160 ) : showUsageStatus && hasIssue ? (
1161 <CircleAlertIcon1281 <CircleAlertIcon
1162 className="size-3.5 text-warning-foreground"1282 className="size-3.5 text-warning-foreground"
1163 aria-label="Some environments could not report usage"1283 aria-label="Some environments could not report usage"
...@@ -1183,6 +1303,7 @@ function UsageEnvironmentFilter({...@@ -1183,6 +1303,7 @@ function UsageEnvironmentFilter({
1183 const checked =1303 const checked =
1184 selectedEnvironmentIds === null ||1304 selectedEnvironmentIds === null ||
1185 selectedEnvironmentIds.has(environment.environmentId);1305 selectedEnvironmentIds.has(environment.environmentId);
1306 const progress = usageEnvironmentProgress(environment, refreshing);
1186 const status =1307 const status =
1187 environment.error !== null1308 environment.error !== null
1188 ? "Unavailable"1309 ? "Unavailable"
...@@ -1192,11 +1313,15 @@ function UsageEnvironmentFilter({...@@ -1192,11 +1313,15 @@ function UsageEnvironmentFilter({
1192 USAGE_CONTRACT_VERSION,1313 USAGE_CONTRACT_VERSION,
1193 )1314 )
1194 ? "Update required"1315 ? "Update required"
1195 : environment.summary === null1316 : !environment.isConnected
1196 ? "Scanning…"1317 ? "Connecting…"
1197 : environment.isPending1318 : progress.phase === "loading"
1198 ? "Refreshing…"1319 ? "Scanning…"
1199 : "Ready";1320 : progress.phase === "stale"
1321 ? "Refreshing…"
1322 : progress.phase === "partway"
1323 ? updatingProvidersLabel(progress.providers, providerLabel)
1324 : "Ready";
1200 return (1325 return (
1201 <MenuCheckboxItem1326 <MenuCheckboxItem
1202 key={environment.environmentId}1327 key={environment.environmentId}
apps/web/src/components/usage/UsageProviderChart.test.ts+22-1
...@@ -1,8 +1,29 @@...@@ -1,8 +1,29 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { buildPeriodColumns, niceScale } from "./UsageProviderChart";3import { buildPeriodColumns, chartScale, niceScale } from "./UsageProviderChart";
4import { providersWithUsage } from "./usageProviders";4import { providersWithUsage } from "./usageProviders";
55
6describe("chartScale", () => {
7 const column = (codex: number) => ({
8 total: codex,
9 bands: [{ provider: "codex" as const, value: codex }],
10 });
11
12 it("holds unlabeled placeholder gridlines while loading providers have nothing to show", () => {
13 const scale = chartScale([column(0)], new Set(["codex" as const]));
14
15 expect(scale.labeled).toBe(false);
16 expect(scale.ticks.length).toBeGreaterThan(1);
17 });
18
19 it("scales to what is on screen, loading or not", () => {
20 expect(chartScale([column(40)], new Set(["codex" as const]))).toMatchObject({
21 max: 40,
22 labeled: true,
23 });
24 });
25});
26
6describe("niceScale", () => {27describe("niceScale", () => {
7 it("never puts the peak above the top of the scale", () => {28 it("never puts the peak above the top of the scale", () => {
8 // Regression: an earlier version stopped at the last step below the peak,29 // Regression: an earlier version stopped at the last step below the peak,
apps/web/src/components/usage/UsageProviderChart.tsx+111-68
...@@ -10,17 +10,21 @@ import {...@@ -10,17 +10,21 @@ import {
10 formatTokens,10 formatTokens,
11 formatUsd,11 formatUsd,
12} from "@t3tools/shared/usageFormat";12} from "@t3tools/shared/usageFormat";
13import { cn } from "~/lib/utils";
13import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";14import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders";
1415
15const VIEW_WIDTH = 960;16const VIEW_WIDTH = 960;
16const VIEW_HEIGHT = 260;17const VIEW_HEIGHT = 260;
17const TICK_COUNT = 4;18const TICK_COUNT = 4;
18const PLOT_TOP = 8;19const PLOT_TOP = 8;
20const NONE_LOADING: ReadonlySet<UsageProviderKind> = new Set();
1921
20export type UsageChartMetric = "tokens" | "cost";22export type UsageChartMetric = "tokens" | "cost";
2123
22interface UsageProviderChartProps {24interface UsageProviderChartProps {
23 readonly providers: readonly UsageProviderKind[];25 readonly providers: readonly UsageProviderKind[];
26 /** Providers whose figures are still coming in: their lines are muted. */
27 readonly loadingProviders?: ReadonlySet<UsageProviderKind>;
24 readonly days: readonly string[];28 readonly days: readonly string[];
25 readonly daily: readonly DailyTotals[];29 readonly daily: readonly DailyTotals[];
26 readonly hours: readonly string[];30 readonly hours: readonly string[];
...@@ -148,6 +152,10 @@ function curvePath(segments: readonly CurveSegment[]): string {...@@ -148,6 +152,10 @@ function curvePath(segments: readonly CurveSegment[]): string {
148 return path;152 return path;
149}153}
150154
155function areaPath(line: string) {
156 return line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`;
157}
158
151/**159/**
152 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the160 * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the
153 * peak.161 * peak.
...@@ -170,8 +178,71 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re...@@ -170,8 +178,71 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re
170 return { max, ticks };178 return { max, ticks };
171}179}
172180
181const PLACEHOLDER_TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, index) => index);
182
183/**
184 * Scales to the largest single provider-period. With nothing to show yet while
185 * providers load, unlabeled placeholder gridlines hold their usual spacing so
186 * nothing shifts on arrival.
187 */
188export function chartScale(
189 columns: readonly DayColumn[],
190 loadingProviders: ReadonlySet<UsageProviderKind>,
191) {
192 // Not the sum: layered series each measure from zero, so a combined peak
193 // would leave the plot permanently half empty.
194 const peak = columns.reduce(
195 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
196 0,
197 );
198 return peak === 0 && loadingProviders.size > 0
199 ? { max: TICK_COUNT, ticks: PLACEHOLDER_TICKS, labeled: false }
200 : { ...niceScale(peak, TICK_COUNT), labeled: true };
201}
202
203// Leave room above the top gridline so the constant-width stroke is not
204// clipped when a series reaches the peak.
205function valueToY(value: number, max: number) {
206 return max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
207}
208
209/** Per-provider paths in paint order, heaviest first. */
210function buildChart(
211 periods: readonly string[],
212 byPeriod: ReadonlyMap<string, DailyTotals | HourlyTotals>,
213 metric: UsageChartMetric,
214 providers: readonly UsageProviderKind[],
215 loadingProviders: ReadonlySet<UsageProviderKind>,
216) {
217 const columns = buildPeriodColumns(periods, byPeriod, metric);
218 const scale = chartScale(columns, loadingProviders);
219 const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1);
220 const paths = providers.map((provider) => {
221 const slot = PROVIDER_ORDER.indexOf(provider);
222 const line = curvePath(
223 smoothCurve(
224 columns.map((column, periodIndex) => ({
225 x: periodIndex * stepX,
226 y: valueToY(column.bands[slot]?.value ?? 0, scale.max),
227 })),
228 ),
229 );
230 return {
231 provider,
232 loading: loadingProviders.has(provider),
233 total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0),
234 line,
235 area: areaPath(line),
236 };
237 });
238
239 // Paint the heavier series first so the lighter one is not buried.
240 return { columns, scale, stepX, paths: paths.toSorted((a, b) => b.total - a.total) };
241}
242
173export function UsageProviderChart({243export function UsageProviderChart({
174 providers,244 providers,
245 loadingProviders = NONE_LOADING,
175 days,246 days,
176 daily,247 daily,
177 hours,248 hours,
...@@ -194,59 +265,14 @@ export function UsageProviderChart({...@@ -194,59 +265,14 @@ export function UsageProviderChart({
194 const tooltipRef = useRef<HTMLDivElement | null>(null);265 const tooltipRef = useRef<HTMLDivElement | null>(null);
195 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);266 const hoverPositionRef = useRef<{ x: number; y: number } | null>(null);
196267
197 const { paths, ticks, stepX, toY, series } = useMemo(() => {268 const { columns, scale, paths, stepX } = useMemo(
198 if (periods.length === 0) {269 () => buildChart(periods, byPeriod, metric, providers, loadingProviders),
199 return {270 [byPeriod, loadingProviders, metric, periods, providers],
200 paths: [],271 );
201 series: [] as readonly DayColumn[],272 const toY = (value: number) => valueToY(value, scale.max);
202 stepX: 0,273 // The delay keeps a quick answer from flashing, as with the page's figures.
203 ticks: [0] as readonly number[],274 const seriesClassName = (loading: boolean) =>
204 toY: () => VIEW_HEIGHT,275 cn("transition-opacity", loading && "opacity-40 delay-150");
205 };
206 }
207
208 const columns = buildPeriodColumns(periods, byPeriod, metric);
209 // The scale tops out at the largest single provider-period, not the sum:
210 // layered series each measure from zero, so a combined peak would leave
211 // the plot permanently half empty.
212 const peak = columns.reduce(
213 (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max),
214 0,
215 );
216 const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT);
217 const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1);
218 // Leave room above the top gridline so the constant-width stroke is not
219 // clipped when a series reaches the peak.
220 const toY = (value: number) =>
221 max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP);
222
223 const built = providers.map((provider) => {
224 const providerIndex = PROVIDER_ORDER.indexOf(provider);
225 const line = curvePath(
226 smoothCurve(
227 columns.map((column, periodIndex) => ({
228 x: periodIndex * step,
229 y: toY(column.bands[providerIndex]?.value ?? 0),
230 })),
231 ),
232 );
233 return {
234 provider,
235 total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0),
236 area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`,
237 line,
238 };
239 });
240
241 // Paint the heavier series first so the lighter one is not buried.
242 return {
243 paths: built.toSorted((a, b) => b.total - a.total),
244 series: columns,
245 stepX: step,
246 ticks: tickValues,
247 toY,
248 };
249 }, [byPeriod, metric, periods, providers]);
250276
251 const format = metric === "tokens" ? formatTokens : formatUsd;277 const format = metric === "tokens" ? formatTokens : formatUsd;
252278
...@@ -307,7 +333,8 @@ export function UsageProviderChart({...@@ -307,7 +333,8 @@ export function UsageProviderChart({
307 );333 );
308334
309 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];335 const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex];
310 const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex];336 const hoveredColumn = hoverIndex === null ? undefined : columns[hoverIndex];
337 const partial = providers.some((provider) => loadingProviders.has(provider));
311 const formatPeriod = (period: string) =>338 const formatPeriod = (period: string) =>
312 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);339 resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period);
313 const formatTooltipPeriod = (period: string) =>340 const formatTooltipPeriod = (period: string) =>
...@@ -320,15 +347,17 @@ export function UsageProviderChart({...@@ -320,15 +347,17 @@ export function UsageProviderChart({
320 <div className="flex gap-2">347 <div className="flex gap-2">
321 {/* Axis labels sit outside the plot so they stay aligned to gridlines. */}348 {/* Axis labels sit outside the plot so they stay aligned to gridlines. */}
322 <div className="relative h-56 w-14 shrink-0">349 <div className="relative h-56 w-14 shrink-0">
323 {ticks.map((tick) => (350 {scale.labeled
324 <span351 ? scale.ticks.map((tick) => (
325 key={tick}352 <span
326 className="absolute right-0 -translate-y-1/2 text-3xs text-muted-foreground tabular-nums"353 key={tick}
327 style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }}354 className="absolute right-0 -translate-y-1/2 text-3xs text-muted-foreground tabular-nums"
328 >355 style={{ top: `${(toY(tick) / VIEW_HEIGHT) * 100}%` }}
329 {tick === 0 ? "0" : format(tick)}356 >
330 </span>357 {tick === 0 ? "0" : format(tick)}
331 ))}358 </span>
359 ))
360 : null}
332 </div>361 </div>
333362
334 <div363 <div
...@@ -347,7 +376,7 @@ export function UsageProviderChart({...@@ -347,7 +376,7 @@ export function UsageProviderChart({
347 role="img"376 role="img"
348 aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}377 aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`}
349 >378 >
350 {ticks.map((tick) => {379 {scale.ticks.map((tick) => {
351 const y = toY(tick);380 const y = toY(tick);
352 return (381 return (
353 <line382 <line
...@@ -365,18 +394,20 @@ export function UsageProviderChart({...@@ -365,18 +394,20 @@ export function UsageProviderChart({
365 })}394 })}
366395
367 {/* Fills first, then every stroke, so no series covers another's line. */}396 {/* Fills first, then every stroke, so no series covers another's line. */}
368 {paths.map(({ provider, area }) => (397 {paths.map(({ provider, loading, area }) => (
369 <path398 <path
370 key={provider}399 key={provider}
371 d={area}400 d={area}
401 className={seriesClassName(loading)}
372 fill={PROVIDER_PRESENTATION[provider].color}402 fill={PROVIDER_PRESENTATION[provider].color}
373 fillOpacity={0.12}403 fillOpacity={0.12}
374 />404 />
375 ))}405 ))}
376 {paths.map(({ provider, line }) => (406 {paths.map(({ provider, loading, line }) => (
377 <path407 <path
378 key={provider}408 key={provider}
379 d={line}409 d={line}
410 className={seriesClassName(loading)}
380 fill="none"411 fill="none"
381 stroke={PROVIDER_PRESENTATION[provider].color}412 stroke={PROVIDER_PRESENTATION[provider].color}
382 strokeWidth={2}413 strokeWidth={2}
...@@ -420,7 +451,14 @@ export function UsageProviderChart({...@@ -420,7 +451,14 @@ export function UsageProviderChart({
420 />451 />
421 {label}452 {label}
422 </span>453 </span>
423 <span className="text-foreground tabular-nums">454 <span
455 className={cn(
456 "tabular-nums",
457 loadingProviders.has(provider)
458 ? "text-muted-foreground"
459 : "text-foreground",
460 )}
461 >
424 {format(462 {format(
425 hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0,463 hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0,
426 )}464 )}
...@@ -430,7 +468,12 @@ export function UsageProviderChart({...@@ -430,7 +468,12 @@ export function UsageProviderChart({
430 })}468 })}
431 <div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1">469 <div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-1">
432 <span className="text-muted-foreground">Total</span>470 <span className="text-muted-foreground">Total</span>
433 <span className="text-foreground tabular-nums">471 <span
472 className={cn(
473 "tabular-nums",
474 partial ? "text-muted-foreground" : "text-foreground",
475 )}
476 >
434 {format(hoveredColumn?.total ?? 0)}477 {format(hoveredColumn?.total ?? 0)}
435 </span>478 </span>
436 </div>479 </div>
apps/web/src/state/usage.test.tsx+43-1
...@@ -33,6 +33,7 @@ function environment(...@@ -33,6 +33,7 @@ function environment(
33 label: id,33 label: id,
34 isPending: cost === null,34 isPending: cost === null,
35 canReadDiagnostics: true,35 canReadDiagnostics: true,
36 isConnected: true,
36 error: null,37 error: null,
37 needsCursorKeychainAccess: false,38 needsCursorKeychainAccess: false,
38 summary:39 summary:
...@@ -90,11 +91,13 @@ let latest: UsageView;...@@ -90,11 +91,13 @@ let latest: UsageView;
90function Probe({91function Probe({
91 selected,92 selected,
92 hidden,93 hidden,
94 window = input,
93}: {95}: {
94 selected: ReadonlySet<EnvironmentId> | null;96 selected: ReadonlySet<EnvironmentId> | null;
95 hidden?: ReadonlySet<UsageProviderKind>;97 hidden?: ReadonlySet<UsageProviderKind>;
98 window?: typeof input;
96}) {99}) {
97 const usage = useUsage(input, selected, hidden);100 const usage = useUsage(window, selected, hidden);
98 useLayoutEffect(() => {101 useLayoutEffect(() => {
99 latest = usage;102 latest = usage;
100 }, [usage]);103 }, [usage]);
...@@ -181,6 +184,45 @@ describe("usage environment selection", () => {...@@ -181,6 +184,45 @@ describe("usage environment selection", () => {
181 expect(latest.isPending).toBe(false);184 expect(latest.isPending).toBe(false);
182 expect(latest.isPartial).toBe(false);185 expect(latest.isPartial).toBe(false);
183 });186 });
187
188 it("shows the last answered usage until the next window answers, for the same selection", async () => {
189 const selected = new Set([EnvironmentId.make("a")]);
190 await act(() => renderer?.update(<Probe selected={selected} />));
191 expect(latest.shown?.merged.costUsd).toBe(10);
192
193 // A new window that nothing has answered yet.
194 const nextWindow = { ...input, sinceDay: UsageDay.make("2026-08-28") };
195 testState.environments = [environment("a", null)];
196 await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />));
197 expect(latest.isPending).toBe(true);
198 expect(latest.shown?.window).toBe(input);
199 expect(latest.shown?.merged.costUsd).toBe(10);
200
201 // A window that fails everywhere keeps it too.
202 testState.environments = [{ ...environment("a", null), isPending: false, error: "Offline" }];
203 await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />));
204 expect(latest.isPending).toBe(false);
205 expect(latest.shown?.window).toBe(input);
206 expect(latest.shown?.merged.costUsd).toBe(10);
207
208 // Once the new window answers, it replaces the kept one.
209 testState.environments = [environment("a", 30)];
210 await act(() => renderer?.update(<Probe selected={selected} window={nextWindow} />));
211 expect(latest.shown?.window).toBe(nextWindow);
212 expect(latest.shown?.merged.costUsd).toBe(30);
213
214 // A different provider filter does not reuse usage merged with the old one.
215 testState.environments = [environment("a", null)];
216 await act(() =>
217 renderer?.update(<Probe selected={selected} hidden={new Set(["claude"])} window={input} />),
218 );
219 expect(latest.shown).toBeNull();
220
221 // Another selection has nothing of its own to show.
222 testState.environments = [environment("a", null)];
223 await select("a");
224 expect(latest.shown).toBeNull();
225 });
184});226});
185227
186describe("usage provider filter", () => {228describe("usage provider filter", () => {
apps/web/src/state/usage.ts+48-2
...@@ -20,7 +20,7 @@ import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime...@@ -20,7 +20,7 @@ import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime
20import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";20import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
21import * as Option from "effect/Option";21import * as Option from "effect/Option";
22import { AsyncResult, Atom } from "effect/reactivity";22import { AsyncResult, Atom } from "effect/reactivity";
23import { useCallback, useMemo } from "react";23import { useCallback, useMemo, useState } from "react";
2424
25import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";25import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
26import { appAtomRegistry } from "../rpc/atomRegistry";26import { appAtomRegistry } from "../rpc/atomRegistry";
...@@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus {...@@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus {
33 readonly label: string;33 readonly label: string;
34 readonly isPending: boolean;34 readonly isPending: boolean;
35 readonly canReadDiagnostics: boolean;35 readonly canReadDiagnostics: boolean;
36 readonly isConnected: boolean;
36 readonly error: string | null;37 readonly error: string | null;
37 readonly summary: UsageSummary | null;38 readonly summary: UsageSummary | null;
38 readonly needsCursorKeychainAccess: boolean;39 readonly needsCursorKeychainAccess: boolean;
...@@ -52,6 +53,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -52,6 +53,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
5253
53 const statuses: EnvironmentUsageStatus[] = [];54 const statuses: EnvironmentUsageStatus[] = [];
54 for (const [environmentId, presentation] of presentations) {55 for (const [environmentId, presentation] of presentations) {
56 const isConnected = presentation.connection.phase === "connected";
55 const sessionResult = get(environmentSession.sessionStateAtom(environmentId));57 const sessionResult = get(environmentSession.sessionStateAtom(environmentId));
56 const access = resolveUsageAccess({58 const access = resolveUsageAccess({
57 connectionPhase: presentation.connection.phase,59 connectionPhase: presentation.connection.phase,
...@@ -62,6 +64,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -62,6 +64,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
62 statuses.push({64 statuses.push({
63 environmentId,65 environmentId,
64 label: presentation.entry.target.label,66 label: presentation.entry.target.label,
67 isConnected,
65 ...access,68 ...access,
66 summary: null,69 summary: null,
67 needsCursorKeychainAccess: false,70 needsCursorKeychainAccess: false,
...@@ -75,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -75,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
75 label: presentation.entry.target.label,78 label: presentation.entry.target.label,
76 isPending: result.waiting,79 isPending: result.waiting,
77 canReadDiagnostics: true,80 canReadDiagnostics: true,
81 isConnected,
78 error: result._tag === "Failure" ? "This environment could not report usage." : null,82 error: result._tag === "Failure" ? "This environment could not report usage." : null,
79 summary,83 summary,
80 needsCursorKeychainAccess: needsCursorKeychainAccess(84 needsCursorKeychainAccess: needsCursorKeychainAccess(
...@@ -93,6 +97,12 @@ export interface UsageView {...@@ -93,6 +97,12 @@ export interface UsageView {
93 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];97 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
94 /** True until at least one selected environment has answered. */98 /** True until at least one selected environment has answered. */
95 readonly isPending: boolean;99 readonly isPending: boolean;
100 /**
101 * The usage to draw: this window's once a selected environment answers,
102 * until then the last window answered for the same selection. Null while
103 * nothing has answered and something still could.
104 */
105 readonly shown: { readonly window: UsageSummaryInput; readonly merged: MergedUsage } | null;
96 /**106 /**
97 * True while environments that have not failed are still answering. Failed107 * True while environments that have not failed are still answering. Failed
98 * environments are reported through their own error rows: totals will not108 * environments are reported through their own error rows: totals will not
...@@ -225,12 +235,48 @@ export function useUsage(...@@ -225,12 +235,48 @@ export function useUsage(
225 const stillReporting = selectedEnvironments.filter(235 const stillReporting = selectedEnvironments.filter(
226 (environment) => environment.summary === null && environment.error === null,236 (environment) => environment.summary === null && environment.error === null,
227 ).length;237 ).length;
238 const isPending = answeredCount === 0 && stillReporting > 0;
239
240 // Stored during render, as React recommends for state that follows props, so
241 // the kept usage is on screen in the same frame the new window starts pending.
242 const [lastAnswered, setLastAnswered] = useState<
243 | (NonNullable<UsageView["shown"]> & {
244 readonly selection: typeof selectedEnvironmentIds;
245 readonly hidden: typeof hiddenProviders;
246 })
247 | null
248 >(null);
249 if (
250 answeredCount > 0 &&
251 (lastAnswered?.merged !== merged ||
252 lastAnswered.window !== input ||
253 lastAnswered.selection !== selectedEnvironmentIds ||
254 lastAnswered.hidden !== hiddenProviders)
255 ) {
256 setLastAnswered({
257 window: input,
258 merged,
259 selection: selectedEnvironmentIds,
260 hidden: hiddenProviders,
261 });
262 }
263 // Kept usage only stands in for the same environments and provider filter.
264 const kept =
265 lastAnswered?.selection === selectedEnvironmentIds && lastAnswered.hidden === hiddenProviders
266 ? lastAnswered
267 : null;
268 // With no answers, even failed ones keep the last answered usage on screen.
269 const shown =
270 answeredCount > 0
271 ? { window: input, merged }
272 : (kept ?? (isPending ? null : { window: input, merged }));
228273
229 return {274 return {
230 merged,275 merged,
231 environments,276 environments,
232 selectedEnvironments,277 selectedEnvironments,
233 isPending: answeredCount === 0 && stillReporting > 0,278 isPending,
279 shown,
234 isPartial: answeredCount > 0 && stillReporting > 0,280 isPartial: answeredCount > 0 && stillReporting > 0,
235 refresh,281 refresh,
236 };282 };
docs/user/usage.md+2-1
...@@ -39,7 +39,8 @@ your app understands. Update the app to include newly supported providers....@@ -39,7 +39,8 @@ your app understands. Update the app to include newly supported providers.
3939
40On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All40On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
41environments are selected by default. The dropdown shows which environments are still scanning;41environments are selected by default. The dropdown shows which environments are still scanning;
42results appear as each one responds.42results appear as each one responds, and figures still updating are dimmed. Cursor shows its last
43saved totals first, then updates them when Cursor's API responds.
4344
44If recent work is missing or a new model shows no cost, refresh to rescan session history and45If recent work is missing or a new model shows no cost, refresh to rescan session history and
45update model pricing.46update model pricing.
packages/client-runtime/package.json+4
...@@ -231,6 +231,10 @@...@@ -231,6 +231,10 @@
231 "types": "./src/state/usage.ts",231 "types": "./src/state/usage.ts",
232 "default": "./src/state/usage.ts"232 "default": "./src/state/usage.ts"
233 },233 },
234 "./state/usage-progress": {
235 "types": "./src/state/usageProgress.ts",
236 "default": "./src/state/usageProgress.ts"
237 },
234 "./state/server": {238 "./state/server": {
235 "types": "./src/state/server.ts",239 "types": "./src/state/server.ts",
236 "default": "./src/state/server.ts"240 "default": "./src/state/server.ts"
packages/client-runtime/src/state/runtime.ts+25-8
...@@ -53,12 +53,18 @@ interface EnvironmentCommandAtomOptions<Input, A, E, R> extends Omit<...@@ -53,12 +53,18 @@ interface EnvironmentCommandAtomOptions<Input, A, E, R> extends Omit<
53 ) => Effect.Effect<A, E, R>;53 ) => Effect.Effect<A, E, R>;
54}54}
5555
56interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOptions<56interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit<
57 Input,57 EnvironmentAtomOptions<Input, A, E, R>,
58 A,58 "execute"
59 E,
60 R
61> {59> {
60 /**
61 * `emit` shows a provisional value while the query keeps waiting. The settled
62 * result replaces it, so catch failures that should keep it on screen.
63 */
64 readonly execute: (
65 input: Input,
66 emit: (value: A) => Effect.Effect<void>,
67 ) => Effect.Effect<A, E, R>;
62 readonly staleTimeMs?: number;68 readonly staleTimeMs?: number;
63 readonly idleTtlMs?: number;69 readonly idleTtlMs?: number;
64 readonly refreshIntervalMs?: number;70 readonly refreshIntervalMs?: number;
...@@ -547,10 +553,18 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(...@@ -547,10 +553,18 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(
547 return Effect.never;553 return Effect.never;
548 }554 }
549 const [connectionState, session] = connection;555 const [connectionState, session] = connection;
556 const emit = (value: A) =>
557 Effect.sync(() => get.setSelf(AsyncResult.success(value, { waiting: true }))).pipe(
558 // A value set while the atom builds wins over the build's own
559 // result, so a query that then settled synchronously would stay
560 // waiting forever. Suspend on a promise (`yieldNow` is drained
561 // inside the build) so the settled result arrives asynchronously.
562 Effect.andThen(Effect.promise(() => Promise.resolve())),
563 );
550 switch (connectionState.phase) {564 switch (connectionState.phase) {
551 case "connected":565 case "connected":
552 return Option.isSome(session)566 return Option.isSome(session)
553 ? runInEnvironment(target.environmentId, options.execute(target.input))567 ? runInEnvironment(target.environmentId, options.execute(target.input, emit))
554 : Effect.never;568 : Effect.never;
555 case "connecting":569 case "connecting":
556 case "backoff":570 case "backoff":
...@@ -650,6 +664,7 @@ export function createEnvironmentRpcQueryAtomFamily<...@@ -650,6 +664,7 @@ export function createEnvironmentRpcQueryAtomFamily<
650 readonly tag: TTag;664 readonly tag: TTag;
651 readonly execute?: (665 readonly execute?: (
652 input: EnvironmentRpcInput<TTag>,666 input: EnvironmentRpcInput<TTag>,
667 emit: (value: EnvironmentRpcSuccess<TTag>) => Effect.Effect<void>,
653 ) => Effect.Effect<668 ) => Effect.Effect<
654 EnvironmentRpcSuccess<TTag>,669 EnvironmentRpcSuccess<TTag>,
655 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,670 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
...@@ -672,8 +687,10 @@ export function createEnvironmentRpcQueryAtomFamily<...@@ -672,8 +687,10 @@ export function createEnvironmentRpcQueryAtomFamily<
672 ? {}687 ? {}
673 : { refreshIntervalMs: options.refreshIntervalMs }),688 : { refreshIntervalMs: options.refreshIntervalMs }),
674 ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }),689 ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }),
675 execute: (input: EnvironmentRpcInput<TTag>) =>690 execute: (
676 options.execute?.(input) ?? request(options.tag, input),691 input: EnvironmentRpcInput<TTag>,
692 emit: (value: EnvironmentRpcSuccess<TTag>) => Effect.Effect<void>,
693 ) => options.execute?.(input, emit) ?? request(options.tag, input),
677 });694 });
678}695}
679696
packages/client-runtime/src/state/server.ts+22
...@@ -1091,11 +1091,33 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -1091,11 +1091,33 @@ export function createServerEnvironmentAtoms<R, E>(
1091 }),1091 }),
1092 // A cold transcript scan is measured in seconds, so keep the result around1092 // A cold transcript scan is measured in seconds, so keep the result around
1093 // long enough that switching windows or re-rendering does not rescan.1093 // long enough that switching windows or re-rendering does not rescan.
1094 // Slow sources answer from cache first: that summary stays on screen, with
1095 // the query waiting, until one `awaitRefresh` request replaces it.
1094 usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, {1096 usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, {
1095 label: "environment-data:server:usage-summary",1097 label: "environment-data:server:usage-summary",
1096 tag: WS_METHODS.serverGetUsageSummary,1098 tag: WS_METHODS.serverGetUsageSummary,
1097 staleTimeMs: 60_000,1099 staleTimeMs: 60_000,
1098 refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId),1100 refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId),
1101 execute: (input, emit) =>
1102 request(WS_METHODS.serverGetUsageSummary, input).pipe(
1103 Effect.flatMap((summary) =>
1104 summary.sources.some((source) => source.refreshing === true)
1105 ? emit(summary).pipe(
1106 Effect.andThen(
1107 request(WS_METHODS.serverGetUsageSummary, { ...input, awaitRefresh: true }),
1108 ),
1109 // The cached summary is still the best data, and a failure
1110 // would read as the environment not reporting usage at all.
1111 Effect.catch((error) =>
1112 Effect.logWarning("Could not refresh slow usage sources.").pipe(
1113 Effect.annotateLogs({ ...safeErrorLogAttributes(error) }),
1114 Effect.as(summary),
1115 ),
1116 ),
1117 )
1118 : Effect.succeed(summary),
1119 ),
1120 ),
1099 }),1121 }),
1100 resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, {1122 resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
1101 label: "environment-data:server:resource-telemetry",1123 label: "environment-data:server:resource-telemetry",
packages/client-runtime/src/state/serverUsage.test.ts+150-4
...@@ -2,11 +2,13 @@ import {...@@ -2,11 +2,13 @@ import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,3 EnvironmentId,
4 UsageDay,4 UsageDay,
5 UsageReadError,
5 USAGE_CONTRACT_VERSION,6 USAGE_CONTRACT_VERSION,
6 WS_METHODS,7 WS_METHODS,
7 type ServerConfig,8 type ServerConfig,
8 type ServerConfigStreamEvent,9 type ServerConfigStreamEvent,
9 type ServerSettings,10 type ServerSettings,
11 type UsageSource,
10 type UsageSummary,12 type UsageSummary,
11 type UsageSummaryInput,13 type UsageSummaryInput,
12} from "@t3tools/contracts";14} from "@t3tools/contracts";
...@@ -16,6 +18,7 @@ import * as Effect from "effect/Effect";...@@ -16,6 +18,7 @@ import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";19import * as Option from "effect/Option";
18import * as Queue from "effect/Queue";20import * as Queue from "effect/Queue";
21import * as Result from "effect/Result";
19import * as Stream from "effect/Stream";22import * as Stream from "effect/Stream";
20import * as SubscriptionRef from "effect/SubscriptionRef";23import * as SubscriptionRef from "effect/SubscriptionRef";
21import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";24import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
...@@ -26,12 +29,14 @@ import {...@@ -26,12 +29,14 @@ import {
26 type PreparedConnection,29 type PreparedConnection,
27 type SupervisorConnectionState,30 type SupervisorConnectionState,
28} from "../connection/model.ts";31} from "../connection/model.ts";
32import type { EnvironmentPresentation } from "../connection/presentation.ts";
29import * as EnvironmentRegistry from "../connection/registry.ts";33import * as EnvironmentRegistry from "../connection/registry.ts";
30import * as EnvironmentSupervisor from "../connection/supervisor.ts";34import * as EnvironmentSupervisor from "../connection/supervisor.ts";
31import * as Persistence from "../platform/persistence.ts";35import * as Persistence from "../platform/persistence.ts";
32import type { WsRpcProtocolClient } from "../rpc/protocol.ts";36import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
33import type { RpcSession } from "../rpc/session.ts";37import type { RpcSession } from "../rpc/session.ts";
34import { createServerEnvironmentAtoms } from "./server.ts";38import { createServerEnvironmentAtoms } from "./server.ts";
39import { refreshUsage } from "./usage.ts";
3540
36const TARGET = new PrimaryConnectionTarget({41const TARGET = new PrimaryConnectionTarget({
37 environmentId: EnvironmentId.make("usage-environment"),42 environmentId: EnvironmentId.make("usage-environment"),
...@@ -51,13 +56,37 @@ const CONFIG = {...@@ -51,13 +56,37 @@ const CONFIG = {
51 environment: { serverVersion: "0.0.1", capabilities: {} },56 environment: { serverVersion: "0.0.1", capabilities: {} },
52} as ServerConfig;57} as ServerConfig;
5358
59const PRICING = { status: "unavailable" as const, source: "test", fetchedAt: null, knownModels: 0 };
60
61const cursorSource = (refreshing: boolean): UsageSource => ({
62 fingerprint: {
63 hostId: "cursor.com",
64 provider: "cursor",
65 resolvedHomePath: "cursor-account:abc",
66 volumeId: "abc",
67 },
68 status: "ok",
69 scannedFiles: 1,
70 skippedFiles: 0,
71 malformedRecords: 0,
72 distinctSessions: 1,
73 message: null,
74 ...(refreshing ? { refreshing: true as const } : {}),
75});
76
54const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (77const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
55 beforeRead: (request: number) => Effect.Effect<void> = () => Effect.void,78 beforeRead: (
79 request: number,
80 input: UsageSummaryInput,
81 ) => Effect.Effect<void, UsageReadError> = () => Effect.void,
82 sources: (input: UsageSummaryInput) => readonly UsageSource[] = () => [],
56) {83) {
57 const events = yield* Queue.unbounded<ServerConfigStreamEvent>();84 const events = yield* Queue.unbounded<ServerConfigStreamEvent>();
58 let settings = DEFAULT_SERVER_SETTINGS;85 let settings = DEFAULT_SERVER_SETTINGS;
59 let requests = 0;86 let requests = 0;
87 const inputs: UsageSummaryInput[] = [];
60 const client = {88 const client = {
89 [WS_METHODS.serverRefreshUsageRates]: () => Effect.succeed(PRICING),
61 [WS_METHODS.subscribeServerConfig]: () =>90 [WS_METHODS.subscribeServerConfig]: () =>
62 Stream.concat(91 Stream.concat(
63 Stream.make({ version: 1 as const, type: "snapshot" as const, config: CONFIG }),92 Stream.make({ version: 1 as const, type: "snapshot" as const, config: CONFIG }),
...@@ -66,8 +95,9 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (...@@ -66,8 +95,9 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
66 [WS_METHODS.serverGetUsageSummary]: (input: UsageSummaryInput) =>95 [WS_METHODS.serverGetUsageSummary]: (input: UsageSummaryInput) =>
67 Effect.gen(function* () {96 Effect.gen(function* () {
68 requests += 1;97 requests += 1;
98 inputs.push(input);
69 const price = settings.usagePriceOverrides["custom-model"]?.inputCostPerMillionTokens ?? 0;99 const price = settings.usagePriceOverrides["custom-model"]?.inputCostPerMillionTokens ?? 0;
70 yield* beforeRead(requests);100 yield* beforeRead(requests, input);
71 return {101 return {
72 contractVersion: USAGE_CONTRACT_VERSION,102 contractVersion: USAGE_CONTRACT_VERSION,
73 readAt: "2026-09-04T12:00:00Z",103 readAt: "2026-09-04T12:00:00Z",
...@@ -92,8 +122,8 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (...@@ -92,8 +122,8 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
92 sessions: 1,122 sessions: 1,
93 },123 },
94 ],124 ],
95 sources: [],125 sources: sources(input),
96 pricing: { status: "unavailable", source: "test", fetchedAt: null, knownModels: 0 },126 pricing: PRICING,
97 scanDurationMs: 0,127 scanDurationMs: 0,
98 } satisfies UsageSummary;128 } satisfies UsageSummary;
99 }),129 }),
...@@ -167,12 +197,44 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (...@@ -167,12 +197,44 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* (
167 });197 });
168 return {198 return {
169 registry,199 registry,
200 atoms,
170 requests: () => requests,201 requests: () => requests,
202 inputs: () => inputs,
171 updateSettings,203 updateSettings,
172 summary: (input = INPUT) => atoms.usageSummary({ environmentId: TARGET.environmentId, input }),204 summary: (input = INPUT) => atoms.usageSummary({ environmentId: TARGET.environmentId, input }),
173 };205 };
174});206});
175207
208function settledSummary<E>(
209 registry: AtomRegistry.AtomRegistry,
210 atom: Atom.Atom<AsyncResult.AsyncResult<UsageSummary, E>>,
211) {
212 return AtomRegistry.toStream(registry, atom).pipe(
213 Stream.filterMap((result) =>
214 AsyncResult.isSuccess(result) && !result.waiting
215 ? Result.succeed(result.value)
216 : Result.failVoid,
217 ),
218 Stream.runHead,
219 Effect.map(Option.getOrThrow),
220 );
221}
222
223/** Answers with a refreshing Cursor source; the `awaitRefresh` request runs `followUp` first. */
224const makeRefreshingHarness = Effect.fn("ServerUsageTest.makeRefreshingHarness")(function* (
225 followUp: Effect.Effect<void, UsageReadError>,
226) {
227 const followUpStarted = yield* Deferred.make<void>();
228 const harness = yield* makeHarness(
229 (_request, input) =>
230 input.awaitRefresh === true
231 ? Deferred.succeed(followUpStarted, undefined).pipe(Effect.andThen(followUp))
232 : Effect.void,
233 (input) => [cursorSource(input.awaitRefresh !== true)],
234 );
235 return { ...harness, followUpStarted };
236});
237
176function waitForCost<E>(238function waitForCost<E>(
177 registry: AtomRegistry.AtomRegistry,239 registry: AtomRegistry.AtomRegistry,
178 atom: Atom.Atom<AsyncResult.AsyncResult<UsageSummary, E>>,240 atom: Atom.Atom<AsyncResult.AsyncResult<UsageSummary, E>>,
...@@ -261,3 +323,87 @@ it.effect("restarts a pending usage read after a price change", () =>...@@ -261,3 +323,87 @@ it.effect("restarts a pending usage read after a price change", () =>
261 }),323 }),
262 ),324 ),
263);325);
326
327it.effect("settles after one usage read when no source is refreshing", () =>
328 Effect.scoped(
329 Effect.gen(function* () {
330 const harness = yield* makeHarness();
331 const summary = harness.summary();
332 const unmount = harness.registry.mount(summary);
333 yield* settledSummary(harness.registry, summary);
334 expect(harness.inputs()).toEqual([INPUT]);
335 unmount();
336 }),
337 ),
338);
339
340it.effect("shows the cached usage summary until one awaited refresh replaces it", () =>
341 Effect.scoped(
342 Effect.gen(function* () {
343 const refreshed = yield* Deferred.make<void>();
344 const harness = yield* makeRefreshingHarness(Deferred.await(refreshed));
345 const summary = harness.summary();
346 const unmount = harness.registry.mount(summary);
347 yield* Deferred.await(harness.followUpStarted);
348
349 const provisional = harness.registry.get(summary);
350 expect(AsyncResult.isSuccess(provisional) && provisional.waiting).toBe(true);
351 expect(Option.getOrNull(AsyncResult.value(provisional))?.sources).toEqual([
352 cursorSource(true),
353 ]);
354 expect(harness.inputs()).toEqual([INPUT, { ...INPUT, awaitRefresh: true }]);
355
356 yield* Deferred.succeed(refreshed, undefined);
357 expect((yield* settledSummary(harness.registry, summary)).sources).toEqual([
358 cursorSource(false),
359 ]);
360 expect(harness.requests()).toBe(2);
361 unmount();
362 }),
363 ),
364);
365
366it.effect("keeps the cached usage summary when the awaited refresh fails", () =>
367 Effect.scoped(
368 Effect.gen(function* () {
369 const harness = yield* makeRefreshingHarness(
370 Effect.fail(new UsageReadError({ reason: "scanFailed", detail: "Cursor is down" })),
371 );
372 const summary = harness.summary();
373 const unmount = harness.registry.mount(summary);
374 expect((yield* settledSummary(harness.registry, summary)).sources).toEqual([
375 cursorSource(true),
376 ]);
377 expect(AsyncResult.isSuccess(harness.registry.get(summary))).toBe(true);
378 expect(harness.requests()).toBe(2);
379 unmount();
380 }),
381 ),
382);
383
384it.effect("finishes a manual usage refresh only after the awaited refresh", () =>
385 Effect.scoped(
386 Effect.gen(function* () {
387 const refreshed = yield* Deferred.make<void>();
388 const harness = yield* makeRefreshingHarness(Deferred.await(refreshed));
389 const presentation = Atom.make({
390 connection: { phase: "connected" },
391 } as EnvironmentPresentation | null);
392 let finished = false;
393 const refreshing = refreshUsage({
394 registry: harness.registry,
395 server: harness.atoms,
396 presentations: { presentationAtom: () => presentation },
397 environmentIds: [TARGET.environmentId],
398 input: INPUT,
399 }).then(() => {
400 finished = true;
401 });
402 yield* Deferred.await(harness.followUpStarted);
403 expect(finished).toBe(false);
404 yield* Deferred.succeed(refreshed, undefined);
405 yield* Effect.promise(() => refreshing);
406 expect(harness.requests()).toBe(2);
407 }),
408 ),
409);
packages/client-runtime/src/state/usageProgress.test.ts created+150
...@@ -0,0 +1,150 @@
1import {
2 USAGE_CONTRACT_VERSION,
3 UsageDay,
4 type UsageProviderKind,
5 type UsageSummary,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "vite-plus/test";
8
9import { usageEnvironmentProgress, usageLoadingState, usageProgress } from "./usageProgress.ts";
10
11function summary(refreshing: readonly UsageProviderKind[] = []): UsageSummary {
12 return {
13 contractVersion: USAGE_CONTRACT_VERSION,
14 readAt: "2026-09-05T12:00:00Z",
15 timeZone: "UTC",
16 sinceDay: UsageDay.make("2026-09-05"),
17 untilDay: UsageDay.make("2026-09-05"),
18 buckets: [],
19 sources: (["codex", ...refreshing] as const).map((provider) => ({
20 fingerprint: { hostId: "host", provider, resolvedHomePath: "/home", volumeId: "1:1" },
21 status: "ok" as const,
22 scannedFiles: 1,
23 skippedFiles: 0,
24 malformedRecords: 0,
25 distinctSessions: 1,
26 message: null,
27 ...(provider === "codex" ? {} : { refreshing: true as const }),
28 })),
29 pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 },
30 scanDurationMs: 1,
31 };
32}
33
34function environment(
35 label: string,
36 overrides: Partial<{
37 isConnected: boolean;
38 isPending: boolean;
39 error: string | null;
40 summary: UsageSummary | null;
41 }> = {},
42) {
43 return {
44 label,
45 isConnected: true,
46 isPending: false,
47 error: null as string | null,
48 summary: summary(),
49 ...overrides,
50 };
51}
52
53const options = { providerLabel: (provider: UsageProviderKind) => `${provider} label` };
54
55describe("usageProgress", () => {
56 it("dims a lone environment while it refetches over its old totals", () => {
57 expect(usageProgress([environment("a", { isPending: true })], options)).toEqual({
58 dimmed: true,
59 label: "Updating…",
60 });
61 expect(usageProgress([environment("a")], options)).toEqual({ dimmed: false, label: null });
62 });
63
64 it("undims once one device answers and names the slow one", () => {
65 const waiting = [environment("a", { isPending: true }), environment("b", { isPending: true })];
66 expect(usageProgress(waiting, options)).toEqual({
67 dimmed: true,
68 label: "Updating 2 environments…",
69 });
70 expect(
71 usageProgress([environment("a"), ...waiting.slice(1), environment("c")], options),
72 ).toEqual({ dimmed: false, label: "Updating b…" });
73 expect(
74 usageProgress(
75 [environment("a"), environment("b", { summary: null, isPending: true })],
76 options,
77 ),
78 ).toEqual({ dimmed: false, label: "Updating b…" });
79 });
80
81 it("names a slow source without dimming once its environment answers from cache", () => {
82 const partway = environment("a", { isPending: true, summary: summary(["cursor"]) });
83 expect(usageEnvironmentProgress(partway)).toEqual({ phase: "partway", providers: ["cursor"] });
84 expect(usageProgress([partway, environment("b")], options)).toEqual({
85 dimmed: false,
86 label: "Updating cursor label…",
87 });
88 expect(
89 usageProgress(
90 [partway, environment("b", { isPending: true, summary: summary(["cursor", "grok"]) })],
91 options,
92 ),
93 ).toEqual({ dimmed: false, label: "Updating 2 providers…" });
94 // Without a follow-up in flight, a leftover refreshing source is not waited on.
95 expect(usageProgress([{ ...partway, isPending: false }], options)).toEqual({
96 dimmed: false,
97 label: null,
98 });
99 });
100
101 it("ignores reconnecting and failed environments", () => {
102 const ignored = [
103 environment("reconnecting", { isPending: true, isConnected: false }),
104 environment("failed", { error: "Offline", summary: null }),
105 ];
106 expect(usageProgress([environment("a"), ...ignored], options)).toEqual({
107 dimmed: false,
108 label: null,
109 });
110 expect(
111 usageProgress(
112 [environment("a"), ...ignored, environment("slow", { summary: null, isPending: true })],
113 options,
114 ),
115 ).toEqual({ dimmed: false, label: "Updating slow…" });
116 expect(usageEnvironmentProgress(ignored[0]!)).toEqual({ phase: "inactive" });
117 });
118
119 it("dims every answered environment while a manual refresh runs", () => {
120 // Pricing refreshes first, so neither query is pending yet.
121 expect(
122 usageProgress([environment("a"), environment("b")], { ...options, refreshing: true }),
123 ).toEqual({ dimmed: true, label: "Updating 2 environments…" });
124 });
125});
126
127describe("usageLoadingState", () => {
128 it("marks only refreshing providers while the rest have answered", () => {
129 const state = usageLoadingState([
130 environment("a", { isPending: true, summary: summary(["cursor"]) }),
131 environment("b"),
132 ]);
133 expect(state).toEqual({ partial: true, everyProvider: false, providers: new Set(["cursor"]) });
134 });
135
136 it("marks every provider while an environment has not answered", () => {
137 const state = usageLoadingState([environment("a"), environment("b", { summary: null })]);
138 expect(state.everyProvider).toBe(true);
139 expect(state.partial).toBe(true);
140 });
141
142 it("ignores environments that cannot answer and settles once all have", () => {
143 const state = usageLoadingState([
144 environment("a"),
145 environment("b", { summary: null, isConnected: false }),
146 environment("c", { summary: null, error: "Offline" }),
147 ]);
148 expect(state).toEqual({ partial: false, everyProvider: false, providers: new Set() });
149 });
150});
packages/client-runtime/src/state/usageProgress.ts created+114
...@@ -0,0 +1,114 @@
1import type { UsageProviderKind, UsageSummary } from "@t3tools/contracts";
2
3interface UsageProgressEnvironment {
4 readonly label: string;
5 readonly isConnected: boolean;
6 readonly isPending: boolean;
7 readonly error: string | null;
8 readonly summary: UsageSummary | null;
9}
10
11/**
12 * Where one environment's answer stands for the usage on screen.
13 *
14 * - `inactive`: disconnected or failed, so waiting on it changes nothing.
15 * - `loading`: no summary yet.
16 * - `stale`: its summary on screen predates the current request.
17 * - `partway`: answered this round while slow sources finish refreshing.
18 * - `ready`: answered.
19 *
20 * While a manual `refreshing` runs, every summary counts as stale.
21 */
22export function usageEnvironmentProgress(
23 environment: UsageProgressEnvironment,
24 refreshing = false,
25) {
26 const { summary } = environment;
27 if (!environment.isConnected || environment.error !== null) return { phase: "inactive" } as const;
28 if (summary === null) return { phase: "loading" } as const;
29 if (refreshing) return { phase: "stale" } as const;
30 // A refreshing source without a pending follow-up has nothing left to wait on.
31 if (!environment.isPending) return { phase: "ready" } as const;
32 const providers = [
33 ...new Set(
34 summary.sources.flatMap((source) => (source.refreshing ? [source.fingerprint.provider] : [])),
35 ),
36 ];
37 return providers.length > 0
38 ? ({ phase: "partway", providers } as const)
39 : ({ phase: "stale" } as const);
40}
41
42export function updatingProvidersLabel(
43 providers: readonly UsageProviderKind[],
44 providerLabel: (provider: UsageProviderKind) => string,
45) {
46 return providers.length === 1
47 ? `Updating ${providerLabel(providers[0]!)}…`
48 : `Updating ${providers.length} providers…`;
49}
50
51/**
52 * Loading state for the selected environments' merged usage.
53 *
54 * The page dims only while old numbers are all that is on screen. Once any
55 * environment answers, the label carries what is still coming, so one slow
56 * device never holds the page dimmed.
57 */
58export function usageProgress(
59 environments: readonly UsageProgressEnvironment[],
60 {
61 refreshing = false,
62 providerLabel,
63 }: {
64 readonly refreshing?: boolean;
65 readonly providerLabel: (provider: UsageProviderKind) => string;
66 },
67) {
68 const progress = environments.map((environment) => ({
69 label: environment.label,
70 ...usageEnvironmentProgress(environment, refreshing),
71 }));
72 const waiting = progress.filter(({ phase }) => phase === "loading" || phase === "stale");
73 const providers = [
74 ...new Set(progress.flatMap((entry) => (entry.phase === "partway" ? entry.providers : []))),
75 ];
76 const answered = progress.some(({ phase }) => phase === "partway" || phase === "ready");
77 return {
78 dimmed: !answered && progress.some(({ phase }) => phase === "stale"),
79 label:
80 waiting.length === 0
81 ? providers.length === 0
82 ? null
83 : updatingProvidersLabel(providers, providerLabel)
84 : environments.length === 1
85 ? "Updating…"
86 : waiting.length === 1
87 ? `Updating ${waiting[0]!.label}…`
88 : `Updating ${waiting.length} environments…`,
89 };
90}
91
92/**
93 * Which figures in the merged usage on screen are still coming in, so clients
94 * can mute just those.
95 *
96 * - `partial`: totals will change because some environment is still answering.
97 * - `providers`: providers whose own figures will change. An environment that
98 * has not answered yet, or whose whole summary is stale, could change any
99 * provider, so it sets `everyProvider`.
100 */
101export function usageLoadingState(
102 environments: readonly UsageProgressEnvironment[],
103 refreshing = false,
104) {
105 const providers = new Set<UsageProviderKind>();
106 let everyProvider = false;
107 for (const environment of environments) {
108 const progress = usageEnvironmentProgress(environment, refreshing);
109 if (progress.phase === "loading" || progress.phase === "stale") everyProvider = true;
110 if (progress.phase === "partway")
111 for (const provider of progress.providers) providers.add(provider);
112 }
113 return { partial: everyProvider || providers.size > 0, everyProvider, providers };
114}
packages/contracts/src/usage.ts+12
...@@ -183,6 +183,12 @@ export const UsageSource = Schema.Struct({...@@ -183,6 +183,12 @@ export const UsageSource = Schema.Struct({
183 message: Schema.NullOr(TrimmedNonEmptyString),183 message: Schema.NullOr(TrimmedNonEmptyString),
184 /** An action the client can offer to make this source available. */184 /** An action the client can offer to make this source available. */
185 action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")),185 action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")),
186 /**
187 * Present when this source answered from its cache while a slow refresh (an
188 * account API, for example) runs. Repeat the request with `awaitRefresh` to
189 * get the refreshed source.
190 */
191 refreshing: Schema.optionalKey(Schema.Literal(true)),
186});192});
187export type UsageSource = typeof UsageSource.Type;193export type UsageSource = typeof UsageSource.Type;
188194
...@@ -217,6 +223,12 @@ export const UsageSummaryInput = Schema.Struct({...@@ -217,6 +223,12 @@ export const UsageSummaryInput = Schema.Struct({
217 sinceTime: Schema.optional(TrimmedNonEmptyString),223 sinceTime: Schema.optional(TrimmedNonEmptyString),
218 /** Exclusive UTC instant for an hourly rolling window. */224 /** Exclusive UTC instant for an hourly rolling window. */
219 untilTime: Schema.optional(TrimmedNonEmptyString),225 untilTime: Schema.optional(TrimmedNonEmptyString),
226 /**
227 * Wait for slow sources to finish refreshing instead of answering from their
228 * cache. Clients send it as the follow-up to a summary with a `refreshing`
229 * source. Older servers ignore it and always wait.
230 */
231 awaitRefresh: Schema.optional(Schema.Boolean),
220});232});
221export type UsageSummaryInput = typeof UsageSummaryInput.Type;233export type UsageSummaryInput = typeof UsageSummaryInput.Type;
222234