From 7c00df5867ce92db21165bb25ab42f553421f1fd Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:22:58 -0700 Subject: [PATCH 1/5] perf(usage): the usage page loads in a fraction of a second and dims only what is still updating The usage page waited on every source before showing anything. Cursor's account API alone took 19-32s, and the loading state dimmed the whole page, so it was hard to tell what was stale. Server: - Fetch Cursor account pages concurrently after the first page. - Cache Cursor account records per credential and refresh only the newest edge. Answer from the cache with the source marked `refreshing`; a request with `awaitRefresh` waits for the fresh records. - Scan transcript, OpenCode, Antigravity and Cursor sources concurrently and persist the scan cache after responding. Client: - The usage query shows the cached answer, then sends one `awaitRefresh` follow-up when a source is refreshing. - A shared helper reports which providers are still loading. Web mutes only those figures, keeps the previous window on screen while a new one loads, and animates chart lines between states; loading lines sit flat and rise into place. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/usage/UsageRouteScreen.tsx | 170 +++-- apps/server/src/usage/UsageService.test.ts | 278 ++++++- apps/server/src/usage/UsageService.ts | 686 ++++++++++++------ .../src/usage/cursorAccountCache.test.ts | 55 ++ apps/server/src/usage/cursorAccountCache.ts | 303 ++++++++ .../src/usage/cursorUsageReader.test.ts | 129 ++++ apps/server/src/usage/cursorUsageReader.ts | 188 +++-- apps/web/src/components/usage/UsagePage.tsx | 380 ++++++---- .../usage/UsageProviderChart.test.ts | 90 ++- .../components/usage/UsageProviderChart.tsx | 419 +++++++++-- apps/web/src/state/usage.test.tsx | 1 + apps/web/src/state/usage.ts | 4 + docs/user/usage.md | 3 +- packages/client-runtime/package.json | 4 + packages/client-runtime/src/state/runtime.ts | 33 +- packages/client-runtime/src/state/server.ts | 22 + .../src/state/serverUsage.test.ts | 154 +++- .../src/state/usageProgress.test.ts | 159 ++++ .../client-runtime/src/state/usageProgress.ts | 117 +++ packages/contracts/src/usage.ts | 12 + 20 files changed, 2625 insertions(+), 582 deletions(-) create mode 100644 apps/server/src/usage/cursorAccountCache.test.ts create mode 100644 apps/server/src/usage/cursorAccountCache.ts create mode 100644 packages/client-runtime/src/state/usageProgress.test.ts create mode 100644 packages/client-runtime/src/state/usageProgress.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index e2022058616b..25fa7f2e665e 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,8 +1,18 @@ import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; -import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { + EnvironmentId, + USAGE_CONTRACT_VERSION, + type UsageProviderKind, + type UsageSummary, +} from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; +import { + updatingProvidersLabel, + usageEnvironmentProgress, + usageProgress, +} from "@t3tools/client-runtime/state/usage-progress"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -21,9 +31,16 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, Pressable, RefreshControl, View } from "react-native"; -import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native"; +import Animated, { + FadeIn, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SegmentedControl } from "../../components/SegmentedControl"; @@ -65,6 +82,7 @@ const METRIC_OPTIONS = [ ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; const CHART_HEIGHT = 180; +const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider]; const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; /** @@ -153,9 +171,11 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - const [refreshingUsage, setRefreshingUsage] = useState(false); + // Summaries on screen when a pull to refresh began. + const [refreshingFrom, setRefreshingFrom] = useState | null>(null); const refreshingRef = useRef(false); const showingLimits = tab === "limits"; + const progress = usageProgress(selectedEnvironments, { refreshingFrom, providerLabel }); const selectWindow = (days: number) => { setWindowSelection({ days, @@ -174,18 +194,16 @@ export function UsageRouteScreen() { setWindowSelection({ days: windowDays, window: nextWindow }); } refreshingRef.current = true; - setRefreshingUsage(true); + setRefreshingFrom( + new Set(selectedEnvironments.flatMap(({ summary }) => (summary === null ? [] : [summary]))), + ); void refresh(nextWindow).finally(() => { refreshingRef.current = false; - setRefreshingUsage(false); + setRefreshingFrom(null); }); }; const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; - const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading); - const filterAccessibilityLabel = hasLoadingEnvironments - ? "Filter usage environments, some environments are loading" - : "Filter usage environments"; const filterIcon = selectedEnvironmentIds === null ? "line.3.horizontal.decrease" @@ -201,14 +219,14 @@ export function UsageRouteScreen() { ...environments.map((environment) => ({ id: environment.environmentId, title: environment.label, - subtitle: usageEnvironmentStatus(environment), + subtitle: usageEnvironmentStatus(environment, refreshingFrom), state: selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) ? ("on" as const) : ("off" as const), })), ], - [environments, selectedEnvironmentIds], + [environments, refreshingFrom, selectedEnvironmentIds], ); const selectEnvironment = useCallback( (value: string) => { @@ -227,37 +245,24 @@ export function UsageRouteScreen() { selectEnvironment(nativeEvent.event)} > - {hasLoadingEnvironments ? ( - - ) : null} ) : null, - [ - showEnvironmentFilter, - environmentActions, - selectEnvironment, - filterAccessibilityLabel, - filterIcon, - hasLoadingEnvironments, - ], + [showEnvironmentFilter, environmentActions, selectEnvironment, filterIcon], ); useLayoutEffect(() => { @@ -277,7 +282,7 @@ export function UsageRouteScreen() { refreshControl={ showingLimits || canReadDiagnostics ? ( void limits.refresh() : refreshWindow} /> ) : undefined @@ -361,26 +366,28 @@ export function UsageRouteScreen() { {message} ))} - - 1} - onCursorEnabled={refreshAfterCursorEnable} - /> - - - + + + 1} + onCursorEnabled={refreshAfterCursorEnable} + /> + + + + )} @@ -391,6 +398,53 @@ export function UsageRouteScreen() { ); } +/** + * Dims totals that are about to change and says what is still updating. The + * status overlays the first child's top-right corner, the chart card's label + * row, so appearing never moves anything. + */ +function UsageUpdating({ + dimmed, + label, + children, +}: { + readonly dimmed: boolean; + readonly label: string | null; + readonly children: ReactNode; +}) { + const opacity = useSharedValue(1); + useLayoutEffect(() => { + // The delay keeps a quick cached answer from flashing the dim. + opacity.set( + dimmed + ? withDelay(150, withTiming(0.5, { duration: 150, reduceMotion: ReduceMotion.System })) + : withTiming(1, { duration: 150, reduceMotion: ReduceMotion.System }), + ); + }, [opacity, dimmed]); + const dimStyle = useAnimatedStyle(() => ({ opacity: opacity.get() })); + + return ( + + + {children} + + {label !== null ? ( + + + + {label} + + + ) : null} + + ); +} + function CursorEnableAction({ environmentId, label, @@ -860,11 +914,10 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U * one that failed, or one whose transcripts another environment already * reported. */ -function isUsageLoading(environment: EnvironmentUsageStatus) { - return environment.isPending || (environment.summary === null && environment.error === null); -} - -function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { +function usageEnvironmentStatus( + environment: EnvironmentUsageStatus, + refreshingFrom: ReadonlySet | null, +): string { if ( environment.summary && !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) @@ -881,7 +934,10 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { return environment.summary ? `${environment.error} Showing saved totals.` : environment.error; if (!environment.isConnected) return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; - if (isUsageLoading(environment)) - return environment.summary ? "Updating usage…" : "Loading usage…"; + const progress = usageEnvironmentProgress(environment, refreshingFrom); + if (progress.phase === "loading") return "Loading usage…"; + if (progress.phase === "stale") return "Updating usage…"; + if (progress.phase === "partway") + return updatingProvidersLabel(progress.providers, providerLabel); return "Usage up to date"; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 037eb5cc8ef4..3cd3d173d784 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -15,6 +15,7 @@ import { ProviderDriverKind, ProviderInstanceId, UsageDay, + type UsageSource, type UsageSummaryInput, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; @@ -31,6 +32,8 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { CursorAccountReader } from "./cursorAccountCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; import * as UsageService from "./UsageService.ts"; const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -157,6 +160,71 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); } +/** Inside `WINDOW`: the time Cursor tests run at. */ +const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z"); +const HOUR_MS = 60 * 60 * 1000; +/** `WINDOW` starts at its first midnight, less the service's 36-hour slack. */ +const CURSOR_WINDOW_START = Date.parse("2026-07-31T00:00:00Z") - 36 * HOUR_MS; + +/** + * Stands in for Cursor's dashboard API. Each read returns the account's events + * in its range, keyed like the real reader: occurrences count within one read. + * While `gate` is set, reads wait for it. + */ +function makeFakeCursor() { + const state = { + accountKey: "account-a", + events: [] as { readonly timestampMs: number; readonly outputTokens: number }[], + error: null as string | null, + gate: undefined as Deferred.Deferred | undefined, + calls: [] as { readonly sinceMs: number; readonly untilMs: number }[], + }; + const read = (_credential: unknown, sinceMs: number, untilMs: number) => + Effect.gen(function* () { + state.calls.push({ sinceMs, untilMs }); + if (state.gate !== undefined) yield* Deferred.await(state.gate); + const { accountKey, error } = state; + if (error !== null) return { accountKey, records: [], missing: false, error }; + const occurrences = new Map(); + const records = state.events + .filter(({ timestampMs }) => timestampMs >= sinceMs && timestampMs <= untilMs) + .map(({ timestampMs, outputTokens }): UsageRecord => { + const key = `${timestampMs}:${outputTokens}`; + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + return { + provider: "cursor", + timestampMs, + model: "claude-fable-5", + sessionId: "conversation-1", + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens, + reasoningTokens: 0, + }, + reportedCostUsd: null, + speed: "standard", + dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, + }; + }); + return { accountKey, records, missing: false, error: null }; + }); + return { state, read }; +} + +const writeCursorLogin = (home: string) => + Effect.promise(async () => { + const authPath = NodePath.join(home, "config", "cursor", "auth.json"); + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile(authPath, "{}"); + }); + +function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { + return summary.sources.find((source) => source.fingerprint.provider === "cursor"); +} + describe("UsageService", () => { it.live.each([ { explicitDefault: true, label: "explicit" }, @@ -282,12 +350,211 @@ describe("UsageService", () => { layerService({ prefix: "usage-service-cursor-invalid-login", home, settings }), ), ); - const summary = yield* service.readSummary(WINDOW); + const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); assert.strictEqual(cursor?.message, "Cursor credentials could not be read."); }).pipe(Effect.scoped), ); + it.live("answers Cursor from its cache while one shared refresh runs", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* writeCursorLogin(home); + yield* TestClock.setTime(CURSOR_NOW); + const cursor = makeFakeCursor(); + cursor.state.events = [{ timestampMs: CURSOR_NOW - 2 * HOUR_MS, outputTokens: 5 }]; + const gate = yield* Deferred.make(); + cursor.state.gate = gate; + yield* Effect.gen(function* () { + const service = yield* UsageService.make.pipe( + Effect.provideService(CursorAccountReader, cursor.read), + ); + // Cold: nothing cached yet, so Cursor answers empty while it refreshes. + const cold = yield* service.readSummary(WINDOW); + assert.strictEqual(cursorSource(cold)?.refreshing, true); + assert.strictEqual(cursorSource(cold)?.status, "ok"); + assert.strictEqual(totalOutputTokens(cold), 0); + // A second reader joins the refresh in flight. + const joined = yield* service.readSummary(WINDOW); + assert.strictEqual(cursorSource(joined)?.refreshing, true); + + const waited = yield* service + .readSummary({ ...WINDOW, awaitRefresh: true }) + .pipe(Effect.forkChild); + yield* Deferred.succeed(gate, undefined); + const refreshed = yield* Fiber.join(waited); + assert.isFalse(refreshed.sources.some((source) => source.refreshing)); + assert.strictEqual(cursorSource(refreshed)?.status, "ok"); + assert.strictEqual(totalOutputTokens(refreshed), 5); + assert.strictEqual(cursor.state.calls.length, 1); + + // Inside the TTL a plain read answers from the cache without refreshing. + yield* TestClock.adjust(Duration.seconds(30)); + const cached = yield* service.readSummary(WINDOW); + assert.isUndefined(cursorSource(cached)?.refreshing); + assert.strictEqual(totalOutputTokens(cached), 5); + assert.strictEqual(cursor.state.calls.length, 1); + + // Past it, a plain read answers from the stale cache and refreshes. + yield* TestClock.adjust(Duration.seconds(31)); + cursor.state.events.push({ timestampMs: CURSOR_NOW, outputTokens: 7 }); + const stale = yield* service.readSummary(WINDOW); + assert.strictEqual(cursorSource(stale)?.refreshing, true); + assert.strictEqual(totalOutputTokens(stale), 5); + const fresh = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.isUndefined(cursorSource(fresh)?.refreshing); + assert.strictEqual(totalOutputTokens(fresh), 12); + assert.strictEqual(cursor.state.calls.length, 2); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-cursor-swr", home, settings })), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.live("refetches only the Cursor history its cache is missing", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* writeCursorLogin(home); + yield* TestClock.setTime(CURSOR_NOW); + const cursor = makeFakeCursor(); + // Two identical billed rows inside the refetched hour must both survive it, once each. + cursor.state.events = [ + { timestampMs: CURSOR_NOW - 24 * HOUR_MS, outputTokens: 5 }, + { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, + { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, + ]; + yield* Effect.gen(function* () { + const service = yield* UsageService.make.pipe( + Effect.provideService(CursorAccountReader, cursor.read), + ); + const read = (input: UsageSummaryInput) => + service.readSummary({ ...input, awaitRefresh: true }); + assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19); + assert.deepStrictEqual(cursor.state.calls, [ + { sinceMs: CURSOR_WINDOW_START, untilMs: CURSOR_NOW }, + ]); + + // An event finalized late, inside the overlap, and a new one. + cursor.state.events.push( + { timestampMs: CURSOR_NOW - HOUR_MS / 6, outputTokens: 11 }, + { timestampMs: CURSOR_NOW + HOUR_MS, outputTokens: 13 }, + ); + yield* TestClock.adjust(Duration.hours(2)); + assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 43); + assert.deepStrictEqual(cursor.state.calls[1], { + sinceMs: CURSOR_NOW - HOUR_MS, + untilMs: CURSOR_NOW + 2 * HOUR_MS, + }); + + // A wider window fetches only the older history. + const wide = { ...WINDOW, sinceDay: UsageDay.make("2026-07-01") }; + cursor.state.events.push({ + timestampMs: Date.parse("2026-07-10T10:00:00Z"), + outputTokens: 17, + }); + assert.strictEqual(totalOutputTokens(yield* read(wide)), 60); + assert.deepStrictEqual(cursor.state.calls.slice(2), [ + { + sinceMs: Date.parse("2026-07-01T00:00:00Z") - 36 * HOUR_MS, + untilMs: CURSOR_WINDOW_START, + }, + ]); + + // Another login replaces the cached account's history instead of adding to it. + cursor.state.accountKey = "account-b"; + yield* TestClock.adjust(Duration.minutes(2)); + assert.strictEqual(totalOutputTokens(yield* read(wide)), 60); + assert.strictEqual(cursor.state.calls.length, 5); + assert.strictEqual(cursorSource(yield* read(wide))?.fingerprint.volumeId, "account-b"); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-cursor-incremental", home, settings }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.live("reports a failed Cursor refresh from its cache without refreshing", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* writeCursorLogin(home); + yield* TestClock.setTime(CURSOR_NOW); + const cursor = makeFakeCursor(); + cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }]; + yield* Effect.gen(function* () { + const service = yield* UsageService.make.pipe( + Effect.provideService(CursorAccountReader, cursor.read), + ); + yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + + yield* TestClock.adjust(Duration.minutes(2)); + cursor.state.error = "Sign in to Cursor again to read account usage."; + const failed = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.strictEqual(cursorSource(failed)?.status, "partial"); + assert.strictEqual(cursorSource(failed)?.message, cursor.state.error); + assert.isUndefined(cursorSource(failed)?.refreshing); + assert.strictEqual(totalOutputTokens(failed), 5); + + // The failure stands for the TTL; a plain read does not retry it. + const again = yield* service.readSummary(WINDOW); + assert.strictEqual(cursorSource(again)?.status, "partial"); + assert.isUndefined(cursorSource(again)?.refreshing); + assert.strictEqual(cursor.state.calls.length, 2); + + // A failing read of another login drops the previous account's history. + yield* TestClock.adjust(Duration.minutes(2)); + cursor.state.accountKey = "account-b"; + const switched = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.strictEqual(cursorSource(switched)?.status, "missing"); + assert.strictEqual(cursorSource(switched)?.message, cursor.state.error); + assert.strictEqual(totalOutputTokens(switched), 0); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-cursor-failure", home, settings })), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + + it.live("restores the Cursor account cache after a restart", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* writeCursorLogin(home); + yield* TestClock.setTime(CURSOR_NOW); + const before = makeFakeCursor(); + before.state.events = [ + { timestampMs: CURSOR_NOW - 30 * HOUR_MS, outputTokens: 5 }, + { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }, + ]; + yield* Effect.gen(function* () { + const first = yield* UsageService.make.pipe( + Effect.provideService(CursorAccountReader, before.read), + ); + const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true }); + yield* first.awaitPersisted; + + const after = makeFakeCursor(); + after.state.events = before.state.events; + const restarted = yield* UsageService.make.pipe( + Effect.provideService(CursorAccountReader, after.read), + ); + const restored = yield* restarted.readSummary(WINDOW); + assert.isUndefined(cursorSource(restored)?.refreshing); + assert.deepStrictEqual(restored.buckets, original.buckets); + assert.deepStrictEqual(cursorSource(restored), cursorSource(original)); + assert.strictEqual(after.state.calls.length, 0); + + // Once stale, the restored cache refreshes only its newest edge. + yield* TestClock.adjust(Duration.minutes(2)); + const refreshed = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.deepStrictEqual(refreshed.buckets, original.buckets); + assert.deepStrictEqual(after.state.calls, [ + { sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 }, + ]); + }).pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-cursor-restart", home, settings })), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -801,9 +1068,11 @@ describe("UsageService", () => { appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0), 20, ); + yield* service.awaitPersisted; const restarted = yield* UsageService.make; const restored = yield* restarted.readSummary(WINDOW); assert.deepStrictEqual(restored.buckets, appended.buckets); + yield* restarted.awaitPersisted; yield* Effect.promise(() => NodeFSP.rm(transcript)); const afterCleanup = yield* UsageService.make; assert.deepStrictEqual( @@ -865,7 +1134,9 @@ describe("UsageService", () => { const { stateDir } = yield* ServerConfig.ServerConfig; const cachePath = NodePath.join(stateDir, "usage-scan-cache-v5.json"); const legacyPath = NodePath.join(stateDir, "usage-scan-cache.json"); - yield* (yield* UsageService.make).readSummary(WINDOW); + const first = yield* UsageService.make; + yield* first.readSummary(WINDOW); + yield* first.awaitPersisted; // Rewrite the cache as a v4 server left it: every Codex record at // speed 0 (standard), and no tier in the reducer state. @@ -937,6 +1208,7 @@ describe("UsageService", () => { assert.deepStrictEqual(deleted.buckets, first.buckets); assert.deepStrictEqual(deleted.sources, first.sources); + yield* service.awaitPersisted; const restarted = yield* UsageService.make; const restored = yield* restarted.readSummary(WINDOW); assert.deepStrictEqual(restored.buckets, first.buckets); @@ -947,6 +1219,7 @@ describe("UsageService", () => { const moved = yield* restarted.readSummary(WINDOW); assert.deepStrictEqual(moved.buckets, first.buckets); assert.strictEqual(moved.sources[0]?.distinctSessions, 1); + yield* restarted.awaitPersisted; const replacementProjects = NodePath.join(home, "replacement-projects"); yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects)); @@ -1039,6 +1312,7 @@ describe("UsageService", () => { const saved = yield* service.readSummary(WINDOW); assert.deepStrictEqual(saved.buckets, live.buckets); assert.deepStrictEqual(saved.sources, live.sources); + yield* service.awaitPersisted; const restored = yield* (yield* UsageService.make).readSummary(WINDOW); assert.deepStrictEqual(restored.buckets, live.buckets); }).pipe( diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index c2206a53d33c..78f2ec46655b 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -10,6 +10,10 @@ * from its cached parse position so only the appended bytes are read. * SQLite readers query live databases each scan so WAL writes remain visible. * + * Cursor's account API is slow, so its source answers from a cache and marks + * itself `refreshing` while a background refresh runs; `awaitRefresh` waits + * for that refresh instead. See `cursorAccountCache`. + * * @module UsageService */ import * as NodeOS from "node:os"; @@ -36,6 +40,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -53,7 +59,18 @@ import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAu import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { readAntigravityUsage } from "./antigravityUsageReader.ts"; -import { readCursorAccountUsage } from "./cursorUsageReader.ts"; +import { + CURSOR_ACCOUNT_CACHE_FILE_NAME, + CURSOR_ACCOUNT_TTL_MS, + CursorAccountReader, + cursorFetchRanges, + decodeCursorAccountCaches, + encodeCursorAccountCaches, + isCursorCacheFresh, + mergeCursorFetches, + type CursorAccountCache, + type CursorCredentialSource, +} from "./cursorAccountCache.ts"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -92,6 +109,8 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; + /** Transcripts parsed at once. More gains little once the disk stays busy. */ const TRANSCRIPT_READ_CONCURRENCY = 4; @@ -193,10 +212,33 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; const platform = yield* HostProcessPlatform; + const readCursorAccount = yield* CursorAccountReader; const fileCache: ScanCache = new Map(); const sourceCache = new Map(); let cacheDirty = false; + /** Cursor account caches by credential source. */ + const cursorCaches = new Map(); + let cursorCacheDirty = false; + /** + * The last failed refresh per credential source, standing for a TTL so a + * client refetching a broken login does not refetch Cursor each time. A + * `null` error means there is no login: no source to report. + */ + const cursorFailures = new Map< + string, + { readonly atMs: number; readonly error: string | null } + >(); + /** + * The newest refresh per credential source. A request for a window it + * covers joins it; a wider one queues its own behind it. + */ + const cursorRefreshes = new Map< + string, + { readonly windowStartMs: number; readonly done: Deferred.Deferred } + >(); + // A refresh reads, extends and replaces the cache; two must not interleave. + const cursorLock = yield* Semaphore.make(1); const isWithinDirectory = (filePath: string, dir: string) => { const relative = path.relative(dir, filePath); return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); @@ -205,6 +247,7 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); + const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); const writeCacheFile = (filePath: string, contents: string) => writeFileStringAtomically({ filePath, contents }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -395,7 +438,7 @@ export const make = Effect.gen(function* () { }); /** - * Loads the persisted scan cache exactly once per process. + * Loads the persisted scan and Cursor account caches exactly once per process. * * `Effect.cached` makes concurrent first readers await the same load rather * than each seeing a "loaded" flag set before the read finished and cold @@ -408,6 +451,9 @@ export const make = Effect.gen(function* () { Effect.flatMap((raw) => decodeScanCacheFile(raw)), Effect.catchCause(() => Effect.succeed(null)), ); + for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) { + cursorCaches.set(key, cache); + } let document = yield* readDocument(scanCachePath); if (document === null) { document = yield* readDocument(legacyScanCachePath); @@ -429,25 +475,49 @@ export const make = Effect.gen(function* () { // keeps an older snapshot from landing after a newer one. const persistLock = yield* Semaphore.make(1); - const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () { - if (!cacheDirty) return; - // Cleared before encoding, so a scan that changes the cache while this - // write is in flight marks it dirty again. A failed write restores the - // flag, so the next scan retries instead of leaving disk stale. - cacheDirty = false; - yield* Effect.sync(() => - writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), - ).pipe( - Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)), - // A cache we cannot write is a slower next start, not a failed read. - Effect.catchCause(() => - Effect.sync(() => { - cacheDirty = true; - }), - ), - persistLock.withPermit, - ); - }); + // Each dirty flag is cleared before encoding, so a change while the write is + // in flight marks it dirty again. A failed write restores the flag, so the + // next persist retries instead of leaving disk stale. A cache we cannot + // write is a slower next start, not a failed read. + const persistCaches = Effect.gen(function* () { + if (cacheDirty) { + cacheDirty = false; + yield* Effect.sync(() => + writeScanCache(fileCache, { sources: Object.fromEntries(sourceCache) }), + ).pipe( + Effect.flatMap((contents) => writeCacheFile(scanCachePath, contents)), + Effect.catchCause(() => + Effect.sync(() => { + cacheDirty = true; + }), + ), + ); + } + if (cursorCacheDirty) { + cursorCacheDirty = false; + yield* Effect.sync(() => encodeCursorAccountCaches(cursorCaches)).pipe( + Effect.flatMap((contents) => writeCacheFile(cursorCachePath, contents)), + Effect.catchCause(() => + Effect.sync(() => { + cursorCacheDirty = true; + }), + ), + ); + } + }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches")); + + const pendingPersists = new Set>(); + /** Writes dirty caches in the background, after the summary that dirtied them answers. */ + const schedulePersist = Effect.forkDetach(persistCaches).pipe( + Effect.map((fiber) => { + pendingPersists.add(fiber); + fiber.addObserver(() => pendingPersists.delete(fiber)); + }), + ); + /** Waits for every write scheduled so far, as a restart would need. */ + const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( + Effect.asVoid, + ); /** * Parses one transcript, reusing the cached result when it is unchanged. @@ -538,58 +608,230 @@ export const make = Effect.gen(function* () { readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] | null; + /** Answered from a cache while a refresh runs. */ + readonly refreshing?: true; } + const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* ( + source: { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly volumeId: string; + readonly fileName?: string; + }, + windowStartMs: number, + ) { + const { provider, dir, volumeId, fileName } = source; + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) return { provider, dir, volumeId, files: null } satisfies ScannedDir; + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + // A cold parse waits on disk reads, so a few files in flight read + // close to twice as fast. Results keep walk order. + const read = yield* Effect.forEach( + files, + (file) => + readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( + Effect.map((result) => ({ path: file.path, ...result })), + ), + { concurrency: TRANSCRIPT_READ_CONCURRENCY }, + ); + const parsedFiles = read.map(({ path, records, update }) => { + if (update === undefined) return { path, records }; + // A scan of another window may have cached its own read of this file + // meanwhile. Then keep whichever read saw the later file, so a slower + // scan never replaces newer usage with older. + const current = fileCache.get(path); + if ( + current === update.replaces || + current === undefined || + !isLaterRead(current, update.entry) + ) { + fileCache.set(path, update.entry); + cacheDirty = true; + } + return { path, records }; + }); + return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir; + }); + + /** Fetches what one account cache is missing for the window, then persists it. */ + const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* ( + credential: CursorCredentialSource, + credentialKey: string, + windowStartMs: number, + ) { + const nowMs = yield* Clock.currentTimeMillis; + const fetchMissing = (cache: CursorAccountCache | undefined) => + Effect.forEach( + cursorFetchRanges(cache, windowStartMs, nowMs), + (range) => + readCursorAccount(credential, range.sinceMs, range.untilMs).pipe( + Effect.map((result) => ({ range, result })), + ), + { concurrency: "unbounded" }, + ); + const cached = cursorCaches.get(credentialKey); + let base = cached; + let fetched = yield* fetchMissing(base); + // A refresh queued behind another can find nothing left to fetch. + if (fetched.length === 0) return; + // Another login's history replaces the cached account's, even if reading it fails. + if ( + cached !== undefined && + fetched.some( + ({ result }) => result.accountKey !== null && result.accountKey !== cached.accountKey, + ) + ) { + cursorCaches.delete(credentialKey); + cursorCacheDirty = true; + base = undefined; + fetched = yield* fetchMissing(base); + } + const failed = fetched.find(({ result }) => result.missing || result.error !== null)?.result; + const accountKey = fetched[0]?.result.accountKey ?? null; + if (failed !== undefined || accountKey === null) { + cursorFailures.set(credentialKey, { + atMs: nowMs, + error: failed === undefined ? CURSOR_ACCOUNT_READ_ERROR : failed.error, + }); + yield* schedulePersist; + return; + } + cursorCaches.set( + credentialKey, + mergeCursorFetches( + base, + accountKey, + fetched.map(({ range, result }) => ({ range, records: result.records })), + nowMs, + nowMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + ), + ); + cursorFailures.delete(credentialKey); + cursorCacheDirty = true; + yield* schedulePersist; + }); + + /** Joins the newest refresh when it covers the window, or starts one. */ + const startCursorRefresh = ( + credential: CursorCredentialSource, + credentialKey: string, + windowStartMs: number, + ) => + // Enrollment and fork are atomic, so an interrupted caller cannot leave a + // registered refresh that nothing will finish. + Effect.uninterruptible( + Effect.gen(function* () { + const current = cursorRefreshes.get(credentialKey); + if (current !== undefined && current.windowStartMs <= windowStartMs) return current.done; + const refresh = { windowStartMs, done: Deferred.makeUnsafe() }; + cursorRefreshes.set(credentialKey, refresh); + // Detached: a departing client must not cancel a fetch later reads reuse. + yield* refreshCursorAccount(credential, credentialKey, windowStartMs).pipe( + cursorLock.withPermit, + Effect.catchCause(() => + Clock.currentTimeMillis.pipe( + Effect.map((atMs) => + cursorFailures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }), + ), + ), + ), + Effect.ensuring( + Effect.suspend(() => { + if (cursorRefreshes.get(credentialKey) === refresh) { + cursorRefreshes.delete(credentialKey); + } + return Deferred.succeed(refresh.done, undefined); + }), + ), + Effect.forkDetach, + ); + return refresh.done; + }), + ); + + /** + * The Cursor account source. A cache inside its TTL, or a refresh that failed + * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh` + * waits for it, and anything else answers from the cache marked `refreshing`. + */ + const cursorAccountSource = Effect.fn("UsageService.cursorAccountSource")(function* ( + credential: CursorCredentialSource, + authPath: string, + windowStartMs: number, + awaitRefresh: boolean, + ) { + // No saved login means there is no account source to report, not a setup error. + if ( + typeof credential === "string" && + !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) + ) { + return null; + } + const credentialKey = typeof credential === "string" ? credential : "keychain"; + const nowMs = yield* Clock.currentTimeMillis; + const recentFailure = cursorFailures.get(credentialKey); + let refreshing = false; + if ( + (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && + !isCursorCacheFresh(cursorCaches.get(credentialKey), windowStartMs, nowMs) + ) { + const refresh = yield* startCursorRefresh(credential, credentialKey, windowStartMs); + if (awaitRefresh) yield* Deferred.await(refresh); + else refreshing = true; + } + + const cache = cursorCaches.get(credentialKey); + const failure = refreshing ? undefined : cursorFailures.get(credentialKey); + const failureMessage = failure === undefined ? undefined : failure.error; + if (failureMessage === null) return null; + if (cache === undefined) { + return { + provider: "cursor", + dir: authPath, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(authPath)), + // Never combine a local fallback with another server's account-wide history. + ...(refreshing + ? { files: [], refreshing: true } + : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), + } satisfies ScannedDir; + } + // The same account includes CLI and desktop history from every machine. + // A stable remote fingerprint prevents connected environments counting it twice. + const source = `cursor-account:${cache.accountKey}`; + return { + provider: "cursor", + dir: source, + hostId: "cursor.com", + volumeId: cache.accountKey, + files: [ + { + path: source, + records: cache.records.filter((record) => record.timestampMs >= windowStartMs), + }, + ], + ...(failureMessage === undefined + ? { status: "ok" } + : { status: "partial", message: failureMessage }), + ...(refreshing ? { refreshing: true } : {}), + } satisfies ScannedDir; + }); + const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, retentionCutoffMs: number, + awaitRefresh: boolean, ) { // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so the scan stays context-free. const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( Effect.provideService(Path.Path, path), ); - const scanned: ScannedDir[] = []; - for (const { provider, dir, volumeId, fileName } of dirs) { - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - if (!exists) { - scanned.push({ provider, dir, volumeId, files: null }); - continue; - } - const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); - // A cold parse waits on disk reads, so a few files in flight read - // close to twice as fast. Results keep walk order. - const read = yield* Effect.forEach( - files, - (file) => - readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe( - Effect.map((result) => ({ path: file.path, ...result })), - ), - { concurrency: TRANSCRIPT_READ_CONCURRENCY }, - ); - const parsedFiles = read.map(({ path, records, update }) => { - if (update === undefined) return { path, records }; - // A scan of another window may have cached its own read of this file - // meanwhile. Then keep whichever read saw the later file, so a slower - // scan never replaces newer usage with older. - const current = fileCache.get(path); - if ( - current === update.replaces || - current === undefined || - !isLaterRead(current, update.entry) - ) { - fileCache.set(path, update.entry); - cacheDirty = true; - } - return { path, records }; - }); - scanned.push({ provider, dir, volumeId, files: parsedFiles }); - } const home = NodeOS.homedir(); const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) { @@ -607,156 +849,170 @@ export const make = Effect.gen(function* () { return [...canonical]; }); const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim(); - for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [ - path.join( - dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), - "opencode", - ), - ])) { - const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)); - scanned.push({ - provider: "opencode", - dir, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), - files: result.missing && !result.error ? null : result.files, - status: result.error ? "partial" : "ok", - ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), - }); - } - const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ - ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => - path.join(home, ".gemini", name), - ), - path.join(home, ".config", "antigravity"), - ]); - for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { - if (instance.driver === "antigravity") { - const directories = yield* resolveAntigravityInstanceDirectories( - config.stateDir, - ProviderInstanceId.make(instanceId), - ).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new UsageReadError({ - reason: "scanFailed", - detail: "Antigravity profile directory could not be resolved.", - cause, - }), - ), - ); - antigravityRoots.push(path.join(directories.profile, "antigravity-acp")); - } - } - const antigravityDirs = new Set(); - for (const root of antigravityRoots) { - const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); - const nested = path.join(resolvedRoot, "conversations"); - const dir = (yield* fileSystem - .exists(nested) - .pipe(Effect.catchCause(() => Effect.succeed(false)))) - ? nested - : resolvedRoot; - antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); - } - const antigravity = yield* Effect.promise(() => - readAntigravityUsage([...antigravityDirs], windowStartMs), - ); - for (const dir of antigravityDirs) { - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - const failed = antigravity.errors.some( - (error) => error === dir || error.startsWith(`${dir}${path.sep}`), + + const openCode = Effect.gen(function* () { + const roots = yield* envRoots("OPENCODE_DATA_DIR", [ + path.join( + dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), + "opencode", + ), + ]); + return yield* Effect.forEach( + roots, + (dir) => + Effect.gen(function* () { + const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)); + return { + provider: "opencode", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: result.missing && !result.error ? null : result.files, + status: result.error ? "partial" : "ok", + ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), + } satisfies ScannedDir; + }), + { concurrency: "unbounded" }, ); - scanned.push({ - provider: "antigravity", - dir, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), - files: !exists && !failed ? null : antigravity.files.filter((file) => file.root === dir), - status: failed ? "partial" : "ok", - ...(failed ? { message: "Some Antigravity history could not be read." } : {}), - }); - } - const cursorUserHome = - (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; - const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); - const cursorHome = - platform === "darwin" - ? path.join(cursorUserHome, "Library", "Application Support") - : platform === "win32" - ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") - : configHome && path.isAbsolute(configHome) - ? configHome - : path.join(cursorUserHome, ".config"); - const cursorAuthPath = - platform === "darwin" - ? path.join(cursorUserHome, ".cursor", "auth.json") - : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); - const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; - const loginUnavailable = - Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || - Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || - credentialStore === "memory"; - if ( - platform === "darwin" && - credentialStore !== "file" && - !loginUnavailable && - !settings.cursorKeychainUsageEnabled - ) { - scanned.push({ - provider: "cursor", - dir: cursorAuthPath, - volumeId: "", - files: null, - message: "Cursor account usage is off on this environment.", - action: "enableCursorKeychain", - }); - return scanned; - } - const cursorUntilMs = yield* Clock.currentTimeMillis; - const account = loginUnavailable - ? { - accountKey: null, - records: [], - missing: true, - error: "Cursor account history needs a Cursor CLI login on this server.", + }); + + const antigravity = Effect.gen(function* () { + const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ + ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => + path.join(home, ".gemini", name), + ), + path.join(home, ".config", "antigravity"), + ]); + for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { + if (instance.driver === "antigravity") { + const directories = yield* resolveAntigravityInstanceDirectories( + config.stateDir, + ProviderInstanceId.make(instanceId), + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Antigravity profile directory could not be resolved.", + cause, + }), + ), + ); + antigravityRoots.push(path.join(directories.profile, "antigravity-acp")); } - : yield* Effect.promise(() => - readCursorAccountUsage( - platform === "darwin" && credentialStore !== "file" - ? { kind: "keychain" } - : cursorAuthPath, - windowStartMs, - cursorUntilMs, - ), + } + const antigravityDirs = new Set(); + for (const root of antigravityRoots) { + const resolvedRoot = yield* fileSystem + .realPath(root) + .pipe(Effect.orElseSucceed(() => root)); + const nested = path.join(resolvedRoot, "conversations"); + const dir = (yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false)))) + ? nested + : resolvedRoot; + antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); + } + const result = yield* Effect.promise(() => + readAntigravityUsage([...antigravityDirs], windowStartMs), + ); + const scanned: ScannedDir[] = []; + for (const dir of antigravityDirs) { + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const failed = result.errors.some( + (error) => error === dir || error.startsWith(`${dir}${path.sep}`), ); - // No saved login means there is no account source to report, not a setup error. - if (account.missing && account.error === null) return scanned; - if (account.accountKey !== null && account.error === null && !account.missing) { - // The same account includes CLI and desktop history from every machine. - // A stable remote fingerprint prevents connected environments counting it twice. - const source = `cursor-account:${account.accountKey}`; - scanned.push({ - provider: "cursor", - dir: source, - hostId: "cursor.com", - volumeId: account.accountKey, - files: [{ path: source, records: account.records }], - status: "ok", - }); + scanned.push({ + provider: "antigravity", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: !exists && !failed ? null : result.files.filter((file) => file.root === dir), + status: failed ? "partial" : "ok", + ...(failed ? { message: "Some Antigravity history could not be read." } : {}), + }); + } return scanned; - } - scanned.push({ - provider: "cursor", - dir: cursorAuthPath, - volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), - // Never combine a local fallback with another server's account-wide history. - files: null, - message: - account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.", }); + + const cursor = Effect.gen(function* () { + const cursorUserHome = + (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; + const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); + const cursorHome = + platform === "darwin" + ? path.join(cursorUserHome, "Library", "Application Support") + : platform === "win32" + ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") + : configHome && path.isAbsolute(configHome) + ? configHome + : path.join(cursorUserHome, ".config"); + const cursorAuthPath = + platform === "darwin" + ? path.join(cursorUserHome, ".cursor", "auth.json") + : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); + const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; + const loginUnavailable = + Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || + Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || + credentialStore === "memory"; + const useKeychain = platform === "darwin" && credentialStore !== "file"; + if (useKeychain && !loginUnavailable && !settings.cursorKeychainUsageEnabled) { + return [ + { + provider: "cursor", + dir: cursorAuthPath, + volumeId: "", + files: null, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + } satisfies ScannedDir, + ]; + } + if (loginUnavailable) { + return [ + { + provider: "cursor", + dir: cursorAuthPath, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), + files: null, + message: "Cursor account history needs a Cursor CLI login on this server.", + } satisfies ScannedDir, + ]; + } + const source = yield* cursorAccountSource( + useKeychain ? { kind: "keychain" } : cursorAuthPath, + cursorAuthPath, + // Clamped to the retention, which prunes the cache: a wider start + // would never be covered and would refetch on every read. + Math.max(windowStartMs, retentionCutoffMs), + awaitRefresh, + ); + return source === null ? [] : [source]; + }); + + // Independent sources scan together. The result keeps this order, since + // aggregation keeps the first copy of a duplicate. + const [transcripts, openCodeDirs, antigravityDirs, cursorDirs] = yield* Effect.all( + [ + Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs), { + concurrency: "unbounded", + }), + openCode, + antigravity, + cursor, + ], + { concurrency: "unbounded" }, + ); + const scanned: readonly ScannedDir[] = [ + ...transcripts, + ...openCodeDirs, + ...antigravityDirs, + ...cursorDirs, + ]; return scanned; }); @@ -815,7 +1071,10 @@ export const make = Effect.gen(function* () { // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. const [, scannedDirs] = yield* Effect.all( - [ensureRates(false), collectDirs(windowStartMs, settings, retentionCutoffMs)], + [ + ensureRates(false), + collectDirs(windowStartMs, settings, retentionCutoffMs, input.awaitRefresh === true), + ], { concurrency: 2 }, ); @@ -856,7 +1115,7 @@ export const make = Effect.gen(function* () { for (const [ index, - { provider, dir, volumeId, files, status, message, action, hostId: sourceHostId }, + { provider, dir, volumeId, files, status, message, action, refreshing, hostId: sourceHostId }, ] of scannedDirs.entries()) { let scannedFiles = 0; let skippedFiles = 0; @@ -907,12 +1166,12 @@ export const make = Effect.gen(function* () { message: message ?? (files === null ? "No transcript directory on this environment." : null), ...(action ? { action } : {}), + ...(refreshing ? { refreshing } : {}), }); } const pruned = pruneScanCache(fileCache, retentionCutoffMs); if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); const aggregated = aggregator.finish(); const readAt = yield* DateTime.now; @@ -949,6 +1208,8 @@ export const make = Effect.gen(function* () { settings.usagePriceOverrides, settings.usageModelAliases, settings.cursorKeychainUsageEnabled, + // A waiting read must never share a scan that answers with `refreshing`. + input.awaitRefresh === true, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { @@ -965,9 +1226,12 @@ export const make = Effect.gen(function* () { inflightScans.set(key, created); // Detached so one departing client cannot tear the scan out from under // the fibers awaiting it; a finished scan warms the cache either way. + // The cache write is registered before the waiters resume, so they + // can await it, but its fiber starts after they have the summary. yield* scanSummary(input, settings).pipe( Effect.onExit((exit) => Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Exit.isSuccess(exit) ? schedulePersist : Effect.void), Effect.andThen(Deferred.done(created, exit)), ), ), @@ -981,7 +1245,9 @@ export const make = Effect.gen(function* () { return yield* Deferred.await(deferred); }); - return { readSummary, refreshRates } as const; + // `awaitPersisted` is outside the service interface: tests use it to restart + // against what a previous instance wrote. + return { readSummary, refreshRates, awaitPersisted } as const; }); export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/cursorAccountCache.test.ts b/apps/server/src/usage/cursorAccountCache.test.ts new file mode 100644 index 000000000000..65e27f5cb9fc --- /dev/null +++ b/apps/server/src/usage/cursorAccountCache.test.ts @@ -0,0 +1,55 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { cursorFetchRanges, mergeCursorFetches } from "./cursorAccountCache.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const record = (timestampMs: number): UsageRecord => ({ + provider: "cursor", + timestampMs, + model: "claude-fable-5", + sessionId: "conversation-1", + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 1, + reasoningTokens: 0, + }, + reportedCostUsd: null, + speed: "standard", + dedupeKey: `cursor-account:a:${timestampMs}:0`, +}); + +describe("cursorAccountCache", () => { + it("does not claim the unfetched gap after a cache that ended before the window", () => { + const cache = { + accountKey: "a", + sinceMs: 0, + untilMs: 10 * DAY_MS, + fetchedAtMs: 10 * DAY_MS, + records: [record(5 * DAY_MS)], + }; + const nowMs = 60 * DAY_MS; + const ranges = cursorFetchRanges(cache, 59 * DAY_MS, nowMs); + assert.deepStrictEqual(ranges, [{ sinceMs: 59 * DAY_MS, untilMs: nowMs }]); + + const merged = mergeCursorFetches( + cache, + "a", + ranges.map((range) => ({ range, records: [record(59.5 * DAY_MS)] })), + nowMs, + 0, + ); + assert.strictEqual(merged.sinceMs, 59 * DAY_MS); + assert.deepStrictEqual( + merged.records.map((entry) => entry.timestampMs), + [59.5 * DAY_MS], + ); + // A wider window now fetches the history it is missing. + assert.deepStrictEqual(cursorFetchRanges(merged, 30 * DAY_MS, nowMs), [ + { sinceMs: 30 * DAY_MS, untilMs: 59 * DAY_MS }, + ]); + }); +}); diff --git a/apps/server/src/usage/cursorAccountCache.ts b/apps/server/src/usage/cursorAccountCache.ts new file mode 100644 index 000000000000..b6f148feb2f2 --- /dev/null +++ b/apps/server/src/usage/cursorAccountCache.ts @@ -0,0 +1,303 @@ +/** + * Cursor account usage, cached per credential source. + * + * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched + * records are kept with the time range they are complete for, and a refresh + * fetches only what the cache is missing: the newest edge, and older history + * when a wider window is asked for. + * + * A record's dedupe key carries its timestamp and an occurrence index counted + * within one fetch. A refetched range therefore replaces every cached record in + * that range rather than adding to it, so each timestamp's records always come + * from a single fetch and their occurrence indexes cannot collide. + * + * @module cursorAccountCache + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +import { cursorRateModel, readCursorAccountUsage } from "./cursorUsageReader.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +export type CursorCredentialSource = string | { readonly kind: "keychain" }; + +/** Reads one range of account usage. A reference so tests can stand in for Cursor's API. */ +export const CursorAccountReader = Context.Reference( + "t3/usage/cursorAccountCache/CursorAccountReader", + { + defaultValue: + () => (credentialSource: CursorCredentialSource, sinceMs: number, untilMs: number) => + Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)), + }, +); + +/** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */ +export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; + +/** Dashboard events can be finalized after they were first read. */ +const REFETCH_OVERLAP_MS = 60 * 60 * 1000; + +export interface CursorAccountCache { + readonly accountKey: string; + /** Inclusive range of event timestamps the records are complete for. */ + readonly sinceMs: number; + readonly untilMs: number; + /** When the newest edge was fetched. */ + readonly fetchedAtMs: number; + readonly records: readonly UsageRecord[]; +} + +interface CursorFetchRange { + readonly sinceMs: number; + readonly untilMs: number; +} + +/** Whether the cache answers a window without a refresh. */ +export function isCursorCacheFresh( + cache: CursorAccountCache | undefined, + windowStartMs: number, + nowMs: number, +): cache is CursorAccountCache { + return ( + cache !== undefined && + nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS && + cache.sinceMs <= windowStartMs + ); +} + +/** + * Ranges to fetch so the cache covers `[windowStartMs, nowMs]`. A cache that + * ends before the window starts is useless; its replacement is a full fetch. + */ +export function cursorFetchRanges( + cache: CursorAccountCache | undefined, + windowStartMs: number, + nowMs: number, +): readonly CursorFetchRange[] { + if (cache === undefined || cache.untilMs - REFETCH_OVERLAP_MS < windowStartMs) { + return [{ sinceMs: windowStartMs, untilMs: nowMs }]; + } + const ranges: CursorFetchRange[] = []; + if (nowMs - cache.fetchedAtMs >= CURSOR_ACCOUNT_TTL_MS) { + ranges.push({ + sinceMs: Math.max(cache.sinceMs, cache.untilMs - REFETCH_OVERLAP_MS), + untilMs: nowMs, + }); + } + if (windowStartMs < cache.sinceMs) { + ranges.push({ sinceMs: windowStartMs, untilMs: cache.sinceMs }); + } + return ranges; +} + +/** + * Applies fetched ranges to the cache, each replacing the cached records it + * covers. A range that does not touch the covered range starts the cache over, + * since the gap between them was never fetched. Pass `undefined` for a cache + * that belongs to another account. + */ +export function mergeCursorFetches( + cache: CursorAccountCache | undefined, + accountKey: string, + fetches: readonly { + readonly range: CursorFetchRange; + readonly records: readonly UsageRecord[]; + }[], + nowMs: number, + retentionCutoffMs: number, +): CursorAccountCache { + let records = cache?.records ?? []; + let sinceMs = cache?.sinceMs ?? Number.POSITIVE_INFINITY; + let untilMs = cache?.untilMs ?? Number.NEGATIVE_INFINITY; + let fetchedAtMs = cache?.fetchedAtMs ?? nowMs; + for (const { range, records: fetched } of fetches) { + if (range.sinceMs > untilMs || range.untilMs < sinceMs) { + records = []; + sinceMs = Number.POSITIVE_INFINITY; + untilMs = Number.NEGATIVE_INFINITY; + } + const inRange = (record: UsageRecord) => + record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs; + records = [...records.filter((record) => !inRange(record)), ...fetched.filter(inRange)]; + sinceMs = Math.min(sinceMs, range.sinceMs); + if (range.untilMs >= untilMs) { + untilMs = range.untilMs; + fetchedAtMs = nowMs; + } + } + if (sinceMs < retentionCutoffMs) { + sinceMs = retentionCutoffMs; + records = records.filter((record) => record.timestampMs >= retentionCutoffMs); + } + return { accountKey, sinceMs, untilMs, fetchedAtMs, records }; +} + +/** + * Each version writes its own file, as the scan cache does, so servers of + * different versions sharing a state directory keep their own caches. + */ +export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; +const CURSOR_ACCOUNT_CACHE_VERSION = 1; + +/** Positional rows with interned strings, as in the scan cache. */ +type SerializedRecord = readonly [ + timestampMs: number, + modelIndex: number, + sessionIndex: number, + uncachedInputTokens: number, + cachedInputTokens: number, + cacheCreationTokens: number, + outputTokens: number, + reportedCostUsd: number | null, + dedupeKey: string | null, +]; + +interface SerializedAccount { + readonly accountKey: string; + readonly sinceMs: number; + readonly untilMs: number; + readonly fetchedAtMs: number; + readonly records: readonly SerializedRecord[]; +} + +/** Serialises the caches by credential source. */ +export function encodeCursorAccountCaches(caches: ReadonlyMap): string { + const strings: string[] = []; + const indexes = new Map(); + const intern = (value: string) => { + let index = indexes.get(value); + if (index === undefined) { + index = strings.length; + strings.push(value); + indexes.set(value, index); + } + return index; + }; + const accounts: Record = {}; + for (const [credentialKey, cache] of caches) { + accounts[credentialKey] = { + accountKey: cache.accountKey, + sinceMs: cache.sinceMs, + untilMs: cache.untilMs, + fetchedAtMs: cache.fetchedAtMs, + records: cache.records.map((record) => [ + record.timestampMs, + intern(record.model), + intern(record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.reportedCostUsd, + record.dedupeKey, + ]), + }; + } + return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts }); +} + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +/** + * Rebuilds the caches from a parsed document. A malformed account is dropped + * whole, costing one refetch; keeping its survivors would leave a gap the + * covered range claims is complete. + */ +export function decodeCursorAccountCaches(document: unknown): Map { + const caches = new Map(); + if (typeof document !== "object" || document === null) return caches; + const root = document as { + readonly version?: unknown; + readonly strings?: unknown; + readonly accounts?: unknown; + }; + const strings = root.strings; + if ( + root.version !== CURSOR_ACCOUNT_CACHE_VERSION || + !Array.isArray(strings) || + !strings.every((value): value is string => typeof value === "string") || + typeof root.accounts !== "object" || + root.accounts === null + ) { + return caches; + } + const stringAt = (index: unknown): string | undefined => + typeof index === "number" ? strings[index] : undefined; + + const decodeRecord = (row: unknown): UsageRecord | null => { + if (!Array.isArray(row)) return null; + const [ + timestampMs, + modelIndex, + sessionIndex, + uncached, + cached, + creation, + output, + cost, + key, + ]: readonly unknown[] = row; + const model = stringAt(modelIndex); + const sessionId = stringAt(sessionIndex); + if ( + !isFiniteNumber(timestampMs) || + model === undefined || + sessionId === undefined || + !isFiniteNumber(uncached) || + !isFiniteNumber(cached) || + !isFiniteNumber(creation) || + !isFiniteNumber(output) || + (cost !== null && !isFiniteNumber(cost)) || + (key !== null && typeof key !== "string") + ) { + return null; + } + return { + provider: "cursor", + timestampMs, + model, + rateModel: cursorRateModel(model), + sessionId, + totals: { + uncachedInputTokens: uncached, + cachedInputTokens: cached, + cacheCreationTokens: creation, + outputTokens: output, + reasoningTokens: 0, + }, + reportedCostUsd: cost, + speed: "standard", + dedupeKey: key, + }; + }; + + for (const [credentialKey, raw] of Object.entries(root.accounts)) { + if (typeof raw !== "object" || raw === null) continue; + const account = raw as Partial>; + if ( + typeof account.accountKey !== "string" || + !isFiniteNumber(account.sinceMs) || + !isFiniteNumber(account.untilMs) || + !isFiniteNumber(account.fetchedAtMs) || + !Array.isArray(account.records) + ) { + continue; + } + const records: UsageRecord[] = []; + for (const row of account.records) { + const record = decodeRecord(row); + if (record === null) break; + records.push(record); + } + if (records.length !== account.records.length) continue; + caches.set(credentialKey, { + accountKey: account.accountKey, + sinceMs: account.sinceMs, + untilMs: account.untilMs, + fetchedAtMs: account.fetchedAtMs, + records, + }); + } + return caches; +} diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts index 0a96448123ab..0c8797b1b0b9 100644 --- a/apps/server/src/usage/cursorUsageReader.test.ts +++ b/apps/server/src/usage/cursorUsageReader.test.ts @@ -3,6 +3,50 @@ import { assert, describe, it } from "@effect/vitest"; import { CursorKeychainTimeoutError } from "../provider/cursorKeychainToken.ts"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; +const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; +const baseMs = 1780000000000; + +const events = (from: number, to: number) => + Array.from({ length: to - from }, (_, offset) => ({ + timestamp: String(baseMs + from + offset), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, + })); + +const read = (request: (url: string, init: RequestInit) => Promise) => + readCursorAccountUsage({ kind: "keychain" }, 0, baseMs * 2, request, async () => accessToken); + +const requestedPage = (init: RequestInit) => Number(JSON.parse(String(init.body)).page); + +/** Holds every request issued in one synchronous burst, then answers the burst newest first. */ +function reversedBursts(respond: (page: number) => unknown) { + const pages: number[] = []; + const completed: number[] = []; + const held: (() => void)[] = []; + let inFlight = 0; + let maxInFlight = 0; + const request = async (_url: string, init: RequestInit) => { + const page = requestedPage(init); + pages.push(page); + maxInFlight = Math.max(maxInFlight, ++inFlight); + if (held.length === 0) { + queueMicrotask(() => { + for (const release of held.splice(0).toReversed()) release(); + }); + } + await new Promise((resolve) => held.push(resolve)); + inFlight--; + completed.push(page); + return Response.json(respond(page)); + }; + return { request, pages, completed, maxInFlight: () => maxInFlight }; +} + +const offsets = (records: readonly { readonly timestampMs: number }[]) => + records.map((record) => record.timestampMs - baseMs); + +const sequence = (length: number) => Array.from({ length }, (_, index) => index); + describe("readCursorAccountUsage", () => { it("asks for Keychain approval when the prompt goes unanswered", async () => { const result = await readCursorAccountUsage( @@ -19,4 +63,89 @@ describe("readCursorAccountUsage", () => { error: "Allow Keychain access on the Mac running T3 Code, then refresh.", }); }); + + it("fetches pages after the first concurrently and keeps them in page order", async () => { + const total = 9500; + const { request, pages, completed, maxInFlight } = reversedBursts((page) => ({ + totalUsageEventsCount: total, + usageEventsDisplay: events((page - 1) * 1000, Math.min(page * 1000, total)), + })); + const result = await read(request); + assert.isNull(result.error); + assert.deepStrictEqual(offsets(result.records), sequence(total)); + assert.deepStrictEqual( + pages.toSorted((a, b) => a - b), + sequence(10).map((index) => index + 1), + ); + assert.notDeepEqual( + completed, + completed.toSorted((a, b) => a - b), + ); + assert.isAbove(maxInFlight(), 1); + assert.isBelow(maxInFlight(), 9); + }); + + it("reconciles boundary overlap across concurrent pages and continues past the expected range", async () => { + const total = 2999; + const { request, pages, maxInFlight } = reversedBursts((page) => ({ + totalUsageEventsCount: total, + usageEventsDisplay: + page === 1 + ? events(0, 1000) + : page === 2 + ? events(999, 1999) + : page === 3 + ? events(1999, 2999) + : [], + })); + const result = await read(request); + assert.isNull(result.error); + assert.deepStrictEqual(offsets(result.records), sequence(total)); + assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); + assert.strictEqual(maxInFlight(), 2); + assert.strictEqual(pages.at(-1), 4); + }); + + it("aborts in-flight pages and stops requesting after the first failure", async () => { + for (const { status, error } of [ + { status: 500, error: "Cursor account usage could not be read." }, + { status: 401, error: "Sign in to Cursor again to read account usage." }, + ]) { + const pages: number[] = []; + const signals: AbortSignal[] = []; + const result = await read(async (_url, init) => { + const page = requestedPage(init); + pages.push(page); + if (page === 1) { + return Response.json({ + totalUsageEventsCount: 30_500, + usageEventsDisplay: events(0, 1000), + }); + } + if (page === 3) return new Response(null, { status }); + const signal = init.signal; + if (!signal) throw new Error("Missing abort signal"); + signals.push(signal); + return new Promise((_, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { once: true }), + ); + }); + assert.strictEqual(result.error, error); + assert.deepStrictEqual(result.records, []); + assert.isAbove(signals.length, 0); + assert.isTrue(signals.every((signal) => signal.aborted)); + assert.isBelow(pages.length, 31); + } + }); + + it("reads pages one at a time when the first page has no total", async () => { + const { request, pages, maxInFlight } = reversedBursts((page) => ({ + usageEventsDisplay: events((page - 1) * 1000, page === 3 ? 2500 : page * 1000), + })); + const result = await read(request); + assert.isNull(result.error); + assert.deepStrictEqual(offsets(result.records), sequence(2500)); + assert.deepStrictEqual(pages, [1, 2, 3]); + assert.strictEqual(maxInFlight(), 1); + }); }); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 0931718cea64..253d7df94f78 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -65,6 +65,29 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] return lengths.at(-1) ?? 0; } +const usageEventsUrl = "https://cursor.com/api/dashboard/get-filtered-usage-events"; +// Cursor rejects larger pages with a 400. +const pageSize = 1000; +const pageConcurrency = 6; + +/** Runs `run` for indexes `0..count-1` with bounded concurrency, keeping results in index order. */ +async function mapBounded( + count: number, + concurrency: number, + run: (index: number) => Promise, +) { + const results: R[] = []; + let next = 0; + const worker = async () => { + while (next < count) { + const index = next++; + results[index] = await run(index); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, count) }, worker)); + return results; +} + /** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ export async function readCursorAccountUsage( credentialSource: string | { readonly kind: "keychain" }, @@ -118,76 +141,109 @@ export async function readCursorAccountUsage( if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) throw new Error("Invalid date window"); const deadline = AbortSignal.timeout(60_000); - const records: UsageRecord[] = []; - const occurrences = new Map(); - const pages: unknown[][] = []; - let completed = false; - const pageSize = 1000; - let total: number | undefined; - for (let page = 1; ; page++) { - // A count can include overlapping page boundaries. Allow room to - // reconcile them without imposing a fixed account-size limit. - if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { - throw new Error("Account usage page limit exceeded"); - } - const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { - method: "POST", - redirect: "error", - signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), - headers: { - "Content-Type": "application/json", - Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, - }, - body: JSON.stringify({ - page, - pageSize, - startDate: String(sinceMs), - endDate: String(endDate), - }), - }); - if (response.status === 401 || response.status === 403) { - return { - accountKey, - records: [], - missing: false, - error: "Sign in to Cursor again to read account usage.", - }; - } - if (!response.ok) throw new Error("Account usage request failed"); - const parsed: unknown = await response.json(); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Invalid account usage page"); + const abort = new AbortController(); + const fetchPage = async (page: number, total: number | undefined) => { + try { + const response = await request(usageEventsUrl, { + method: "POST", + redirect: "error", + signal: AbortSignal.any([deadline, abort.signal, AbortSignal.timeout(10_000)]), + headers: { + "Content-Type": "application/json", + Origin: "https://cursor.com", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, + }, + body: JSON.stringify({ + page, + pageSize, + startDate: String(sinceMs), + endDate: String(endDate), + }), + }); + if (response.status === 401 || response.status === 403) return "denied" as const; + if (!response.ok) return "failed" as const; + const parsed: unknown = await response.json(); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return "failed" as const; + } + const body = object(parsed); + const keys = Object.keys(body); + if ("error" in body || "message" in body || "code" in body) return "failed" as const; + const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; + const events = + keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") + ? [] + : body.usageEventsDisplay; + if ( + (count !== undefined && + (typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 || + (total !== undefined && count !== total))) || + !Array.isArray(events) || + events.length > pageSize || + (count === undefined && !Array.isArray(body.usageEventsDisplay)) + ) { + return "failed" as const; + } + return { count: typeof count === "number" ? count : undefined, events }; + } catch { + return "failed" as const; } - const body = object(parsed); - const keys = Object.keys(body); - if ("error" in body || "message" in body || "code" in body) - throw new Error("Account usage error response"); - const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; - const events = - keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") - ? [] - : body.usageEventsDisplay; - if ( - (count !== undefined && - (typeof count !== "number" || - !Number.isSafeInteger(count) || - count < 0 || - (total !== undefined && count !== total))) || - !Array.isArray(events) || - events.length > pageSize || - (count === undefined && !Array.isArray(body.usageEventsDisplay)) - ) { - throw new Error("Inconsistent account usage page"); + }; + const fetchPages = async () => { + const first = await fetchPage(1, undefined); + if (typeof first === "string") return first; + let total = first.count; + const pages: unknown[][] = [first.events]; + if (total !== undefined && first.events.length === pageSize) { + // Without boundary overlap, the short terminal page is the last one in + // this range. The first failure aborts the requests still in flight. + const knownTotal = total; + const rest = await mapBounded( + Math.floor(knownTotal / pageSize), + pageConcurrency, + async (index) => { + if (abort.signal.aborted) return "failed" as const; + const result = await fetchPage(index + 2, knownTotal); + if (typeof result === "string") abort.abort(); + return result; + }, + ); + if (rest.includes("denied")) return "denied" as const; + for (const result of rest) { + if (typeof result === "string") return result; + pages.push(result.events); + if (result.events.length < pageSize) break; + } } - if (typeof count === "number") total = count; - pages.push(events); - if (events.length < pageSize) { - completed = true; - break; + // Unknown totals, and ranges extended by boundary overlap, continue one page at a time. + while (pages[pages.length - 1]?.length === pageSize) { + // A count can include overlapping page boundaries. Allow room to + // reconcile them without imposing a fixed account-size limit. + if (pages.length >= (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { + return "failed" as const; + } + const result = await fetchPage(pages.length + 1, total); + if (typeof result === "string") return result; + total = result.count ?? total; + pages.push(result.events); } + return { pages, total }; + }; + const fetched = await fetchPages(); + if (fetched === "denied") { + return { + accountKey, + records: [], + missing: false, + error: "Sign in to Cursor again to read account usage.", + }; } - if (!completed) throw new Error("Account usage page limit exceeded"); + if (fetched === "failed") throw new Error("Account usage request failed"); + const { pages, total } = fetched; + const records: UsageRecord[] = []; + const occurrences = new Map(); const rawCount = pages.reduce((sum, page) => sum + page.length, 0); if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages"); let removalsRemaining = total === undefined ? 0 : rawCount - total; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 0a525054915f..64ddfaac6e5e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -7,19 +7,20 @@ import { USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageProviderKind, + type UsageSummary, + type UsageSummaryInput, } from "@t3tools/contracts"; -import { - CircleAlertIcon, - ChevronDownIcon, - CircleDashedIcon, - InfoIcon, - SlidersHorizontalIcon, -} from "lucide-react"; +import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { cursorKeychainAccessEnvironments, refreshUsageLimits, } from "@t3tools/client-runtime/state/usage"; +import { + updatingProvidersLabel, + usageEnvironmentProgress, + usageLoadingState, +} from "@t3tools/client-runtime/state/usage-progress"; import { isCompatibleUsageContractVersion, @@ -64,7 +65,6 @@ import { import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; -import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -109,6 +109,8 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window return WINDOW_OPTIONS.some((option) => option.days === value); } +const providerLabel = (provider: UsageProviderKind) => PROVIDER_PRESENTATION[provider].label; + export function UsagePage() { const [preferences, setPreferences] = useState(readUsagePagePreferences); useEscapeToGoBack(); @@ -131,7 +133,9 @@ export function UsagePage() { })); const metric = preferences.metric; const showingLimits = metric === "limits"; - const [isRefreshing, setIsRefreshing] = useState(false); + // Summaries on screen when a manual refresh began; empty while limits refresh. + const [refreshingFrom, setRefreshingFrom] = useState | null>(null); + const isRefreshing = refreshingFrom !== null; const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); @@ -141,10 +145,41 @@ export function UsagePage() { useState | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( - window, - selectedEnvironmentIds, + const { + merged: answeredUsage, + environments, + selectedEnvironments, + isPending, + isPartial, + refresh, + } = useUsage(window, selectedEnvironmentIds); + // Captured when the window changes: until the new window's first answer, the + // previous usage stays on screen, muted, so the chart can move between them. + const [keptUsage, setKeptUsage] = useState<{ + readonly window: UsageSummaryInput; + readonly merged: MergedUsage; + readonly selection: ReadonlySet | null; + } | null>(null); + // Nothing renders until some environment answers: only then is the provider + // list known. + const shown = !isPending + ? { window, merged: answeredUsage } + : keptUsage?.selection === selectedEnvironmentIds + ? keptUsage + : null; + const merged = shown?.merged ?? answeredUsage; + const shownWindow = shown?.window ?? window; + const shownHourly = shownWindow.resolution === "hour"; + // Kept usage is all old, so every figure waits on the new window. + const loading = useMemo( + () => + isPending + ? { partial: true, everyProvider: true, providers: new Set() } + : usageLoadingState(selectedEnvironments, refreshingFrom), + [isPending, refreshingFrom, selectedEnvironments], ); + const isProviderLoading = (provider: UsageProviderKind) => + loading.everyProvider || loading.providers.has(provider); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); const sourceMessages = [ @@ -172,21 +207,21 @@ export function UsagePage() { ); const days = useMemo( - () => enumerateDays(window.sinceDay, window.untilDay), - [window.sinceDay, window.untilDay], + () => enumerateDays(shownWindow.sinceDay, shownWindow.untilDay), + [shownWindow.sinceDay, shownWindow.untilDay], ); const hours = useMemo( () => - window.sinceTime === undefined || window.untilTime === undefined + shownWindow.sinceTime === undefined || shownWindow.untilTime === undefined ? [] - : enumerateHourStarts(window.sinceTime, window.untilTime), - [window.sinceTime, window.untilTime], + : enumerateHourStarts(shownWindow.sinceTime, shownWindow.untilTime), + [shownWindow.sinceTime, shownWindow.untilTime], ); // Newest first: the window can run 90 periods, so the interesting end // belongs at the top of the table. const breakdownPeriods = useMemo( - () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), - [isPast24Hours, merged.daily, merged.hourly], + () => (shownHourly ? merged.hourly : merged.daily).toReversed(), + [shownHourly, merged.daily, merged.hourly], ); const breakdownModels = useMemo( () => @@ -195,7 +230,25 @@ export function UsagePage() { : merged.models, [breakdown, merged.models, metric], ); - const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + const providersWithData = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + // A provider still refreshing keeps its row and line before its usage lands. + const activeProviders = useMemo( + () => + PROVIDER_ORDER.filter( + (provider) => providersWithData.includes(provider) || loading.providers.has(provider), + ), + [loading.providers, providersWithData], + ); + // Lines with nothing to draw yet stay flat until their usage lands. + const chartLoadingProviders = useMemo( + () => + new Set( + activeProviders.filter( + (provider) => loading.providers.has(provider) && !providersWithData.includes(provider), + ), + ), + [activeProviders, loading.providers, providersWithData], + ); const selectedModel = selectedModelKey === null ? undefined @@ -217,8 +270,18 @@ export function UsagePage() { ); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; + const keepShownUsage = () => { + if (shown !== null) { + setKeptUsage({ + window: shown.window, + merged: shown.merged, + selection: selectedEnvironmentIds, + }); + } + }; const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; + keepShownUsage(); const nextPreferences = { metric, windowDays: days }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); @@ -283,10 +346,10 @@ export function UsagePage() { if (showingLimits) { refreshingRef.current = true; - setIsRefreshing(true); + setRefreshingFrom(new Set()); void refreshLimits().finally(() => { refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingFrom(null); }); return; } @@ -297,13 +360,16 @@ export function UsagePage() { nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { + keepShownUsage(); setWindowSelection({ days: windowDays, window: nextWindow }); } refreshingRef.current = true; - setIsRefreshing(true); + setRefreshingFrom( + new Set(selectedEnvironments.flatMap(({ summary }) => (summary === null ? [] : [summary]))), + ); void refresh(nextWindow).finally(() => { refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingFrom(null); }); }; const connectedLimitsEnvironments = [...presentations] @@ -341,6 +407,7 @@ export function UsagePage() { selectedEnvironmentIds={selectedEnvironmentIds} onSelectionChange={setSelectedEnvironmentIds} showUsageStatus={!showingLimits} + refreshingFrom={refreshingFrom} isPartial={isPartial} duplicateSources={merged.duplicateSources} contractMismatches={merged.contractMismatches} @@ -495,9 +562,7 @@ export function UsagePage() { ) : null } /> - ) : isPending ? ( - - ) : !canReadDiagnostics ? ( + ) : shown === null ? null : !canReadDiagnostics ? (
{selectedEnvironments.map((environment) => (

@@ -507,7 +572,7 @@ export function UsagePage() { ))}

) : ( - <> +
{sourceMessages.map((message) => (

{message} @@ -516,13 +581,20 @@ export function UsagePage() {

- + {metric === "cost" ? formatUsd(merged.costUsd) : formatTokens(merged.totalTokens)} - {formatCount(merged.sessions)} sessions + + {formatCount(merged.sessions)} sessions + {metric === "cost" && ( <> {" · API estimate"} @@ -584,6 +656,8 @@ export function UsagePage() { const sessionLabel = `${formatCount(providerSessions)} ${ providerSessions === 1 ? "session" : "sessions" }`; + const providerLoading = isProviderLoading(provider); + const awaitingData = providerLoading && !providersWithData.includes(provider); return (
@@ -600,18 +674,38 @@ export function UsagePage() { {PROVIDER_PRESENTATION[provider].label} - + {sessionLabel} - - {metric === "cost" - ? formatUsd(totals?.costUsd ?? 0) - : formatTokens(totals?.totalTokens ?? 0)} + + {awaitingData + ? "—" + : metric === "cost" + ? formatUsd(totals?.costUsd ?? 0) + : formatTokens(totals?.totalTokens ?? 0)}
- + {/* Kept while awaiting data so the row does not grow when it lands. */} + {metric === "cost" ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} @@ -623,19 +717,20 @@ export function UsagePage() {

- {isPast24Hours ? "Hourly" : "Daily"}{" "} + {shownHourly ? "Hourly" : "Daily"}{" "} {metric === "tokens" ? "processed tokens" : "cost"}

@@ -643,14 +738,28 @@ export function UsagePage() {

Totals

- - + + - + @@ -658,7 +767,12 @@ export function UsagePage() {
{merged.totalTokens > 0 ? ( -
+
{metric === "tokens" ? ( ( @@ -736,6 +850,7 @@ export function UsagePage() { model, metric === "tokens" ? "tokens" : "cost", ); + const rowFigures = figureClass(isProviderLoading(model.provider)); return ( {model.model} -
+
- + {isModelCostUnknown(model) ? ( Unpriced ) : ( formatUsd(model.costUsd) )} - + {share === null ? "" : formatPercent(share)} - {formatTokens(model.totalTokens)} + + {formatTokens(model.totalTokens)} + ); }) @@ -797,7 +917,7 @@ export function UsagePage() { - {isPast24Hours ? "Hour" : "Day"} + {shownHourly ? "Hour" : "Day"} {activeProviders.map((provider) => ( {PROVIDER_PRESENTATION[provider].label} @@ -825,21 +945,34 @@ export function UsagePage() { > {"hourStart" in period - ? formatHourShort(period.hourStart, window.timeZone) + ? formatHourShort(period.hourStart, shownWindow.timeZone) : formatDayShort(period.day)} {activeProviders.map((provider) => ( {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} ))} - + {formatUsd(period.costUsd)} - + {formatTokens(period.totalTokens)} @@ -849,7 +982,7 @@ export function UsagePage() { )}
- +
)} @@ -862,9 +995,9 @@ export function UsagePage() { chartWindow={{ days, hours, - resolution: isPast24Hours ? "hour" : "day", - timeZone: window.timeZone, - referenceTime: window.untilTime, + resolution: shownHourly ? "hour" : "day", + timeZone: shownWindow.timeZone, + referenceTime: shownWindow.untilTime, }} onSetPrice={() => { setSelectedModelKey(null); @@ -1036,11 +1169,28 @@ function ProviderMark({ ); } -function Metric({ label, value }: { readonly label: string; readonly value: string }) { +/** Mutes a figure that is still coming in. The delay keeps a quick answer from flashing. */ +function figureClass(loading: boolean) { + return cn("transition-opacity", loading && "opacity-40 delay-150"); +} + +function Metric({ + label, + value, + loading, +}: { + readonly label: string; + readonly value: string; + readonly loading: boolean; +}) { return (
{label} - {value} + + {value} +
); } @@ -1092,13 +1242,14 @@ function UsageCoverageNotice({ ); } -/** Environment selection and scan progress share a permanent header control. */ +/** Environment selection, with each environment's scan status in the menu. */ function UsageEnvironmentFilter({ environments, selectedEnvironments, selectedEnvironmentIds, onSelectionChange, showUsageStatus, + refreshingFrom, isPartial, duplicateSources, contractMismatches, @@ -1109,6 +1260,7 @@ function UsageEnvironmentFilter({ readonly selectedEnvironmentIds: ReadonlySet | null; readonly onSelectionChange: (ids: ReadonlySet | null) => void; readonly showUsageStatus: boolean; + readonly refreshingFrom: ReadonlySet | null; readonly isPartial: boolean; readonly duplicateSources: readonly string[]; readonly contractMismatches: MergedUsage["contractMismatches"]; @@ -1120,10 +1272,6 @@ function UsageEnvironmentFilter({ : selectedEnvironments.length === 1 ? selectedEnvironments[0]!.label : `${selectedEnvironments.length} environments`; - const pendingCount = selectedEnvironments.filter( - (environment) => - environment.error === null && (environment.isPending || environment.summary === null), - ).length; const hasIssue = selectedEnvironments.some((environment) => environment.error !== null) || contractMismatches.length > 0; @@ -1133,15 +1281,7 @@ function UsageEnvironmentFilter({ } className="group/usage-environment min-w-0 max-w-full"> {label} - {showUsageStatus && pendingCount > 0 ? ( - <> - - - {pendingCount} {pendingCount === 1 ? "environment" : "environments"} still scanning - {isPartial ? "; totals are partial" : ""} - - - ) : showUsageStatus && hasIssue ? ( + {showUsageStatus && hasIssue ? ( ); } - -/** - * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it - * breathes with the same `animate-skeleton` pulse as every other loading state. - * Replaced by results as soon as the first environment answers. - */ -function UsageSkeleton() { - return ( - <> -
-
-
- - -
- {PROVIDER_ORDER.map((provider) => ( -
-
- - - - - - -
- -
- ))} -
- -
- -
- - -
-
-
- -
-

Totals

- -
- -
-
- - - -
-
- -
-
-

Breakdown

- -
- -
- - ); -} - -function MetricSkeletons({ labels }: { readonly labels: readonly string[] }) { - return ( -
- {labels.map((label) => ( -
- {label} - -
- ))} -
- ); -} diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index e3060dbaefd3..b94d46456365 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,8 +1,96 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPeriodColumns, niceScale } from "./UsageProviderChart"; +import { + buildPeriodColumns, + chartScale, + mixSamples, + niceScale, + SAMPLE_XS, + sampleCurve, + smoothCurve, + VIEW_WIDTH, +} from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; +/** Points spread evenly across the plot, as the chart lays out its periods. */ +const spread = (ys: readonly number[]) => + ys.map((y, index) => ({ x: (index * VIEW_WIDTH) / (ys.length - 1), y })); + +describe("sampleCurve", () => { + it("passes through every period exactly", () => { + const points = spread([200, 40, 120, 260, 90]); + const sampled = sampleCurve( + smoothCurve(points), + points.map((point) => point.x), + ); + + sampled.forEach((y, index) => expect(y).toBeCloseTo(points[index]?.y ?? Number.NaN, 9)); + }); + + it("stays monotone between rising periods instead of overshooting", () => { + const sampled = sampleCurve(smoothCurve(spread([250, 248, 120, 118, 10, 8])), SAMPLE_XS); + + sampled.slice(1).forEach((y, index) => expect(y).toBeLessThanOrEqual(sampled[index] ?? 0)); + }); + + it("lands curves with different period counts on the same grid", () => { + // Monotone cubics reproduce a straight line, so 7 and 30 periods of the + // same trend must resample to the same values, sample for sample. + const line = (count: number) => + sampleCurve( + smoothCurve( + spread(Array.from({ length: count }, (_, index) => 240 - (index * 200) / (count - 1))), + ), + SAMPLE_XS, + ); + const weekly = line(7); + const monthly = line(30); + + expect(weekly).toHaveLength(SAMPLE_XS.length); + expect(monthly).toHaveLength(SAMPLE_XS.length); + weekly.forEach((y, index) => expect(y).toBeCloseTo(monthly[index] ?? Number.NaN, 6)); + expect(weekly[0]).toBeCloseTo(240, 9); + expect(weekly[weekly.length - 1]).toBeCloseTo(40, 9); + }); +}); + +describe("mixSamples", () => { + it("runs from the old samples to the new ones", () => { + const from = [0, 100, 200]; + const to = [200, 100, 0]; + + expect(mixSamples(from, to, 0)).toEqual(from); + expect(mixSamples(from, to, 1)).toEqual(to); + expect(mixSamples(from, to, 0.25)).toEqual([50, 100, 150]); + }); +}); + +describe("chartScale", () => { + const columns = [ + { + total: 0, + bands: [ + { provider: "codex" as const, value: 40 }, + { provider: "cursor" as const, value: 900 }, + ], + }, + ]; + + it("scales to providers that have answered", () => { + const scale = chartScale(columns, ["codex", "cursor"], new Set(["cursor" as const])); + + expect(scale.max).toBe(40); + expect(scale.labeled).toBe(true); + }); + + it("holds unlabeled placeholder gridlines until something answers", () => { + const scale = chartScale(columns, ["codex", "cursor"], new Set(["codex", "cursor"] as const)); + + expect(scale.labeled).toBe(false); + expect(scale.ticks.length).toBeGreaterThan(1); + }); +}); + describe("niceScale", () => { it("never puts the peak above the top of the scale", () => { // Regression: an earlier version stopped at the last step below the peak, diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 62772a6478dc..b9f2efb5406c 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,6 +1,6 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -10,17 +10,29 @@ import { formatTokens, formatUsd, } from "@t3tools/shared/usageFormat"; +import { cn } from "~/lib/utils"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; -const VIEW_WIDTH = 960; +export const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; const TICK_COUNT = 4; const PLOT_TOP = 8; +const SAMPLE_COUNT = 193; +const MORPH_MS = 500; +const NONE_LOADING: ReadonlySet = new Set(); + +/** The fixed x grid every morph resamples onto, so any two curves can blend. */ +export const SAMPLE_XS = Array.from( + { length: SAMPLE_COUNT }, + (_, index) => (index * VIEW_WIDTH) / (SAMPLE_COUNT - 1), +); export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { readonly providers: readonly UsageProviderKind[]; + /** Providers without data yet: flat along the bottom, left out of the scale, rising when they land. */ + readonly loadingProviders?: ReadonlySet; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; readonly hours: readonly string[]; @@ -118,7 +130,7 @@ interface CurveSegment { readonly to: Point; } -function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { +export function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); const segments: CurveSegment[] = []; @@ -148,6 +160,44 @@ function curvePath(segments: readonly CurveSegment[]): string { return path; } +/** + * Evaluates a curve at ascending x positions. Control points sit at x thirds, + * so x is linear in t and each y is exact, wherever the periods fall. + */ +export function sampleCurve(segments: readonly CurveSegment[], xs: readonly number[]) { + let index = 0; + return xs.map((x) => { + while (index < segments.length - 1 && x > (segments[index]?.to.x ?? x)) index += 1; + const segment = segments[index]; + if (segment === undefined) return VIEW_HEIGHT; + const { from, c1, c2, to } = segment; + const span = to.x - from.x; + const t = span === 0 ? 0 : Math.min(1, Math.max(0, (x - from.x) / span)); + const u = 1 - t; + return u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y; + }); +} + +/** Blends two sample sets on the shared grid: progress 0 is `from`, 1 is `to`. */ +export function mixSamples(from: readonly number[], to: readonly number[], progress: number) { + return from.map((y, index) => y + ((to[index] ?? y) - y) * progress); +} + +/** Straight segments through samples; the grid is dense enough to read as a curve. */ +function polylinePath(xs: readonly number[], ys: readonly number[]) { + return ys + .map((y, index) => `${index === 0 ? "M" : "L"}${(xs[index] ?? 0).toFixed(2)},${y.toFixed(2)}`) + .join(" "); +} + +function areaPath(line: string) { + return line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`; +} + +/** A loading line: flat along the zero line until its usage lands. */ +const FLAT_SAMPLES = SAMPLE_XS.map(() => VIEW_HEIGHT); +const FLAT_LINE = `M0,${VIEW_HEIGHT} L${VIEW_WIDTH},${VIEW_HEIGHT}`; + /** * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the * peak. @@ -170,8 +220,216 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re return { max, ticks }; } +const PLACEHOLDER_TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, index) => index); + +/** + * Scales to the providers that have answered. Until any has, unlabeled + * placeholder gridlines hold their usual spacing so nothing shifts on arrival. + */ +export function chartScale( + columns: readonly DayColumn[], + providers: readonly UsageProviderKind[], + loadingProviders: ReadonlySet, +) { + if (loadingProviders.size > 0 && providers.every((provider) => loadingProviders.has(provider))) { + return { max: TICK_COUNT, ticks: PLACEHOLDER_TICKS, labeled: false }; + } + // The scale tops out at the largest single provider-period, not the sum: + // layered series each measure from zero, so a combined peak would leave + // the plot permanently half empty. + const peak = columns.reduce( + (max, column) => + column.bands.reduce( + (inner, band) => + loadingProviders.has(band.provider) ? inner : Math.max(inner, band.value), + max, + ), + 0, + ); + return { ...niceScale(peak, TICK_COUNT), labeled: true }; +} + +// Leave room above the top gridline so the constant-width stroke is not +// clipped when a series reaches the peak. +function valueToY(value: number, max: number) { + return max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); +} + +/** Per-provider shapes in paint order: answered curves heaviest first, loading providers flat. */ +function buildChart( + periods: readonly string[], + byPeriod: ReadonlyMap, + metric: UsageChartMetric, + providers: readonly UsageProviderKind[], + loadingProviders: ReadonlySet, +) { + const columns = buildPeriodColumns(periods, byPeriod, metric); + const scale = chartScale(columns, providers, loadingProviders); + const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); + const shapes = providers.map((provider) => { + const slot = PROVIDER_ORDER.indexOf(provider); + if (loadingProviders.has(provider)) + return { provider, kind: "loading" as const, slot, total: 0 }; + const segments = smoothCurve( + columns.map((column, periodIndex) => ({ + x: periodIndex * stepX, + y: valueToY(column.bands[slot]?.value ?? 0, scale.max), + })), + ); + const line = curvePath(segments); + return { + provider, + kind: "data" as const, + slot, + total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0), + segments, + line, + }; + }); + + // Paint the heavier series first so the lighter one is not buried. + return { columns, scale, stepX, shapes: shapes.toSorted((a, b) => b.total - a.total) }; +} + +type ChartShape = ReturnType["shapes"][number]; + +/** One provider's drawn paths, its exact curve on the sample grid, and the morph moving it. */ +interface DrawnSeries { + readonly shape: ChartShape; + readonly fill: SVGPathElement | null; + readonly line: SVGPathElement | null; + /** Null for a curve with fewer than two periods. */ + readonly curve: readonly number[] | null; + motion: { readonly from: readonly number[]; readonly start: number } | null; +} + +const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3; + +/** + * What a series shows at `time`, so an interrupted morph continues from the + * screen. Null when there is no curve to show. + */ +function shownSamples(series: DrawnSeries, time: number) { + const { motion, curve: target } = series; + if (motion === null || target === null || time >= motion.start + MORPH_MS) return target; + const progress = Math.max(0, (time - motion.start) / MORPH_MS); + return mixSamples(motion.from, target, easeOutCubic(progress)); +} + +function sameTarget(a: ChartShape, b: ChartShape) { + return a.kind === "loading" ? b.kind === "loading" : b.kind === "data" && a.line === b.line; +} + +/** + * Writes one frame of a series straight to its paths and returns whether a + * morph is still running. A settled curve gets its exact path. + */ +function drawSeries(series: DrawnSeries, time: number) { + const moving = series.motion !== null && time < series.motion.start + MORPH_MS; + const samples = shownSamples(series, time); + const line = moving + ? samples === null + ? "" + : polylinePath(SAMPLE_XS, samples) + : series.shape.kind === "data" + ? series.shape.line + : FLAT_LINE; + series.line?.setAttribute("d", line); + series.fill?.setAttribute("d", areaPath(line)); + return moving; +} + +/** + * Drives the series paths outside React with short morphs between shapes. The + * rAF loop runs only while a morph is in flight on a visible chart. + */ +function createSeriesAnimator() { + let series: Map | null = null; + let visible = true; + let frame: number | null = null; + let motionQuery: MediaQueryList | null = null; + + const reducedMotion = () => { + motionQuery ??= window.matchMedia("(prefers-reduced-motion: reduce)"); + return motionQuery.matches; + }; + const active = (entries: ReadonlyMap) => + [...entries.values()].some((entry) => entry.motion !== null); + + const tick = (time: number) => { + frame = null; + if (series === null) return; + for (const entry of series.values()) { + if (entry.motion !== null && !drawSeries(entry, time)) entry.motion = null; + } + if (active(series)) frame = requestAnimationFrame(tick); + }; + + const start = () => { + if (frame === null && visible && series !== null && active(series)) { + frame = requestAnimationFrame(tick); + } + }; + + // A hidden chart lands every morph at once rather than resuming midway. + const stop = () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + for (const entry of series?.values() ?? []) { + entry.motion = null; + drawSeries(entry, performance.now()); + } + }; + + return { + /** + * Points each provider's paths at its new shape. Changed series morph from + * what is on screen; the first draw, an off-screen chart, and reduced + * motion snap. + */ + sync(shapes: readonly ChartShape[], svg: SVGSVGElement) { + const now = performance.now(); + const animate = series !== null && visible && !reducedMotion(); + const next = new Map(); + for (const shape of shapes) { + const kept = series?.get(shape.provider); + if (kept !== undefined && sameTarget(kept.shape, shape)) { + next.set(shape.provider, kept); + continue; + } + const curve = + shape.kind === "loading" + ? FLAT_SAMPLES + : shape.segments.length > 0 + ? sampleCurve(shape.segments, SAMPLE_XS) + : null; + // A provider that just appeared rises from the zero line. + const from = !animate ? null : kept === undefined ? FLAT_SAMPLES : shownSamples(kept, now); + const entry: DrawnSeries = { + shape, + fill: svg.querySelector(`path[data-fill="${shape.provider}"]`), + line: svg.querySelector(`path[data-line="${shape.provider}"]`), + curve, + motion: from === null || curve === null ? null : { from, start: now }, + }; + next.set(shape.provider, entry); + drawSeries(entry, now); + } + series = next; + start(); + }, + setVisible(next: boolean) { + visible = next; + if (visible) start(); + else stop(); + }, + stop, + }; +} + export function UsageProviderChart({ providers, + loadingProviders = NONE_LOADING, days, daily, hours, @@ -193,60 +451,46 @@ export function UsageProviderChart({ const plotRef = useRef(null); const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + const svgRef = useRef(null); + const [animator] = useState(createSeriesAnimator); - const { paths, ticks, stepX, toY, series } = useMemo(() => { - if (periods.length === 0) { - return { - paths: [], - series: [] as readonly DayColumn[], - stepX: 0, - ticks: [0] as readonly number[], - toY: () => VIEW_HEIGHT, - }; - } - - const columns = buildPeriodColumns(periods, byPeriod, metric); - // The scale tops out at the largest single provider-period, not the sum: - // layered series each measure from zero, so a combined peak would leave - // the plot permanently half empty. - const peak = columns.reduce( - (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), - 0, - ); - const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); - const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); - // Leave room above the top gridline so the constant-width stroke is not - // clipped when a series reaches the peak. - const toY = (value: number) => - max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); - - const built = providers.map((provider) => { - const providerIndex = PROVIDER_ORDER.indexOf(provider); - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), - ); - return { - provider, - total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), - area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`, - line, - }; - }); + const { columns, scale, shapes, stepX } = useMemo( + () => buildChart(periods, byPeriod, metric, providers, loadingProviders), + [byPeriod, loadingProviders, metric, periods, providers], + ); + const toY = (value: number) => valueToY(value, scale.max); + // Once anything has answered, lines still loading fade back behind it. + const fadeLoading = providers.some((provider) => !loadingProviders.has(provider)); + const seriesClassName = (kind: ChartShape["kind"]) => + cn("transition-opacity duration-300", fadeLoading && kind === "loading" && "opacity-30"); + + // Series paths are written by the animator rather than rendered, so morphs + // never re-render. + useLayoutEffect(() => { + if (svgRef.current !== null) animator.sync(shapes, svgRef.current); + }, [animator, shapes]); - // Paint the heavier series first so the lighter one is not buried. - return { - paths: built.toSorted((a, b) => b.total - a.total), - series: columns, - stepX: step, - ticks: tickValues, - toY, + // Morphs land at once while the chart is off screen or the tab is hidden. + useEffect(() => { + const plot = plotRef.current; + let intersecting = true; + const update = () => + animator.setVisible(intersecting && document.visibilityState === "visible"); + const observer = + plot === null || typeof IntersectionObserver === "undefined" + ? null + : new IntersectionObserver((entries) => { + for (const entry of entries) intersecting = entry.isIntersecting; + update(); + }); + if (plot !== null) observer?.observe(plot); + document.addEventListener("visibilitychange", update); + return () => { + observer?.disconnect(); + document.removeEventListener("visibilitychange", update); + animator.stop(); }; - }, [byPeriod, metric, periods, providers]); + }, [animator]); const format = metric === "tokens" ? formatTokens : formatUsd; @@ -307,7 +551,13 @@ export function UsageProviderChart({ ); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; - const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; + const hoveredColumn = hoverIndex === null ? undefined : columns[hoverIndex]; + const hoveredTotal = + hoveredColumn?.bands.reduce( + (sum, band) => (loadingProviders.has(band.provider) ? sum : sum + band.value), + 0, + ) ?? 0; + const partial = providers.some((provider) => loadingProviders.has(provider)); const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => @@ -320,15 +570,17 @@ export function UsageProviderChart({
{/* Axis labels sit outside the plot so they stay aligned to gridlines. */}
- {ticks.map((tick) => ( - - {tick === 0 ? "0" : format(tick)} - - ))} + {scale.labeled + ? scale.ticks.map((tick) => ( + + {tick === 0 ? "0" : format(tick)} + + )) + : null}
- {ticks.map((tick) => { + {scale.ticks.map((tick) => { const y = toY(tick); return ( ( + {shapes.map(({ provider, kind }) => ( ))} - {paths.map(({ provider, line }) => ( + {shapes.map(({ provider, kind }) => ( {label} - - {format( - hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, - )} - + {loadingProviders.has(provider) ? ( + … + ) : ( + + {format( + hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? + 0, + )} + + )}
); })}
Total - - {format(hoveredColumn?.total ?? 0)} - + {scale.labeled ? ( + + {format(hoveredTotal)} + {partial ? + : null} + + ) : ( + … + )}
)} diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index afe7457ea80e..84242817473f 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -23,6 +23,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU label: id, isPending: cost === null, canReadDiagnostics: true, + isConnected: true, error: null, needsCursorKeychainAccess: false, summary: diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index d489c0d62410..fead64f71738 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -32,6 +32,7 @@ export interface EnvironmentUsageStatus { readonly label: string; readonly isPending: boolean; readonly canReadDiagnostics: boolean; + readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean; @@ -51,6 +52,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { + const isConnected = presentation.connection.phase === "connected"; const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); const access = resolveUsageAccess({ connectionPhase: presentation.connection.phase, @@ -61,6 +63,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => statuses.push({ environmentId, label: presentation.entry.target.label, + isConnected, ...access, summary: null, needsCursorKeychainAccess: false, @@ -74,6 +77,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => label: presentation.entry.target.label, isPending: result.waiting, canReadDiagnostics: true, + isConnected, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary, needsCursorKeychainAccess: needsCursorKeychainAccess( diff --git a/docs/user/usage.md b/docs/user/usage.md index 1ac918d5c52c..7474a30e8bc6 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,7 +39,8 @@ your app understands. Update the app to include newly supported providers. On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; -results appear as each one responds. +results appear as each one responds, and figures still updating are dimmed. Cursor shows its last +saved totals first, then updates them when Cursor's API responds. If recent work is missing or a new model shows no cost, refresh to rescan session history and update model pricing. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 3b72e948454a..3a8b50e5ecbc 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -231,6 +231,10 @@ "types": "./src/state/usage.ts", "default": "./src/state/usage.ts" }, + "./state/usage-progress": { + "types": "./src/state/usageProgress.ts", + "default": "./src/state/usageProgress.ts" + }, "./state/server": { "types": "./src/state/server.ts", "default": "./src/state/server.ts" diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 2f224e80b661..27a2348f4d42 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -53,12 +53,18 @@ interface EnvironmentCommandAtomOptions extends Omit< ) => Effect.Effect; } -interface EnvironmentQueryAtomOptions extends EnvironmentAtomOptions< - Input, - A, - E, - R +interface EnvironmentQueryAtomOptions extends Omit< + EnvironmentAtomOptions, + "execute" > { + /** + * `emit` shows a provisional value while the query keeps waiting. The settled + * result replaces it, so catch failures that should keep it on screen. + */ + readonly execute: ( + input: Input, + emit: (value: A) => Effect.Effect, + ) => Effect.Effect; readonly staleTimeMs?: number; readonly idleTtlMs?: number; readonly refreshIntervalMs?: number; @@ -547,10 +553,18 @@ export function createEnvironmentQueryAtomFamily( return Effect.never; } const [connectionState, session] = connection; + const emit = (value: A) => + Effect.sync(() => get.setSelf(AsyncResult.success(value, { waiting: true }))).pipe( + // A value set while the atom builds wins over the build's own + // result, so a query that then settled synchronously would stay + // waiting forever. Suspend on a promise (`yieldNow` is drained + // inside the build) so the settled result arrives asynchronously. + Effect.andThen(Effect.promise(() => Promise.resolve())), + ); switch (connectionState.phase) { case "connected": return Option.isSome(session) - ? runInEnvironment(target.environmentId, options.execute(target.input)) + ? runInEnvironment(target.environmentId, options.execute(target.input, emit)) : Effect.never; case "connecting": case "backoff": @@ -650,6 +664,7 @@ export function createEnvironmentRpcQueryAtomFamily< readonly tag: TTag; readonly execute?: ( input: EnvironmentRpcInput, + emit: (value: EnvironmentRpcSuccess) => Effect.Effect, ) => Effect.Effect< EnvironmentRpcSuccess, EnvironmentRpcFailure | EnvironmentRpcUnavailableError, @@ -672,8 +687,10 @@ export function createEnvironmentRpcQueryAtomFamily< ? {} : { refreshIntervalMs: options.refreshIntervalMs }), ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }), - execute: (input: EnvironmentRpcInput) => - options.execute?.(input) ?? request(options.tag, input), + execute: ( + input: EnvironmentRpcInput, + emit: (value: EnvironmentRpcSuccess) => Effect.Effect, + ) => options.execute?.(input, emit) ?? request(options.tag, input), }); } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 17037af3c986..91464b16a9b1 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1091,11 +1091,33 @@ export function createServerEnvironmentAtoms( }), // A cold transcript scan is measured in seconds, so keep the result around // long enough that switching windows or re-rendering does not rescan. + // Slow sources answer from cache first: that summary stays on screen, with + // the query waiting, until one `awaitRefresh` request replaces it. usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:usage-summary", tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => usageScanSettingsAtom(environmentId), + execute: (input, emit) => + request(WS_METHODS.serverGetUsageSummary, input).pipe( + Effect.flatMap((summary) => + summary.sources.some((source) => source.refreshing === true) + ? emit(summary).pipe( + Effect.andThen( + request(WS_METHODS.serverGetUsageSummary, { ...input, awaitRefresh: true }), + ), + // The cached summary is still the best data, and a failure + // would read as the environment not reporting usage at all. + Effect.catch((error) => + Effect.logWarning("Could not refresh slow usage sources.").pipe( + Effect.annotateLogs({ ...safeErrorLogAttributes(error) }), + Effect.as(summary), + ), + ), + ) + : Effect.succeed(summary), + ), + ), }), resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:resource-telemetry", diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index d6600e57e85c..3410fd3506fe 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -2,11 +2,13 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, UsageDay, + UsageReadError, USAGE_CONTRACT_VERSION, WS_METHODS, type ServerConfig, type ServerConfigStreamEvent, type ServerSettings, + type UsageSource, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; @@ -16,6 +18,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -26,12 +29,14 @@ import { type PreparedConnection, type SupervisorConnectionState, } from "../connection/model.ts"; +import type { EnvironmentPresentation } from "../connection/presentation.ts"; import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { createServerEnvironmentAtoms } from "./server.ts"; +import { refreshUsage } from "./usage.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("usage-environment"), @@ -51,13 +56,37 @@ const CONFIG = { environment: { serverVersion: "0.0.1", capabilities: {} }, } as ServerConfig; +const PRICING = { status: "unavailable" as const, source: "test", fetchedAt: null, knownModels: 0 }; + +const cursorSource = (refreshing: boolean): UsageSource => ({ + fingerprint: { + hostId: "cursor.com", + provider: "cursor", + resolvedHomePath: "cursor-account:abc", + volumeId: "abc", + }, + status: "ok", + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: null, + ...(refreshing ? { refreshing: true as const } : {}), +}); + const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( - beforeRead: (request: number) => Effect.Effect = () => Effect.void, + beforeRead: ( + request: number, + input: UsageSummaryInput, + ) => Effect.Effect = () => Effect.void, + sources: (input: UsageSummaryInput) => readonly UsageSource[] = () => [], ) { const events = yield* Queue.unbounded(); let settings = DEFAULT_SERVER_SETTINGS; let requests = 0; + const inputs: UsageSummaryInput[] = []; const client = { + [WS_METHODS.serverRefreshUsageRates]: () => Effect.succeed(PRICING), [WS_METHODS.subscribeServerConfig]: () => Stream.concat( Stream.make({ version: 1 as const, type: "snapshot" as const, config: CONFIG }), @@ -66,8 +95,9 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( [WS_METHODS.serverGetUsageSummary]: (input: UsageSummaryInput) => Effect.gen(function* () { requests += 1; + inputs.push(input); const price = settings.usagePriceOverrides["custom-model"]?.inputCostPerMillionTokens ?? 0; - yield* beforeRead(requests); + yield* beforeRead(requests, input); return { contractVersion: USAGE_CONTRACT_VERSION, readAt: "2026-09-04T12:00:00Z", @@ -92,8 +122,8 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( sessions: 1, }, ], - sources: [], - pricing: { status: "unavailable", source: "test", fetchedAt: null, knownModels: 0 }, + sources: sources(input), + pricing: PRICING, scanDurationMs: 0, } satisfies UsageSummary; }), @@ -167,12 +197,44 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( }); return { registry, + atoms, requests: () => requests, + inputs: () => inputs, updateSettings, summary: (input = INPUT) => atoms.usageSummary({ environmentId: TARGET.environmentId, input }), }; }); +function settledSummary( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom>, +) { + return AtomRegistry.toStream(registry, atom).pipe( + Stream.filterMap((result) => + AsyncResult.isSuccess(result) && !result.waiting + ? Result.succeed(result.value) + : Result.failVoid, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); +} + +/** Answers with a refreshing Cursor source; the `awaitRefresh` request runs `followUp` first. */ +const makeRefreshingHarness = Effect.fn("ServerUsageTest.makeRefreshingHarness")(function* ( + followUp: Effect.Effect, +) { + const followUpStarted = yield* Deferred.make(); + const harness = yield* makeHarness( + (_request, input) => + input.awaitRefresh === true + ? Deferred.succeed(followUpStarted, undefined).pipe(Effect.andThen(followUp)) + : Effect.void, + (input) => [cursorSource(input.awaitRefresh !== true)], + ); + return { ...harness, followUpStarted }; +}); + function waitForCost( registry: AtomRegistry.AtomRegistry, atom: Atom.Atom>, @@ -261,3 +323,87 @@ it.effect("restarts a pending usage read after a price change", () => }), ), ); + +it.effect("settles after one usage read when no source is refreshing", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + const summary = harness.summary(); + const unmount = harness.registry.mount(summary); + yield* settledSummary(harness.registry, summary); + expect(harness.inputs()).toEqual([INPUT]); + unmount(); + }), + ), +); + +it.effect("shows the cached usage summary until one awaited refresh replaces it", () => + Effect.scoped( + Effect.gen(function* () { + const refreshed = yield* Deferred.make(); + const harness = yield* makeRefreshingHarness(Deferred.await(refreshed)); + const summary = harness.summary(); + const unmount = harness.registry.mount(summary); + yield* Deferred.await(harness.followUpStarted); + + const provisional = harness.registry.get(summary); + expect(AsyncResult.isSuccess(provisional) && provisional.waiting).toBe(true); + expect(Option.getOrNull(AsyncResult.value(provisional))?.sources).toEqual([ + cursorSource(true), + ]); + expect(harness.inputs()).toEqual([INPUT, { ...INPUT, awaitRefresh: true }]); + + yield* Deferred.succeed(refreshed, undefined); + expect((yield* settledSummary(harness.registry, summary)).sources).toEqual([ + cursorSource(false), + ]); + expect(harness.requests()).toBe(2); + unmount(); + }), + ), +); + +it.effect("keeps the cached usage summary when the awaited refresh fails", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeRefreshingHarness( + Effect.fail(new UsageReadError({ reason: "scanFailed", detail: "Cursor is down" })), + ); + const summary = harness.summary(); + const unmount = harness.registry.mount(summary); + expect((yield* settledSummary(harness.registry, summary)).sources).toEqual([ + cursorSource(true), + ]); + expect(AsyncResult.isSuccess(harness.registry.get(summary))).toBe(true); + expect(harness.requests()).toBe(2); + unmount(); + }), + ), +); + +it.effect("finishes a manual usage refresh only after the awaited refresh", () => + Effect.scoped( + Effect.gen(function* () { + const refreshed = yield* Deferred.make(); + const harness = yield* makeRefreshingHarness(Deferred.await(refreshed)); + const presentation = Atom.make({ + connection: { phase: "connected" }, + } as EnvironmentPresentation | null); + let finished = false; + const refreshing = refreshUsage({ + registry: harness.registry, + server: harness.atoms, + presentations: { presentationAtom: () => presentation }, + environmentIds: [TARGET.environmentId], + input: INPUT, + }).then(() => { + finished = true; + }); + yield* Deferred.await(harness.followUpStarted); + expect(finished).toBe(false); + yield* Deferred.succeed(refreshed, undefined); + yield* Effect.promise(() => refreshing); + expect(harness.requests()).toBe(2); + }), + ), +); diff --git a/packages/client-runtime/src/state/usageProgress.test.ts b/packages/client-runtime/src/state/usageProgress.test.ts new file mode 100644 index 000000000000..a0506472fd50 --- /dev/null +++ b/packages/client-runtime/src/state/usageProgress.test.ts @@ -0,0 +1,159 @@ +import { + USAGE_CONTRACT_VERSION, + UsageDay, + type UsageProviderKind, + type UsageSummary, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { usageEnvironmentProgress, usageLoadingState, usageProgress } from "./usageProgress.ts"; + +function summary(refreshing: readonly UsageProviderKind[] = []): UsageSummary { + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-09-05T12:00:00Z", + timeZone: "UTC", + sinceDay: UsageDay.make("2026-09-05"), + untilDay: UsageDay.make("2026-09-05"), + buckets: [], + sources: (["codex", ...refreshing] as const).map((provider) => ({ + fingerprint: { hostId: "host", provider, resolvedHomePath: "/home", volumeId: "1:1" }, + status: "ok" as const, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: null, + ...(provider === "codex" ? {} : { refreshing: true as const }), + })), + pricing: { status: "fresh", source: "test", fetchedAt: null, knownModels: 1 }, + scanDurationMs: 1, + }; +} + +function environment( + label: string, + overrides: Partial<{ + isConnected: boolean; + isPending: boolean; + error: string | null; + summary: UsageSummary | null; + }> = {}, +) { + return { + label, + isConnected: true, + isPending: false, + error: null as string | null, + summary: summary(), + ...overrides, + }; +} + +const options = { providerLabel: (provider: UsageProviderKind) => `${provider} label` }; + +describe("usageProgress", () => { + it("dims a lone environment while it refetches over its old totals", () => { + expect(usageProgress([environment("a", { isPending: true })], options)).toEqual({ + dimmed: true, + label: "Updating…", + }); + expect(usageProgress([environment("a")], options)).toEqual({ dimmed: false, label: null }); + }); + + it("undims once one device answers and names the slow one", () => { + const waiting = [environment("a", { isPending: true }), environment("b", { isPending: true })]; + expect(usageProgress(waiting, options)).toEqual({ + dimmed: true, + label: "Updating 2 environments…", + }); + expect( + usageProgress([environment("a"), ...waiting.slice(1), environment("c")], options), + ).toEqual({ dimmed: false, label: "Updating b…" }); + expect( + usageProgress( + [environment("a"), environment("b", { summary: null, isPending: true })], + options, + ), + ).toEqual({ dimmed: false, label: "Updating b…" }); + }); + + it("names a slow source without dimming once its environment answers from cache", () => { + const partway = environment("a", { isPending: true, summary: summary(["cursor"]) }); + expect(usageEnvironmentProgress(partway)).toEqual({ phase: "partway", providers: ["cursor"] }); + expect(usageProgress([partway, environment("b")], options)).toEqual({ + dimmed: false, + label: "Updating cursor label…", + }); + expect( + usageProgress( + [partway, environment("b", { isPending: true, summary: summary(["cursor", "grok"]) })], + options, + ), + ).toEqual({ dimmed: false, label: "Updating 2 providers…" }); + // Without a follow-up in flight, a leftover refreshing source is not waited on. + expect(usageProgress([{ ...partway, isPending: false }], options)).toEqual({ + dimmed: false, + label: null, + }); + }); + + it("ignores reconnecting and failed environments", () => { + const ignored = [ + environment("reconnecting", { isPending: true, isConnected: false }), + environment("failed", { error: "Offline", summary: null }), + ]; + expect(usageProgress([environment("a"), ...ignored], options)).toEqual({ + dimmed: false, + label: null, + }); + expect( + usageProgress( + [environment("a"), ...ignored, environment("slow", { summary: null, isPending: true })], + options, + ), + ).toEqual({ dimmed: false, label: "Updating slow…" }); + expect(usageEnvironmentProgress(ignored[0]!)).toEqual({ phase: "inactive" }); + }); + + it("keeps a manual refresh dimmed until a new summary replaces the old one", () => { + const onScreen = [summary(), summary()] as const; + const a = environment("a", { summary: onScreen[0] }); + const b = environment("b", { summary: onScreen[1] }); + const refreshing = { ...options, refreshingFrom: new Set(onScreen) }; + // Pricing refreshes first, so neither query is pending yet. + expect(usageProgress([a, b], refreshing)).toEqual({ + dimmed: true, + label: "Updating 2 environments…", + }); + expect(usageProgress([a, environment("b")], refreshing)).toEqual({ + dimmed: false, + label: "Updating a…", + }); + }); +}); + +describe("usageLoadingState", () => { + it("marks only refreshing providers while the rest have answered", () => { + const state = usageLoadingState([ + environment("a", { isPending: true, summary: summary(["cursor"]) }), + environment("b"), + ]); + expect(state).toEqual({ partial: true, everyProvider: false, providers: new Set(["cursor"]) }); + }); + + it("marks every provider while an environment has not answered", () => { + const state = usageLoadingState([environment("a"), environment("b", { summary: null })]); + expect(state.everyProvider).toBe(true); + expect(state.partial).toBe(true); + }); + + it("ignores environments that cannot answer and settles once all have", () => { + const state = usageLoadingState([ + environment("a"), + environment("b", { summary: null, isConnected: false }), + environment("c", { summary: null, error: "Offline" }), + ]); + expect(state).toEqual({ partial: false, everyProvider: false, providers: new Set() }); + }); +}); diff --git a/packages/client-runtime/src/state/usageProgress.ts b/packages/client-runtime/src/state/usageProgress.ts new file mode 100644 index 000000000000..b8f51ab2e447 --- /dev/null +++ b/packages/client-runtime/src/state/usageProgress.ts @@ -0,0 +1,117 @@ +import type { UsageProviderKind, UsageSummary } from "@t3tools/contracts"; + +interface UsageProgressEnvironment { + readonly label: string; + readonly isConnected: boolean; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +/** + * Where one environment's answer stands for the usage on screen. + * + * - `inactive`: disconnected or failed, so waiting on it changes nothing. + * - `loading`: no summary yet. + * - `stale`: its summary on screen predates the current request. + * - `partway`: answered this round while slow sources finish refreshing. + * - `ready`: answered. + * + * `refreshingFrom` holds the summaries on screen when a manual refresh began. + * They count as stale until replaced, since the refresh reaches each query only + * after its environment refreshes pricing. + */ +export function usageEnvironmentProgress( + environment: UsageProgressEnvironment, + refreshingFrom: ReadonlySet | null = null, +) { + const { summary } = environment; + if (!environment.isConnected || environment.error !== null) return { phase: "inactive" } as const; + if (summary === null) return { phase: "loading" } as const; + if (refreshingFrom?.has(summary)) return { phase: "stale" } as const; + // A refreshing source without a pending follow-up has nothing left to wait on. + if (!environment.isPending) return { phase: "ready" } as const; + const providers = [ + ...new Set( + summary.sources.flatMap((source) => (source.refreshing ? [source.fingerprint.provider] : [])), + ), + ]; + return providers.length > 0 + ? ({ phase: "partway", providers } as const) + : ({ phase: "stale" } as const); +} + +export function updatingProvidersLabel( + providers: readonly UsageProviderKind[], + providerLabel: (provider: UsageProviderKind) => string, +) { + return providers.length === 1 + ? `Updating ${providerLabel(providers[0]!)}…` + : `Updating ${providers.length} providers…`; +} + +/** + * Loading state for the selected environments' merged usage. + * + * The page dims only while old numbers are all that is on screen. Once any + * environment answers, the label carries what is still coming, so one slow + * device never holds the page dimmed. + */ +export function usageProgress( + environments: readonly UsageProgressEnvironment[], + { + refreshingFrom = null, + providerLabel, + }: { + readonly refreshingFrom?: ReadonlySet | null; + readonly providerLabel: (provider: UsageProviderKind) => string; + }, +) { + const progress = environments.map((environment) => ({ + label: environment.label, + ...usageEnvironmentProgress(environment, refreshingFrom), + })); + const waiting = progress.filter(({ phase }) => phase === "loading" || phase === "stale"); + const providers = [ + ...new Set(progress.flatMap((entry) => (entry.phase === "partway" ? entry.providers : []))), + ]; + const answered = progress.some(({ phase }) => phase === "partway" || phase === "ready"); + return { + dimmed: !answered && progress.some(({ phase }) => phase === "stale"), + label: + waiting.length === 0 + ? providers.length === 0 + ? null + : updatingProvidersLabel(providers, providerLabel) + : environments.length === 1 + ? "Updating…" + : waiting.length === 1 + ? `Updating ${waiting[0]!.label}…` + : `Updating ${waiting.length} environments…`, + }; +} + +/** + * Which figures in the merged usage on screen are still coming in, so clients + * can mute just those. + * + * - `partial`: totals will change because some environment is still answering. + * - `providers`: providers whose own figures will change. An environment that + * has not answered yet could add to any provider, so it sets `everyProvider`. + */ +export function usageLoadingState( + environments: readonly UsageProgressEnvironment[], + refreshingFrom: ReadonlySet | null = null, +) { + const providers = new Set(); + let everyProvider = false; + for (const environment of environments) { + const progress = usageEnvironmentProgress(environment, refreshingFrom); + if (progress.phase === "loading") everyProvider = true; + if (progress.phase === "partway") + for (const provider of progress.providers) providers.add(provider); + if (progress.phase === "stale") + for (const bucket of environment.summary?.buckets ?? []) providers.add(bucket.provider); + } + return { partial: everyProvider || providers.size > 0, everyProvider, providers }; +} diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index f6282b7b2935..9aecc49ce5c3 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -183,6 +183,12 @@ export const UsageSource = Schema.Struct({ message: Schema.NullOr(TrimmedNonEmptyString), /** An action the client can offer to make this source available. */ action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")), + /** + * Present when this source answered from its cache while a slow refresh (an + * account API, for example) runs. Repeat the request with `awaitRefresh` to + * get the refreshed source. + */ + refreshing: Schema.optionalKey(Schema.Literal(true)), }); export type UsageSource = typeof UsageSource.Type; @@ -217,6 +223,12 @@ export const UsageSummaryInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), /** Exclusive UTC instant for an hourly rolling window. */ untilTime: Schema.optional(TrimmedNonEmptyString), + /** + * Wait for slow sources to finish refreshing instead of answering from their + * cache. Clients send it as the follow-up to a summary with a `refreshing` + * source. Older servers ignore it and always wait. + */ + awaitRefresh: Schema.optional(Schema.Boolean), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; From d83e9e1b2809f06cbdbccd4f2534b5dfbbd1858e Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:22:18 -0700 Subject: [PATCH 2/5] refactor(usage): simpler loading states and Cursor cache - Bring back the skeleton until the first environment answers. - Keep the previous window on screen from useUsage instead of capturing it at each entry point in the page. - Track a manual refresh as a boolean rather than a set of summaries. - Replace the chart morph animator with an opacity fade for loading lines. - Cache Cursor's whole retention, so a refresh only ever fetches the newest edge: no window joins, backfill ranges, or refresh lock. - Skip rewriting the Cursor cache file when an edge refresh changed nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/usage/UsageRouteScreen.tsx | 34 +-- apps/server/src/usage/UsageService.test.ts | 30 +- apps/server/src/usage/UsageService.ts | 108 +++----- .../src/usage/cursorAccountCache.test.ts | 53 +++- apps/server/src/usage/cursorAccountCache.ts | 109 +++----- apps/web/src/components/usage/UsagePage.tsx | 138 ++++++--- .../usage/UsageProviderChart.test.ts | 64 +---- .../components/usage/UsageProviderChart.tsx | 261 ++---------------- apps/web/src/state/usage.test.tsx | 16 ++ apps/web/src/state/usage.ts | 30 +- .../src/state/usageProgress.test.ts | 17 +- .../client-runtime/src/state/usageProgress.ts | 18 +- 12 files changed, 334 insertions(+), 544 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 25fa7f2e665e..99ac66db0fd2 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,11 +1,6 @@ import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; -import { - EnvironmentId, - USAGE_CONTRACT_VERSION, - type UsageProviderKind, - type UsageSummary, -} from "@t3tools/contracts"; +import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; import { @@ -171,11 +166,13 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // Summaries on screen when a pull to refresh began. - const [refreshingFrom, setRefreshingFrom] = useState | null>(null); + const [refreshingUsage, setRefreshingUsage] = useState(false); const refreshingRef = useRef(false); const showingLimits = tab === "limits"; - const progress = usageProgress(selectedEnvironments, { refreshingFrom, providerLabel }); + const progress = usageProgress(selectedEnvironments, { + refreshing: refreshingUsage, + providerLabel, + }); const selectWindow = (days: number) => { setWindowSelection({ days, @@ -194,12 +191,10 @@ export function UsageRouteScreen() { setWindowSelection({ days: windowDays, window: nextWindow }); } refreshingRef.current = true; - setRefreshingFrom( - new Set(selectedEnvironments.flatMap(({ summary }) => (summary === null ? [] : [summary]))), - ); + setRefreshingUsage(true); void refresh(nextWindow).finally(() => { refreshingRef.current = false; - setRefreshingFrom(null); + setRefreshingUsage(false); }); }; @@ -219,14 +214,14 @@ export function UsageRouteScreen() { ...environments.map((environment) => ({ id: environment.environmentId, title: environment.label, - subtitle: usageEnvironmentStatus(environment, refreshingFrom), + subtitle: usageEnvironmentStatus(environment, refreshingUsage), state: selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) ? ("on" as const) : ("off" as const), })), ], - [environments, refreshingFrom, selectedEnvironmentIds], + [environments, refreshingUsage, selectedEnvironmentIds], ); const selectEnvironment = useCallback( (value: string) => { @@ -282,7 +277,7 @@ export function UsageRouteScreen() { refreshControl={ showingLimits || canReadDiagnostics ? ( void limits.refresh() : refreshWindow} /> ) : undefined @@ -914,10 +909,7 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U * one that failed, or one whose transcripts another environment already * reported. */ -function usageEnvironmentStatus( - environment: EnvironmentUsageStatus, - refreshingFrom: ReadonlySet | null, -): string { +function usageEnvironmentStatus(environment: EnvironmentUsageStatus, refreshing: boolean): string { if ( environment.summary && !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) @@ -934,7 +926,7 @@ function usageEnvironmentStatus( return environment.summary ? `${environment.error} Showing saved totals.` : environment.error; if (!environment.isConnected) return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; - const progress = usageEnvironmentProgress(environment, refreshingFrom); + const progress = usageEnvironmentProgress(environment, refreshing); if (progress.phase === "loading") return "Loading usage…"; if (progress.phase === "stale") return "Updating usage…"; if (progress.phase === "partway") diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 3cd3d173d784..79ae98e15555 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -163,8 +163,8 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens /** Inside `WINDOW`: the time Cursor tests run at. */ const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z"); const HOUR_MS = 60 * 60 * 1000; -/** `WINDOW` starts at its first midnight, less the service's 36-hour slack. */ -const CURSOR_WINDOW_START = Date.parse("2026-07-31T00:00:00Z") - 36 * HOUR_MS; +/** The service's cache retention, which the Cursor account cache always covers. */ +const CURSOR_RETENTION_MS = 90 * 24 * HOUR_MS; /** * Stands in for Cursor's dashboard API. Each read returns the account's events @@ -411,7 +411,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); - it.live("refetches only the Cursor history its cache is missing", () => + it.live("caches Cursor's whole retention and then refetches only the newest edge", () => Effect.gen(function* () { const { settings, home } = yield* setup; yield* writeCursorLogin(home); @@ -419,6 +419,7 @@ describe("UsageService", () => { const cursor = makeFakeCursor(); // Two identical billed rows inside the refetched hour must both survive it, once each. cursor.state.events = [ + { timestampMs: Date.parse("2026-07-10T10:00:00Z"), outputTokens: 17 }, { timestampMs: CURSOR_NOW - 24 * HOUR_MS, outputTokens: 5 }, { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, @@ -431,9 +432,14 @@ describe("UsageService", () => { service.readSummary({ ...input, awaitRefresh: true }); assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19); assert.deepStrictEqual(cursor.state.calls, [ - { sinceMs: CURSOR_WINDOW_START, untilMs: CURSOR_NOW }, + { sinceMs: CURSOR_NOW - CURSOR_RETENTION_MS, untilMs: CURSOR_NOW }, ]); + // A wider window is answered from the same cache. + const wide = { ...WINDOW, sinceDay: UsageDay.make("2026-07-01") }; + assert.strictEqual(totalOutputTokens(yield* read(wide)), 36); + assert.strictEqual(cursor.state.calls.length, 1); + // An event finalized late, inside the overlap, and a new one. cursor.state.events.push( { timestampMs: CURSOR_NOW - HOUR_MS / 6, outputTokens: 11 }, @@ -446,25 +452,11 @@ describe("UsageService", () => { untilMs: CURSOR_NOW + 2 * HOUR_MS, }); - // A wider window fetches only the older history. - const wide = { ...WINDOW, sinceDay: UsageDay.make("2026-07-01") }; - cursor.state.events.push({ - timestampMs: Date.parse("2026-07-10T10:00:00Z"), - outputTokens: 17, - }); - assert.strictEqual(totalOutputTokens(yield* read(wide)), 60); - assert.deepStrictEqual(cursor.state.calls.slice(2), [ - { - sinceMs: Date.parse("2026-07-01T00:00:00Z") - 36 * HOUR_MS, - untilMs: CURSOR_WINDOW_START, - }, - ]); - // Another login replaces the cached account's history instead of adding to it. cursor.state.accountKey = "account-b"; yield* TestClock.adjust(Duration.minutes(2)); assert.strictEqual(totalOutputTokens(yield* read(wide)), 60); - assert.strictEqual(cursor.state.calls.length, 5); + assert.strictEqual(cursor.state.calls.length, 4); assert.strictEqual(cursorSource(yield* read(wide))?.fingerprint.volumeId, "account-b"); }).pipe( Effect.provide( diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 78f2ec46655b..eae6d6367690 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -63,11 +63,11 @@ import { CURSOR_ACCOUNT_CACHE_FILE_NAME, CURSOR_ACCOUNT_TTL_MS, CursorAccountReader, - cursorFetchRanges, + cursorFetchRange, decodeCursorAccountCaches, encodeCursorAccountCaches, isCursorCacheFresh, - mergeCursorFetches, + mergeCursorFetch, type CursorAccountCache, type CursorCredentialSource, } from "./cursorAccountCache.ts"; @@ -229,16 +229,8 @@ export const make = Effect.gen(function* () { string, { readonly atMs: number; readonly error: string | null } >(); - /** - * The newest refresh per credential source. A request for a window it - * covers joins it; a wider one queues its own behind it. - */ - const cursorRefreshes = new Map< - string, - { readonly windowStartMs: number; readonly done: Deferred.Deferred } - >(); - // A refresh reads, extends and replaces the cache; two must not interleave. - const cursorLock = yield* Semaphore.make(1); + /** The refresh in flight per credential source, which every read joins. */ + const cursorRefreshes = new Map>(); const isWithinDirectory = (filePath: string, dir: string) => { const relative = path.relative(dir, filePath); return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); @@ -658,81 +650,75 @@ export const make = Effect.gen(function* () { return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir; }); - /** Fetches what one account cache is missing for the window, then persists it. */ + /** Fetches what one account cache is missing, then persists it if anything changed. */ const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* ( credential: CursorCredentialSource, credentialKey: string, - windowStartMs: number, + retentionStartMs: number, ) { const nowMs = yield* Clock.currentTimeMillis; - const fetchMissing = (cache: CursorAccountCache | undefined) => - Effect.forEach( - cursorFetchRanges(cache, windowStartMs, nowMs), - (range) => - readCursorAccount(credential, range.sinceMs, range.untilMs).pipe( - Effect.map((result) => ({ range, result })), - ), - { concurrency: "unbounded" }, + const fetchMissing = (cache: CursorAccountCache | undefined) => { + const range = cursorFetchRange(cache, retentionStartMs, nowMs); + return readCursorAccount(credential, range.sinceMs, range.untilMs).pipe( + Effect.map((result) => ({ range, result })), ); - const cached = cursorCaches.get(credentialKey); - let base = cached; + }; + let base = cursorCaches.get(credentialKey); let fetched = yield* fetchMissing(base); - // A refresh queued behind another can find nothing left to fetch. - if (fetched.length === 0) return; // Another login's history replaces the cached account's, even if reading it fails. if ( - cached !== undefined && - fetched.some( - ({ result }) => result.accountKey !== null && result.accountKey !== cached.accountKey, - ) + base !== undefined && + fetched.result.accountKey !== null && + fetched.result.accountKey !== base.accountKey ) { cursorCaches.delete(credentialKey); cursorCacheDirty = true; base = undefined; fetched = yield* fetchMissing(base); } - const failed = fetched.find(({ result }) => result.missing || result.error !== null)?.result; - const accountKey = fetched[0]?.result.accountKey ?? null; - if (failed !== undefined || accountKey === null) { + const { range, result } = fetched; + if (result.missing || result.error !== null || result.accountKey === null) { cursorFailures.set(credentialKey, { atMs: nowMs, - error: failed === undefined ? CURSOR_ACCOUNT_READ_ERROR : failed.error, + error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR, }); yield* schedulePersist; return; } - cursorCaches.set( - credentialKey, - mergeCursorFetches( - base, - accountKey, - fetched.map(({ range, result }) => ({ range, records: result.records })), - nowMs, - nowMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - ), + const merged = mergeCursorFetch( + base, + result.accountKey, + range, + result.records, + nowMs, + retentionStartMs, ); + cursorCaches.set(credentialKey, merged.cache); cursorFailures.delete(credentialKey); - cursorCacheDirty = true; - yield* schedulePersist; + // An unchanged edge is not worth rewriting the file for: after a restart + // the cache just refetches a slightly wider edge. + if (merged.changed) { + cursorCacheDirty = true; + yield* schedulePersist; + } }); - /** Joins the newest refresh when it covers the window, or starts one. */ + /** Joins the refresh in flight, or starts one. */ const startCursorRefresh = ( credential: CursorCredentialSource, credentialKey: string, - windowStartMs: number, + retentionStartMs: number, ) => // Enrollment and fork are atomic, so an interrupted caller cannot leave a // registered refresh that nothing will finish. Effect.uninterruptible( Effect.gen(function* () { const current = cursorRefreshes.get(credentialKey); - if (current !== undefined && current.windowStartMs <= windowStartMs) return current.done; - const refresh = { windowStartMs, done: Deferred.makeUnsafe() }; - cursorRefreshes.set(credentialKey, refresh); + if (current !== undefined) return current; + const done = Deferred.makeUnsafe(); + cursorRefreshes.set(credentialKey, done); // Detached: a departing client must not cancel a fetch later reads reuse. - yield* refreshCursorAccount(credential, credentialKey, windowStartMs).pipe( - cursorLock.withPermit, + yield* refreshCursorAccount(credential, credentialKey, retentionStartMs).pipe( Effect.catchCause(() => Clock.currentTimeMillis.pipe( Effect.map((atMs) => @@ -742,15 +728,13 @@ export const make = Effect.gen(function* () { ), Effect.ensuring( Effect.suspend(() => { - if (cursorRefreshes.get(credentialKey) === refresh) { - cursorRefreshes.delete(credentialKey); - } - return Deferred.succeed(refresh.done, undefined); + cursorRefreshes.delete(credentialKey); + return Deferred.succeed(done, undefined); }), ), Effect.forkDetach, ); - return refresh.done; + return done; }), ); @@ -763,6 +747,7 @@ export const make = Effect.gen(function* () { credential: CursorCredentialSource, authPath: string, windowStartMs: number, + retentionStartMs: number, awaitRefresh: boolean, ) { // No saved login means there is no account source to report, not a setup error. @@ -778,9 +763,9 @@ export const make = Effect.gen(function* () { let refreshing = false; if ( (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && - !isCursorCacheFresh(cursorCaches.get(credentialKey), windowStartMs, nowMs) + !isCursorCacheFresh(cursorCaches.get(credentialKey), nowMs) ) { - const refresh = yield* startCursorRefresh(credential, credentialKey, windowStartMs); + const refresh = yield* startCursorRefresh(credential, credentialKey, retentionStartMs); if (awaitRefresh) yield* Deferred.await(refresh); else refreshing = true; } @@ -986,9 +971,8 @@ export const make = Effect.gen(function* () { const source = yield* cursorAccountSource( useKeychain ? { kind: "keychain" } : cursorAuthPath, cursorAuthPath, - // Clamped to the retention, which prunes the cache: a wider start - // would never be covered and would refetch on every read. - Math.max(windowStartMs, retentionCutoffMs), + windowStartMs, + retentionCutoffMs, awaitRefresh, ); return source === null ? [] : [source]; diff --git a/apps/server/src/usage/cursorAccountCache.test.ts b/apps/server/src/usage/cursorAccountCache.test.ts index 65e27f5cb9fc..b6d20d36cf6a 100644 --- a/apps/server/src/usage/cursorAccountCache.test.ts +++ b/apps/server/src/usage/cursorAccountCache.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { cursorFetchRanges, mergeCursorFetches } from "./cursorAccountCache.ts"; +import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -23,7 +23,7 @@ const record = (timestampMs: number): UsageRecord => ({ }); describe("cursorAccountCache", () => { - it("does not claim the unfetched gap after a cache that ended before the window", () => { + it("refetches everything once the cache no longer reaches the retention start", () => { const cache = { accountKey: "a", sinceMs: 0, @@ -31,25 +31,50 @@ describe("cursorAccountCache", () => { fetchedAtMs: 10 * DAY_MS, records: [record(5 * DAY_MS)], }; - const nowMs = 60 * DAY_MS; - const ranges = cursorFetchRanges(cache, 59 * DAY_MS, nowMs); - assert.deepStrictEqual(ranges, [{ sinceMs: 59 * DAY_MS, untilMs: nowMs }]); + const nowMs = 100 * DAY_MS; + const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs); + assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs }); - const merged = mergeCursorFetches( + const { cache: merged } = mergeCursorFetch( cache, "a", - ranges.map((range) => ({ range, records: [record(59.5 * DAY_MS)] })), + range, + [record(50 * DAY_MS)], nowMs, - 0, + 10 * DAY_MS, ); - assert.strictEqual(merged.sinceMs, 59 * DAY_MS); assert.deepStrictEqual( merged.records.map((entry) => entry.timestampMs), - [59.5 * DAY_MS], + [50 * DAY_MS], + ); + // Then only the newest edge. + assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), { + sinceMs: nowMs - 60 * 60 * 1000, + untilMs: nowMs + DAY_MS, + }); + }); + + it("reports an edge that only confirms the cache as unchanged", () => { + const cache = { + accountKey: "a", + sinceMs: 0, + untilMs: 10 * DAY_MS, + fetchedAtMs: 10 * DAY_MS, + records: [record(DAY_MS), record(10 * DAY_MS)], + }; + const range = cursorFetchRange(cache, 0, 11 * DAY_MS); + assert.isFalse( + mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed, + ); + assert.isTrue( + mergeCursorFetch( + cache, + "a", + range, + [record(10 * DAY_MS), record(10.5 * DAY_MS)], + 11 * DAY_MS, + 0, + ).changed, ); - // A wider window now fetches the history it is missing. - assert.deepStrictEqual(cursorFetchRanges(merged, 30 * DAY_MS, nowMs), [ - { sinceMs: 30 * DAY_MS, untilMs: 59 * DAY_MS }, - ]); }); }); diff --git a/apps/server/src/usage/cursorAccountCache.ts b/apps/server/src/usage/cursorAccountCache.ts index b6f148feb2f2..9bf6c7016432 100644 --- a/apps/server/src/usage/cursorAccountCache.ts +++ b/apps/server/src/usage/cursorAccountCache.ts @@ -2,9 +2,8 @@ * Cursor account usage, cached per credential source. * * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched - * records are kept with the time range they are complete for, and a refresh - * fetches only what the cache is missing: the newest edge, and older history - * when a wider window is asked for. + * records are kept for the whole retention period, whatever window was asked + * for. After one full fetch, a refresh fetches only the newest edge. * * A record's dedupe key carries its timestamp and an occurrence index counted * within one fetch. A refetched range therefore replaces every cached record in @@ -52,84 +51,66 @@ interface CursorFetchRange { readonly untilMs: number; } -/** Whether the cache answers a window without a refresh. */ +/** Whether the cache answers without a refresh. */ export function isCursorCacheFresh( cache: CursorAccountCache | undefined, - windowStartMs: number, nowMs: number, ): cache is CursorAccountCache { - return ( - cache !== undefined && - nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS && - cache.sinceMs <= windowStartMs - ); + return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS; } /** - * Ranges to fetch so the cache covers `[windowStartMs, nowMs]`. A cache that - * ends before the window starts is useless; its replacement is a full fetch. + * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the + * newest edge, or everything when the cache does not reach back that far. */ -export function cursorFetchRanges( +export function cursorFetchRange( cache: CursorAccountCache | undefined, - windowStartMs: number, + retentionStartMs: number, nowMs: number, -): readonly CursorFetchRange[] { - if (cache === undefined || cache.untilMs - REFETCH_OVERLAP_MS < windowStartMs) { - return [{ sinceMs: windowStartMs, untilMs: nowMs }]; - } - const ranges: CursorFetchRange[] = []; - if (nowMs - cache.fetchedAtMs >= CURSOR_ACCOUNT_TTL_MS) { - ranges.push({ - sinceMs: Math.max(cache.sinceMs, cache.untilMs - REFETCH_OVERLAP_MS), - untilMs: nowMs, - }); - } - if (windowStartMs < cache.sinceMs) { - ranges.push({ sinceMs: windowStartMs, untilMs: cache.sinceMs }); - } - return ranges; +): CursorFetchRange { + return cache === undefined || + cache.sinceMs > retentionStartMs || + cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs + ? { sinceMs: retentionStartMs, untilMs: nowMs } + : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs }; } /** - * Applies fetched ranges to the cache, each replacing the cached records it - * covers. A range that does not touch the covered range starts the cache over, - * since the gap between them was never fetched. Pass `undefined` for a cache - * that belongs to another account. + * Applies a fetched range to the cache, replacing the cached records it covers + * and dropping those before the retention start. Pass `undefined` for a cache + * that belongs to another account. `changed` is false when the fetch only + * confirmed the cached records, so there is nothing new to persist. */ -export function mergeCursorFetches( +export function mergeCursorFetch( cache: CursorAccountCache | undefined, accountKey: string, - fetches: readonly { - readonly range: CursorFetchRange; - readonly records: readonly UsageRecord[]; - }[], + range: CursorFetchRange, + fetched: readonly UsageRecord[], nowMs: number, - retentionCutoffMs: number, -): CursorAccountCache { - let records = cache?.records ?? []; - let sinceMs = cache?.sinceMs ?? Number.POSITIVE_INFINITY; - let untilMs = cache?.untilMs ?? Number.NEGATIVE_INFINITY; - let fetchedAtMs = cache?.fetchedAtMs ?? nowMs; - for (const { range, records: fetched } of fetches) { - if (range.sinceMs > untilMs || range.untilMs < sinceMs) { - records = []; - sinceMs = Number.POSITIVE_INFINITY; - untilMs = Number.NEGATIVE_INFINITY; - } - const inRange = (record: UsageRecord) => - record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs; - records = [...records.filter((record) => !inRange(record)), ...fetched.filter(inRange)]; - sinceMs = Math.min(sinceMs, range.sinceMs); - if (range.untilMs >= untilMs) { - untilMs = range.untilMs; - fetchedAtMs = nowMs; - } - } - if (sinceMs < retentionCutoffMs) { - sinceMs = retentionCutoffMs; - records = records.filter((record) => record.timestampMs >= retentionCutoffMs); - } - return { accountKey, sinceMs, untilMs, fetchedAtMs, records }; + retentionStartMs: number, +) { + const inRange = (record: UsageRecord) => + record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs; + const previous = cache?.records ?? []; + const added = fetched.filter(inRange); + const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey)); + const changed = + cache === undefined || + replacedKeys.size !== added.length || + added.some((record) => !replacedKeys.has(record.dedupeKey)); + return { + changed, + cache: { + accountKey, + sinceMs: retentionStartMs, + untilMs: range.untilMs, + fetchedAtMs: nowMs, + records: [ + ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs), + ...added, + ], + } satisfies CursorAccountCache, + }; } /** diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 64ddfaac6e5e..4ff116c9ce80 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -7,8 +7,6 @@ import { USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageProviderKind, - type UsageSummary, - type UsageSummaryInput, } from "@t3tools/contracts"; import { CircleAlertIcon, ChevronDownIcon, InfoIcon, SlidersHorizontalIcon } from "lucide-react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; @@ -65,6 +63,7 @@ import { import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; +import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -133,9 +132,7 @@ export function UsagePage() { })); const metric = preferences.metric; const showingLimits = metric === "limits"; - // Summaries on screen when a manual refresh began; empty while limits refresh. - const [refreshingFrom, setRefreshingFrom] = useState | null>(null); - const isRefreshing = refreshingFrom !== null; + const [isRefreshing, setIsRefreshing] = useState(false); const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); @@ -150,33 +147,22 @@ export function UsagePage() { environments, selectedEnvironments, isPending, + shown, isPartial, refresh, } = useUsage(window, selectedEnvironmentIds); - // Captured when the window changes: until the new window's first answer, the - // previous usage stays on screen, muted, so the chart can move between them. - const [keptUsage, setKeptUsage] = useState<{ - readonly window: UsageSummaryInput; - readonly merged: MergedUsage; - readonly selection: ReadonlySet | null; - } | null>(null); - // Nothing renders until some environment answers: only then is the provider - // list known. - const shown = !isPending - ? { window, merged: answeredUsage } - : keptUsage?.selection === selectedEnvironmentIds - ? keptUsage - : null; + // Until a new window's first answer, the previous one stays on screen, muted. const merged = shown?.merged ?? answeredUsage; const shownWindow = shown?.window ?? window; const shownHourly = shownWindow.resolution === "hour"; + const refreshingUsage = isRefreshing && !showingLimits; // Kept usage is all old, so every figure waits on the new window. const loading = useMemo( () => isPending ? { partial: true, everyProvider: true, providers: new Set() } - : usageLoadingState(selectedEnvironments, refreshingFrom), - [isPending, refreshingFrom, selectedEnvironments], + : usageLoadingState(selectedEnvironments, refreshingUsage), + [isPending, refreshingUsage, selectedEnvironments], ); const isProviderLoading = (provider: UsageProviderKind) => loading.everyProvider || loading.providers.has(provider); @@ -270,18 +256,8 @@ export function UsagePage() { ); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; - const keepShownUsage = () => { - if (shown !== null) { - setKeptUsage({ - window: shown.window, - merged: shown.merged, - selection: selectedEnvironmentIds, - }); - } - }; const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; - keepShownUsage(); const nextPreferences = { metric, windowDays: days }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); @@ -346,10 +322,10 @@ export function UsagePage() { if (showingLimits) { refreshingRef.current = true; - setRefreshingFrom(new Set()); + setIsRefreshing(true); void refreshLimits().finally(() => { refreshingRef.current = false; - setRefreshingFrom(null); + setIsRefreshing(false); }); return; } @@ -360,16 +336,13 @@ export function UsagePage() { nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { - keepShownUsage(); setWindowSelection({ days: windowDays, window: nextWindow }); } refreshingRef.current = true; - setRefreshingFrom( - new Set(selectedEnvironments.flatMap(({ summary }) => (summary === null ? [] : [summary]))), - ); + setIsRefreshing(true); void refresh(nextWindow).finally(() => { refreshingRef.current = false; - setRefreshingFrom(null); + setIsRefreshing(false); }); }; const connectedLimitsEnvironments = [...presentations] @@ -407,7 +380,7 @@ export function UsagePage() { selectedEnvironmentIds={selectedEnvironmentIds} onSelectionChange={setSelectedEnvironmentIds} showUsageStatus={!showingLimits} - refreshingFrom={refreshingFrom} + refreshing={refreshingUsage} isPartial={isPartial} duplicateSources={merged.duplicateSources} contractMismatches={merged.contractMismatches} @@ -562,7 +535,9 @@ export function UsagePage() { ) : null } /> - ) : shown === null ? null : !canReadDiagnostics ? ( + ) : shown === null ? ( + + ) : !canReadDiagnostics ? (
{selectedEnvironments.map((environment) => (

@@ -1249,7 +1224,7 @@ function UsageEnvironmentFilter({ selectedEnvironmentIds, onSelectionChange, showUsageStatus, - refreshingFrom, + refreshing, isPartial, duplicateSources, contractMismatches, @@ -1260,7 +1235,7 @@ function UsageEnvironmentFilter({ readonly selectedEnvironmentIds: ReadonlySet | null; readonly onSelectionChange: (ids: ReadonlySet | null) => void; readonly showUsageStatus: boolean; - readonly refreshingFrom: ReadonlySet | null; + readonly refreshing: boolean; readonly isPartial: boolean; readonly duplicateSources: readonly string[]; readonly contractMismatches: MergedUsage["contractMismatches"]; @@ -1307,7 +1282,7 @@ function UsageEnvironmentFilter({ const checked = selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId); - const progress = usageEnvironmentProgress(environment, refreshingFrom); + const progress = usageEnvironmentProgress(environment, refreshing); const status = environment.error !== null ? "Unavailable" @@ -1378,3 +1353,80 @@ function UsageEnvironmentFilter({ ); } + +/** + * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it + * breathes with the same `animate-skeleton` pulse as every other loading state. + * Replaced by results as soon as the first environment answers. + */ +function UsageSkeleton() { + return ( + <> +

+
+
+ + +
+ {PROVIDER_ORDER.map((provider) => ( +
+
+ + + + + + +
+ +
+ ))} +
+ +
+ +
+ + +
+
+
+ +
+

Totals

+ +
+ +
+
+ + + +
+
+ +
+
+

Breakdown

+ +
+ +
+ + ); +} + +function MetricSkeletons({ labels }: { readonly labels: readonly string[] }) { + return ( +
+ {labels.map((label) => ( +
+ {label} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index b94d46456365..b422efbf8eb9 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,70 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { - buildPeriodColumns, - chartScale, - mixSamples, - niceScale, - SAMPLE_XS, - sampleCurve, - smoothCurve, - VIEW_WIDTH, -} from "./UsageProviderChart"; +import { buildPeriodColumns, chartScale, niceScale } from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; -/** Points spread evenly across the plot, as the chart lays out its periods. */ -const spread = (ys: readonly number[]) => - ys.map((y, index) => ({ x: (index * VIEW_WIDTH) / (ys.length - 1), y })); - -describe("sampleCurve", () => { - it("passes through every period exactly", () => { - const points = spread([200, 40, 120, 260, 90]); - const sampled = sampleCurve( - smoothCurve(points), - points.map((point) => point.x), - ); - - sampled.forEach((y, index) => expect(y).toBeCloseTo(points[index]?.y ?? Number.NaN, 9)); - }); - - it("stays monotone between rising periods instead of overshooting", () => { - const sampled = sampleCurve(smoothCurve(spread([250, 248, 120, 118, 10, 8])), SAMPLE_XS); - - sampled.slice(1).forEach((y, index) => expect(y).toBeLessThanOrEqual(sampled[index] ?? 0)); - }); - - it("lands curves with different period counts on the same grid", () => { - // Monotone cubics reproduce a straight line, so 7 and 30 periods of the - // same trend must resample to the same values, sample for sample. - const line = (count: number) => - sampleCurve( - smoothCurve( - spread(Array.from({ length: count }, (_, index) => 240 - (index * 200) / (count - 1))), - ), - SAMPLE_XS, - ); - const weekly = line(7); - const monthly = line(30); - - expect(weekly).toHaveLength(SAMPLE_XS.length); - expect(monthly).toHaveLength(SAMPLE_XS.length); - weekly.forEach((y, index) => expect(y).toBeCloseTo(monthly[index] ?? Number.NaN, 6)); - expect(weekly[0]).toBeCloseTo(240, 9); - expect(weekly[weekly.length - 1]).toBeCloseTo(40, 9); - }); -}); - -describe("mixSamples", () => { - it("runs from the old samples to the new ones", () => { - const from = [0, 100, 200]; - const to = [200, 100, 0]; - - expect(mixSamples(from, to, 0)).toEqual(from); - expect(mixSamples(from, to, 1)).toEqual(to); - expect(mixSamples(from, to, 0.25)).toEqual([50, 100, 150]); - }); -}); - describe("chartScale", () => { const columns = [ { diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index b9f2efb5406c..95b0a66e85a3 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,6 +1,6 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -13,25 +13,17 @@ import { import { cn } from "~/lib/utils"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; -export const VIEW_WIDTH = 960; +const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; const TICK_COUNT = 4; const PLOT_TOP = 8; -const SAMPLE_COUNT = 193; -const MORPH_MS = 500; const NONE_LOADING: ReadonlySet = new Set(); -/** The fixed x grid every morph resamples onto, so any two curves can blend. */ -export const SAMPLE_XS = Array.from( - { length: SAMPLE_COUNT }, - (_, index) => (index * VIEW_WIDTH) / (SAMPLE_COUNT - 1), -); - export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { readonly providers: readonly UsageProviderKind[]; - /** Providers without data yet: flat along the bottom, left out of the scale, rising when they land. */ + /** Providers without data yet: flat and faded along the bottom, left out of the scale. */ readonly loadingProviders?: ReadonlySet; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; @@ -130,7 +122,7 @@ interface CurveSegment { readonly to: Point; } -export function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { +function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); const segments: CurveSegment[] = []; @@ -160,42 +152,11 @@ function curvePath(segments: readonly CurveSegment[]): string { return path; } -/** - * Evaluates a curve at ascending x positions. Control points sit at x thirds, - * so x is linear in t and each y is exact, wherever the periods fall. - */ -export function sampleCurve(segments: readonly CurveSegment[], xs: readonly number[]) { - let index = 0; - return xs.map((x) => { - while (index < segments.length - 1 && x > (segments[index]?.to.x ?? x)) index += 1; - const segment = segments[index]; - if (segment === undefined) return VIEW_HEIGHT; - const { from, c1, c2, to } = segment; - const span = to.x - from.x; - const t = span === 0 ? 0 : Math.min(1, Math.max(0, (x - from.x) / span)); - const u = 1 - t; - return u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y; - }); -} - -/** Blends two sample sets on the shared grid: progress 0 is `from`, 1 is `to`. */ -export function mixSamples(from: readonly number[], to: readonly number[], progress: number) { - return from.map((y, index) => y + ((to[index] ?? y) - y) * progress); -} - -/** Straight segments through samples; the grid is dense enough to read as a curve. */ -function polylinePath(xs: readonly number[], ys: readonly number[]) { - return ys - .map((y, index) => `${index === 0 ? "M" : "L"}${(xs[index] ?? 0).toFixed(2)},${y.toFixed(2)}`) - .join(" "); -} - function areaPath(line: string) { return line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`; } /** A loading line: flat along the zero line until its usage lands. */ -const FLAT_SAMPLES = SAMPLE_XS.map(() => VIEW_HEIGHT); const FLAT_LINE = `M0,${VIEW_HEIGHT} L${VIEW_WIDTH},${VIEW_HEIGHT}`; /** @@ -255,7 +216,7 @@ function valueToY(value: number, max: number) { return max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); } -/** Per-provider shapes in paint order: answered curves heaviest first, loading providers flat. */ +/** Per-provider paths in paint order: answered curves heaviest first, loading providers flat. */ function buildChart( periods: readonly string[], byPeriod: ReadonlyMap, @@ -266,165 +227,30 @@ function buildChart( const columns = buildPeriodColumns(periods, byPeriod, metric); const scale = chartScale(columns, providers, loadingProviders); const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); - const shapes = providers.map((provider) => { + const paths = providers.map((provider) => { + if (loadingProviders.has(provider)) { + return { provider, loading: true, total: 0, line: FLAT_LINE, area: "" }; + } const slot = PROVIDER_ORDER.indexOf(provider); - if (loadingProviders.has(provider)) - return { provider, kind: "loading" as const, slot, total: 0 }; - const segments = smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * stepX, - y: valueToY(column.bands[slot]?.value ?? 0, scale.max), - })), + const line = curvePath( + smoothCurve( + columns.map((column, periodIndex) => ({ + x: periodIndex * stepX, + y: valueToY(column.bands[slot]?.value ?? 0, scale.max), + })), + ), ); - const line = curvePath(segments); return { provider, - kind: "data" as const, - slot, + loading: false, total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0), - segments, line, + area: areaPath(line), }; }); // Paint the heavier series first so the lighter one is not buried. - return { columns, scale, stepX, shapes: shapes.toSorted((a, b) => b.total - a.total) }; -} - -type ChartShape = ReturnType["shapes"][number]; - -/** One provider's drawn paths, its exact curve on the sample grid, and the morph moving it. */ -interface DrawnSeries { - readonly shape: ChartShape; - readonly fill: SVGPathElement | null; - readonly line: SVGPathElement | null; - /** Null for a curve with fewer than two periods. */ - readonly curve: readonly number[] | null; - motion: { readonly from: readonly number[]; readonly start: number } | null; -} - -const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3; - -/** - * What a series shows at `time`, so an interrupted morph continues from the - * screen. Null when there is no curve to show. - */ -function shownSamples(series: DrawnSeries, time: number) { - const { motion, curve: target } = series; - if (motion === null || target === null || time >= motion.start + MORPH_MS) return target; - const progress = Math.max(0, (time - motion.start) / MORPH_MS); - return mixSamples(motion.from, target, easeOutCubic(progress)); -} - -function sameTarget(a: ChartShape, b: ChartShape) { - return a.kind === "loading" ? b.kind === "loading" : b.kind === "data" && a.line === b.line; -} - -/** - * Writes one frame of a series straight to its paths and returns whether a - * morph is still running. A settled curve gets its exact path. - */ -function drawSeries(series: DrawnSeries, time: number) { - const moving = series.motion !== null && time < series.motion.start + MORPH_MS; - const samples = shownSamples(series, time); - const line = moving - ? samples === null - ? "" - : polylinePath(SAMPLE_XS, samples) - : series.shape.kind === "data" - ? series.shape.line - : FLAT_LINE; - series.line?.setAttribute("d", line); - series.fill?.setAttribute("d", areaPath(line)); - return moving; -} - -/** - * Drives the series paths outside React with short morphs between shapes. The - * rAF loop runs only while a morph is in flight on a visible chart. - */ -function createSeriesAnimator() { - let series: Map | null = null; - let visible = true; - let frame: number | null = null; - let motionQuery: MediaQueryList | null = null; - - const reducedMotion = () => { - motionQuery ??= window.matchMedia("(prefers-reduced-motion: reduce)"); - return motionQuery.matches; - }; - const active = (entries: ReadonlyMap) => - [...entries.values()].some((entry) => entry.motion !== null); - - const tick = (time: number) => { - frame = null; - if (series === null) return; - for (const entry of series.values()) { - if (entry.motion !== null && !drawSeries(entry, time)) entry.motion = null; - } - if (active(series)) frame = requestAnimationFrame(tick); - }; - - const start = () => { - if (frame === null && visible && series !== null && active(series)) { - frame = requestAnimationFrame(tick); - } - }; - - // A hidden chart lands every morph at once rather than resuming midway. - const stop = () => { - if (frame !== null) cancelAnimationFrame(frame); - frame = null; - for (const entry of series?.values() ?? []) { - entry.motion = null; - drawSeries(entry, performance.now()); - } - }; - - return { - /** - * Points each provider's paths at its new shape. Changed series morph from - * what is on screen; the first draw, an off-screen chart, and reduced - * motion snap. - */ - sync(shapes: readonly ChartShape[], svg: SVGSVGElement) { - const now = performance.now(); - const animate = series !== null && visible && !reducedMotion(); - const next = new Map(); - for (const shape of shapes) { - const kept = series?.get(shape.provider); - if (kept !== undefined && sameTarget(kept.shape, shape)) { - next.set(shape.provider, kept); - continue; - } - const curve = - shape.kind === "loading" - ? FLAT_SAMPLES - : shape.segments.length > 0 - ? sampleCurve(shape.segments, SAMPLE_XS) - : null; - // A provider that just appeared rises from the zero line. - const from = !animate ? null : kept === undefined ? FLAT_SAMPLES : shownSamples(kept, now); - const entry: DrawnSeries = { - shape, - fill: svg.querySelector(`path[data-fill="${shape.provider}"]`), - line: svg.querySelector(`path[data-line="${shape.provider}"]`), - curve, - motion: from === null || curve === null ? null : { from, start: now }, - }; - next.set(shape.provider, entry); - drawSeries(entry, now); - } - series = next; - start(); - }, - setVisible(next: boolean) { - visible = next; - if (visible) start(); - else stop(); - }, - stop, - }; + return { columns, scale, stepX, paths: paths.toSorted((a, b) => b.total - a.total) }; } export function UsageProviderChart({ @@ -451,46 +277,16 @@ export function UsageProviderChart({ const plotRef = useRef(null); const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); - const svgRef = useRef(null); - const [animator] = useState(createSeriesAnimator); - const { columns, scale, shapes, stepX } = useMemo( + const { columns, scale, paths, stepX } = useMemo( () => buildChart(periods, byPeriod, metric, providers, loadingProviders), [byPeriod, loadingProviders, metric, periods, providers], ); const toY = (value: number) => valueToY(value, scale.max); // Once anything has answered, lines still loading fade back behind it. const fadeLoading = providers.some((provider) => !loadingProviders.has(provider)); - const seriesClassName = (kind: ChartShape["kind"]) => - cn("transition-opacity duration-300", fadeLoading && kind === "loading" && "opacity-30"); - - // Series paths are written by the animator rather than rendered, so morphs - // never re-render. - useLayoutEffect(() => { - if (svgRef.current !== null) animator.sync(shapes, svgRef.current); - }, [animator, shapes]); - - // Morphs land at once while the chart is off screen or the tab is hidden. - useEffect(() => { - const plot = plotRef.current; - let intersecting = true; - const update = () => - animator.setVisible(intersecting && document.visibilityState === "visible"); - const observer = - plot === null || typeof IntersectionObserver === "undefined" - ? null - : new IntersectionObserver((entries) => { - for (const entry of entries) intersecting = entry.isIntersecting; - update(); - }); - if (plot !== null) observer?.observe(plot); - document.addEventListener("visibilitychange", update); - return () => { - observer?.disconnect(); - document.removeEventListener("visibilitychange", update); - animator.stop(); - }; - }, [animator]); + const seriesClassName = (loading: boolean) => + cn("transition-opacity duration-300", fadeLoading && loading && "opacity-30"); const format = metric === "tokens" ? formatTokens : formatUsd; @@ -593,7 +389,6 @@ export function UsageProviderChart({ }} > ( + {paths.map(({ provider, loading, area }) => ( ))} - {shapes.map(({ provider, kind }) => ( + {paths.map(({ provider, loading, line }) => ( { expect(latest.isPending).toBe(false); expect(latest.isPartial).toBe(false); }); + + it("shows the last answered usage until the next window answers, for the same selection", async () => { + const selected = new Set([EnvironmentId.make("a")]); + await act(() => renderer?.update()); + expect(latest.shown?.merged.costUsd).toBe(10); + + // A new window that nothing has answered yet. + testState.environments = [environment("a", null)]; + await act(() => renderer?.update()); + expect(latest.isPending).toBe(true); + expect(latest.shown?.merged.costUsd).toBe(10); + + // Another selection has nothing of its own to show. + await select("a"); + expect(latest.shown).toBeNull(); + }); }); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index fead64f71738..b8181453a8ce 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -19,7 +19,7 @@ import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -96,6 +96,11 @@ export interface UsageView { readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; /** True until at least one selected environment has answered. */ readonly isPending: boolean; + /** + * The usage to draw: this window's once a selected environment answers, + * until then the last window answered for the same selection, else null. + */ + readonly shown: { readonly window: UsageSummaryInput; readonly merged: MergedUsage } | null; /** * True while environments that have not failed are still answering. Failed * environments are reported through their own error rows: totals will not @@ -195,12 +200,33 @@ export function useUsage( const stillReporting = selectedEnvironments.filter( (environment) => environment.summary === null && environment.error === null, ).length; + const isPending = answeredCount === 0 && stillReporting > 0; + + // Stored during render, as React recommends for state that follows props, so + // the kept usage is on screen in the same frame the new window starts pending. + const [lastAnswered, setLastAnswered] = useState< + (NonNullable & { readonly selection: typeof selectedEnvironmentIds }) | null + >(null); + if ( + !isPending && + (lastAnswered?.merged !== merged || + lastAnswered.window !== input || + lastAnswered.selection !== selectedEnvironmentIds) + ) { + setLastAnswered({ window: input, merged, selection: selectedEnvironmentIds }); + } + const shown = !isPending + ? { window: input, merged } + : lastAnswered?.selection === selectedEnvironmentIds + ? lastAnswered + : null; return { merged, environments, selectedEnvironments, - isPending: answeredCount === 0 && stillReporting > 0, + isPending, + shown, isPartial: answeredCount > 0 && stillReporting > 0, refresh, }; diff --git a/packages/client-runtime/src/state/usageProgress.test.ts b/packages/client-runtime/src/state/usageProgress.test.ts index a0506472fd50..889e696bfdd4 100644 --- a/packages/client-runtime/src/state/usageProgress.test.ts +++ b/packages/client-runtime/src/state/usageProgress.test.ts @@ -116,20 +116,11 @@ describe("usageProgress", () => { expect(usageEnvironmentProgress(ignored[0]!)).toEqual({ phase: "inactive" }); }); - it("keeps a manual refresh dimmed until a new summary replaces the old one", () => { - const onScreen = [summary(), summary()] as const; - const a = environment("a", { summary: onScreen[0] }); - const b = environment("b", { summary: onScreen[1] }); - const refreshing = { ...options, refreshingFrom: new Set(onScreen) }; + it("dims every answered environment while a manual refresh runs", () => { // Pricing refreshes first, so neither query is pending yet. - expect(usageProgress([a, b], refreshing)).toEqual({ - dimmed: true, - label: "Updating 2 environments…", - }); - expect(usageProgress([a, environment("b")], refreshing)).toEqual({ - dimmed: false, - label: "Updating a…", - }); + expect( + usageProgress([environment("a"), environment("b")], { ...options, refreshing: true }), + ).toEqual({ dimmed: true, label: "Updating 2 environments…" }); }); }); diff --git a/packages/client-runtime/src/state/usageProgress.ts b/packages/client-runtime/src/state/usageProgress.ts index b8f51ab2e447..49381ea0a30a 100644 --- a/packages/client-runtime/src/state/usageProgress.ts +++ b/packages/client-runtime/src/state/usageProgress.ts @@ -17,18 +17,16 @@ interface UsageProgressEnvironment { * - `partway`: answered this round while slow sources finish refreshing. * - `ready`: answered. * - * `refreshingFrom` holds the summaries on screen when a manual refresh began. - * They count as stale until replaced, since the refresh reaches each query only - * after its environment refreshes pricing. + * While a manual `refreshing` runs, every summary counts as stale. */ export function usageEnvironmentProgress( environment: UsageProgressEnvironment, - refreshingFrom: ReadonlySet | null = null, + refreshing = false, ) { const { summary } = environment; if (!environment.isConnected || environment.error !== null) return { phase: "inactive" } as const; if (summary === null) return { phase: "loading" } as const; - if (refreshingFrom?.has(summary)) return { phase: "stale" } as const; + if (refreshing) return { phase: "stale" } as const; // A refreshing source without a pending follow-up has nothing left to wait on. if (!environment.isPending) return { phase: "ready" } as const; const providers = [ @@ -60,16 +58,16 @@ export function updatingProvidersLabel( export function usageProgress( environments: readonly UsageProgressEnvironment[], { - refreshingFrom = null, + refreshing = false, providerLabel, }: { - readonly refreshingFrom?: ReadonlySet | null; + readonly refreshing?: boolean; readonly providerLabel: (provider: UsageProviderKind) => string; }, ) { const progress = environments.map((environment) => ({ label: environment.label, - ...usageEnvironmentProgress(environment, refreshingFrom), + ...usageEnvironmentProgress(environment, refreshing), })); const waiting = progress.filter(({ phase }) => phase === "loading" || phase === "stale"); const providers = [ @@ -101,12 +99,12 @@ export function usageProgress( */ export function usageLoadingState( environments: readonly UsageProgressEnvironment[], - refreshingFrom: ReadonlySet | null = null, + refreshing = false, ) { const providers = new Set(); let everyProvider = false; for (const environment of environments) { - const progress = usageEnvironmentProgress(environment, refreshingFrom); + const progress = usageEnvironmentProgress(environment, refreshing); if (progress.phase === "loading") everyProvider = true; if (progress.phase === "partway") for (const provider of progress.providers) providers.add(provider); From aadaab0933f3e79fab4ba89956325e046f336306 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:29:24 -0700 Subject: [PATCH 3/5] fix(usage): cache writes finish before the usage service shuts down Cache files are written after the summary answers, now through atomic temp-file writes. A write still running when the service's scope closed could race the removal of its state directory, and at shutdown the last scan's cache could be lost. The service now waits for pending writes in a finalizer, and the tests that build it inside a temporary state directory scope it there. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/usage/UsageService.test.ts | 16 ++++++++++------ apps/server/src/usage/UsageService.ts | 3 +++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 79ae98e15555..276329ea5709 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -264,6 +264,9 @@ describe("UsageService", () => { const service = yield* UsageService.make; return yield* service.readSummary(WINDOW); }).pipe( + // Scoped inside the state directory, so pending cache writes land + // before it is removed. + Effect.scoped, Effect.provide( layerService({ prefix: "usage-managed-accounts", @@ -405,9 +408,7 @@ describe("UsageService", () => { assert.isUndefined(cursorSource(fresh)?.refreshing); assert.strictEqual(totalOutputTokens(fresh), 12); assert.strictEqual(cursor.state.calls.length, 2); - }).pipe( - Effect.provide(serviceLayers({ prefix: "usage-service-cursor-swr", home, settings })), - ); + }).pipe(Effect.provide(layerService({ prefix: "usage-service-cursor-swr", home, settings }))); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); @@ -460,7 +461,7 @@ describe("UsageService", () => { assert.strictEqual(cursorSource(yield* read(wide))?.fingerprint.volumeId, "account-b"); }).pipe( Effect.provide( - serviceLayers({ prefix: "usage-service-cursor-incremental", home, settings }), + layerService({ prefix: "usage-service-cursor-incremental", home, settings }), ), ); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), @@ -501,7 +502,7 @@ describe("UsageService", () => { assert.strictEqual(cursorSource(switched)?.message, cursor.state.error); assert.strictEqual(totalOutputTokens(switched), 0); }).pipe( - Effect.provide(serviceLayers({ prefix: "usage-service-cursor-failure", home, settings })), + Effect.provide(layerService({ prefix: "usage-service-cursor-failure", home, settings })), ); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); @@ -542,7 +543,7 @@ describe("UsageService", () => { { sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 }, ]); }).pipe( - Effect.provide(serviceLayers({ prefix: "usage-service-cursor-restart", home, settings })), + Effect.provide(layerService({ prefix: "usage-service-cursor-restart", home, settings })), ); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); @@ -1259,6 +1260,9 @@ describe("UsageService", () => { assert.deepStrictEqual(outsideWindow.buckets, []); assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0); }).pipe( + // Scoped inside the state directory, so pending cache writes land + // before it is removed. + Effect.scoped, Effect.provide( layerService({ prefix: "usage-service-cleanup-test", diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index eae6d6367690..9e414e155002 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -510,6 +510,9 @@ export const make = Effect.gen(function* () { const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( Effect.asVoid, ); + // A write still running when the service shuts down finishes first, so the + // next start does not lose the last scan. + yield* Effect.addFinalizer(() => awaitPersisted); /** * Parses one transcript, reusing the cached result when it is unchanged. From 3b44c3c47f1a675759a57f4609133eb1f335f2e2 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:39:14 -0700 Subject: [PATCH 4/5] fix(usage): address review findings - Count a stale environment as able to change every provider, so totals dim during a refresh even when its old summary had no usage. - Mute every loading chart line, including stale ones still on screen, and scale to everything drawn. - Scan transcript directories one at a time so open files stay bounded. - Keep 92 days of cache, covering the 90-day window and its slack. - Provide the Cursor account reader as a service instead of a reference whose default closes over network and filesystem access. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/server.ts | 6 +- apps/server/src/usage/UsageService.test.ts | 15 ++-- apps/server/src/usage/UsageService.ts | 26 +++--- apps/server/src/usage/cursorAccountCache.ts | 15 +--- apps/server/src/usage/cursorUsageReader.ts | 25 ++++++ apps/web/src/components/usage/UsagePage.tsx | 5 +- .../usage/UsageProviderChart.test.ts | 29 +++---- .../components/usage/UsageProviderChart.tsx | 87 ++++++++----------- .../client-runtime/src/state/usageProgress.ts | 7 +- 9 files changed, 105 insertions(+), 110 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a43..0f5c71fc8481 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -153,6 +153,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as CursorUsageReader from "./usage/cursorUsageReader.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; @@ -225,7 +226,10 @@ const layerBackground = BackgroundPolicy.layer.pipe( Layer.provideMerge(layerServerSettings), ); -const layerUsage = UsageService.layer.pipe(Layer.provide(layerServerSettings)); +const layerUsage = UsageService.layer.pipe( + Layer.provide(layerServerSettings), + Layer.provide(CursorUsageReader.layer), +); const layerResourceDiagnostics = Layer.mergeAll( HostResources.layer, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 276329ea5709..e0f3ce23c60f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -32,7 +32,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { CursorAccountReader } from "./cursorAccountCache.ts"; +import * as CursorUsageReader from "./cursorUsageReader.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; import * as UsageService from "./UsageService.ts"; @@ -93,6 +93,7 @@ const layerService = (input: { }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(CursorUsageReader.layer), Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( @@ -164,7 +165,7 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z"); const HOUR_MS = 60 * 60 * 1000; /** The service's cache retention, which the Cursor account cache always covers. */ -const CURSOR_RETENTION_MS = 90 * 24 * HOUR_MS; +const CURSOR_RETENTION_MS = 92 * 24 * HOUR_MS; /** * Stands in for Cursor's dashboard API. Each read returns the account's events @@ -370,7 +371,7 @@ describe("UsageService", () => { cursor.state.gate = gate; yield* Effect.gen(function* () { const service = yield* UsageService.make.pipe( - Effect.provideService(CursorAccountReader, cursor.read), + Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), ); // Cold: nothing cached yet, so Cursor answers empty while it refreshes. const cold = yield* service.readSummary(WINDOW); @@ -427,7 +428,7 @@ describe("UsageService", () => { ]; yield* Effect.gen(function* () { const service = yield* UsageService.make.pipe( - Effect.provideService(CursorAccountReader, cursor.read), + Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), ); const read = (input: UsageSummaryInput) => service.readSummary({ ...input, awaitRefresh: true }); @@ -476,7 +477,7 @@ describe("UsageService", () => { cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }]; yield* Effect.gen(function* () { const service = yield* UsageService.make.pipe( - Effect.provideService(CursorAccountReader, cursor.read), + Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), ); yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); @@ -519,7 +520,7 @@ describe("UsageService", () => { ]; yield* Effect.gen(function* () { const first = yield* UsageService.make.pipe( - Effect.provideService(CursorAccountReader, before.read), + Effect.provideService(CursorUsageReader.CursorAccountReader, { read: before.read }), ); const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true }); yield* first.awaitPersisted; @@ -527,7 +528,7 @@ describe("UsageService", () => { const after = makeFakeCursor(); after.state.events = before.state.events; const restarted = yield* UsageService.make.pipe( - Effect.provideService(CursorAccountReader, after.read), + Effect.provideService(CursorUsageReader.CursorAccountReader, { read: after.read }), ); const restored = yield* restarted.readSummary(WINDOW); assert.isUndefined(cursorSource(restored)?.refreshing); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 9e414e155002..b0bf3414b854 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -62,7 +62,6 @@ import { readAntigravityUsage } from "./antigravityUsageReader.ts"; import { CURSOR_ACCOUNT_CACHE_FILE_NAME, CURSOR_ACCOUNT_TTL_MS, - CursorAccountReader, cursorFetchRange, decodeCursorAccountCaches, encodeCursorAccountCaches, @@ -71,6 +70,7 @@ import { type CursorAccountCache, type CursorCredentialSource, } from "./cursorAccountCache.ts"; +import * as CursorUsageReader from "./cursorUsageReader.ts"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -106,8 +106,11 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000; const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; -/** Longest window the UI offers, plus slack. Older entries are pruned. */ -const CACHE_RETENTION_DAYS = 90; +/** + * The longest window the UI offers, 90 days, plus its `MTIME_SLACK_MS`, rounded + * up. Older entries are pruned. + */ +const CACHE_RETENTION_DAYS = 92; const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; @@ -212,7 +215,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; const platform = yield* HostProcessPlatform; - const readCursorAccount = yield* CursorAccountReader; + const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader; const fileCache: ScanCache = new Map(); const sourceCache = new Map(); @@ -662,9 +665,9 @@ export const make = Effect.gen(function* () { const nowMs = yield* Clock.currentTimeMillis; const fetchMissing = (cache: CursorAccountCache | undefined) => { const range = cursorFetchRange(cache, retentionStartMs, nowMs); - return readCursorAccount(credential, range.sinceMs, range.untilMs).pipe( - Effect.map((result) => ({ range, result })), - ); + return cursorAccountReader + .read(credential, range.sinceMs, range.untilMs) + .pipe(Effect.map((result) => ({ range, result }))); }; let base = cursorCaches.get(credentialKey); let fetched = yield* fetchMissing(base); @@ -981,13 +984,12 @@ export const make = Effect.gen(function* () { return source === null ? [] : [source]; }); - // Independent sources scan together. The result keeps this order, since - // aggregation keeps the first copy of a duplicate. + // Independent sources scan together. Transcript directories go one at a + // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result + // keeps this order, since aggregation keeps the first copy of a duplicate. const [transcripts, openCodeDirs, antigravityDirs, cursorDirs] = yield* Effect.all( [ - Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs), { - concurrency: "unbounded", - }), + Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), openCode, antigravity, cursor, diff --git a/apps/server/src/usage/cursorAccountCache.ts b/apps/server/src/usage/cursorAccountCache.ts index 9bf6c7016432..4f3a127ec959 100644 --- a/apps/server/src/usage/cursorAccountCache.ts +++ b/apps/server/src/usage/cursorAccountCache.ts @@ -12,24 +12,11 @@ * * @module cursorAccountCache */ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; - -import { cursorRateModel, readCursorAccountUsage } from "./cursorUsageReader.ts"; +import { cursorRateModel } from "./cursorUsageReader.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; export type CursorCredentialSource = string | { readonly kind: "keychain" }; -/** Reads one range of account usage. A reference so tests can stand in for Cursor's API. */ -export const CursorAccountReader = Context.Reference( - "t3/usage/cursorAccountCache/CursorAccountReader", - { - defaultValue: - () => (credentialSource: CursorCredentialSource, sinceMs: number, untilMs: number) => - Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)), - }, -); - /** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */ export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 253d7df94f78..7b712738a4b5 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -4,6 +4,10 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; import * as NodeTimersPromises from "node:timers/promises"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + import type { UsageRecord } from "./usageTranscripts.ts"; import { CursorKeychainTimeoutError, @@ -326,3 +330,24 @@ export async function readCursorAccountUsage( }; } } + +/** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */ +export class CursorAccountReader extends Context.Service< + CursorAccountReader, + { + readonly read: ( + credentialSource: string | { readonly kind: "keychain" }, + sinceMs: number, + untilMs: number, + ) => Effect.Effect; + } +>()("t3/usage/cursorUsageReader/CursorAccountReader") {} + +/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */ +export const layer = Layer.succeed( + CursorAccountReader, + CursorAccountReader.of({ + read: (credentialSource, sinceMs, untilMs) => + Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)), + }), +); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 4ff116c9ce80..c4d5c9ef6777 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -225,15 +225,14 @@ export function UsagePage() { ), [loading.providers, providersWithData], ); - // Lines with nothing to draw yet stay flat until their usage lands. const chartLoadingProviders = useMemo( () => new Set( activeProviders.filter( - (provider) => loading.providers.has(provider) && !providersWithData.includes(provider), + (provider) => loading.everyProvider || loading.providers.has(provider), ), ), - [activeProviders, loading.providers, providersWithData], + [activeProviders, loading.everyProvider, loading.providers], ); const selectedModel = selectedModelKey === null diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index b422efbf8eb9..e3a453a5770a 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -4,29 +4,24 @@ import { buildPeriodColumns, chartScale, niceScale } from "./UsageProviderChart" import { providersWithUsage } from "./usageProviders"; describe("chartScale", () => { - const columns = [ - { - total: 0, - bands: [ - { provider: "codex" as const, value: 40 }, - { provider: "cursor" as const, value: 900 }, - ], - }, - ]; - - it("scales to providers that have answered", () => { - const scale = chartScale(columns, ["codex", "cursor"], new Set(["cursor" as const])); - - expect(scale.max).toBe(40); - expect(scale.labeled).toBe(true); + const column = (codex: number) => ({ + total: codex, + bands: [{ provider: "codex" as const, value: codex }], }); - it("holds unlabeled placeholder gridlines until something answers", () => { - const scale = chartScale(columns, ["codex", "cursor"], new Set(["codex", "cursor"] as const)); + it("holds unlabeled placeholder gridlines while loading providers have nothing to show", () => { + const scale = chartScale([column(0)], new Set(["codex" as const])); expect(scale.labeled).toBe(false); expect(scale.ticks.length).toBeGreaterThan(1); }); + + it("scales to what is on screen, loading or not", () => { + expect(chartScale([column(40)], new Set(["codex" as const]))).toMatchObject({ + max: 40, + labeled: true, + }); + }); }); describe("niceScale", () => { diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 95b0a66e85a3..d8571f0b36d8 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -23,7 +23,7 @@ export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { readonly providers: readonly UsageProviderKind[]; - /** Providers without data yet: flat and faded along the bottom, left out of the scale. */ + /** Providers whose figures are still coming in: their lines are muted. */ readonly loadingProviders?: ReadonlySet; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; @@ -156,9 +156,6 @@ function areaPath(line: string) { return line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`; } -/** A loading line: flat along the zero line until its usage lands. */ -const FLAT_LINE = `M0,${VIEW_HEIGHT} L${VIEW_WIDTH},${VIEW_HEIGHT}`; - /** * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the * peak. @@ -184,30 +181,23 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re const PLACEHOLDER_TICKS = Array.from({ length: TICK_COUNT + 1 }, (_, index) => index); /** - * Scales to the providers that have answered. Until any has, unlabeled - * placeholder gridlines hold their usual spacing so nothing shifts on arrival. + * Scales to the largest single provider-period. With nothing to show yet while + * providers load, unlabeled placeholder gridlines hold their usual spacing so + * nothing shifts on arrival. */ export function chartScale( columns: readonly DayColumn[], - providers: readonly UsageProviderKind[], loadingProviders: ReadonlySet, ) { - if (loadingProviders.size > 0 && providers.every((provider) => loadingProviders.has(provider))) { - return { max: TICK_COUNT, ticks: PLACEHOLDER_TICKS, labeled: false }; - } - // The scale tops out at the largest single provider-period, not the sum: - // layered series each measure from zero, so a combined peak would leave - // the plot permanently half empty. + // Not the sum: layered series each measure from zero, so a combined peak + // would leave the plot permanently half empty. const peak = columns.reduce( - (max, column) => - column.bands.reduce( - (inner, band) => - loadingProviders.has(band.provider) ? inner : Math.max(inner, band.value), - max, - ), + (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), 0, ); - return { ...niceScale(peak, TICK_COUNT), labeled: true }; + return peak === 0 && loadingProviders.size > 0 + ? { max: TICK_COUNT, ticks: PLACEHOLDER_TICKS, labeled: false } + : { ...niceScale(peak, TICK_COUNT), labeled: true }; } // Leave room above the top gridline so the constant-width stroke is not @@ -216,7 +206,7 @@ function valueToY(value: number, max: number) { return max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); } -/** Per-provider paths in paint order: answered curves heaviest first, loading providers flat. */ +/** Per-provider paths in paint order, heaviest first. */ function buildChart( periods: readonly string[], byPeriod: ReadonlyMap, @@ -225,12 +215,9 @@ function buildChart( loadingProviders: ReadonlySet, ) { const columns = buildPeriodColumns(periods, byPeriod, metric); - const scale = chartScale(columns, providers, loadingProviders); + const scale = chartScale(columns, loadingProviders); const stepX = periods.length < 2 ? 0 : VIEW_WIDTH / (periods.length - 1); const paths = providers.map((provider) => { - if (loadingProviders.has(provider)) { - return { provider, loading: true, total: 0, line: FLAT_LINE, area: "" }; - } const slot = PROVIDER_ORDER.indexOf(provider); const line = curvePath( smoothCurve( @@ -242,7 +229,7 @@ function buildChart( ); return { provider, - loading: false, + loading: loadingProviders.has(provider), total: columns.reduce((sum, column) => sum + (column.bands[slot]?.value ?? 0), 0), line, area: areaPath(line), @@ -283,10 +270,9 @@ export function UsageProviderChart({ [byPeriod, loadingProviders, metric, periods, providers], ); const toY = (value: number) => valueToY(value, scale.max); - // Once anything has answered, lines still loading fade back behind it. - const fadeLoading = providers.some((provider) => !loadingProviders.has(provider)); + // The delay keeps a quick answer from flashing, as with the page's figures. const seriesClassName = (loading: boolean) => - cn("transition-opacity duration-300", fadeLoading && loading && "opacity-30"); + cn("transition-opacity", loading && "opacity-40 delay-150"); const format = metric === "tokens" ? formatTokens : formatUsd; @@ -348,11 +334,6 @@ export function UsageProviderChart({ const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : columns[hoverIndex]; - const hoveredTotal = - hoveredColumn?.bands.reduce( - (sum, band) => (loadingProviders.has(band.provider) ? sum : sum + band.value), - 0, - ) ?? 0; const partial = providers.some((provider) => loadingProviders.has(provider)); const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); @@ -470,29 +451,31 @@ export function UsageProviderChart({ /> {label} - {loadingProviders.has(provider) ? ( - … - ) : ( - - {format( - hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? - 0, - )} - - )} + + {format( + hoveredColumn?.bands.find((band) => band.provider === provider)?.value ?? 0, + )} +
); })}
Total - {scale.labeled ? ( - - {format(hoveredTotal)} - {partial ? + : null} - - ) : ( - … - )} + + {format(hoveredColumn?.total ?? 0)} +
)} diff --git a/packages/client-runtime/src/state/usageProgress.ts b/packages/client-runtime/src/state/usageProgress.ts index 49381ea0a30a..7e5f885c2874 100644 --- a/packages/client-runtime/src/state/usageProgress.ts +++ b/packages/client-runtime/src/state/usageProgress.ts @@ -95,7 +95,8 @@ export function usageProgress( * * - `partial`: totals will change because some environment is still answering. * - `providers`: providers whose own figures will change. An environment that - * has not answered yet could add to any provider, so it sets `everyProvider`. + * has not answered yet, or whose whole summary is stale, could change any + * provider, so it sets `everyProvider`. */ export function usageLoadingState( environments: readonly UsageProgressEnvironment[], @@ -105,11 +106,9 @@ export function usageLoadingState( let everyProvider = false; for (const environment of environments) { const progress = usageEnvironmentProgress(environment, refreshing); - if (progress.phase === "loading") everyProvider = true; + if (progress.phase === "loading" || progress.phase === "stale") everyProvider = true; if (progress.phase === "partway") for (const provider of progress.providers) providers.add(provider); - if (progress.phase === "stale") - for (const bucket of environment.summary?.buckets ?? []) providers.add(bucket.provider); } return { partial: everyProvider || providers.size > 0, everyProvider, providers }; } From 55c78610351235f7749b5fec5e33e1c4c9501b17 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:47:34 -0700 Subject: [PATCH 5/5] fix(web): a window that fails everywhere keeps the last usage on screen useUsage saved a snapshot whenever nothing was pending, so a window where every selected environment failed replaced the kept usage with an empty one and showed zero totals. It now saves only answered usage, and the page mutes kept usage whenever it belongs to another window. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/usage/UsagePage.refresh.test.tsx | 1 + apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 8 ++++---- apps/web/src/state/usage.test.tsx | 7 +++++++ apps/web/src/state/usage.ts | 16 +++++++++------- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index 61ad487ade70..bbe53e36936a 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -50,6 +50,7 @@ vi.mock("../../state/usage", () => ({ }, ], isPending: false, + shown: null, isPartial: false, refresh: async () => undefined, }), diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 53093e7ef23f..f69a60b5acb1 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -74,6 +74,7 @@ beforeEach(() => { environments, selectedEnvironments: environments, isPending: false, + shown: null, isPartial: false, refresh: vi.fn(), }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index c4d5c9ef6777..b8c9e9a5bdf6 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -146,7 +146,6 @@ export function UsagePage() { merged: answeredUsage, environments, selectedEnvironments, - isPending, shown, isPartial, refresh, @@ -156,13 +155,14 @@ export function UsagePage() { const shownWindow = shown?.window ?? window; const shownHourly = shownWindow.resolution === "hour"; const refreshingUsage = isRefreshing && !showingLimits; - // Kept usage is all old, so every figure waits on the new window. + // Usage kept from another window is all old, so every figure stays muted. + const showingKept = shown !== null && shown.window !== window; const loading = useMemo( () => - isPending + showingKept ? { partial: true, everyProvider: true, providers: new Set() } : usageLoadingState(selectedEnvironments, refreshingUsage), - [isPending, refreshingUsage, selectedEnvironments], + [showingKept, refreshingUsage, selectedEnvironments], ); const isProviderLoading = (provider: UsageProviderKind) => loading.everyProvider || loading.providers.has(provider); diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index f272a32ce080..a3cddd2394dc 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -178,7 +178,14 @@ describe("usage environment selection", () => { expect(latest.isPending).toBe(true); expect(latest.shown?.merged.costUsd).toBe(10); + // A window that fails everywhere keeps it too. + testState.environments = [{ ...environment("a", null), isPending: false, error: "Offline" }]; + await act(() => renderer?.update()); + expect(latest.isPending).toBe(false); + expect(latest.shown?.merged.costUsd).toBe(10); + // Another selection has nothing of its own to show. + testState.environments = [environment("a", null)]; await select("a"); expect(latest.shown).toBeNull(); }); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index b8181453a8ce..3b6ec73d1366 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -98,7 +98,8 @@ export interface UsageView { readonly isPending: boolean; /** * The usage to draw: this window's once a selected environment answers, - * until then the last window answered for the same selection, else null. + * until then the last window answered for the same selection. Null while + * nothing has answered and something still could. */ readonly shown: { readonly window: UsageSummaryInput; readonly merged: MergedUsage } | null; /** @@ -208,18 +209,19 @@ export function useUsage( (NonNullable & { readonly selection: typeof selectedEnvironmentIds }) | null >(null); if ( - !isPending && + answeredCount > 0 && (lastAnswered?.merged !== merged || lastAnswered.window !== input || lastAnswered.selection !== selectedEnvironmentIds) ) { setLastAnswered({ window: input, merged, selection: selectedEnvironmentIds }); } - const shown = !isPending - ? { window: input, merged } - : lastAnswered?.selection === selectedEnvironmentIds - ? lastAnswered - : null; + const kept = lastAnswered?.selection === selectedEnvironmentIds ? lastAnswered : null; + // With no answers, even failed ones keep the last answered usage on screen. + const shown = + answeredCount > 0 + ? { window: input, merged } + : (kept ?? (isPending ? null : { window: input, merged })); return { merged,