From c329b6d7d98526f944d78186d24a1d1941dec9a5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 11 Sep 2026 12:57:00 +1000 Subject: [PATCH] feat(usage): add thread and subagent breakdown The Usage page can drill project, provider and model totals down to the threads and subagents that produced them. The server maps provider sessions and worktrees to T3 threads with bounded rows, and the web and mobile clients filter by environment, project and provider ownership. Includes the stacked usage time-range (#9014) and project-breakdown (#9015) changes this feature builds on. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/usage/UsageRouteScreen.tsx | 17 +- apps/mobile/src/state/usage.ts | 20 +- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.ts | 11 +- apps/server/src/usage/UsageService.test.ts | 397 ++++++++++++- apps/server/src/usage/UsageService.ts | 455 ++++++++++++-- .../server/src/usage/usageAggregation.test.ts | 160 ++++- apps/server/src/usage/usageAggregation.ts | 195 +++++- apps/server/src/usage/usagePaths.test.ts | 27 + apps/server/src/usage/usagePaths.ts | 35 ++ apps/server/src/usage/usagePricing.ts | 31 + apps/server/src/usage/usageScanCache.test.ts | 22 +- apps/server/src/usage/usageScanCache.ts | 56 +- apps/server/src/usage/usageThreads.test.ts | 560 ++++++++++++++++++ apps/server/src/usage/usageThreads.ts | 546 +++++++++++++++++ .../src/usage/usageTranscriptReader.test.ts | 90 ++- .../server/src/usage/usageTranscriptReader.ts | 148 +++++ .../server/src/usage/usageTranscripts.test.ts | 29 +- apps/server/src/usage/usageTranscripts.ts | 14 + apps/server/src/ws.ts | 8 + apps/web/src/components/ui/input.tsx | 19 +- .../components/ui/segmented-control-styles.ts | 8 + apps/web/src/components/ui/toggle-group.tsx | 3 +- apps/web/src/components/ui/toggle.tsx | 10 +- .../src/components/usage/UsagePage.test.tsx | 278 ++++++++- apps/web/src/components/usage/UsagePage.tsx | 431 +++++++++++++- .../UsageProviderChart.interaction.test.tsx | 81 +++ .../usage/UsageProviderChart.test.ts | 71 ++- .../components/usage/UsageProviderChart.tsx | 216 ++++++- .../usage/UsageThreadTable.test.tsx | 156 +++++ .../src/components/usage/UsageThreadTable.tsx | 420 +++++++++++++ apps/web/src/state/usage.test.ts | 165 ++++++ apps/web/src/state/usage.test.tsx | 99 +++- apps/web/src/state/usage.ts | 256 +++++++- docs/user/usage.md | 24 + packages/client-runtime/src/state/server.ts | 8 + .../src/state/serverUsage.test.ts | 50 ++ .../client-runtime/src/state/usage.test.ts | 91 ++- packages/client-runtime/src/state/usage.ts | 24 +- packages/contracts/src/rpc.ts | 17 +- packages/contracts/src/usage.test.ts | 22 + packages/contracts/src/usage.ts | 132 ++++- packages/shared/src/usageFormat.test.ts | 75 +++ packages/shared/src/usageFormat.ts | 100 +++- packages/shared/src/usageMerge.test.ts | 272 ++++++++- packages/shared/src/usageMerge.ts | 212 ++++++- 46 files changed, 5788 insertions(+), 274 deletions(-) create mode 100644 apps/server/src/usage/usagePaths.test.ts create mode 100644 apps/server/src/usage/usagePaths.ts create mode 100644 apps/server/src/usage/usageThreads.test.ts create mode 100644 apps/server/src/usage/usageThreads.ts create mode 100644 apps/web/src/components/ui/segmented-control-styles.ts create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx create mode 100644 apps/web/src/components/usage/UsageThreadTable.test.tsx create mode 100644 apps/web/src/components/usage/UsageThreadTable.tsx create mode 100644 apps/web/src/state/usage.test.ts create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index ad54d6e323ee..a8057c9d1666 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -18,7 +18,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -129,10 +129,17 @@ export function UsageRouteScreen() { } refreshingRef.current = true; setRefreshingUsage(true); - void refresh(nextWindow).finally(() => { - refreshingRef.current = false; - setRefreshingUsage(false); - }); + void refresh(nextWindow) + .catch((error: unknown) => { + Alert.alert( + "Could not refresh usage", + error instanceof Error ? error.message : "Try again.", + ); + }) + .finally(() => { + refreshingRef.current = false; + setRefreshingUsage(false); + }); }; const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index d49c26a40a44..7b7530002ed0 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -1,3 +1,4 @@ +import { uuidv4 } from "../lib/uuid"; /** * Multi-environment usage state. * @@ -17,10 +18,16 @@ import { type UsageSummaryInput, } from "@t3tools/contracts"; import { refreshUsage } from "@t3tools/client-runtime/state/usage"; -import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import { + mergeUsage, + retainUsageStatuses, + type SettledUsageStatuses, + type EnvironmentUsage, + type MergedUsage, +} from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; @@ -102,7 +109,13 @@ export function useUsage( ], ); const atom = usageByWindowAtom(windowKey); - const environments = useAtomValue(atom); + const currentEnvironments = useAtomValue(atom); + const settledStatuses = useRef | null>(null); + const retained = retainUsageStatuses(windowKey, currentEnvironments, settledStatuses.current); + useEffect(() => { + settledStatuses.current = retained.settled; + }, [retained.settled]); + const environments = retained.visible; const selectedEnvironments = useMemo( () => selectedEnvironmentIds === null @@ -115,6 +128,7 @@ export function useUsage( (nextInput?: UsageSummaryInput) => refreshUsage({ registry: appAtomRegistry, + refreshToken: uuidv4(), server: serverEnvironment, presentations: environmentPresentations, environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index d7d1be455fa1..07fc35727784 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -58,6 +58,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetUsageThreadBreakdown]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e3b42a2637a1..8141e5bf45c2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -28,6 +28,8 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import { ProjectionProjectRepositoryLive } from "./persistence/Layers/ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "./persistence/Layers/ProjectionThreads.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -202,7 +204,14 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), ); -const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); +const UsageLayerLive = UsageService.layer.pipe( + // Projects resolve each session's cwd to the project it ran in; threads and + // resume cursors attribute sessions to threads for the drill-down. + Layer.provide(ProjectionProjectRepositoryLive), + Layer.provide(ProjectionThreadRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), + Layer.provide(ServerSettingsLayerLive), +); const ResourceDiagnosticsLayerLive = Layer.mergeAll( HostResources.layer, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..eb04bee7db7e 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -8,27 +8,40 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; 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 Scheduler from "effect/Scheduler"; +import * as Tracer from "effect/Tracer"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; +import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + cwd?: string, +): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", requestId: `req_${id}`, sessionId: "session-1", + ...(cwd === undefined ? {} : { cwd }), message: { id: `msg_${id}`, model, @@ -43,6 +56,12 @@ const WINDOW: UsageSummaryInput = { untilDay: UsageDay.make("2026-08-02"), }; +const NARROW_WINDOW: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-01"), +}; + const setup = Effect.gen(function* () { const home = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), @@ -71,6 +90,8 @@ const serviceLayers = (input: { readonly onRatesFetch?: () => void; /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; + readonly projectRepository?: ProjectionProjectRepository["Service"]; + readonly runtimeRepository?: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"]; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -91,6 +112,21 @@ const serviceLayers = (input: { Layer.provideMerge( Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), ), + Layer.provideMerge( + Layer.mergeAll( + input.projectRepository === undefined + ? ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)) + : Layer.succeed(ProjectionProjectRepository, input.projectRepository), + ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + input.runtimeRepository === undefined + ? ProviderSessionRuntime.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)) + : Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + input.runtimeRepository, + ), + SqlitePersistenceMemory, + ), + ), ); function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { @@ -98,6 +134,36 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("does not hide a project repository defect as unknown attribution", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const defect = new Error("project repository defect"); + const repositoryDefect = Effect.die(defect); + const defectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => repositoryDefect, + getById: () => repositoryDefect, + listAll: () => repositoryDefect, + deleteById: () => repositoryDefect, + }; + const exit = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* Effect.exit(service.readSummary(WINDOW)); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-project-defect-test", + home, + settings, + projectRepository: defectRepository, + }), + ), + ); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) assert.strictEqual(Cause.squash(exit.cause), defect); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -150,15 +216,95 @@ describe("UsageService", () => { Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), ); - const first = yield* service.readSummary(WINDOW); + const first = yield* service.readSummary(NARROW_WINDOW); assert.strictEqual(totalOutputTokens(first), 5); yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + // Expanding beyond the cached coverage requires a source update. The + // grown transcript resumes at its cached byte position. const second = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(second), 12); }).pipe(Effect.scoped), ); + it.live("replaces a cached progressive snapshot when a transcript grows", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-progressive-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(1, 12))); + const second = yield* service.readSummary({ ...WINDOW, refreshToken: "progressive-final" }); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("keeps project attribution unknown when the project repository cannot be read", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile(transcript, claudeLine(1, 5, "claude-fable-5", "/work/app")), + ); + const repositoryFailure = Effect.fail( + new PersistenceSqlError({ operation: "ProjectionProjectRepository.listAll:test" }), + ); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => repositoryFailure, + getById: () => repositoryFailure, + listAll: () => repositoryFailure, + deleteById: () => repositoryFailure, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-project-failure-test", + home, + settings, + projectRepository, + }), + ), + ); + + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.projectAttribution, "unknown"); + }).pipe(Effect.scoped), + ); + + it.live("returns a usage read error when provider runtime state cannot be read", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const repositoryFailure = Effect.die(new Error("runtime repository unavailable")); + const runtimeRepository: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"] = + { + upsert: () => repositoryFailure, + recordImportedTranscript: () => repositoryFailure, + getByThreadId: () => repositoryFailure, + list: () => repositoryFailure, + deleteByThreadId: () => repositoryFailure, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-runtime-failure-test", + home, + settings, + runtimeRepository, + }), + ), + ); + + const error = yield* service.readThreadBreakdown(WINDOW).pipe(Effect.flip); + assert.strictEqual(error.reason, "scanFailed"); + assert.strictEqual(error.detail, "Provider runtime state could not be read"); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -166,31 +312,14 @@ describe("UsageService", () => { yield* Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; - const fileSystem = yield* FileSystem.FileSystem; const firstScanStarted = yield* Deferred.make(); - const secondScanStarted = yield* Deferred.make(); const releaseRates = yield* Deferred.make(); - let homeProbes = 0; const service = yield* UsageService.make.pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fileSystem, - exists: (path) => - fileSystem.exists(path).pipe( - Effect.tap(() => { - if (path !== NodePath.join(home, "claude", ".claude", "projects")) - return Effect.void; - homeProbes += 1; - return Deferred.succeed( - homeProbes === 1 ? firstScanStarted : secondScanStarted, - undefined, - ); - }), - ), - }), Effect.provideService( HttpClient.HttpClient, HttpClient.make((request) => - Deferred.await(releaseRates).pipe( + Deferred.succeed(firstScanStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRates)), Effect.as(HttpClientResponse.fromWeb(request, Response.json({}))), ), ), @@ -204,7 +333,19 @@ describe("UsageService", () => { "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, }, }); - const second = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + const secondScanStarted = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + if (span.name === "UsageService.scanSummary") { + Deferred.doneUnsafe(secondScanStarted, Effect.void); + } + return span; + }, + }); + const second = yield* service + .readSummary(WINDOW) + .pipe(Effect.withTracer(tracer), Effect.forkChild); yield* Deferred.await(secondScanStarted); yield* Deferred.succeed(releaseRates, undefined); @@ -244,8 +385,141 @@ describe("UsageService", () => { assert.deepStrictEqual(first, second); assert.strictEqual(ratesFetches, 1); - // A later request is fresh work again, not a stale cached answer. + // A later request within the freshness window reuses the source snapshot. yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 1); + }).pipe(Effect.scoped), + ); + + it.live("coalesces concurrent caller refresh tokens for the same summary", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const scanStarted = yield* Deferred.make(); + const releaseScan = yield* Deferred.make(); + let projectReads = 0; + const unused = Effect.die(new Error("unused project repository operation")); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => unused, + getById: () => unused, + listAll: () => + Effect.sync(() => { + projectReads += 1; + }).pipe( + Effect.andThen(Deferred.succeed(scanStarted, undefined)), + Effect.andThen(Deferred.await(releaseScan)), + Effect.as([]), + ), + deleteById: () => unused, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-refresh-coalescing-test", + home, + settings, + projectRepository, + }), + ), + ); + + const reads = yield* Effect.forEach( + Array.from({ length: 16 }, (_, index) => `caller-${index}`), + (refreshToken) => service.readSummary({ ...WINDOW, refreshToken }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(scanStarted); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseScan, undefined); + const summaries = yield* Fiber.join(reads); + + assert.strictEqual(projectReads, 1); + assert.strictEqual(new Set(summaries.map(({ readAt }) => readAt)).size, 1); + }).pipe(Effect.scoped), + ); + + it.live("reuses a recent scan when only the date range changes", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-window-cache-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + yield* service.readSummary(WINDOW); + const narrower = yield* service.readSummary(NARROW_WINDOW); + + assert.strictEqual(totalOutputTokens(narrower), 5); + assert.strictEqual(ratesFetches, 1); + }).pipe(Effect.scoped), + ); + + it.live("folds thread rows from the same source snapshot as the summary", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const summary = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const breakdown = yield* service.readThreadBreakdown(WINDOW); + + assert.strictEqual(totalOutputTokens(summary), 5); + assert.strictEqual( + breakdown.rows.reduce((total, row) => total + row.totals.outputTokens, 0), + 5, + ); + assert.strictEqual(breakdown.readAt, summary.readAt); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-thread-source-cache-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("updates fresh source data for a new manual refresh token", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-manual-refresh-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const refreshedInput = { ...WINDOW, refreshToken: "manual-refresh-1" }; + const refreshed = yield* service.readSummary(refreshedInput); + + assert.strictEqual(totalOutputTokens(refreshed), 12); + assert.strictEqual(ratesFetches, 2); + + yield* service.readSummary(refreshedInput); assert.strictEqual(ratesFetches, 2); }).pipe(Effect.scoped), ); @@ -373,4 +647,79 @@ describe("UsageService", () => { ); }).pipe(Effect.scoped), ); + + it.live("rejects exact thread windows longer than 24 hours", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-thread-window-test", home, settings }), + ), + ); + const reason = yield* service + .readThreadBreakdown({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-02"), + sinceTime: "2026-08-01T00:00:00.000Z", + untilTime: "2026-08-02T01:00:00.000Z", + }) + .pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + ); + + assert.strictEqual(reason, "invalidWindow"); + }).pipe(Effect.scoped), + ); }); + +describe("isValidUsageDay", () => { + it("rejects impossible start and end dates instead of normalising them", () => { + assert.isTrue(UsageService.isValidUsageDay("2026-02-28")); + assert.isFalse(UsageService.isValidUsageDay("2026-02-29")); + assert.isFalse(UsageService.isValidUsageDay("2026-13-01")); + }); +}); + +describe("shortSessionLabel", () => { + it("never exposes a file-derived path", () => { + assert.strictEqual( + UsageService.shortSessionLabel("claude:file:session-dir:updates"), + "Untitled session", + ); + }); +}); + +it.live("shares project reads within a thread request and reloads them for the next request", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + let projectReads = 0; + const unused = Effect.die(new Error("unused project operation")); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => unused, + getById: () => unused, + listAll: () => + Effect.sync(() => { + projectReads += 1; + return []; + }), + deleteById: () => unused, + }; + const dependencies = yield* Layer.build( + serviceLayers({ + prefix: "usage-one-project-snapshot", + home, + settings, + projectRepository, + }), + ); + const service = yield* UsageService.make.pipe(Effect.provide(dependencies)); + yield* service.readThreadBreakdown(WINDOW); + assert.equal(projectReads, 1); + yield* service.readThreadBreakdown(WINDOW); + assert.equal(projectReads, 2); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..a651450fe839 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -22,6 +22,8 @@ import { type UsagePricing, type UsageSummary, type UsageSummaryInput, + type UsageThreadBreakdown, + type UsageThreadBreakdownInput, UsageReadError, } from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; @@ -41,16 +43,22 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; +import { ProjectionThreadRepository } from "../persistence/Services/ProjectionThreads.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; +import { dedicatedUsageWorktreePath } from "./usagePaths.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, readDirectoryVolumeId, readTranscriptRecords, + readTranscriptTitle, } from "./usageTranscriptReader.ts"; +import { foldThreadRows, ThreadUsageAccumulator, type ThreadRef } from "./usageThreads.ts"; import { decodeScanCache, dedupeWithinFile, @@ -76,9 +84,18 @@ const RATES_REFRESH_FLOOR_MS = 60 * 1000; const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Match the client query TTL so changing a date range does not rescan fresh sources. */ +const SOURCE_SCAN_TTL_MS = 60 * 1000; + /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +/** + * Maximum rows sent per breakdown request, including grouped remainders. A + * window can hold thousands of sessions, so lower-cost rows fold together. + */ +const THREAD_ROW_CAP = 40; + /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -95,11 +112,20 @@ const encodeRatesCache = Schema.encodeEffect( const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +const encodeSourceKey = Schema.encodeSync(ScanCacheJson); + +export function isValidUsageDay(day: string): boolean { + const parsed = DateTime.make(`${day}T00:00:00Z`); + return Option.isSome(parsed) && DateTime.formatIso(parsed.value).slice(0, 10) === day; +} export class UsageService extends Context.Service< UsageService, { readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + readonly readThreadBreakdown: ( + input: UsageThreadBreakdownInput, + ) => Effect.Effect; /** Refetches the rate table ahead of its TTL. See `ensureRates`. */ readonly refreshRates: Effect.Effect; } @@ -128,6 +154,16 @@ export const layerTest = Layer.succeed( pricing: EMPTY_PRICING, scanDurationMs: 0, }), + readThreadBreakdown: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rows: [], + truncatedRows: 0, + scanDurationMs: 0, + }), refreshRates: Effect.succeed(EMPTY_PRICING), }), ); @@ -139,6 +175,9 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; + const projectRepository = yield* ProjectionProjectRepository; + const threadRepository = yield* ProjectionThreadRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -271,6 +310,47 @@ export const make = Effect.gen(function* () { ]; }); + const loadProjectThreads = Effect.gen(function* () { + const projects = yield* projectRepository + .listAll() + .pipe(Effect.catch(() => Effect.succeed(null))); + if (projects === null) return null; + return yield* Effect.forEach( + projects, + Effect.fnUntraced(function* (project) { + const threads = yield* threadRepository + .listByProjectId({ projectId: project.projectId }) + .pipe(Effect.catchCause(() => Effect.succeed([]))); + return { project, threads }; + }), + { concurrency: 8 }, + ); + }); + + /** Project names and worktree ownership are re-read for each request. */ + const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* ( + snapshot: typeof loadProjectThreads = loadProjectThreads, + ) { + const projects = yield* snapshot; + if (projects === null) return undefined; + return makeProjectResolver( + projects.flatMap(({ project, threads }) => { + const root = { + projectId: project.projectId, + workspaceRoot: project.workspaceRoot, + title: project.title, + deleted: project.deletedAt !== null, + }; + return [ + root, + ...threads.flatMap((thread) => + thread.worktreePath === null ? [] : [{ ...root, workspaceRoot: thread.worktreePath }], + ), + ]; + }), + ); + }); + /** * Loads the persisted scan cache exactly once per process. * @@ -329,7 +409,7 @@ export const make = Effect.gen(function* () { ) { return cached.tailRecords.length === 0 ? cached.records - : [...cached.records, ...cached.tailRecords]; + : dedupeWithinFile([...cached.records, ...cached.tailRecords]); } // Only a strictly grown file may resume. Same size with a new mtime, or @@ -347,13 +427,11 @@ export const make = Effect.gen(function* () { if (parsed === null) return []; // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. One - // seen set spans the cached base, the new lines, and the tail so a - // resumed parse dedupes exactly like a full one. + // duplicates. The final snapshot wins so a resumed Claude parse can + // replace an earlier progressive snapshot from the cached base. const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + const records = dedupeWithinFile([...base, ...parsed.records]); + const tailRecords = dedupeWithinFile(parsed.tailRecords); fileCache.set(filePath, { size, @@ -364,7 +442,7 @@ export const make = Effect.gen(function* () { position: parsed.position, }); cacheDirty = true; - return tailRecords.length === 0 ? records : [...records, ...tailRecords]; + return tailRecords.length === 0 ? records : dedupeWithinFile([...records, ...tailRecords]); }); /** One provider directory's walk and parse, before rates are involved. */ @@ -378,6 +456,19 @@ export const make = Effect.gen(function* () { | null; } + interface SourceSnapshot { + readonly completedAtMs: number; + readonly scanRevision: number; + readonly windowStartMs: number; + readonly sourceKey: string; + readonly dirs: readonly ScannedDir[]; + } + + let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; + let lastRefreshToken: string | null = null; + const sourceScanSemaphore = yield* Semaphore.make(1); + const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, @@ -410,6 +501,71 @@ export const make = Effect.gen(function* () { return scanned; }); + const getSourceSnapshot = Effect.fn("UsageService.getSourceSnapshot")(function* ( + windowStartMs: number, + refreshToken: string | undefined, + settings: ServerSettingsValue, + ) { + return yield* sourceScanSemaphore.withPermits(1)( + Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const currentSnapshot = sourceSnapshot; + const snapshotAgeMs = + currentSnapshot === null + ? Number.POSITIVE_INFINITY + : startedAtMs - currentSnapshot.completedAtMs; + const snapshotCoversWindow = + currentSnapshot !== null && currentSnapshot.windowStartMs <= windowStartMs; + const sourceKey = encodeSourceKey([ + settings.providers.claudeAgent, + settings.providers.codex, + ]); + const snapshotCoversSources = currentSnapshot?.sourceKey === sourceKey; + const manualRefresh = refreshToken !== undefined && refreshToken !== lastRefreshToken; + + if ( + !manualRefresh && + currentSnapshot !== null && + snapshotCoversWindow && + snapshotCoversSources && + snapshotAgeMs < SOURCE_SCAN_TTL_MS + ) { + return currentSnapshot; + } + + // Preserve the widest coverage already loaded. A stale narrow request + // should update changed files, not discard older records and force the + // next wider range to read them again. + const scanWindowStartMs = Math.min( + windowStartMs, + currentSnapshot?.windowStartMs ?? windowStartMs, + ); + + // Pricing only matters once records are aggregated, so the rate table + // 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. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; + const [, dirs] = yield* Effect.all( + [ensureRates(false), collectDirs(scanWindowStartMs, settings)], + { concurrency: 2 }, + ); + const now = yield* Clock.currentTimeMillis; + const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); + const nextSnapshot = { + completedAtMs, + scanRevision, + windowStartMs: scanWindowStartMs, + sourceKey, + dirs, + } satisfies SourceSnapshot; + sourceSnapshot = nextSnapshot; + if (refreshToken !== undefined) lastRefreshToken = refreshToken; + return nextSnapshot; + }), + ); + }); + const scanSummary = Effect.fn("UsageService.scanSummary")(function* ( input: UsageSummaryInput, settings: ServerSettingsValue, @@ -458,15 +614,11 @@ export const make = Effect.gen(function* () { } const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); + const scannedDirs = currentSnapshot.dirs; + const sourceReadAtMs = currentSnapshot.completedAtMs; - // Pricing only matters once records are aggregated, so the rate table - // 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)], - { concurrency: 2 }, - ); - + const resolveProject = yield* resolveProjects(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -474,6 +626,7 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, + ...(resolveProject === undefined ? {} : { resolveProject }), priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), }); @@ -498,10 +651,6 @@ export const make = Effect.gen(function* () { walkedRoots.push(dir); let scannedFiles = 0; let skippedFiles = 0; - // Distinct per directory. Buckets carry per-cell session counts, but a - // session spans days and models, so clients total this figure instead. - const sessionIds = new Set(); - for (const file of files) { livePaths.add(file.path); if (file.records.length === 0) { @@ -510,11 +659,7 @@ export const make = Effect.gen(function* () { } scannedFiles += 1; for (const record of file.records) { - // Only sessions that contributed in-window count: the mtime slack - // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { - sessionIds.add(record.sessionId); - } + aggregator.add(record); } } @@ -524,27 +669,31 @@ export const make = Effect.gen(function* () { scannedFiles, skippedFiles, malformedRecords: 0, - distinctSessions: sessionIds.size, + distinctSessions: aggregator.distinctSessions(provider), message: null, }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); - const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; return { contractVersion: USAGE_CONTRACT_VERSION, - readAt: DateTime.formatIso(readAt), + readAt: DateTime.formatIso(DateTime.makeUnsafe(sourceReadAtMs)), timeZone: input.timeZone, sinceDay: input.sinceDay, untilDay: input.untilDay, @@ -573,6 +722,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); @@ -606,7 +756,236 @@ export const make = Effect.gen(function* () { return yield* Deferred.await(deferred); }); - return { readSummary, refreshRates } as const; + /** + * Maps each thread's current provider session to the thread, from resume + * cursors. Historic sessions of the same thread attribute through the + * worktree map instead; sessions that never ran through T3 Code stay + * session-granular. + */ + const loadThreadAttribution = Effect.fn("UsageService.loadThreadAttribution")(function* ( + snapshot: typeof loadProjectThreads = loadProjectThreads, + ) { + const sessionToThread = new Map(); + const worktreeToThread = new Map(); + const titles = new Map(); + + const projects = yield* snapshot; + const worktreeClaims = new Map(); + for (const { project, threads } of projects ?? []) { + for (const thread of threads) { + const title = thread.title.trim(); + if (title.length > 0) titles.set(thread.threadId, title); + const worktree = dedicatedUsageWorktreePath(project.workspaceRoot, thread.worktreePath); + // The project root is not a dedicated worktree: interactive sessions + // run there too, and several threads usually share it. + if (worktree === null) continue; + const ref: ThreadRef = { threadId: thread.threadId, title: title || thread.threadId }; + const claim = worktreeClaims.get(worktree); + if (claim === undefined) worktreeClaims.set(worktree, { ref, shared: false }); + else claim.shared = true; + } + } + for (const [worktree, claim] of worktreeClaims) { + if (!claim.shared) worktreeToThread.set(worktree, claim.ref); + } + + const runtimes = yield* runtimeRepository.list().pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Provider runtime state could not be read", + cause: Cause.squash(cause), + }), + ), + ); + for (const runtime of runtimes) { + const cursor = runtime.resumeCursor; + if (typeof cursor !== "object" || cursor === null) continue; + const cursorRecord = cursor as Record; + // Claude cursors carry the transcript uuid as `resume`; Codex cursors + // carry the rollout uuid as `threadId`. Other providers do not surface + // usage transcripts, so their cursors are irrelevant here. + const sessionId = + runtime.providerName === "claudeAgent" + ? cursorRecord["resume"] + : runtime.providerName === "codex" + ? cursorRecord["threadId"] + : undefined; + if (typeof sessionId !== "string" || sessionId.length === 0) continue; + const provider = runtime.providerName === "claudeAgent" ? "claude" : "codex"; + sessionToThread.set(`${provider}:${sessionId}`, { + threadId: runtime.threadId, + title: titles.get(runtime.threadId) ?? runtime.threadId, + }); + } + + return { sessionToThread, worktreeToThread }; + }); + + const readThreadBreakdown = Effect.fn("UsageService.readThreadBreakdown")(function* ( + input: UsageThreadBreakdownInput, + ) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + const windowEnd = DateTime.make(`${input.untilDay}T00:00:00Z`); + if ( + Option.isNone(windowStart) || + Option.isNone(windowEnd) || + !isValidUsageDay(input.sinceDay) || + !isValidUsageDay(input.untilDay) + ) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage requires valid sinceDay and untilDay dates", + }); + } + + let exactWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; + if (input.sinceTime !== undefined || input.untilTime !== undefined) { + const sinceTime = + input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); + const untilTime = + input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime); + if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage requires both valid sinceTime and untilTime instants", + }); + } + const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); + const untilTimeMs = DateTime.toEpochMillis(untilTime.value); + const durationMs = untilTimeMs - sinceTimeMs; + if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage exact window must be greater than zero and at most 24 hours", + }); + } + exactWindow = { sinceTimeMs, untilTimeMs }; + } + + const startedAtMs = yield* Clock.currentTimeMillis; + const settings = yield* readSettings; + yield* ensureScanCacheLoaded; + + const windowStartMs = + (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Thread rows and the summary must fold the same transcript snapshot. In + // particular, a file that grows during the source-cache TTL belongs to the + // next refresh on both RPCs instead of appearing in the drill-down alone. + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); + + const projectSnapshot = yield* Effect.cached(loadProjectThreads); + const resolveProject = yield* resolveProjects(projectSnapshot); + const accumulator = new ThreadUsageAccumulator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + ...exactWindow, + rates, + priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + ...(resolveProject === undefined ? {} : { resolveProject }), + }); + + // Preferred transcript per session for title extraction: the main file, + // never a subagent's. + const titleFiles = new Map< + string, + { readonly path: string; readonly provider: UsageProviderKind } + >(); + const livePaths = new Set(); + const walkedRoots: string[] = []; + + for (const { provider, dir, files } of currentSnapshot.dirs) { + if (input.providers !== undefined && !input.providers.includes(provider)) continue; + if (files === null) continue; + walkedRoots.push(dir); + for (const file of files) { + livePaths.add(file.path); + if (file.records.length === 0) continue; + const isSubagent = + provider === "claude" && path.basename(path.dirname(file.path)) === "subagents"; + const agentId = isSubagent ? path.basename(file.path, ".jsonl") : null; + for (const record of file.records) { + const sessionKey = + record.sessionId.length > 0 + ? `${provider}:${record.sessionId}` + : `${provider}:file:${path.basename(path.dirname(file.path))}:${path.basename(file.path, ".jsonl")}`; + accumulator.add(record, { sessionKey, agentId }); + if (!isSubagent && !titleFiles.has(sessionKey)) { + titleFiles.set(sessionKey, { path: file.path, provider }); + } + } + } + } + + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + // A thread-only client must warm and bound the same durable cache as the + // summary RPC, otherwise restarts repeat parsing and stale entries grow. + yield* persistScanCache(); + } + + const attribution = yield* loadThreadAttribution(projectSnapshot); + const folded = foldThreadRows(accumulator.finish(), attribution, { + cap: THREAD_ROW_CAP, + ...(input.projectKey === undefined ? {} : { projectFilter: input.projectKey }), + }); + + // Transcript titles only for retained unattributed rows. Grouped remainder + // rows already carry a generated title. + const rows = yield* Effect.forEach( + folded.rows, + Effect.fnUntraced(function* ({ titleSessionKey, ...row }) { + if (row.title !== null) return { ...row, title: row.title }; + const source = titleFiles.get(titleSessionKey); + const transcriptTitle = + source === undefined + ? null + : yield* Effect.promise(() => readTranscriptTitle(source.path, source.provider)); + const fallback = row.key.startsWith("remainder:") + ? row.key + : shortSessionLabel(titleSessionKey); + return { ...row, title: transcriptTitle ?? fallback }; + }), + { concurrency: 8 }, + ); + + const finishedAtMs = yield* Clock.currentTimeMillis; + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(DateTime.makeUnsafe(currentSnapshot.completedAtMs)), + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rows, + truncatedRows: folded.truncatedRows, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageThreadBreakdown; + }); + + return { readSummary, readThreadBreakdown, refreshRates } as const; }); +/** `claude:8f14e45f-...` reads as `Session 8f14e45f`. */ +export function shortSessionLabel(sessionKey: string): string { + if (sessionKey.includes(":file:")) return "Untitled session"; + const sessionId = sessionKey.slice(sessionKey.lastIndexOf(":") + 1); + return sessionId.length > 8 ? `Session ${sessionId.slice(0, 8)}` : `Session ${sessionId}`; +} + export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 8da4e920ac06..369f07e3baf3 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,8 @@ +import { vi } from "vite-plus/test"; +import { ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -23,6 +25,7 @@ function record(overrides: Partial = {}): UsageRecord { timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), model: "claude-fable-5", sessionId: "session-a", + cwd: "", totals: { uncachedInputTokens: 100, cachedInputTokens: 1000, @@ -74,17 +77,25 @@ describe("UsageAggregator", () => { ).toThrow("requires exact time bounds"); }); - it("keeps only the first record for a repeated dedupe key", () => { + it("uses the final complete snapshot for a repeated dedupe key", () => { const result = aggregate([ - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:", totals: { ...record().totals, outputTokens: 1 } }), + record({ dedupeKey: "msg_1:", totals: { ...record().totals, outputTokens: 310 } }), ]); - expect(result.duplicatesDropped).toBe(2); + expect(result.duplicatesDropped).toBe(1); expect(result.buckets).toHaveLength(1); expect(result.buckets[0]?.records).toBe(1); - expect(result.buckets[0]?.totals.outputTokens).toBe(50); + expect(result.buckets[0]?.totals.outputTokens).toBe(310); + }); + + it("applies the window to the final complete snapshot", () => { + const result = aggregate([ + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-09-01T00:00:00Z") }), + ]); + + expect(result).toMatchObject({ buckets: [], duplicatesDropped: 1, outOfWindow: 1 }); }); it("still sums records that carry no dedupe key", () => { @@ -94,6 +105,38 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(100); }); + it("distinguishes project, outside, and unknown attribution", () => { + const projectId = ProjectId.make("project-app"); + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd === "/work/app" ? { projectId, title: "App" } : null), + }); + aggregator.add(record({ cwd: "/work/app" })); + aggregator.add(record({ cwd: "/work/app" })); + aggregator.add(record({ cwd: "/elsewhere" })); + aggregator.add(record({ cwd: "", model: "grok-4" })); + const { buckets } = aggregator.finish(); + + expect(buckets).toHaveLength(3); + const outside = buckets.find((bucket) => bucket.projectAttribution === "outside"); + expect(outside?.project).toBeUndefined(); + expect(outside?.records).toBe(1); + const project = buckets.find((bucket) => bucket.projectAttribution === "project"); + expect(project?.project).toBe("App"); + expect(project?.projectId).toBe(projectId); + expect(project?.records).toBe(2); + expect(buckets.some((bucket) => bucket.projectAttribution === "unknown")).toBe(true); + }); + + it("marks every bucket unknown when no project resolver is available", () => { + const result = aggregate([record({ cwd: "/work/app" })]); + + expect(result.buckets[0]?.projectAttribution).toBe("unknown"); + }); + it("buckets by the day in the requested time zone", () => { const utc = aggregate([record()], "UTC"); const losAngeles = aggregate([record()], "America/Los_Angeles"); @@ -179,7 +222,7 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(0); }); - it("reports whether a record contributed", () => { + it("reports whether a record falls in the window", () => { const aggregator = new UsageAggregator({ timeZone: "UTC", sinceDay: "2026-08-01", @@ -188,7 +231,7 @@ describe("UsageAggregator", () => { }); expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); @@ -202,3 +245,102 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("makeProjectResolver", () => { + const appId = ProjectId.make("project-app"); + const vendoredId = ProjectId.make("project-vendored"); + const legacyDeletedId = ProjectId.make("project-legacy-deleted"); + const legacyId = ProjectId.make("project-legacy"); + const untitledId = ProjectId.make("project-untitled"); + const resolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "/work/app", title: "App", deleted: false }, + { + projectId: vendoredId, + workspaceRoot: "/work/app/vendored", + title: "Vendored", + deleted: false, + }, + { + projectId: legacyDeletedId, + workspaceRoot: "/work/legacy", + title: "Legacy Was Deleted", + deleted: true, + }, + { + projectId: legacyId, + workspaceRoot: "/work/legacy", + title: "Legacy", + deleted: false, + }, + { + projectId: untitledId, + workspaceRoot: "/work/untitled", + title: " ", + deleted: false, + }, + ]); + + it("matches the root itself and any path under it", () => { + expect(resolver("/work/app")).toEqual({ projectId: appId, title: "App" }); + expect(resolver("/work/app/src/deep")).toEqual({ projectId: appId, title: "App" }); + }); + + it("requires a path-segment boundary, not a bare prefix", () => { + expect(resolver("/work/app-sibling")).toBeNull(); + }); + + it("prefers the deepest matching root", () => { + expect(resolver("/work/app/vendored/lib")).toEqual({ + projectId: vendoredId, + title: "Vendored", + }); + }); + + it("prefers a live project over a deleted one sharing the root", () => { + expect(resolver("/work/legacy/src")).toEqual({ projectId: legacyId, title: "Legacy" }); + }); + + it("never attributes to a blank title or an empty cwd", () => { + expect(resolver("/work/untitled/src")).toBeNull(); + expect(resolver("")).toBeNull(); + }); + + it("matches descendants when the project root is the filesystem root", () => { + const rootId = ProjectId.make("project-root"); + const rootResolver = makeProjectResolver([ + { projectId: rootId, workspaceRoot: "/", title: "Root", deleted: false }, + ]); + + expect(rootResolver("/work/app")).toEqual({ projectId: rootId, title: "Root" }); + }); + + it("matches mixed slash styles and normalized segments", () => { + expect(resolver("\\work\\app\\src")).toEqual({ projectId: appId, title: "App" }); + expect(resolver("/work/app/other/../src")).toEqual({ projectId: appId, title: "App" }); + + const windowsResolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "C:\\Work\\App", title: "App", deleted: false }, + ]); + expect(windowsResolver("c:/work/app/src")).toEqual({ projectId: appId, title: "App" }); + }); +}); + +it("formats a retained record once across provider counts and final folding", () => { + const format = vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts"); + try { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + aggregator.add(record()); + for (const provider of ["codex", "claude", "grok"] as const) + aggregator.distinctSessions(provider); + const result = aggregator.finish(); + expect(result.buckets[0]?.day).toBe("2026-08-07"); + expect(format).toHaveBeenCalledOnce(); + } finally { + format.mockRestore(); + } +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 2ad3893ad4e6..645557794e19 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -1,7 +1,7 @@ // @effect-diagnostics globalDate:off /** - * Folds parsed transcript records into `(day, hourStart?, provider, model)` - * buckets. + * Folds parsed transcript records into `(day, hourStart?, project, provider, + * model)` buckets. * * `Intl.DateTimeFormat` is the only reliable way to resolve a wall-clock day in * an arbitrary IANA zone, and it takes a `Date`. That is why the raw `Date` @@ -12,18 +12,25 @@ * * @module usageAggregation */ -import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts"; +import type { + ProjectId, + UsageBucket, + UsageDay, + UsageResolution, + UsageTokenTotals, +} from "@t3tools/contracts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { normalizeUsagePath } from "./usagePaths.ts"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** * Formats an instant as a `YYYY-MM-DD` day in `timeZone`. * - * `en-CA` yields ISO-ordered parts, which is why it is used here rather than - * assembling the day from `Date` getters (those are host-local only). + * Numeric parts preserve the requested time zone without depending on the + * locale's punctuation or date ordering. */ -function makeDayFormatter(timeZone: string): (timestampMs: number) => string { +export function makeDayFormatter(timeZone: string): (timestampMs: number) => string { let format: Intl.DateTimeFormat; try { format = new Intl.DateTimeFormat("en-CA", { @@ -41,11 +48,70 @@ function makeDayFormatter(timeZone: string): (timestampMs: number) => string { day: "2-digit", }); } - return (timestampMs) => format.format(new Date(timestampMs)); + return (timestampMs) => { + const parts = Object.fromEntries( + format.formatToParts(new Date(timestampMs)).map(({ type, value }) => [type, value]), + ); + return `${parts.year?.padStart(4, "0")}-${parts.month}-${parts.day}`; + }; } const HOUR_MS = 60 * 60 * 1000; +export interface ProjectRoot { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly title: string; + /** Soft-deleted projects still attribute: the spend happened while they existed. */ + readonly deleted: boolean; +} + +export interface ProjectAttribution { + readonly projectId: ProjectId; + readonly title: string; +} + +/** + * Builds the cwd → project resolver used by {@link AggregateOptions}. + * + * Deepest root wins, so a session in a project nested inside another + * attributes to the inner one. Live projects outrank deleted ones sharing a + * root, since deleting and re-creating a project leaves both rows. Results are + * memoised per cwd; a scan sees few distinct cwds but many records. + */ +export function makeProjectResolver( + projects: readonly ProjectRoot[], +): (cwd: string) => ProjectAttribution | null { + const roots = projects + .map((project) => ({ + projectId: project.projectId, + root: normalizeUsagePath(project.workspaceRoot), + title: project.title.trim(), + deleted: project.deleted, + })) + .filter((entry) => entry.root.length > 0 && entry.title.length > 0) + .sort((a, b) => b.root.length - a.root.length || Number(a.deleted) - Number(b.deleted)); + + const byCwd = new Map(); + return (cwd) => { + if (cwd.length === 0) return null; + if (byCwd.has(cwd)) return byCwd.get(cwd) ?? null; + const normalizedCwd = normalizeUsagePath(cwd); + let resolved: ProjectAttribution | null = null; + for (const { projectId, root, title } of roots) { + if ( + normalizedCwd === root || + (root === "/" ? normalizedCwd.startsWith("/") : normalizedCwd.startsWith(`${root}/`)) + ) { + resolved = { projectId, title }; + break; + } + } + byCwd.set(cwd, resolved); + return resolved; + }; +} + interface MutableBucket { totals: UsageTokenTotals; costUsd: number; @@ -65,13 +131,18 @@ export interface AggregateOptions { readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; + /** + * Maps a record's working directory to the project it ran in, or `null` when + * it ran outside every project. Omitting it leaves every bucket unattributed. + */ + readonly resolveProject?: (cwd: string) => ProjectAttribution | null; } export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ readonly duplicatesDropped: number; - /** Records whose day fell outside the requested window. */ + /** Retained records whose day fell outside the requested window. */ readonly outOfWindow: number; } @@ -83,13 +154,13 @@ export interface AggregateResult { * the same `dedupeKey` legitimately appears in several transcripts. */ export class UsageAggregator { - readonly #buckets = new Map(); - readonly #seen = new Set(); + readonly #recordsByKey = new Map(); + readonly #unkeyedRecords: UsageRecord[] = []; readonly #toDay: (timestampMs: number) => string; + readonly #recordDays = new WeakMap(); readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; #duplicatesDropped = 0; - #outOfWindow = 0; constructor(options: AggregateOptions) { this.#options = options; @@ -107,37 +178,64 @@ export class UsageAggregator { } } - /** - * Folds one record in. Returns whether it actually contributed, so callers - * can derive per-window facts (distinct sessions, for one) from the records - * that landed rather than everything the mtime prefilter happened to admit. - */ + /** Retains one record and reports whether it falls in the requested window. */ add(record: UsageRecord): boolean { - if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) { - this.#duplicatesDropped += 1; - return false; - } - this.#seen.add(record.dedupeKey); + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push(record); + return inWindow; + } + if (this.#recordsByKey.has(record.dedupeKey)) { + this.#recordsByKey.set(record.dedupeKey, record); + this.#duplicatesDropped += 1; + return inWindow; } + this.#recordsByKey.set(record.dedupeKey, record); + return inWindow; + } + #dayFor(record: UsageRecord): string { + const cached = this.#recordDays.get(record); + if (cached !== undefined) return cached; + const day = this.#toDay(record.timestampMs); + this.#recordDays.set(record, day); + return day; + } + + #isInWindow(record: UsageRecord): boolean { if ( this.#hourlyWindow !== null && (record.timestampMs < this.#hourlyWindow.sinceTimeMs || record.timestampMs >= this.#hourlyWindow.untilTimeMs) ) { - this.#outOfWindow += 1; return false; } - const day = this.#toDay(record.timestampMs); + const day = this.#dayFor(record); if ( this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) ) { - this.#outOfWindow += 1; return false; } + return true; + } + + /** Distinct in-window sessions retained after progressive snapshots settle. */ + distinctSessions(provider: UsageRecord["provider"]): number { + const sessionIds = new Set(); + const addSession = (record: UsageRecord): void => { + if (record.provider === provider && this.#isInWindow(record) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + }; + for (const record of this.#unkeyedRecords) addSession(record); + for (const record of this.#recordsByKey.values()) addSession(record); + return sessionIds.size; + } + + #foldRecord(record: UsageRecord, buckets: Map): void { + const day = this.#dayFor(record); const hourStart = this.#hourlyWindow === null @@ -146,8 +244,18 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; - let bucket = this.#buckets.get(key); + // The key is parsed back apart on NUL, which project fields must not carry. + const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectAttribution = + resolvedProject !== null + ? "project" + : this.#options.resolveProject === undefined || record.cwd.length === 0 + ? "unknown" + : "outside"; + const projectId = resolvedProject?.projectId.replaceAll("\u0000", "") ?? ""; + const project = resolvedProject?.title.replaceAll("\u0000", "") ?? ""; + const key = `${day}\u0000${hourStart}\u0000${projectAttribution}\u0000${projectId}\u0000${project}\u0000${record.provider}\u0000${record.model}`; + let bucket = buckets.get(key); if (bucket === undefined) { bucket = { totals: EMPTY_TOTALS, @@ -158,7 +266,7 @@ export class UsageAggregator { providerReportedRecords: 0, sessions: new Set(), }; - this.#buckets.set(key, bucket); + buckets.set(key, bucket); } const priced = priceUsage( @@ -181,16 +289,37 @@ export class UsageAggregator { if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); - return true; } finish(): AggregateResult { + const bucketsByKey = new Map(); + let outOfWindow = 0; + const foldIfInWindow = (record: UsageRecord): void => { + if (this.#isInWindow(record)) { + this.#foldRecord(record, bucketsByKey); + } else { + outOfWindow += 1; + } + }; + for (const record of this.#unkeyedRecords) foldIfInWindow(record); + for (const record of this.#recordsByKey.values()) foldIfInWindow(record); const buckets: UsageBucket[] = []; - for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + for (const [key, bucket] of bucketsByKey) { + const [ + day = "", + hourStart = "", + projectAttribution = "unknown", + projectId = "", + project = "", + provider = "", + model = "", + ] = key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), + ...(project === "" ? {} : { project }), + ...(projectId === "" ? {} : { projectId: projectId as ProjectId }), + projectAttribution: projectAttribution as UsageBucket["projectAttribution"], provider: provider as UsageBucket["provider"], model, totals: bucket.totals, @@ -207,6 +336,8 @@ export class UsageAggregator { (a, b) => a.day.localeCompare(b.day) || (a.hourStart ?? "").localeCompare(b.hourStart ?? "") || + (a.project ?? "").localeCompare(b.project ?? "") || + (a.projectId ?? "").localeCompare(b.projectId ?? "") || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model), ); @@ -214,7 +345,7 @@ export class UsageAggregator { return { buckets, duplicatesDropped: this.#duplicatesDropped, - outOfWindow: this.#outOfWindow, + outOfWindow, }; } } diff --git a/apps/server/src/usage/usagePaths.test.ts b/apps/server/src/usage/usagePaths.test.ts new file mode 100644 index 000000000000..e1d43c5192bf --- /dev/null +++ b/apps/server/src/usage/usagePaths.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { dedicatedUsageWorktreePath, normalizeUsagePath } from "./usagePaths.ts"; + +describe("usage path normalization", () => { + it("folds slash styles, trailing separators, and dot segments", () => { + expect(normalizeUsagePath("C:\\Work\\App\\other\\..\\src\\")).toBe("c:/work/app/src"); + }); + + it("does not treat another spelling of the project root as a dedicated worktree", () => { + expect(dedicatedUsageWorktreePath("C:\\Work\\App", "c:/work/app/")).toBeNull(); + }); + + it("returns one stable key for equivalent dedicated worktree paths", () => { + expect(dedicatedUsageWorktreePath("C:/work/app", "C:\\WORK\\APP\\.wt\\thread-1\\")).toBe( + "c:/work/app/.wt/thread-1", + ); + expect(dedicatedUsageWorktreePath("C:/work/app", "C:/work/app/other/../.wt/thread-1")).toBe( + "c:/work/app/.wt/thread-1", + ); + }); + + it("preserves case-sensitive POSIX comparisons", () => { + expect(normalizeUsagePath("/Work/App")).toBe("/Work/App"); + expect(normalizeUsagePath("/work/app")).toBe("/work/app"); + }); +}); diff --git a/apps/server/src/usage/usagePaths.ts b/apps/server/src/usage/usagePaths.ts new file mode 100644 index 000000000000..abf13b6c4cc7 --- /dev/null +++ b/apps/server/src/usage/usagePaths.ts @@ -0,0 +1,35 @@ +/** + * Normalizes persisted provider and worktree paths for usage attribution. + * + * Provider transcripts can retain paths written on another platform or with a + * different slash style, so attribution cannot rely on the host separator. + */ +export function normalizeUsagePath(value: string): string { + const isWindowsPath = /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); + const slashPath = value.replaceAll("\\", "/"); + const rooted = slashPath.startsWith("/"); + const segments: string[] = []; + for (const segment of slashPath.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (segments.length > 0 && segments.at(-1) !== "..") segments.pop(); + else if (!rooted) segments.push(segment); + continue; + } + segments.push(segment); + } + const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; + const result = normalized === "" ? (rooted ? "/" : ".") : normalized; + return isWindowsPath ? result.toLowerCase() : result; +} + +/** Returns a normalized dedicated worktree, excluding the shared project root. */ +export function dedicatedUsageWorktreePath( + projectRoot: string, + worktree: string | null, +): string | null { + const candidate = worktree?.trim() ?? ""; + if (candidate.length === 0) return null; + const normalized = normalizeUsagePath(candidate); + return normalized === normalizeUsagePath(projectRoot) ? null : normalized; +} diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 6c94be424827..dba95851967c 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -214,3 +214,34 @@ export function cacheSavingsUsd( if (rate === null) return 0; return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); } + +export interface UsageComponentCosts { + readonly cacheWriteUsd: number; + readonly cacheReadUsd: number; + /** Fresh input plus output. */ + readonly freshUsd: number; +} + +const ZERO_COMPONENT_COSTS: UsageComponentCosts = { + cacheWriteUsd: 0, + cacheReadUsd: 0, + freshUsd: 0, +}; + +/** Splits model-priced usage into the three components shown in usage charts. */ +export function usageComponentCosts( + table: RateTable, + model: string, + totals: UsageTokenTotals, + overrides?: RateTable, +): UsageComponentCosts { + const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); + if (rate === null) return ZERO_COMPONENT_COSTS; + return { + cacheWriteUsd: totals.cacheCreationTokens * rate.cacheCreationCostPerToken, + cacheReadUsd: totals.cachedInputTokens * rate.cacheReadCostPerToken, + freshUsd: + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.outputTokens * rate.outputCostPerToken, + }; +} diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fdb0aabafa40..8a4857eb8e2f 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -16,6 +16,7 @@ function record(overrides: Partial = {}): UsageRecord { timestampMs: 1_786_000_000_000, model: "claude-fable-5", sessionId: "session-a", + cwd: "/home/theo/project", totals: { uncachedInputTokens: 2, cachedInputTokens: 1000, @@ -80,6 +81,7 @@ describe("scan cache round trip", () => { codexState: { model: "gpt-5.2-codex", sessionId: "session-c", + cwd: "/home/theo/codex-project", lastUsageSignature: '{"input_tokens":1}', sawSessionMeta: true, suppressingForkCopies: false, @@ -186,6 +188,22 @@ describe("scan cache round trip", () => { const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); expect(restored.has("/a.jsonl")).toBe(false); }); + + it.each([0.5, 99])("drops an entry with invalid cwd index %s", (cwdIndex) => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const row = encoded.files["/a.jsonl"]!.r[0]!; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [[...row.slice(0, 10), cwdIndex]], + }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); }); describe("pruneScanCache", () => { @@ -281,7 +299,7 @@ describe("pruneScanCache with an unwalked root", () => { }); describe("dedupeWithinFile", () => { - it("keeps the first record per dedupe key", () => { + it("keeps the final record per dedupe key", () => { const kept = dedupeWithinFile([ record({ totals: { ...record().totals, outputTokens: 1 } }), record({ totals: { ...record().totals, outputTokens: 999 } }), @@ -289,7 +307,7 @@ describe("dedupeWithinFile", () => { ]); expect(kept).toHaveLength(2); - expect(kept[0]?.totals.outputTokens).toBe(1); + expect(kept[0]?.totals.outputTokens).toBe(999); }); it("keeps every record that has no dedupe key", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 224f109147e4..851982e7e33e 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -26,7 +26,9 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. -const USAGE_SCAN_CACHE_VERSION = 3 as const; +// v4: records carry the session's cwd for project attribution; v3 entries +// would pin every cached file to "no project" forever. +const USAGE_SCAN_CACHE_VERSION = 4 as const; export interface CachedFile { readonly size: number; @@ -61,6 +63,7 @@ type SerializedRecord = readonly [ reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, + cwdIndex: number, ]; interface SerializedFile { @@ -82,15 +85,18 @@ interface SerializedCache { readonly version: number; readonly models: readonly string[]; readonly sessions: readonly string[]; + readonly cwds: readonly string[]; readonly files: Readonly>; } -/** Serialises the cache, interning the repeated model and session strings. */ +/** Serialises the cache, interning the repeated model, session and cwd strings. */ export function encodeScanCache(cache: ScanCache): SerializedCache { const models: string[] = []; const sessions: string[] = []; + const cwds: string[] = []; const modelIndex = new Map(); const sessionIndex = new Map(); + const cwdIndex = new Map(); const intern = (table: string[], index: Map, value: string): number => { const existing = index.get(value); @@ -112,6 +118,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.totals.reasoningTokens, record.dedupeKey, record.reportedCostUsd, + intern(cwds, cwdIndex, record.cwd), ]; const files: Record = {}; @@ -129,7 +136,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { }; } - return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, cwds, files }; } function isRecordArray(value: unknown): value is readonly unknown[] { @@ -148,7 +155,9 @@ export function decodeScanCache(document: unknown): ScanCache { const root = document as Partial; if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; - if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; + if (!isRecordArray(root.models) || !isRecordArray(root.sessions) || !isRecordArray(root.cwds)) { + return cache; + } if (typeof root.files !== "object" || root.files === null) return cache; // The intern tables must be all strings: a numeric entry would pass the @@ -156,8 +165,10 @@ export function decodeScanCache(document: unknown): ScanCache { // at lookupRate. A corrupt table rejects the whole cache. if (!root.models.every((value) => typeof value === "string")) return cache; if (!root.sessions.every((value) => typeof value === "string")) return cache; + if (!root.cwds.every((value) => typeof value === "string")) return cache; const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; + const cwds = root.cwds as readonly string[]; // Any corrupt row disqualifies the whole entry. Keeping the survivors // under the original (size, mtime) would read as a valid warm hit and the @@ -168,7 +179,7 @@ export function decodeScanCache(document: unknown): ScanCache { ): UsageRecord[] | null => { const records: UsageRecord[] = []; for (const row of rows) { - if (!isRecordArray(row) || row.length < 10) return null; + if (!isRecordArray(row) || row.length < 11) return null; const [ timestampMs, modelIndex, @@ -180,13 +191,21 @@ export function decodeScanCache(document: unknown): ScanCache { reasoning, dedupeKey, reportedCostUsd, + cwdIndex, ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + const session = typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined; + const cwd = typeof cwdIndex === "number" ? cwds[cwdIndex] : undefined; if ( typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || model === undefined || + !Number.isInteger(modelIndex) || + session === undefined || + !Number.isInteger(sessionIndex) || + cwd === undefined || + !Number.isInteger(cwdIndex) || !Number.isFinite(uncached) || !Number.isFinite(cached) || !Number.isFinite(cacheCreation) || @@ -200,7 +219,8 @@ export function decodeScanCache(document: unknown): ScanCache { provider, timestampMs, model, - sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", + sessionId: session, + cwd, totals: { uncachedInputTokens: uncached, cachedInputTokens: cached, @@ -277,6 +297,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { if ( typeof state.model !== "string" || typeof state.sessionId !== "string" || + typeof state.cwd !== "string" || (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || typeof state.sawSessionMeta !== "boolean" || typeof state.suppressingForkCopies !== "boolean" || @@ -288,6 +309,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { return { model: state.model, sessionId: state.sessionId, + cwd: state.cwd, lastUsageSignature: state.lastUsageSignature ?? null, sawSessionMeta: state.sawSessionMeta, suppressingForkCopies: state.suppressingForkCopies, @@ -343,22 +365,18 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** - * Within-file de-duplication, applied before an entry is cached. - * - * Callers stitching an incremental parse together pass one `seen` set across - * the line and tail record batches so the whole file stays deduplicated as a - * unit; the set is mutated in place. - */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +/** Within-file de-duplication, retaining the final complete Claude snapshot. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const indexByKey = new Map(); const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const existing = indexByKey.get(record.dedupeKey); + if (existing !== undefined) { + kept[existing] = record; + continue; + } + indexByKey.set(record.dedupeKey, kept.length); } kept.push(record); } diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts new file mode 100644 index 000000000000..86b1ec1d7804 --- /dev/null +++ b/apps/server/src/usage/usageThreads.test.ts @@ -0,0 +1,560 @@ +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { UsageAggregator } from "./usageAggregation.ts"; +import type { RateTable } from "./usagePricing.ts"; +import { foldThreadRows, ThreadUsageAccumulator, type ThreadAttribution } from "./usageThreads.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + }, + ], +]); + +const PROJECT_ONE = { projectId: ProjectId.make("project-one"), title: "Project one" }; +const PROJECT_TWO = { projectId: ProjectId.make("project-two"), title: "Project two" }; + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + cwd: "/work/app", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function accumulate( + entries: readonly (readonly [UsageRecord, { sessionKey: string; agentId: string | null }])[], +) { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const [item, context] of entries) accumulator.add(item, context); + return accumulator.finish(); +} + +const NO_ATTRIBUTION: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map(), +}; + +describe("ThreadUsageAccumulator", () => { + it("groups records by session and splits subagent slices out", () => { + const main = { sessionKey: "claude:session-a", agentId: null }; + const agent = { sessionKey: "claude:session-a", agentId: "agent-1" }; + const groups = accumulate([ + [record(), main], + [record(), agent], + [record({ sessionId: "session-b" }), { sessionKey: "claude:session-b", agentId: null }], + ]); + + expect(groups).toHaveLength(2); + const sessionA = groups.find((group) => group.sessionKey === "claude:session-a"); + expect(sessionA?.totals.outputTokens).toBe(100); + expect(sessionA?.agents.get("agent-1")?.totals.outputTokens).toBe(50); + }); + + it("dedupes globally across files with the summary's semantics", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ dedupeKey: "msg_1:" }), context], + [record({ dedupeKey: "msg_1:" }), context], + ]); + + expect(groups[0]?.totals.outputTokens).toBe(50); + }); + + it("uses the final complete snapshot across files", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 1 } }), + context, + ], + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 310 } }), + context, + ], + ]); + + expect(groups[0]?.totals.outputTokens).toBe(310); + }); + + it("applies the window to the final complete snapshot", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ dedupeKey: "msg_partial:" }), context], + [ + record({ + dedupeKey: "msg_partial:", + timestampMs: Date.parse("2026-09-01T00:00:00Z"), + }), + context, + ], + ]); + + expect(groups).toEqual([]); + }); + + it("splits each day's model-priced cost into cache components", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([[record(), context]]); + const day = groups[0]?.daily.get("2026-08-07"); + + expect(day?.cacheWriteUsd).toBeCloseTo(10 * 1.25e-5, 12); + expect(day?.cacheReadUsd).toBeCloseTo(1000 * 1e-6, 12); + expect(day?.freshUsd).toBeCloseTo(100 * 1e-5 + 50 * 5e-5, 12); + }); + + it("uses custom prices for thread totals and component costs", () => { + const customRates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 2e-5, + outputCostPerToken: 1e-4, + cacheReadCostPerToken: 2e-6, + cacheCreationCostPerToken: 2.5e-5, + }, + ], + ]); + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + priceOverrides: customRates, + }); + accumulator.add(record({ reportedCostUsd: 1.25 }), { + sessionKey: "claude:session-a", + agentId: null, + }); + + const group = accumulator.finish()[0]; + const day = group?.daily.get("2026-08-07"); + expect(group?.costUsd).toBeCloseTo(100 * 2e-5 + 1000 * 2e-6 + 10 * 2.5e-5 + 50 * 1e-4, 12); + expect(day?.cacheWriteUsd).toBeCloseTo(10 * 2.5e-5, 12); + expect(day?.cacheReadUsd).toBeCloseTo(1000 * 2e-6, 12); + expect(day?.freshUsd).toBeCloseTo(100 * 2e-5 + 50 * 1e-4, 12); + }); + + it("does not invent a component split for provider-reported costs", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([[record({ reportedCostUsd: 1.25 }), context]]); + + expect(groups[0]?.costUsd).toBe(1.25); + expect(groups[0]?.daily.size).toBe(0); + }); + + it("drops records outside the window", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ timestampMs: Date.parse("2026-07-01T00:00:00Z") }), context], + ]); + + expect(groups).toHaveLength(0); + }); + + it("drops timestamps outside the JavaScript date range", () => { + const context = { sessionKey: "grok:session-a", agentId: null }; + expect(() => accumulate([[record({ timestampMs: 1e20 }), context]])).not.toThrow(); + expect(accumulate([[record({ timestampMs: 1e20 }), context]])).toEqual([]); + }); + + it("applies exact time bounds inside a shared calendar day", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-07", + untilDay: "2026-08-07", + sinceTimeMs: Date.parse("2026-08-07T04:00:00Z"), + untilTimeMs: Date.parse("2026-08-07T05:00:00Z"), + rates, + }); + const context = { sessionKey: "claude:session-a", agentId: null }; + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T03:59:59Z") }), context); + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T04:30:00Z") }), context); + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T05:00:00Z") }), context); + + expect(accumulator.finish()[0]?.totals.outputTokens).toBe(50); + }); + + it("uses exact bounds without applying a second calendar-day filter", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-07", + untilDay: "2026-08-07", + sinceTimeMs: Date.parse("2026-08-08T04:00:00Z"), + untilTimeMs: Date.parse("2026-08-08T05:00:00Z"), + rates, + }); + + accumulator.add(record({ timestampMs: Date.parse("2026-08-08T04:30:00Z") }), { + sessionKey: "claude:exact-window", + agentId: null, + }); + + expect(accumulator.finish()[0]?.totals.outputTokens).toBe(50); + }); + + it("keeps separate cwd slices when one session crosses projects", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO), + }); + const context = { sessionKey: "claude:session-a", agentId: null }; + accumulator.add(record({ cwd: "/work/one" }), context); + accumulator.add(record({ cwd: "/work/two" }), context); + + expect( + accumulator + .finish() + .map((group) => group.projectKey) + .toSorted(), + ).toEqual(["id:project-one", "id:project-two"]); + }); +}); + +describe("foldThreadRows", () => { + const threadId = ThreadId.make("11111111-1111-4111-8111-111111111111"); + + it("folds sessions into one row per thread via cursor and worktree matches", () => { + const groups = accumulate([ + [record(), { sessionKey: "claude:session-a", agentId: null }], + [ + record({ sessionId: "session-b", cwd: "/work/app/.wt/thread-1" }), + { sessionKey: "claude:session-b", agentId: null }, + ], + [record({ sessionId: "session-c" }), { sessionKey: "claude:session-c", agentId: null }], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map([["claude:session-a", { threadId, title: "Fix the flaky test" }]]), + worktreeToThread: new Map([ + ["/work/app/.wt/thread-1", { threadId, title: "Fix the flaky test" }], + ]), + }; + + const { rows, truncatedRows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(truncatedRows).toBe(0); + expect(rows).toHaveLength(2); + const threadRow = rows.find((row) => row.threadId === threadId); + expect(threadRow?.title).toBe("Fix the flaky test"); + expect(threadRow?.sessions).toBe(2); + const standalone = rows.find((row) => row.threadId === null); + // Standalone rows leave the title to the caller's transcript read. + expect(standalone?.title).toBeNull(); + expect(standalone?.key).toContain("claude:session-c"); + }); + + it("uses the deepest worktree ancestor for sessions run in subdirectories", () => { + const nestedThreadId = ThreadId.make("22222222-2222-4222-8222-222222222222"); + const groups = accumulate([ + [ + record({ sessionId: "nested", cwd: "/work/app/.wt/thread-1/packages/web" }), + { sessionKey: "claude:nested", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["/work/app", { threadId, title: "Shared root" }], + ["/work/app/.wt/thread-1", { threadId: nestedThreadId, title: "Nested worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(rows[0]?.threadId).toBe(nestedThreadId); + expect(rows[0]?.title).toBe("Nested worktree"); + }); + + it("matches Windows worktrees without changing POSIX case sensitivity", () => { + const groups = accumulate([ + [ + record({ sessionId: "windows", cwd: "c:\\work\\app\\.wt\\thread-1\\src" }), + { sessionKey: "claude:windows", agentId: null }, + ], + [ + record({ sessionId: "posix", cwd: "/work/app/.wt/thread-1/src" }), + { sessionKey: "claude:posix", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["C:\\Work\\App\\.wt\\thread-1", { threadId, title: "Windows worktree" }], + ["/Work/App/.wt/thread-1", { threadId, title: "Different POSIX worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + const threadRow = rows.find((row) => row.threadId === threadId); + expect(threadRow?.sessions).toBe(1); + expect(rows.some((row) => row.threadId === null && row.sessions === 1)).toBe(true); + }); + + it("scopes one T3 thread by provider and project", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO), + }); + const entries = [ + [record({ sessionId: "claude-one", cwd: "/work/one" }), "claude:claude-one"], + [record({ sessionId: "claude-two", cwd: "/work/two" }), "claude:claude-two"], + [ + record({ + provider: "codex", + model: "gpt-5.6-sol", + sessionId: "codex-one", + cwd: "/work/one", + }), + "codex:codex-one", + ], + ] as const; + for (const [item, sessionKey] of entries) { + accumulator.add(item, { sessionKey, agentId: null }); + } + const attribution: ThreadAttribution = { + sessionToThread: new Map( + entries.map(([, sessionKey]) => [sessionKey, { threadId, title: "Shared thread" }]), + ), + worktreeToThread: new Map(), + }; + + const { rows } = foldThreadRows(accumulator.finish(), attribution, { cap: 40 }); + + expect(rows).toHaveLength(3); + expect(rows.map((row) => [row.provider, row.project]).toSorted()).toEqual([ + ["claude", "Project one"], + ["claude", "Project two"], + ["codex", "Project one"], + ]); + expect(new Set(rows.map((row) => row.key)).size).toBe(3); + expect(rows.every((row) => row.threadId === threadId && row.title === "Shared thread")).toBe( + true, + ); + + const projectOne = foldThreadRows(accumulator.finish(), attribution, { + cap: 40, + projectFilter: "id:project-one", + }); + expect(projectOne.rows.map((row) => [row.provider, row.project]).toSorted()).toEqual([ + ["claude", "Project one"], + ["codex", "Project one"], + ]); + }); + + it("groups rows past the cap without losing their usage", () => { + const groups = accumulate( + Array.from({ length: 5 }, (_, index) => [ + record({ sessionId: `session-${index}` }), + { sessionKey: `claude:session-${index}`, agentId: null }, + ]), + ); + + const { rows, truncatedRows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 3 }); + + expect(rows).toHaveLength(3); + expect(truncatedRows).toBe(3); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.title).toBe("Other threads (3)"); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.groupedRows).toBe(3); + expect(rows.reduce((sum, row) => sum + row.totals.outputTokens, 0)).toBe(250); + }); + + it("keeps subagent slices when lower-cost rows fold into a remainder", () => { + const groups = accumulate([ + [ + record({ sessionId: "expensive", totals: { ...record().totals, outputTokens: 100 } }), + { sessionKey: "claude:expensive", agentId: null }, + ], + [ + record({ sessionId: "cheaper" }), + { sessionKey: "claude:cheaper", agentId: "agent-cheaper" }, + ], + ]); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 1 }); + const remainder = rows.find((row) => row.key.startsWith("remainder:")); + expect(remainder?.agents.map((agent) => agent.agentId)).toEqual(["agent-cheaper"]); + }); + + it("bounds and reconciles subagents folded into a remainder", () => { + const groups = accumulate([ + [ + record({ sessionId: "expensive", totals: { ...record().totals, outputTokens: 100 } }), + { sessionKey: "claude:expensive", agentId: null }, + ], + ...Array.from( + { length: 5 }, + (_, index) => + [ + record({ sessionId: `cheaper-${index}` }), + { sessionKey: `claude:cheaper-${index}`, agentId: `agent-${index}` }, + ] as const, + ), + ]); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 2 }); + const remainder = rows.find((row) => row.key.startsWith("remainder:")); + + expect(remainder?.agents).toHaveLength(2); + expect(remainder?.agents.some((agent) => agent.agentId === "Other subagents (4)")).toBe(true); + expect(remainder?.agents.reduce((sum, agent) => sum + agent.totals.outputTokens, 0)).toBe(250); + }); + + it("collapses overflow project scopes without exceeding the response cap", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => ({ + projectId: ProjectId.make(`project-${cwd.slice(-1)}`), + title: `Project ${cwd.slice(-1)}`, + }), + }); + for (let index = 0; index < 6; index += 1) { + accumulator.add(record({ sessionId: `session-${index}`, cwd: `/work/${index}` }), { + sessionKey: `claude:session-${index}`, + agentId: null, + }); + } + + const { rows } = foldThreadRows(accumulator.finish(), NO_ATTRIBUTION, { cap: 3 }); + + expect(rows.length).toBeLessThanOrEqual(3); + expect(rows.reduce((sum, row) => sum + row.totals.outputTokens, 0)).toBe(300); + }); + + it("reconciles every provider and project after lower-cost rows are grouped", () => { + const resolveProject = (cwd: string) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO); + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject, + }); + const summary = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + resolution: "day", + rates, + resolveProject, + }); + for (const [index, provider, project] of [ + [0, "claude", "one"], + [1, "claude", "one"], + [2, "claude", "two"], + [3, "codex", "one"], + [4, "codex", "two"], + ] as const) { + const item = record({ + provider, + model: provider === "claude" ? "claude-fable-5" : "gpt-5.6-sol", + sessionId: `session-${index}`, + cwd: `/work/${project}`, + }); + accumulator.add(item, { sessionKey: `${provider}:session-${index}`, agentId: null }); + summary.add(item); + } + const groups = accumulator.finish(); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 4 }); + expect(rows.length).toBeLessThanOrEqual(4); + const expected = new Map(); + for (const bucket of summary.finish().buckets) { + const key = `${bucket.provider}:${bucket.project ?? ""}`; + expected.set(key, (expected.get(key) ?? 0) + bucket.totals.outputTokens); + } + const actual = new Map(); + for (const row of rows) { + const key = `${row.provider}:${row.project ?? ""}`; + actual.set(key, (actual.get(key) ?? 0) + row.totals.outputTokens); + } + + expect(actual).toEqual(expected); + }); + + it("filters by project before capping", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd === "/work/app" ? PROJECT_ONE : null), + }); + accumulator.add(record(), { sessionKey: "claude:session-a", agentId: null }); + accumulator.add(record({ sessionId: "session-b", cwd: "/elsewhere" }), { + sessionKey: "claude:session-b", + agentId: null, + }); + const groups = accumulator.finish(); + + const app = foldThreadRows(groups, NO_ATTRIBUTION, { + cap: 40, + projectFilter: "id:project-one", + }); + expect(app.rows.map((row) => row.key)).toHaveLength(1); + expect(app.rows[0]?.key).toContain("claude:session-a"); + + const outside = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40, projectFilter: null }); + expect(outside.rows.map((row) => row.key)).toHaveLength(1); + expect(outside.rows[0]?.key).toContain("claude:session-b"); + }); + + it("excludes unknown project attribution from the outside-project filter", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: () => null, + }); + accumulator.add(record({ sessionId: "outside", cwd: "/elsewhere" }), { + sessionKey: "claude:outside", + agentId: null, + }); + accumulator.add(record({ provider: "grok", sessionId: "unknown", cwd: "" }), { + sessionKey: "grok:unknown", + agentId: null, + }); + + const outside = foldThreadRows(accumulator.finish(), NO_ATTRIBUTION, { + cap: 40, + projectFilter: null, + }); + + expect(outside.rows).toHaveLength(1); + expect(outside.rows[0]?.key).toContain("claude:outside"); + }); +}); diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts new file mode 100644 index 000000000000..8159ab5ce8a1 --- /dev/null +++ b/apps/server/src/usage/usageThreads.ts @@ -0,0 +1,546 @@ +/** + * Pure grouping behind the thread drill-down: transcript records fold into + * per-session groups, and session groups fold into thread rows using the + * attribution the caller extracted from its own state (resume cursors and + * dedicated worktrees). + * + * Pure, so grouping, de-duplication and attribution are testable without the + * filesystem or the database. + * + * @module usageThreads + */ +import type { + ProjectId, + ThreadId, + UsageAgentRow, + UsageProviderKind, + UsageThreadDayCost, + UsageThreadRow, + UsageTokenTotals, +} from "@t3tools/contracts"; +import { UsageDay } from "@t3tools/contracts"; + +import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts"; +import { normalizeUsagePath } from "./usagePaths.ts"; +import { + priceUsage, + usageComponentCosts, + type RateTable, + type UsageComponentCosts, +} from "./usagePricing.ts"; +import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; + +const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; + +/** How the caller identifies the transcript a record came from. */ +export interface ThreadRecordContext { + /** `provider:sessionId`, or a file-derived fallback when the id is empty. */ + readonly sessionKey: string; + /** Claude subagent id when the record came from a `subagents/agent-*.jsonl` file. */ + readonly agentId: string | null; +} + +interface MutableAgentSlice { + totals: UsageTokenTotals; + costUsd: number; +} + +export interface SessionUsageGroup { + readonly sessionKey: string; + readonly provider: UsageProviderKind; + readonly sessionId: string; + readonly cwd: string; + readonly projectId: ProjectId | null; + readonly projectKey: string | null; + readonly projectAttribution: "project" | "outside" | "unknown"; + readonly project: string; + readonly totals: UsageTokenTotals; + readonly costUsd: number; + readonly daily: ReadonlyMap; + readonly agents: ReadonlyMap; +} + +interface MutableSessionGroup { + sessionKey: string; + provider: UsageProviderKind; + sessionId: string; + cwd: string; + projectId: ProjectId | null; + projectKey: string | null; + projectAttribution: "project" | "outside" | "unknown"; + project: string; + totals: UsageTokenTotals; + costUsd: number; + daily: Map; + agents: Map; +} + +export interface ThreadUsageOptions { + readonly timeZone: string; + readonly sinceDay: string; + readonly untilDay: string; + readonly sinceTimeMs?: number; + readonly untilTimeMs?: number; + readonly rates: RateTable; + readonly priceOverrides?: RateTable; + /** Same stable project resolver the summary uses. */ + readonly resolveProject?: (cwd: string) => ProjectAttribution | null; +} + +/** + * Folds records into per-session groups with per-day component costs. + * + * De-duplication is global across the scan with the same semantics as the + * summary aggregator, so a thread's number here always reconciles with its + * share of the summary. + */ +export class ThreadUsageAccumulator { + readonly #recordsByKey = new Map< + string, + { readonly record: UsageRecord; readonly context: ThreadRecordContext } + >(); + readonly #unkeyedRecords: { + readonly record: UsageRecord; + readonly context: ThreadRecordContext; + }[] = []; + readonly #toDay: (timestampMs: number) => string; + readonly #options: ThreadUsageOptions; + + constructor(options: ThreadUsageOptions) { + this.#options = options; + this.#toDay = makeDayFormatter(options.timeZone); + } + + add(record: UsageRecord, context: ThreadRecordContext): boolean { + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push({ record, context }); + return inWindow; + } + this.#recordsByKey.set(record.dedupeKey, { record, context }); + return inWindow; + } + + #isInWindow(record: UsageRecord): boolean { + if ( + !Number.isFinite(record.timestampMs) || + Math.abs(record.timestampMs) > MAX_DATE_TIMESTAMP_MS + ) { + return false; + } + if ( + this.#options.sinceTimeMs !== undefined && + this.#options.untilTimeMs !== undefined && + (record.timestampMs < this.#options.sinceTimeMs || + record.timestampMs >= this.#options.untilTimeMs) + ) { + return false; + } + const day = this.#toDay(record.timestampMs); + if ( + (this.#options.sinceTimeMs === undefined || this.#options.untilTimeMs === undefined) && + (day < this.#options.sinceDay || day > this.#options.untilDay) + ) + return false; + return true; + } + + #foldRecord( + record: UsageRecord, + context: ThreadRecordContext, + groups: Map, + ): void { + const day = this.#toDay(record.timestampMs); + const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectAttribution = + resolvedProject !== null + ? "project" + : this.#options.resolveProject === undefined || record.cwd.length === 0 + ? "unknown" + : "outside"; + const projectKey = + resolvedProject === null ? null : `id:${resolvedProject.projectId.replaceAll("\u0000", "")}`; + const groupKey = JSON.stringify([context.sessionKey, record.cwd]); + let group = groups.get(groupKey); + if (group === undefined) { + group = { + sessionKey: context.sessionKey, + provider: record.provider, + sessionId: record.sessionId, + cwd: record.cwd, + projectId: resolvedProject?.projectId ?? null, + projectKey, + projectAttribution, + project: resolvedProject?.title ?? "", + totals: EMPTY_TOTALS, + costUsd: 0, + daily: new Map(), + agents: new Map(), + }; + groups.set(groupKey, group); + } + + const priced = priceUsage( + this.#options.rates, + record.model, + record.totals, + record.reportedCostUsd, + this.#options.priceOverrides, + ); + group.totals = addTotals(group.totals, record.totals); + group.costUsd += priced.costUsd; + if (priced.costSource === "modelPriced") { + const components = usageComponentCosts( + this.#options.rates, + record.model, + record.totals, + this.#options.priceOverrides, + ); + const current = group.daily.get(day); + group.daily.set(day, { + cacheWriteUsd: (current?.cacheWriteUsd ?? 0) + components.cacheWriteUsd, + cacheReadUsd: (current?.cacheReadUsd ?? 0) + components.cacheReadUsd, + freshUsd: (current?.freshUsd ?? 0) + components.freshUsd, + }); + } + + if (context.agentId !== null) { + let agent = group.agents.get(context.agentId); + if (agent === undefined) { + agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + group.agents.set(context.agentId, agent); + } + agent.totals = addTotals(agent.totals, record.totals); + agent.costUsd += priced.costUsd; + } + } + + finish(): readonly SessionUsageGroup[] { + const groups = new Map(); + for (const { record, context } of this.#unkeyedRecords) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + for (const { record, context } of this.#recordsByKey.values()) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + return [...groups.values()].map((group) => ({ + sessionKey: group.sessionKey, + provider: group.provider, + sessionId: group.sessionId, + cwd: group.cwd, + projectId: group.projectId, + projectKey: group.projectKey, + projectAttribution: group.projectAttribution, + project: group.project, + totals: group.totals, + costUsd: group.costUsd, + daily: group.daily, + agents: group.agents, + })); + } +} + +/** A thread a session can attribute to, from the environment's own state. */ +export interface ThreadRef { + readonly threadId: ThreadId; + readonly title: string; +} + +export interface ThreadAttribution { + /** `provider:sessionId` of each thread's current session, from resume cursors. */ + readonly sessionToThread: ReadonlyMap; + /** + * Dedicated worktree path → thread. Only paths claimed by exactly one + * thread belong here: a shared root would stamp one thread's identity onto + * every unrelated session running there. + */ + readonly worktreeToThread: ReadonlyMap; +} + +export interface FoldThreadRowsOptions { + /** A title, `null` for outside-projects sessions, `undefined` for no filter. */ + readonly projectFilter?: string | null | undefined; + /** Maximum returned rows, including grouped remainders. */ + readonly cap: number; +} + +interface MutableThreadRow { + threadId: ThreadId | null; + title: string | null; + provider: UsageProviderKind; + project: string; + projectId: ProjectId | null; + projectKey: string | null; + cwd: string; + totals: UsageTokenTotals; + costUsd: number; + sessionKeys: Set; + groupedRows: number; + daily: Map; + agents: Map; + /** Session whose transcript can supply a title when no thread claims the row. */ + titleSessionKey: string; +} + +export interface FoldedThreadRows { + readonly rows: readonly (Omit & { + readonly title: string | null; + readonly titleSessionKey: string; + })[]; + readonly truncatedRows: number; +} + +function addDailyCosts( + target: Map, + source: ReadonlyMap, +): void { + for (const [day, components] of source) { + const current = target.get(day); + target.set(day, { + cacheWriteUsd: (current?.cacheWriteUsd ?? 0) + components.cacheWriteUsd, + cacheReadUsd: (current?.cacheReadUsd ?? 0) + components.cacheReadUsd, + freshUsd: (current?.freshUsd ?? 0) + components.freshUsd, + }); + } +} + +function worktreeThreadForCwd( + cwd: string, + worktreeToThread: Iterable, +): ThreadRef | undefined { + const normalizedCwd = normalizeUsagePath(cwd); + let deepest: { readonly pathLength: number; readonly ref: ThreadRef } | undefined; + for (const [worktree, ref] of worktreeToThread) { + const normalizedWorktree = worktree; + const prefix = normalizedWorktree.endsWith("/") ? normalizedWorktree : `${normalizedWorktree}/`; + if (normalizedCwd !== normalizedWorktree && !normalizedCwd.startsWith(prefix)) continue; + if (deepest === undefined || normalizedWorktree.length > deepest.pathLength) { + deepest = { pathLength: normalizedWorktree.length, ref }; + } + } + return deepest?.ref; +} + +function toAgentRow([agentId, slice]: readonly [string, MutableAgentSlice]): UsageAgentRow { + return { + agentId, + totals: slice.totals, + costUsd: slice.costUsd, + }; +} + +function boundedAgentRows( + agents: ReadonlyMap, + cap: number, +): readonly UsageAgentRow[] { + const sorted = [...agents.entries()].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + if (sorted.length <= cap) return sorted.map(toAgentRow); + + const kept = sorted.slice(0, Math.max(0, cap - 1)); + const omitted = sorted.slice(kept.length); + const overflow = omitted.reduce( + (combined, [, slice]) => ({ + totals: addTotals(combined.totals, slice.totals), + costUsd: combined.costUsd + slice.costUsd, + }), + { + totals: EMPTY_TOTALS, + costUsd: 0, + }, + ); + return [...kept.map(toAgentRow), toAgentRow([`Other subagents (${omitted.length})`, overflow])]; +} + +/** + * Groups sessions into thread rows: resume-cursor matches first, then unique + * worktrees, else one row per session. Rows sort by cost. Rows beyond the cap + * fold into provider/project-specific remainders so the returned hierarchy + * still reconciles. A `null` title marks retained rows whose name must come + * from the transcript. + */ +export function foldThreadRows( + groups: readonly SessionUsageGroup[], + attribution: ThreadAttribution, + options: FoldThreadRowsOptions, +): FoldedThreadRows { + const byKey = new Map(); + const worktrees = Array.from( + attribution.worktreeToThread, + ([worktree, ref]) => [normalizeUsagePath(worktree), ref] as const, + ); + + for (const group of groups) { + if ( + options.projectFilter !== undefined && + (options.projectFilter === null + ? group.projectAttribution !== "outside" + : group.projectKey !== options.projectFilter) + ) + continue; + + const ref = + attribution.sessionToThread.get(group.sessionKey) ?? + (group.cwd.length > 0 ? worktreeThreadForCwd(group.cwd, worktrees) : undefined); + const rowKey = + ref === undefined + ? JSON.stringify(["session", group.provider, group.projectKey, group.sessionKey]) + : JSON.stringify(["thread", group.provider, group.projectKey, ref.threadId]); + + let row = byKey.get(rowKey); + if (row === undefined) { + row = { + threadId: ref?.threadId ?? null, + title: ref?.title ?? null, + provider: group.provider, + project: group.project, + projectId: group.projectId, + projectKey: group.projectKey, + cwd: group.cwd, + totals: EMPTY_TOTALS, + costUsd: 0, + sessionKeys: new Set(), + groupedRows: 0, + daily: new Map(), + agents: new Map(), + titleSessionKey: group.sessionKey, + }; + byKey.set(rowKey, row); + } + + row.totals = addTotals(row.totals, group.totals); + row.costUsd += group.costUsd; + row.sessionKeys.add(group.sessionKey); + addDailyCosts(row.daily, group.daily); + for (const [agentId, slice] of group.agents) { + let agent = row.agents.get(agentId); + if (agent === undefined) { + agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + row.agents.set(agentId, agent); + } + agent.totals = addTotals(agent.totals, slice.totals); + agent.costUsd += slice.costUsd; + } + } + + const sorted = [...byKey.entries()].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + let keptCount = Math.min(sorted.length, options.cap); + const projectScopeCount = (rows: typeof sorted): number => + new Set(rows.map(([, row]) => JSON.stringify([row.provider, row.projectKey]))).size; + while (keptCount > 0 && keptCount + projectScopeCount(sorted.slice(keptCount)) > options.cap) { + keptCount -= 1; + } + + let kept = sorted.slice(0, keptCount); + let omitted = sorted.slice(keptCount); + let remainderScope: "project" | "provider" = "project"; + if (projectScopeCount(omitted) > options.cap) { + // More project scopes than the response can represent. Collapse all named + // rows and preserve provider totals in provider-wide overflow rows. + kept = []; + omitted = sorted; + remainderScope = "provider"; + const providerCount = new Set(omitted.map(([, row]) => row.provider)).size; + if (providerCount > options.cap) { + throw new RangeError("Thread row cap must fit one remainder per provider"); + } + } + + const remainders = new Map(); + for (const [, omittedRow] of omitted) { + const scopeKey = JSON.stringify([ + omittedRow.provider, + remainderScope === "project" ? omittedRow.projectKey : null, + ]); + let remainder = remainders.get(scopeKey); + if (remainder === undefined) { + const key = `remainder:${scopeKey}`; + remainder = { + threadId: null, + title: null, + provider: omittedRow.provider, + project: remainderScope === "project" ? omittedRow.project : "", + projectId: remainderScope === "project" ? omittedRow.projectId : null, + projectKey: remainderScope === "project" ? omittedRow.projectKey : null, + cwd: "", + totals: EMPTY_TOTALS, + costUsd: 0, + sessionKeys: new Set(), + groupedRows: 0, + daily: new Map(), + agents: new Map(), + titleSessionKey: key, + }; + remainders.set(scopeKey, remainder); + } + remainder.groupedRows += 1; + remainder.totals = addTotals(remainder.totals, omittedRow.totals); + remainder.costUsd += omittedRow.costUsd; + for (const sessionKey of omittedRow.sessionKeys) remainder.sessionKeys.add(sessionKey); + addDailyCosts(remainder.daily, omittedRow.daily); + for (const [agentId, slice] of omittedRow.agents) { + let agent = remainder.agents.get(agentId); + if (agent === undefined) { + agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + remainder.agents.set(agentId, agent); + } + agent.totals = addTotals(agent.totals, slice.totals); + agent.costUsd += slice.costUsd; + } + } + + const displayed = [ + ...kept, + ...[...remainders.entries()].map(([scopeKey, remainder]) => { + remainder.title = `Other threads (${remainder.groupedRows})`; + return [`remainder:${scopeKey}`, remainder] as const; + }), + ].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + + return { + rows: displayed.map(([key, row]) => ({ + key, + threadId: row.threadId, + title: row.title, + titleSessionKey: row.titleSessionKey, + provider: row.provider, + ...(row.projectId === null ? {} : { projectId: row.projectId }), + ...(row.project === "" ? {} : { project: row.project }), + totals: row.totals, + costUsd: row.costUsd, + sessions: row.sessionKeys.size, + ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), + agents: boundedAgentRows(row.agents, options.cap), + daily: [...row.daily.entries()] + .map(([day, components]) => ({ + day: day as UsageDay, + ...components, + })) + .sort((a, b) => a.day.localeCompare(b.day)) satisfies UsageThreadDayCost[], + })), + truncatedRows: omitted.length, + }; +} + +function totalOf(totals: UsageTokenTotals): number { + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 5feb68b2ff58..354cc404f8fa 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -7,7 +7,7 @@ import * as NodePath from "node:path"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; -import { readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { readTranscriptRecords, readTranscriptTitle } from "./usageTranscriptReader.ts"; let dir: string; @@ -208,3 +208,91 @@ describe("readTranscriptRecords resume", () => { assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); }); }); + +describe("readTranscriptTitle", () => { + it("keeps a real prompt that begins with an angle bracket", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + JSON.stringify({ type: "user", message: { content: "<3 ship this today" } }), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "<3 ship this today"); + }); + + it("skips a known injected preamble and reads the next user prompt", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + [ + { + type: "user", + message: { content: "generated context" }, + }, + { type: "user", message: { content: "Fix the real bug" } }, + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "Fix the real bug"); + }); + + it("skips a user shell command wrapper", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + [ + { + type: "user", + message: { content: "git status" }, + }, + { type: "user", message: { content: "Explain the failing check" } }, + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "Explain the failing check"); + }); + + it("uses the child prompt instead of copied parent history for a forked Codex rollout", async () => { + const file = NodePath.join(dir, "session.jsonl"); + const message = (timestamp: string, text: string) => ({ + type: "event_msg", + timestamp, + payload: { type: "message", role: "user", content: [{ type: "input_text", text }] }, + }); + await NodeFSP.writeFile( + file, + [ + { + type: "session_meta", + timestamp: "2026-08-01T05:00:00.000Z", + payload: { type: "session_meta", id: "child", forked_from_id: "parent" }, + }, + message("2026-08-01T05:00:00.600Z", "First copied parent prompt"), + message("2026-08-01T05:00:01.100Z", "Second copied parent prompt"), + message("2026-08-01T05:00:02.500Z", "Investigate the child task"), + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "codex"), "Investigate the child task"); + }); + + it("truncates titles without splitting a Unicode code point", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + JSON.stringify({ type: "user", message: { content: `${"a".repeat(78)}🙂more` } }), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), `${"a".repeat(78)}🙂…`); + }); + + it("returns null when the title stream cannot be read", async () => { + assert.isNull(await readTranscriptTitle(NodePath.join(dir, "missing.jsonl"), "claude")); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e5ab6e0c9e0..9bbd95e8ad2b 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -15,6 +15,7 @@ * * @module usageTranscriptReader */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -311,3 +312,150 @@ export async function readTranscriptRecords( await handle.close().catch(() => undefined); } } + +/** Prefixes that mark an injected preamble, not something the user typed. */ +const NOT_TITLE_PREFIXES = [ + "", + "", + "", + "", + "", + "", + "", + "# AGENTS.md instructions", + "Caveat: the messages below", +]; + +const TITLE_MAX_LENGTH = 80; +const TITLE_MAX_LINES = 400; +const TITLE_MAX_BYTES = 1024 * 1024; + +function cleanTitle(text: unknown): string | null { + if (typeof text !== "string") return null; + const collapsed = text.split(/\s+/).join(" ").trim(); + if (collapsed.length === 0) return null; + if (NOT_TITLE_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) return null; + const characters = Array.from(collapsed); + return characters.length > TITLE_MAX_LENGTH + ? `${characters.slice(0, TITLE_MAX_LENGTH - 1).join("")}\u2026` + : collapsed; +} + +function claudeTitleFromLine(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if (record["type"] !== "user") return null; + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const content = (message as Record)["content"]; + if (typeof content === "string") return cleanTitle(content); + if (!Array.isArray(content)) return null; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const entry = block as Record; + if (entry["type"] !== "text") continue; + const title = cleanTitle(entry["text"]); + if (title !== null) return title; + } + return null; +} + +function codexTitleFromLine( + line: string, +): { readonly title: string; readonly timestampMs: number | null } | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const payload = (parsed as Record)["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const record = payload as Record; + if (record["type"] !== "message" || record["role"] !== "user") return null; + const content = record["content"]; + if (!Array.isArray(content)) return null; + const timestamp = (parsed as Record)["timestamp"]; + const parsedTimestamp = typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN; + const timestampMs = Number.isNaN(parsedTimestamp) ? null : parsedTimestamp; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const title = cleanTitle((block as Record)["text"]); + if (title !== null) return { title, timestampMs }; + } + return null; +} + +/** + * First thing the user actually typed in a session, as a display title. + * + * Only called for the handful of unattributed rows that survived the response + * cap, so a second bounded read per row is fine. Returns null when the file + * cannot be read, holds no user text (Grok logs carry none we trust), or only + * injected preambles appear early on. + */ +export async function readTranscriptTitle( + filePath: string, + provider: UsageProviderKind, +): Promise { + if (provider === "grok") return null; + const codexState = provider === "codex" ? initialCodexScanState() : null; + let stream: NodeFS.ReadStream | null = null; + try { + stream = NodeFS.createReadStream(filePath, { encoding: "utf8" }); + let pending = ""; + let seen = 0; + let bytesRead = 0; + for await (const chunk of stream) { + const text = String(chunk); + bytesRead += Buffer.byteLength(text); + pending += text; + for (;;) { + const newline = pending.indexOf("\n"); + if (newline === -1) break; + const line = pending.slice(0, newline).replace(/\r$/, ""); + pending = pending.slice(newline + 1); + seen += 1; + if (seen > TITLE_MAX_LINES) return null; + if (provider === "claude") { + if (!line.includes('"user"')) continue; + const title = claudeTitleFromLine(line); + if (title !== null) return title; + continue; + } + const title = codexTitleFromLine(line); + parseCodexLine(line, codexState!); + if (title === null) continue; + if (!codexState!.suppressingForkCopies) return title.title; + if (title.timestampMs !== null) { + if (title.timestampMs - codexState!.forkCopyAnchorMs >= 1000) return title.title; + codexState!.forkCopyAnchorMs = title.timestampMs; + } + } + if (bytesRead >= TITLE_MAX_BYTES) return null; + } + if (pending.length > 0 && seen < TITLE_MAX_LINES) { + if (provider === "claude") return claudeTitleFromLine(pending); + const title = codexTitleFromLine(pending); + parseCodexLine(pending, codexState!); + if (title === null) return null; + if (!codexState!.suppressingForkCopies) return title.title; + if (title.timestampMs === null) return null; + if (title.timestampMs - codexState!.forkCopyAnchorMs >= 1000) return title.title; + codexState!.forkCopyAnchorMs = title.timestampMs; + return null; + } + } catch { + return null; + } finally { + stream?.destroy(); + } + return null; +} diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index b09db613ed85..95794a4683e2 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -51,6 +51,7 @@ describe("parseClaudeLine", () => { reasoningTokens: 0, }); expect(record?.dedupeKey).toBe("msg_1:"); + expect(record?.cwd).toBe("/home/theo/project"); }); it("gives every content block of one message the same dedupe key", () => { @@ -73,7 +74,11 @@ describe("parseCodexLine", () => { const sessionMeta = JSON.stringify({ type: "session_meta", timestamp: "2026-08-01T05:17:41.289Z", - payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + payload: { + type: "session_meta", + id: "019fbbc1-b12c-7360-a685-28c181f0025f", + cwd: "/home/theo/project", + }, }); const turnContext = JSON.stringify({ type: "turn_context", @@ -107,12 +112,34 @@ describe("parseCodexLine", () => { expect(record?.provider).toBe("codex"); expect(record?.model).toBe("gpt-5.6-sol"); expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + expect(record?.cwd).toBe("/home/theo/project"); // Codex reports input_tokens inclusive of the cached portion. expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); expect(record?.totals.cachedInputTokens).toBe(11008); expect(record?.totals.reasoningTokens).toBe(116); }); + it("attributes resumed usage to the latest turn working directory", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine( + JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { + type: "turn_context", + model: "gpt-5.6-sol", + cwd: "/home/theo/next-project", + }, + }), + state, + ); + + const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(record?.cwd).toBe("/home/theo/next-project"); + }); + it("skips a repeated token_count so deltas are not double counted", () => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 5d909379eb10..015d9ef6b278 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -13,6 +13,11 @@ export interface UsageRecord { readonly timestampMs: number; readonly model: string; readonly sessionId: string; + /** + * Working directory the session ran in, or `""` when the transcript does not + * record one (Grok). Drives project attribution at aggregation time. + */ + readonly cwd: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; /** @@ -136,6 +141,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { timestampMs, model, sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + cwd: typeof record["cwd"] === "string" ? record["cwd"] : "", totals: { uncachedInputTokens: int(usageRecord["input_tokens"]), cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), @@ -163,6 +169,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { export interface CodexScanState { model: string; sessionId: string; + cwd: string; lastUsageSignature: string | null; sawSessionMeta: boolean; /** While true, leading usage events are re-stamped copies of parent history. */ @@ -174,6 +181,7 @@ export function initialCodexScanState(): CodexScanState { return { model: "", sessionId: "", + cwd: "", lastUsageSignature: null, sawSessionMeta: false, suppressingForkCopies: false, @@ -233,6 +241,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; if (typeof id === "string") state.sessionId = id; + if (typeof payloadRecord["cwd"] === "string") state.cwd = payloadRecord["cwd"]; const metaTimestampMs = parseTimestampMs(record["timestamp"]); if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { state.suppressingForkCopies = true; @@ -243,6 +252,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord if (record["type"] === "turn_context") { if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + if (typeof payloadRecord["cwd"] === "string") state.cwd = payloadRecord["cwd"]; return null; } @@ -301,6 +311,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord timestampMs, model: state.model, sessionId: state.sessionId, + cwd: state.cwd, totals, // Codex does not report cost in the rollout. reportedCostUsd: null, @@ -431,6 +442,8 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { timestampMs, model: "grok", sessionId, + // Grok session logs record no working directory. + cwd: "", totals: grokTotalsToUsage(topLevel), reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), // No prompt id means we cannot tell two same-second updates apart. @@ -477,6 +490,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { timestampMs, model: entry.model, sessionId, + cwd: "", totals, reportedCostUsd, dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f59a753d9c72..921055a6374e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2068,6 +2068,14 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetUsageThreadBreakdown]: (input) => + observeRpcEffect( + WS_METHODS.serverGetUsageThreadBreakdown, + usage.readThreadBreakdown(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRefreshUsageRates]: (_input) => observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index cae3dfe62852..bb2958693c24 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -4,9 +4,14 @@ import { Input as InputPrimitive } from "@base-ui/react/input"; import type * as React from "react"; import { cn } from "~/lib/utils"; +import { + segmentedControlItemSizeClassName, + segmentedControlItemVariantClassName, +} from "~/components/ui/segmented-control-styles"; type InputProps = Omit, "size"> & { - size?: "sm" | "compact" | "default" | "lg" | number; + size?: "sm" | "compact" | "default" | "lg" | "segmented" | number; + variant?: "default" | "segmented"; unstyled?: boolean; nativeInput?: boolean; }; @@ -14,6 +19,7 @@ type InputProps = Omit {inputElement} diff --git a/apps/web/src/components/ui/segmented-control-styles.ts b/apps/web/src/components/ui/segmented-control-styles.ts new file mode 100644 index 000000000000..bde9af0fb71e --- /dev/null +++ b/apps/web/src/components/ui/segmented-control-styles.ts @@ -0,0 +1,8 @@ +/** Shared visual contract for segmented controls and segmented inputs. */ +export const segmentedControlGroupClassName = "gap-0.5 rounded-lg bg-input/40 p-0.5"; + +export const segmentedControlItemSizeClassName = + "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]"; + +export const segmentedControlItemVariantClassName = + "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72"; diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx index 23501ec7a96c..2903bb8cd4b2 100644 --- a/apps/web/src/components/ui/toggle-group.tsx +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -6,6 +6,7 @@ import type { VariantProps } from "class-variance-authority"; import * as React from "react"; import { cn } from "~/lib/utils"; +import { segmentedControlGroupClassName } from "~/components/ui/segmented-control-styles"; import { Separator } from "~/components/ui/separator"; import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle"; @@ -31,7 +32,7 @@ function ToggleGroup({ ? "*:pointer-coarse:after:min-w-auto" : "*:pointer-coarse:after:min-h-auto", variant === "segmented" - ? "gap-0.5 rounded-lg bg-input/40 p-0.5" + ? segmentedControlGroupClassName : variant === "default" ? "gap-0.5" : orientation === "horizontal" diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 9f74d4546cc7..14b80a4440fa 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -4,6 +4,10 @@ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "~/lib/utils"; +import { + segmentedControlItemSizeClassName, + segmentedControlItemVariantClassName, +} from "~/components/ui/segmented-control-styles"; const toggleVariants = cva( "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base text-foreground outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-pressed:bg-input/64 data-pressed:text-accent-foreground sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", @@ -18,8 +22,7 @@ const toggleVariants = cva( "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", - segmented: - "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", + segmented: segmentedControlItemSizeClassName, sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -29,8 +32,7 @@ const toggleVariants = cva( "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", - segmented: - "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72", + segmented: segmentedControlItemVariantClassName, }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 0743b91f6edf..b58c87a0eb3e 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,12 +1,22 @@ -import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; +import type { ComponentProps, ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ + customWindow: false, + zoomToDays: undefined as ((since: string, until: string) => void) | undefined, + resetZoom: undefined as (() => void) | undefined, useUsage: vi.fn(), + usageThreadTable: vi.fn((_props: unknown) => null), metric: "cost" as "cost" | "tokens" | "limits", - breakdown: "time" as "model" | "time", + breakdown: "time" as "model" | "project" | "thread" | "time", + projectFilter: undefined as string | null | undefined, + refresh: vi.fn(async () => {}), + + setWindowSelection: vi.fn(), + refreshWindow: undefined as (() => void) | undefined, })); vi.mock("react", async (importOriginal) => { @@ -18,7 +28,8 @@ vi.mock("react", async (importOriginal) => { ? { metric: testState.metric, windowDays: 30 } : typeof initial === "function" ? { - days: 1, + days: testState.customWindow ? 30 : 1, + custom: testState.customWindow, window: { sinceDay: "2026-08-10", untilDay: "2026-08-11", @@ -32,15 +43,36 @@ vi.mock("react", async (importOriginal) => { ? testState.metric : initial === "model" ? testState.breakdown - : initial, - vi.fn(), + : initial === undefined + ? testState.projectFilter + : initial, + typeof initial === "function" && initial !== readUsagePagePreferences + ? testState.setWindowSelection + : vi.fn(), ]), }; }); vi.mock("../../env", () => ({ isElectron: false })); vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); -vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/button", () => ({ + Button: (props: { "aria-label"?: string; children?: ReactNode; onClick?: () => void }) => { + if (props["aria-label"] === "Refresh usage") testState.refreshWindow = props.onClick; + return ; + }, +})); +vi.mock("../ui/input", () => ({ + Input: ({ + nativeInput: _nativeInput, + size, + variant, + ...props + }: Omit, "size"> & { + nativeInput?: boolean; + size?: string; + variant?: string; + }) => , +})); vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); vi.mock("../ui/select", () => ({ Select: "div", @@ -58,7 +90,17 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ })); vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); -vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsageProviderChart", () => ({ + UsageProviderChart: (props: { + onZoomToDays?: (since: string, until: string) => void; + onResetZoom?: () => void; + }) => { + testState.zoomToDays = props.onZoomToDays; + testState.resetZoom = props.onResetZoom; + return
; + }, +})); +vi.mock("./UsageThreadTable", () => ({ UsageThreadTable: testState.usageThreadTable })); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); vi.mock("./usageProviders", async (importOriginal) => { const actual = await importOriginal(); @@ -139,13 +181,43 @@ const environments = [ }, ]; +const projectTotals = Object.freeze([ + { + projectId: ProjectId.make("project-expensive"), + projectKey: "id:project-expensive", + project: "Expensive Project", + costUsd: 9, + totalTokens: 200, + records: 2, + costShare: 9 / 20, + }, + { + projectId: null, + projectKey: null, + project: null, + costUsd: 7, + totalTokens: 900, + records: 1, + costShare: 7 / 20, + }, +]); + beforeEach(() => { + testState.customWindow = false; + testState.zoomToDays = undefined; + testState.resetZoom = undefined; testState.metric = "cost"; testState.breakdown = "time"; + testState.projectFilter = undefined; + testState.usageThreadTable.mockClear(); + testState.refresh.mockReset(); + testState.setWindowSelection.mockReset(); + testState.refreshWindow = undefined; testState.useUsage.mockReturnValue({ merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), models: modelTotals, + projects: projectTotals, hourly: [ { day: "2026-08-10", @@ -167,11 +239,30 @@ beforeEach(() => { selectedEnvironments: environments, isPending: false, isPartial: false, - refresh: vi.fn(), + refresh: testState.refresh, }); }); describe("UsagePage hourly breakdown", () => { + it("refreshes after rebasing a rolling window", () => { + renderToStaticMarkup(); + + testState.refreshWindow?.(); + + expect(testState.setWindowSelection).toHaveBeenCalledOnce(); + expect(testState.refresh).toHaveBeenCalledOnce(); + }); + + it("keeps custom date fields available in both desktop and compact layouts", () => { + const markup = renderToStaticMarkup(); + + expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); + expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); + expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), null, undefined, false); + expect(markup.match(/data-size="segmented"/g)).toHaveLength(4); + expect(markup.match(/data-variant="segmented"/g)).toHaveLength(4); + }); + it("keeps recent activity visible first without empty hourly rows", () => { const markup = renderToStaticMarkup(); const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; @@ -192,6 +283,133 @@ describe("UsagePage hourly breakdown", () => { }); }); +describe("UsagePage project breakdown", () => { + it("offers a lone project filter when unknown attribution remains in the totals", () => { + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { + ...usage.merged, + costUsd: 10, + totalTokens: 300, + projects: [projectTotals[0]], + }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup.match(/aria-label="Project filter"/g)).toHaveLength(2); + }); + + it("hides a lone project filter when it would not narrow the totals", () => { + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { + ...usage.merged, + costUsd: 9, + totalTokens: 200, + projects: [projectTotals[0]], + }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).not.toContain('aria-label="Project filter"'); + }); + + it("ranks projects by cost and labels unattributed work", () => { + testState.breakdown = "project"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/Expensive Project.*Outside projects/); + expect(body).toContain("$9.00"); + expect(body).toContain("$7.00"); + expect(body).toContain("45.0%"); + expect(body).toContain("35.0%"); + }); + + it("ranks projects by tokens when the token metric is selected", () => { + testState.metric = "tokens"; + testState.breakdown = "project"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/Outside projects.*Expensive Project/); + }); + + it("shows only the selected project in the project breakdown", () => { + testState.breakdown = "project"; + testState.projectFilter = "id:project-expensive"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toContain("Expensive Project"); + expect(body).not.toContain("Outside projects"); + expect(body).toContain("100.0%"); + }); + + it("distinguishes unattributed usage from an empty window", () => { + testState.breakdown = "project"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { ...usage.merged, projects: [], records: 1 }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("No project attribution in this window."); + expect(markup).not.toContain("No activity in this window."); + }); + + it("keeps the empty-window message when there is no usage", () => { + testState.breakdown = "project"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { ...usage.merged, projects: [], records: 0 }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("No activity in this window."); + expect(markup).not.toContain("No project attribution in this window."); + }); +}); + +describe("UsagePage thread breakdown", () => { + it("requests thread rows in the selected project scope", () => { + testState.breakdown = "thread"; + testState.projectFilter = "id:project-expensive"; + + renderToStaticMarkup(); + + expect(testState.usageThreadTable).toHaveBeenCalledOnce(); + expect(testState.usageThreadTable.mock.calls[0]?.[0]).toMatchObject({ + input: { + sinceDay: "2026-08-10", + untilDay: "2026-08-11", + timeZone: "UTC", + sinceTime: "2026-08-10T12:37:00.000Z", + untilTime: "2026-08-11T12:37:00.000Z", + projectKey: "id:project-expensive", + }, + providerContributions: [], + }); + expect(testState.useUsage).toHaveBeenLastCalledWith( + expect.anything(), + null, + "id:project-expensive", + true, + ); + }); +}); + describe("UsagePage model breakdown", () => { it("sorts models by cost when the cost metric is selected", () => { testState.breakdown = "model"; @@ -229,3 +447,47 @@ describe("UsagePage model breakdown", () => { ]); }); }); + +it("excludes deselected environments from thread failure counts", () => { + testState.breakdown = "thread"; + const usage = testState.useUsage(); + const excluded = { + ...environments[0]!, + environmentId: EnvironmentId.make("excluded"), + label: "Excluded", + error: "offline", + summary: null, + }; + testState.useUsage.mockReturnValue({ + ...usage, + environments: [...usage.environments, excluded], + selectedEnvironments: usage.selectedEnvironments, + }); + renderToStaticMarkup(); + expect(testState.usageThreadTable.mock.calls[0]?.[0]).toMatchObject({ + summaryFailedEnvironments: 0, + }); +}); + +it("restores the original custom window after repeated chart zooms", () => { + testState.customWindow = true; + renderToStaticMarkup(); + expect(testState.zoomToDays).toBeTypeOf("function"); + testState.zoomToDays?.("2026-08-10", "2026-08-10"); + testState.zoomToDays?.("2026-08-11", "2026-08-11"); + testState.resetZoom?.(); + expect(testState.setWindowSelection).toHaveBeenLastCalledWith( + expect.objectContaining({ + custom: true, + window: expect.objectContaining({ sinceDay: "2026-08-10", untilDay: "2026-08-11" }), + }), + ); +}); + +it("keeps an unzoomed custom range when the plot is double-clicked", () => { + testState.customWindow = true; + renderToStaticMarkup(); + expect(testState.resetZoom).toBeTypeOf("function"); + testState.resetZoom?.(); + expect(testState.setWindowSelection).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 21970c675596..85a491d646da 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -16,8 +16,10 @@ import { useMemo, useRef, useState } from "react"; import { isCompatibleUsageContractVersion, isModelCostUnknown, + projectFilterForEnvironment, type DailyTotals, type HourlyTotals, + type ProjectTotals, } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; @@ -27,6 +29,7 @@ import { serverEnvironment } from "../../state/server"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { useAtomCommand } from "../../state/use-atom-command"; import { + compareUsageDays, enumerateDays, enumerateHourStarts, formatCount, @@ -36,9 +39,13 @@ import { formatPercent, formatTokens, formatUsd, + makeCustomWindow, makeWindow, } from "@t3tools/shared/usageFormat"; +import { useCommitOnBlur } from "../../hooks/useCommitOnBlur"; import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { Input } from "../ui/input"; import { Menu, MenuCheckboxItem, @@ -48,6 +55,7 @@ import { MenuTrigger, } from "../ui/menu"; import { ScrollArea } from "../ui/scroll-area"; +import { segmentedControlGroupClassName } from "../ui/segmented-control-styles"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; import { Skeleton } from "../ui/skeleton"; @@ -62,6 +70,7 @@ import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { UsageThreadTable } from "./UsageThreadTable"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; import { readUsagePagePreferences, @@ -95,24 +104,31 @@ export function UsagePage() { const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ days: preferences.windowDays, + custom: false, window: makeWindow( preferences.windowDays, undefined, preferences.windowDays === 1 ? "hour" : "day", ), })); + const preZoomSelection = useRef(null); const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); const refreshingRef = useRef(false); - const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [breakdown, setBreakdown] = useState<"model" | "project" | "thread" | "time">("model"); + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + // A namespaced project key, null for work outside every project, undefined for all. + const [projectFilter, setProjectFilter] = useState(undefined); + const { days: windowDays, custom: isCustomWindow, window } = windowSelection; + const isPast24Hours = !isCustomWindow && windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( window, selectedEnvironmentIds, + projectFilter, + breakdown === "thread", ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { @@ -145,19 +161,83 @@ export function UsagePage() { : merged.models, [breakdown, merged.models, metric], ); + const breakdownProjects = useMemo(() => { + const scoped = + projectFilter === undefined + ? merged.projects + : merged.projects.filter((project) => project.projectKey === projectFilter); + return metric === "tokens" + ? scoped.toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ) + : scoped; + }, [merged.projects, metric, projectFilter]); + const breakdownProjectCostUsd = useMemo( + () => breakdownProjects.reduce((sum, project) => sum + project.costUsd, 0), + [breakdownProjects], + ); + const projectLabelsRef = useRef(new Map()); + for (const project of merged.projects) { + if (project.projectKey !== null && project.project !== null) { + projectLabelsRef.current.set(project.projectKey, project.project); + } + } + const selectedProjectLabel = + projectFilter === undefined + ? null + : projectFilter === null + ? "Outside projects" + : (projectLabelsRef.current.get(projectFilter) ?? "Selected project"); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; + // Session figures are per transcript directory; a project filter cannot + // split them, so they only render unfiltered. + const sessionsKnown = projectFilter === undefined; + const onlyProject = merged.projects.length === 1 ? merged.projects[0] : undefined; + // Unknown attribution remains in the overall totals but is absent from the + // project list. Keep a lone known project selectable when that distinction + // lets the user remove unknown usage from the page. + const showProjectPicker = + merged.projects.length > 1 || + projectFilter !== undefined || + (onlyProject !== undefined && + (onlyProject.totalTokens !== merged.totalTokens || onlyProject.costUsd !== merged.costUsd)); const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; + preZoomSelection.current = null; const nextPreferences = { metric, windowDays: days }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); setWindowSelection({ days, + custom: false, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectCustomWindow = (sinceDay: string, untilDay: string) => { + preZoomSelection.current = null; + setWindowSelection({ + days: windowDays, + custom: true, + window: makeCustomWindow(sinceDay, untilDay), + }); + }; + const zoomToDays = (sinceDay: string, untilDay: string) => { + preZoomSelection.current ??= windowSelection; + setWindowSelection({ + days: windowDays, + custom: true, + window: makeCustomWindow(sinceDay, untilDay), + }); + }; + const resetZoom = () => { + const original = preZoomSelection.current; + if (original === null) return; + preZoomSelection.current = null; + if (original.custom) setWindowSelection(original); + else selectWindow(original.days); + }; const selectMetric = (nextMetric: UsageMetric) => { const nextPreferences = { metric: nextMetric, windowDays }; setPreferences(nextPreferences); @@ -182,21 +262,31 @@ export function UsagePage() { }); return; } - const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + const nextWindow = isCustomWindow + ? window + : makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || nextWindow.untilDay !== window.untilDay || nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ days: windowDays, custom: false, window: nextWindow }); } refreshingRef.current = true; setIsRefreshing(true); - void refresh(nextWindow).finally(() => { - refreshingRef.current = false; - setIsRefreshing(false); - }); + void refresh(nextWindow) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not refresh usage", + description: error instanceof Error ? error.message : "Try again.", + }); + }) + .finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); }; const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined @@ -228,6 +318,14 @@ export function UsagePage() { ) : null}
+ {showProjectPicker ? ( + + ) : null} ))} + {/* The period does not apply to Limits, so it stays in place but disabled; unmounting it shifted the metric toggle ~300px. */} { const value = next[0]; @@ -273,6 +377,14 @@ export function UsagePage() {
+ {showProjectPicker ? ( + + ) : null} + to + +
+ ); +} + +/** + * Select values are plain strings, so the three filter states get distinct + * encodings: sentinels for "all" and "outside", while attributed projects + * already carry a namespaced stable key from the merge layer. + */ +const ALL_PROJECTS_VALUE = "all"; +const OUTSIDE_PROJECTS_VALUE = "outside"; +const PROJECT_VALUE_PREFIX = "p:"; + +function projectFilterValue(filter: string | null | undefined): string { + if (filter === undefined) return ALL_PROJECTS_VALUE; + if (filter === null) return OUTSIDE_PROJECTS_VALUE; + return `${PROJECT_VALUE_PREFIX}${filter}`; +} + +function projectFilterFromValue(value: string): string | null | undefined { + if (value === OUTSIDE_PROJECTS_VALUE) return null; + if (value.startsWith(PROJECT_VALUE_PREFIX)) return value.slice(PROJECT_VALUE_PREFIX.length); + return undefined; +} + +/** Narrows the whole page to one project's buckets. */ +function UsageProjectSelect({ + projects, + filter, + selectedLabel, + onChange, +}: { + readonly projects: readonly ProjectTotals[]; + readonly filter: string | null | undefined; + readonly selectedLabel: string | null; + readonly onChange: (filter: string | null | undefined) => void; +}) { + const label = filter === undefined ? "All projects" : (selectedLabel ?? "Selected project"); + return ( + + ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 622d73d13844..000c20a3ebf7 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPeriodColumns, niceScale } from "./UsageProviderChart"; +import { + brushSelection, + buildPeriodColumns, + chartLabelIndices, + niceScale, + periodIndexAt, + spanSinglePeriodPoints, +} from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; describe("niceScale", () => { @@ -135,3 +142,65 @@ describe("hourly chart columns", () => { ).toEqual([0, 4, 0]); }); }); + +describe("brushSelection", () => { + const days = ["2026-08-01", "2026-08-02", "2026-08-03", "2026-08-04"]; + + it("returns inclusive bounds for a forward drag", () => { + expect(brushSelection(days, 1, 3)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("normalises a backward drag", () => { + expect(brushSelection(days, 3, 1)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("treats a plain click as no selection", () => { + expect(brushSelection(days, 2, 2)).toBeNull(); + }); + + it("rejects endpoints outside the day list", () => { + expect(brushSelection(days, 0, 9)).toBeNull(); + }); +}); + +describe("periodIndexAt", () => { + it("clamps a captured pointer to either chart edge", () => { + expect(periodIndexAt(-50, 100, 400, 5)).toBe(0); + expect(periodIndexAt(750, 100, 400, 5)).toBe(4); + }); +}); + +describe("spanSinglePeriodPoints", () => { + it("repeats one point across the chart width", () => { + expect(spanSinglePeriodPoints([{ x: 0, y: 42 }])).toEqual([ + { x: 0, y: 42 }, + { x: 960, y: 42 }, + ]); + }); + + it("leaves multi-period points unchanged", () => { + const points = [ + { x: 0, y: 42 }, + { x: 960, y: 12 }, + ]; + + expect(spanSinglePeriodPoints(points)).toBe(points); + }); +}); + +describe("chartLabelIndices", () => { + it("deduplicates labels for one- and two-period windows", () => { + expect(chartLabelIndices(1)).toEqual([0]); + expect(chartLabelIndices(2)).toEqual([0, 1]); + }); + + it("keeps left, middle, and right labels for wider windows", () => { + expect(chartLabelIndices(5)).toEqual([0, 2, 4]); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 4a66349ddfa5..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -2,6 +2,8 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; + +import { cn } from "../../lib/utils"; import { formatDayShort, formatHourShort, @@ -19,6 +21,13 @@ const PLOT_TOP = 8; export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { + /** + * Present only when the window can zoom (daily resolution). Receives the + * inclusive day bounds of a completed drag selection. + */ + readonly onZoomToDays?: (sinceDay: string, untilDay: string) => void; + /** Restores the preset window on double-click. */ + readonly onResetZoom?: () => void; readonly providers: readonly UsageProviderKind[]; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; @@ -44,6 +53,18 @@ interface Point { readonly y: number; } +/** Gives a one-period daily window enough horizontal span to draw a path. */ +export function spanSinglePeriodPoints(points: readonly Point[]): readonly Point[] { + const only = points.length === 1 ? points[0] : undefined; + return only === undefined ? points : [only, { ...only, x: VIEW_WIDTH }]; +} + +/** Selects distinct left, middle, and right labels for the available span. */ +export function chartLabelIndices(periodCount: number): readonly number[] { + if (periodCount <= 0) return []; + return [...new Set([0, Math.floor(periodCount / 2), periodCount - 1])]; +} + function valueFor( totals: DailyTotals | HourlyTotals | undefined, provider: UsageProviderKind, @@ -169,7 +190,40 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re return { max, ticks }; } +/** + * Inclusive day bounds of a brush selection, or null for a plain click. + * Endpoints may arrive in either drag direction. + */ +export function brushSelection( + days: readonly string[], + startIndex: number, + endIndex: number, +): { readonly sinceDay: string; readonly untilDay: string } | null { + if (startIndex === endIndex) return null; + const [first, last] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; + const sinceDay = days[first]; + const untilDay = days[last]; + if (sinceDay === undefined || untilDay === undefined) return null; + return { sinceDay, untilDay }; +} + +/** Period index beneath a pointer, clamped when pointer capture moves outside the plot. */ +export function periodIndexAt( + clientX: number, + plotLeft: number, + plotWidth: number, + periodCount: number, +): number | null { + if (plotWidth <= 0 || periodCount <= 0) return null; + const localX = Math.min(plotWidth, Math.max(0, clientX - plotLeft)); + const fraction = localX / plotWidth; + const index = Math.round(fraction * (periodCount - 1)); + return Math.min(periodCount - 1, Math.max(0, index)); +} + export function UsageProviderChart({ + onZoomToDays, + onResetZoom, providers, days, daily, @@ -189,10 +243,36 @@ export function UsageProviderChart({ [daily, hourly, resolution], ); const [hoverIndex, setHoverIndex] = useState(null); + // Drag-selection endpoints, as period indices. Only daily windows zoom. + const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); + const brushRef = useRef<{ + readonly pointerId: number; + readonly days: readonly string[]; + readonly start: number; + readonly end: number; + } | null>(null); + const zoomable = resolution === "day" && onZoomToDays !== undefined; const plotRef = useRef(null); const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -221,14 +301,11 @@ export function UsageProviderChart({ 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), - })), - ), - ); + const points = columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })); + const line = curvePath(smoothCurve(spanSinglePeriodPoints(points))); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -288,23 +365,96 @@ export function UsageProviderChart({ return () => observer.disconnect(); }, [hoverIndex, positionTooltip]); + const indexAt = useCallback( + (clientX: number): number | null => { + const plot = plotRef.current; + if (plot === null || periods.length === 0) return null; + const bounds = plot.getBoundingClientRect(); + return periodIndexAt(clientX, bounds.left, bounds.width, periods.length); + }, + [periods.length], + ); + const handleMove = useCallback( (event: React.MouseEvent) => { const plot = plotRef.current; if (plot === null || periods.length === 0) return; const bounds = plot.getBoundingClientRect(); if (bounds.width === 0) return; + if (brushRef.current !== null) return; + const index = indexAt(event.clientX); + if (index === null) return; const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); - const fraction = localX / bounds.width; - const index = Math.round(fraction * (periods.length - 1)); hoverPositionRef.current = { x: localX, y: localY }; positionTooltip(); - setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); + setHoverIndex(index); }, - [periods.length, positionTooltip], + [indexAt, periods.length, positionTooltip], ); + const trackBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + !event.currentTarget.hasPointerCapture(event.pointerId) + ) { + return; + } + const index = indexAt(event.clientX); + if (index === null || index === activeBrush.end) return; + const nextBrush = { ...activeBrush, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + }, + [indexAt], + ); + + const beginBrush = useCallback( + (event: React.PointerEvent) => { + if (!zoomable || event.button !== 0 || !event.isPrimary || brushRef.current !== null) return; + const index = indexAt(event.clientX); + if (index === null) return; + event.currentTarget.setPointerCapture(event.pointerId); + hoverPositionRef.current = null; + setHoverIndex(null); + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + }, + [days, indexAt, zoomable], + ); + + const finishBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + onZoomToDays === undefined + ) { + return; + } + const end = indexAt(event.clientX) ?? activeBrush.end; + const selection = brushSelection(days, activeBrush.start, end); + brushRef.current = null; + setBrush(null); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (selection !== null) onZoomToDays(selection.sinceDay, selection.untilDay); + }, + [days, indexAt, onZoomToDays], + ); + + const cancelBrush = useCallback((event: React.PointerEvent) => { + if (brushRef.current?.pointerId !== event.pointerId) return; + brushRef.current = null; + setBrush(null); + }, []); + const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; const formatPeriod = (period: string) => @@ -332,8 +482,17 @@ export function UsageProviderChart({
{ hoverPositionRef.current = null; setHoverIndex(null); @@ -383,7 +542,22 @@ export function UsageProviderChart({ /> ))} - {hoverIndex === null ? null : ( + {brush === null || brush.start === brush.end ? null : ( + + )} + + {hoverIndex === null || periods.length === 1 ? null : (
- {periods[0] === undefined ? "" : formatPeriod(periods[0])} - - {periods[Math.floor(periods.length / 2)] === undefined - ? "" - : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")} - - - {periods[periods.length - 1] === undefined - ? "" - : formatPeriod(periods[periods.length - 1] ?? "")} - + {chartLabelIndices(periods.length).map((index) => ( + {formatPeriod(periods[index] ?? "")} + ))}
); diff --git a/apps/web/src/components/usage/UsageThreadTable.test.tsx b/apps/web/src/components/usage/UsageThreadTable.test.tsx new file mode 100644 index 000000000000..a27ef0615da2 --- /dev/null +++ b/apps/web/src/components/usage/UsageThreadTable.test.tsx @@ -0,0 +1,156 @@ +import { EnvironmentId, ThreadId, UsageDay } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ useUsageThreads: vi.fn() })); + +vi.mock("../../state/usage", () => ({ useUsageThreads: testState.useUsageThreads })); +vi.mock("../ui/tooltip", async () => { + const React = await import("react"); + return { + Tooltip: "span", + TooltipPopup: "span", + TooltipTrigger: ({ + render, + children, + }: { + render: React.ReactElement; + children: React.ReactNode; + }) => React.cloneElement(render, {}, children), + }; +}); +vi.mock("./usageProviders", () => ({ + PROVIDER_PRESENTATION: { + claude: { mark: "span" }, + codex: { mark: "span" }, + grok: { mark: "span" }, + }, +})); + +import { UsageThreadDailyChart, UsageThreadTable } from "./UsageThreadTable"; + +const input = { + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-31"), + timeZone: "UTC", +}; + +beforeEach(() => { + testState.useUsageThreads.mockReset(); +}); + +describe("UsageThreadTable", () => { + it("uses the shared skeleton treatment while thread data is pending", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [], + truncatedRows: 0, + isPending: true, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("motion-safe:animate-skeleton"); + }); + + it("reports an unavailable breakdown when every query failed", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [], + truncatedRows: 0, + isPending: false, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thread activity could not be loaded"); + expect(markup).not.toContain("No activity in this window"); + }); + + it("uses a keyboard-accessible disclosure button without a native title", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [ + { + environmentId: EnvironmentId.make("environment-one"), + key: "row-one", + threadId: ThreadId.make("thread-one"), + title: "Fix the flaky test", + provider: "claude", + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 2, + cacheCreationTokens: 3, + outputTokens: 4, + reasoningTokens: 0, + }, + costUsd: 1, + sessions: 1, + agents: [ + { + agentId: "agent-one", + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 1, + reasoningTokens: 0, + }, + costUsd: 0.1, + }, + ], + daily: [], + }, + ], + truncatedRows: 0, + isPending: false, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('