From 71009034f3e1d37792d6cc14e6d147f79d4e6a8e Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 01:38:31 -0400
Subject: [PATCH 01/14] Add usage scanning: transcripts to priced summaries
over RPC
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Server-side scan of the provider CLIs' own session transcripts
(~/.claude/projects, ~/.codex/sessions), so usage covers turns driven
from any tool sharing the provider home, not just Threadlines. Pure
line parsers handle Claude's per-content-block usage repeats (dedupe by
message/request pair) and Codex forked rollouts, whose copied parent
history would otherwise double count — a first-class concern here since
forking is a native feature. Parsed records are memoised per file by
(size, mtime) in an atomic on-disk cache; a usage.summary RPC returns
timezone-aware daily buckets for a bounded window.
Costs are API list-price equivalents priced from LiteLLM's public rate
table (24h disk cache, stale fallback offline), never billed spend;
records price as provider-reported, model-priced, or unpriced, and the
summary carries the quality split. packages/shared gains pure
usageMerge/usageFormat modules that combine per-environment summaries
with fingerprint-based duplicate-source detection, ready for the
phase-2 page.
---
apps/server/src/server.test.ts | 4 +
apps/server/src/server.ts | 5 +
apps/server/src/usage/UsageService.test.ts | 343 +++++++++++++
apps/server/src/usage/UsageService.ts | 461 ++++++++++++++++++
.../server/src/usage/usageAggregation.test.ts | 131 +++++
apps/server/src/usage/usageAggregation.ts | 185 +++++++
apps/server/src/usage/usagePricing.test.ts | 40 ++
apps/server/src/usage/usagePricing.ts | 158 ++++++
apps/server/src/usage/usageScanCache.test.ts | 187 +++++++
apps/server/src/usage/usageScanCache.ts | 254 ++++++++++
.../server/src/usage/usageTranscriptReader.ts | 142 ++++++
.../server/src/usage/usageTranscripts.test.ts | 267 ++++++++++
apps/server/src/usage/usageTranscripts.ts | 313 ++++++++++++
apps/server/src/ws.ts | 6 +
packages/contracts/src/index.ts | 1 +
packages/contracts/src/rpc.ts | 11 +
packages/contracts/src/usage.ts | 215 ++++++++
packages/shared/package.json | 8 +
packages/shared/src/usageFormat.ts | 117 +++++
packages/shared/src/usageMerge.test.ts | 255 ++++++++++
packages/shared/src/usageMerge.ts | 372 ++++++++++++++
21 files changed, 3475 insertions(+)
create mode 100644 apps/server/src/usage/UsageService.test.ts
create mode 100644 apps/server/src/usage/UsageService.ts
create mode 100644 apps/server/src/usage/usageAggregation.test.ts
create mode 100644 apps/server/src/usage/usageAggregation.ts
create mode 100644 apps/server/src/usage/usagePricing.test.ts
create mode 100644 apps/server/src/usage/usagePricing.ts
create mode 100644 apps/server/src/usage/usageScanCache.test.ts
create mode 100644 apps/server/src/usage/usageScanCache.ts
create mode 100644 apps/server/src/usage/usageTranscriptReader.ts
create mode 100644 apps/server/src/usage/usageTranscripts.test.ts
create mode 100644 apps/server/src/usage/usageTranscripts.ts
create mode 100644 packages/contracts/src/usage.ts
create mode 100644 packages/shared/src/usageFormat.ts
create mode 100644 packages/shared/src/usageMerge.test.ts
create mode 100644 packages/shared/src/usageMerge.ts
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index ea7746800..94a63d3ba 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -86,6 +86,7 @@ import {
type ProjectionSnapshotQueryShape,
} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
import { ThreadSearch, type ThreadSearchShape } from "./orchestration/Services/ThreadSearch.ts";
+import { UsageService } from "./usage/UsageService.ts";
import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
import { PersistenceSqlError } from "./persistence/Errors.ts";
import {
@@ -672,6 +673,9 @@ const buildAppUnderTest = (options?: {
search: () => Effect.succeed({ matches: [], truncated: false }),
...options?.layers?.threadSearch,
}),
+ // Usage scans real provider transcript directories; the empty summary
+ // keeps the RPC surface resolvable without touching the host's files.
+ UsageService.layerTest,
),
),
Layer.provide(
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index e964ef173..591021149 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -93,6 +93,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts
import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
import { ThreadSearchLive } from "./orchestration/Layers/ThreadSearch.ts";
+import { UsageServiceLive } from "./usage/UsageService.ts";
import {
clearPersistedServerRuntimeState,
makePersistedServerRuntimeState,
@@ -356,6 +357,10 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
const RuntimeServicesLive = Layer.mergeAll(
ServerRuntimeStartupLive.pipe(Layer.provideMerge(RuntimeDependenciesLive)),
ThreadSearchLive.pipe(Layer.provide(PersistenceLayerLive)),
+ // Usage reads provider transcripts straight off disk, so it needs only
+ // settings (for the resolved provider homes) plus the platform services and
+ // HTTP client provided further out.
+ UsageServiceLive.pipe(Layer.provide(ServerSettingsLive)),
);
export const makeRoutesLayer = Layer.mergeAll(
diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts
new file mode 100644
index 000000000..db5da67ec
--- /dev/null
+++ b/apps/server/src/usage/UsageService.test.ts
@@ -0,0 +1,343 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeFSP from "node:fs/promises";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+
+import { DEFAULT_SERVER_SETTINGS, UsageDay, type UsageSummary } from "@threadlines/contracts";
+import { NodeServices } from "@effect/platform-node";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Stream from "effect/Stream";
+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
+
+import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../config.ts";
+import { ServerSettingsService } from "../serverSettings.ts";
+import { UsageService, UsageServiceLive } from "./UsageService.ts";
+
+const CLAUDE_MODEL = "claude-fable-5";
+const CODEX_MODEL = "gpt-5.6-sol";
+
+/** Minimal LiteLLM document covering just the two models the fixtures use. */
+const RATE_DOCUMENT = {
+ [CLAUDE_MODEL]: {
+ input_cost_per_token: 1e-5,
+ output_cost_per_token: 5e-5,
+ cache_read_input_token_cost: 1e-6,
+ cache_creation_input_token_cost: 1.25e-5,
+ },
+ [CODEX_MODEL]: {
+ input_cost_per_token: 2e-6,
+ output_cost_per_token: 1e-5,
+ cache_read_input_token_cost: 2e-7,
+ },
+};
+
+const ratesHttpClientLayer = Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) =>
+ Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response(JSON.stringify(RATE_DOCUMENT), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ ),
+ ),
+ ),
+);
+
+function claudeAssistantLine(input: {
+ readonly messageId: string;
+ readonly requestId: string;
+ readonly timestamp: string;
+ readonly sessionId: string;
+ readonly outputTokens: number;
+}): string {
+ return JSON.stringify({
+ type: "assistant",
+ timestamp: input.timestamp,
+ sessionId: input.sessionId,
+ requestId: input.requestId,
+ message: {
+ id: input.messageId,
+ role: "assistant",
+ model: CLAUDE_MODEL,
+ usage: {
+ input_tokens: 10,
+ cache_creation_input_tokens: 100,
+ cache_read_input_tokens: 1000,
+ output_tokens: input.outputTokens,
+ },
+ },
+ });
+}
+
+function codexRollout(input: {
+ readonly sessionId: string;
+ readonly forkedFromId?: string;
+ /** `[timestamp, inputTokens, outputTokens]` per usage event. */
+ readonly events: readonly (readonly [string, number, number])[];
+ readonly openedAt: string;
+}): string {
+ const lines = [
+ JSON.stringify({
+ type: "session_meta",
+ timestamp: input.openedAt,
+ payload: {
+ id: input.sessionId,
+ forked_from_id: input.forkedFromId ?? null,
+ source: "vscode",
+ },
+ }),
+ JSON.stringify({
+ type: "turn_context",
+ timestamp: input.openedAt,
+ payload: { model: CODEX_MODEL },
+ }),
+ ];
+ for (const [timestamp, inputTokens, outputTokens] of input.events) {
+ lines.push(
+ JSON.stringify({
+ type: "event_msg",
+ timestamp,
+ payload: {
+ type: "token_count",
+ info: {
+ last_token_usage: {
+ input_tokens: inputTokens,
+ cached_input_tokens: 0,
+ cache_write_input_tokens: 0,
+ output_tokens: outputTokens,
+ reasoning_output_tokens: 0,
+ },
+ },
+ },
+ }),
+ );
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+/**
+ * Lays out a throwaway provider home pair and the server state dir the scan
+ * cache lives in. Real files on disk: the whole point of the service is what it
+ * finds by walking them.
+ */
+const makeFixture = Effect.fn("makeFixture")(function* () {
+ const root = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "threadlines-usage-")),
+ );
+ const claudeHome = NodePath.join(root, "claude-home");
+ const claudeProjects = NodePath.join(claudeHome, ".claude", "projects", "-tmp-project");
+ const codexHome = NodePath.join(root, "codex-home");
+ const codexSessions = NodePath.join(codexHome, "sessions", "2026", "08", "07");
+
+ yield* Effect.promise(async () => {
+ await NodeFSP.mkdir(claudeProjects, { recursive: true });
+ await NodeFSP.mkdir(codexSessions, { recursive: true });
+
+ // Two content blocks of one message repeat the same usage, and the same
+ // message is replayed into a second transcript: one record must survive.
+ const repeated = [
+ claudeAssistantLine({
+ messageId: "msg_1",
+ requestId: "req_1",
+ timestamp: "2026-08-07T12:00:00.000Z",
+ sessionId: "claude-session",
+ outputTokens: 40,
+ }),
+ claudeAssistantLine({
+ messageId: "msg_1",
+ requestId: "req_1",
+ timestamp: "2026-08-07T12:00:00.000Z",
+ sessionId: "claude-session",
+ outputTokens: 40,
+ }),
+ ];
+ await NodeFSP.writeFile(
+ NodePath.join(claudeProjects, "session-a.jsonl"),
+ `${repeated.join("\n")}\n`,
+ );
+ await NodeFSP.writeFile(NodePath.join(claudeProjects, "session-b.jsonl"), `${repeated[0]!}\n`);
+
+ await NodeFSP.writeFile(
+ NodePath.join(codexSessions, "rollout-parent.jsonl"),
+ codexRollout({
+ sessionId: "codex-parent",
+ openedAt: "2026-08-07T12:00:00.000Z",
+ events: [["2026-08-07T12:00:10.000Z", 500, 20]],
+ }),
+ );
+ // The fork replays the parent's turn re-stamped to the fork instant, then
+ // runs one turn of its own. Only the second may be counted.
+ await NodeFSP.writeFile(
+ NodePath.join(codexSessions, "rollout-fork.jsonl"),
+ codexRollout({
+ sessionId: "codex-fork",
+ forkedFromId: "codex-parent",
+ openedAt: "2026-08-07T13:00:00.000Z",
+ events: [
+ ["2026-08-07T13:00:00.001Z", 500, 20],
+ ["2026-08-07T13:00:30.000Z", 700, 35],
+ ],
+ }),
+ );
+ });
+
+ const derivedPaths = yield* deriveServerPaths(NodePath.join(root, "state"), undefined).pipe(
+ Effect.provide(NodeServices.layer),
+ );
+ const config: ServerConfigShape = {
+ appVersion: "0.0.0-test",
+ logLevel: "Info",
+ traceMinLevel: "Info",
+ traceTimingEnabled: false,
+ traceBatchWindowMs: 200,
+ traceMaxBytes: 1024,
+ traceMaxFiles: 1,
+ otlpTracesUrl: undefined,
+ otlpMetricsUrl: undefined,
+ otlpExportIntervalMs: 10_000,
+ otlpServiceName: "threadlines-server",
+ mode: "web",
+ port: 0,
+ host: "127.0.0.1",
+ cwd: root,
+ baseDir: NodePath.join(root, "state"),
+ ...derivedPaths,
+ staticDir: undefined,
+ devUrl: undefined,
+ noBrowser: true,
+ startupPresentation: "browser",
+ desktopBootstrapToken: undefined,
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: false,
+ tailscaleServeEnabled: false,
+ tailscaleServePort: 443,
+ };
+ yield* Effect.promise(() => NodeFSP.mkdir(config.stateDir, { recursive: true }));
+
+ const settings = {
+ ...DEFAULT_SERVER_SETTINGS,
+ providers: {
+ ...DEFAULT_SERVER_SETTINGS.providers,
+ claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, homePath: claudeHome },
+ codex: { ...DEFAULT_SERVER_SETTINGS.providers.codex, homePath: codexHome },
+ },
+ };
+
+ const layer = UsageServiceLive.pipe(
+ Layer.provide(
+ Layer.mock(ServerSettingsService)({
+ start: Effect.void,
+ ready: Effect.void,
+ getSettings: Effect.succeed(settings),
+ updateSettings: () => Effect.succeed(settings),
+ streamChanges: Stream.empty,
+ }),
+ ),
+ Layer.provide(Layer.succeed(ServerConfig, config)),
+ Layer.provide(ratesHttpClientLayer),
+ Layer.provide(NodeServices.layer),
+ );
+
+ return { root, claudeProjects, config, layer } as const;
+});
+
+const window = {
+ sinceDay: UsageDay.make("2026-08-01"),
+ untilDay: UsageDay.make("2026-08-31"),
+ timeZone: "UTC",
+};
+
+function bucketFor(summary: UsageSummary, provider: "claude" | "codex") {
+ return summary.buckets.find((bucket) => bucket.provider === provider);
+}
+
+describe("UsageService.readSummary", () => {
+ // it.live throughout: the service walks real directories and reads the real
+ // clock, both of which stall under it.effect's TestClock.
+ it.live("prices a window, dropping repeated Claude usage and copied fork turns", () =>
+ Effect.gen(function* () {
+ const fixture = yield* makeFixture();
+ const summary = yield* Effect.provide(
+ Effect.flatMap(UsageService, (usage) => usage.readSummary(window)),
+ fixture.layer,
+ );
+
+ const claude = bucketFor(summary, "claude");
+ // One message, repeated twice in one file and once more in a second.
+ expect(claude?.records).toBe(1);
+ expect(claude?.totals.outputTokens).toBe(40);
+ // 10*1e-5 + 1000*1e-6 + 100*1.25e-5 + 40*5e-5
+ expect(claude?.costUsd).toBeCloseTo(0.00435, 9);
+ expect(claude?.costSource).toBe("modelPriced");
+
+ const codex = bucketFor(summary, "codex");
+ // Parent's single turn plus the fork's own turn; the fork's replayed copy
+ // of the parent turn must not be counted a second time.
+ expect(codex?.records).toBe(2);
+ expect(codex?.totals.outputTokens).toBe(55);
+ expect(codex?.sessions).toBe(2);
+
+ expect(summary.pricing.status).toBe("fresh");
+ expect(summary.sources.map((source) => source.status)).toEqual(["ok", "ok"]);
+ expect(summary.sources.every((source) => source.lastScannedAt.length > 0)).toBe(true);
+ yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true }));
+ }),
+ );
+
+ it.live("picks up a transcript that changed and leaves the rest memoised", () =>
+ Effect.gen(function* () {
+ const fixture = yield* makeFixture();
+ const read = Effect.provide(
+ Effect.flatMap(UsageService, (usage) => usage.readSummary(window)),
+ fixture.layer,
+ );
+
+ const before = yield* read;
+ expect(bucketFor(before, "claude")?.totals.outputTokens).toBe(40);
+
+ yield* Effect.promise(() =>
+ NodeFSP.appendFile(
+ NodePath.join(fixture.claudeProjects, "session-a.jsonl"),
+ `${claudeAssistantLine({
+ messageId: "msg_2",
+ requestId: "req_2",
+ timestamp: "2026-08-08T12:00:00.000Z",
+ sessionId: "claude-session",
+ outputTokens: 7,
+ })}\n`,
+ ),
+ );
+
+ const after = yield* read;
+ const claudeBuckets = after.buckets.filter((bucket) => bucket.provider === "claude");
+ expect(claudeBuckets.map((bucket) => bucket.day)).toEqual(["2026-08-07", "2026-08-08"]);
+ expect(claudeBuckets[1]?.totals.outputTokens).toBe(7);
+
+ yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true }));
+ }),
+ );
+
+ it.live("rejects a window longer than the cache retains", () =>
+ Effect.gen(function* () {
+ const fixture = yield* makeFixture();
+ const failure = yield* Effect.provide(
+ Effect.flatMap(UsageService, (usage) =>
+ usage.readSummary({
+ sinceDay: UsageDay.make("2025-08-01"),
+ untilDay: UsageDay.make("2026-08-31"),
+ timeZone: "UTC",
+ }),
+ ),
+ fixture.layer,
+ ).pipe(Effect.flip);
+
+ expect(failure.reason).toBe("invalidWindow");
+ yield* Effect.promise(() => NodeFSP.rm(fixture.root, { recursive: true, force: true }));
+ }),
+ );
+});
diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts
new file mode 100644
index 000000000..929325267
--- /dev/null
+++ b/apps/server/src/usage/UsageService.ts
@@ -0,0 +1,461 @@
+/**
+ * UsageService - scans provider transcripts and returns priced daily usage.
+ *
+ * The scan reads the provider CLIs' own session files rather than Threadlines'
+ * orchestration projections, so usage covers turns driven from the CLI, an
+ * editor extension, or any other tool sharing the same provider home. This is
+ * the approach `ccusage` takes.
+ *
+ * Transcripts are append-only, so parsed records are memoised per file by
+ * `(size, mtime)`; warm scans only reparse files that changed.
+ *
+ * Cost figures are API list-price equivalents, never billed spend. Subscription
+ * plans bill on their own terms and the transcripts carry no invoice.
+ *
+ * @module UsageService
+ */
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeOS from "node:os";
+
+import {
+ USAGE_CONTRACT_VERSION,
+ USAGE_MAX_WINDOW_DAYS,
+ type UsageProviderKind,
+ type UsageSource,
+ type UsageSummary,
+ type UsageSummaryInput,
+ UsageReadError,
+} from "@threadlines/contracts";
+import * as Cause from "effect/Cause";
+import * as Clock from "effect/Clock";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Path from "effect/Path";
+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
+
+import { writeFileStringAtomically } from "../atomicWrite.ts";
+import { ServerConfig } from "../config.ts";
+import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
+import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
+import { ServerSettingsService } from "../serverSettings.ts";
+import { UsageAggregator } from "./usageAggregation.ts";
+import { parseRateTable, type RateTable } from "./usagePricing.ts";
+import {
+ decodeScanCache,
+ dedupeWithinFile,
+ encodeScanCache,
+ pruneScanCache,
+ type ScanCache,
+} from "./usageScanCache.ts";
+import {
+ listTranscriptFiles,
+ readDirectoryVolumeId,
+ readTranscriptRecords,
+} from "./usageTranscriptReader.ts";
+import type { UsageRecord } from "./usageTranscripts.ts";
+
+const LITELLM_RATES_URL =
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
+
+/** Rates move rarely; a day-old table keeps the page working offline. */
+const RATES_TTL_MS = 24 * 60 * 60 * 1000;
+
+const RATES_FETCH_TIMEOUT_MS = 10_000;
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Files are filtered by mtime before opening. The slack covers a session whose
+ * last write lands just before local midnight on the window's first day.
+ */
+const MTIME_SLACK_MS = 36 * 60 * 60 * 1000;
+
+/** Longest window the UI offers, plus slack. Older entries are pruned. */
+const CACHE_RETENTION_DAYS = USAGE_MAX_WINDOW_DAYS;
+
+interface RatesCacheFile {
+ readonly fetchedAtMs: number;
+ readonly document: unknown;
+}
+
+/**
+ * Both on-disk caches hold documents that are narrowed by hand downstream
+ * (`decodeScanCache`, `parseRateTable`), so a schema here would only restate
+ * `unknown`. A parse failure means one cold scan, never a failed read.
+ */
+const parseJsonFile = (raw: string): Effect.Effect =>
+ Effect.try(() => JSON.parse(raw) as unknown);
+
+function readRatesCacheFile(document: unknown): RatesCacheFile | null {
+ if (typeof document !== "object" || document === null) return null;
+ const record = document as Partial;
+ if (typeof record.fetchedAtMs !== "number" || !Number.isFinite(record.fetchedAtMs)) return null;
+ return { fetchedAtMs: record.fetchedAtMs, document: record.document };
+}
+
+export interface UsageServiceShape {
+ readonly readSummary: (input: UsageSummaryInput) => Effect.Effect;
+}
+
+export class UsageService extends Context.Service()(
+ "threadlines/usage/UsageService",
+) {
+ /** Empty summary, for suites that only need the RPC surface to resolve. */
+ static readonly layerTest = Layer.succeed(
+ UsageService,
+ UsageService.of({
+ readSummary: (input) =>
+ Effect.succeed({
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: "1970-01-01T00:00:00.000Z",
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ buckets: [],
+ sources: [],
+ pricing: {
+ status: "unavailable",
+ source: LITELLM_RATES_URL,
+ fetchedAt: null,
+ knownModels: 0,
+ },
+ scanDurationMs: 0,
+ }),
+ }),
+ );
+}
+
+export const make = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const config = yield* ServerConfig;
+ const settingsService = yield* ServerSettingsService;
+ const httpClient = yield* HttpClient.HttpClient;
+
+ const fileCache: ScanCache = new Map();
+ let cacheDirty = false;
+
+ /**
+ * Both caches are rebuildable, so a failed write costs one cold scan and must
+ * never surface. Written atomically so a crash mid-write cannot leave a
+ * truncated document that the next start would reject wholesale.
+ */
+ const writeCacheFile = (filePath: string, contents: string): Effect.Effect =>
+ writeFileStringAtomically({ filePath, contents }).pipe(
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ Effect.as(true),
+ Effect.catchCause(() => Effect.succeed(false)),
+ );
+
+ const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
+ const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");
+ let rates: RateTable = new Map();
+ let ratesFetchedAtMs: number | null = null;
+ let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable";
+
+ /**
+ * Loads the LiteLLM rate table, preferring a fresh copy and falling back to
+ * the on-disk snapshot. With neither, every model reports as unpriced rather
+ * than the page failing.
+ */
+ const ensureRates = Effect.fn("UsageService.ensureRates")(function* () {
+ const now = yield* Clock.currentTimeMillis;
+ if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return;
+
+ if (ratesFetchedAtMs === null) {
+ const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(
+ Effect.flatMap(parseJsonFile),
+ Effect.map(readRatesCacheFile),
+ Effect.catchCause(() => Effect.succeed(null)),
+ );
+ if (fromDisk !== null) {
+ const parsed = parseRateTable(fromDisk.document);
+ if (parsed.size > 0) {
+ rates = parsed;
+ ratesFetchedAtMs = fromDisk.fetchedAtMs;
+ ratesStatus = "cached";
+ if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return;
+ }
+ }
+ }
+
+ const fetched = yield* httpClient.get(LITELLM_RATES_URL).pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap((response) => response.json),
+ Effect.timeout(RATES_FETCH_TIMEOUT_MS),
+ Effect.catchCause(() => Effect.succeed(null)),
+ );
+ if (fetched === null) {
+ // The refresh failed; whatever we are serving is now past its TTL and
+ // must not keep claiming to be fresh.
+ if (rates.size > 0) ratesStatus = "cached";
+ return;
+ }
+
+ const parsed = parseRateTable(fetched);
+ if (parsed.size === 0) return;
+
+ rates = parsed;
+ ratesFetchedAtMs = now;
+ ratesStatus = "fresh";
+
+ yield* writeCacheFile(
+ ratesCachePath,
+ JSON.stringify({ fetchedAtMs: now, document: fetched } satisfies RatesCacheFile),
+ );
+ });
+
+ /**
+ * `resolveClaudeHomePath` returns the process home the CLI runs under, which
+ * is the home itself when overridden. A default install nests transcripts
+ * under `~/.claude/projects`, so probe that before falling back.
+ */
+ const resolveClaudeTranscriptDir = (homePath: string) =>
+ Effect.gen(function* () {
+ const nested = path.join(homePath, ".claude", "projects");
+ const nestedExists = yield* fileSystem
+ .exists(nested)
+ .pipe(Effect.catchCause(() => Effect.succeed(false)));
+ return nestedExists ? nested : path.join(homePath, "projects");
+ });
+
+ /** Resolves the transcript directory for each provider. */
+ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () {
+ // A settings failure must surface as an error: swallowing it here would
+ // present "zero usage from every provider" as a valid answer.
+ const settings = yield* settingsService.getSettings.pipe(
+ Effect.catchCause(
+ (cause) =>
+ new UsageReadError({
+ reason: "scanFailed",
+ // Bounded description; the squashed failure travels as the cause.
+ // Squashed, not the Cause tree: a full tree in a Defect field is
+ // the unbounded wire payload the bounded detail exists to avoid.
+ detail: "Server settings could not be read.",
+ cause: Cause.squash(cause),
+ }),
+ ),
+ );
+
+ const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
+ const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
+ // The shared home, not the shadow overlay: the overlay only isolates
+ // `auth.json`, and `sessions` is a symlink back to the shared directory.
+ const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
+
+ return [
+ { provider: "claude" as const, dir: claudeDir },
+ { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },
+ ];
+ });
+
+ /**
+ * Loads the persisted scan cache exactly once per process.
+ *
+ * `Effect.cached` makes concurrent first readers await the same load rather
+ * than each seeing a "loaded" flag set before the read finished and cold
+ * scanning against an empty cache.
+ */
+ const ensureScanCacheLoaded = yield* Effect.cached(
+ Effect.gen(function* () {
+ const document = yield* fileSystem.readFileString(scanCachePath).pipe(
+ Effect.flatMap(parseJsonFile),
+ Effect.catchCause(() => Effect.succeed(null)),
+ );
+ if (document === null) return;
+ for (const [cachedPath, entry] of decodeScanCache(document)) fileCache.set(cachedPath, entry);
+ }),
+ );
+
+ const persistScanCache = Effect.fn("UsageService.persistScanCache")(function* () {
+ if (!cacheDirty) return;
+ // Cleared only after the write lands, so a failed persist is retried on the
+ // next scan instead of leaving disk permanently stale.
+ const written = yield* writeCacheFile(
+ scanCachePath,
+ JSON.stringify(encodeScanCache(fileCache)),
+ );
+ if (written) cacheDirty = false;
+ });
+
+ /** Parses one transcript, reusing the cached result when it is unchanged. */
+ const readFileRecords = (
+ filePath: string,
+ size: number,
+ mtimeMs: number,
+ provider: UsageProviderKind,
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const cached = fileCache.get(filePath);
+ // Provider is part of the identity: if both providers were ever pointed
+ // at one directory, a hit parsed by the other parser must not be reused.
+ if (
+ cached &&
+ cached.size === size &&
+ cached.mtimeMs === mtimeMs &&
+ cached.provider === provider
+ ) {
+ return cached.records;
+ }
+
+ const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider));
+ // A read failure is not an empty transcript: caching it under this
+ // (size, mtime) would silently drop the file's usage until it changes.
+ if (parsed === null) return [];
+ // Stored already de-duplicated within the file, which is the bulk of all
+ // duplicates. The aggregator still runs the cross-file dedupe pass.
+ const records = dedupeWithinFile(parsed);
+
+ fileCache.set(filePath, { size, mtimeMs, provider, records });
+ cacheDirty = true;
+ return records;
+ });
+
+ const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) {
+ 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)) {
+ return yield* new UsageReadError({
+ reason: "invalidWindow",
+ detail: `'${input.sinceDay}'..'${input.untilDay}' is not a valid date range`,
+ });
+ }
+
+ const sinceMs = DateTime.toEpochMillis(windowStart.value);
+ const spanDays = Math.round((DateTime.toEpochMillis(windowEnd.value) - sinceMs) / DAY_MS) + 1;
+ // The scan cache only retains this far back, so a longer window would cold
+ // scan every call. Reject it rather than silently taking minutes.
+ if (spanDays > USAGE_MAX_WINDOW_DAYS) {
+ return yield* new UsageReadError({
+ reason: "invalidWindow",
+ detail: `window of ${spanDays} days exceeds the ${USAGE_MAX_WINDOW_DAYS}-day maximum`,
+ });
+ }
+
+ const startedAtMs = yield* Clock.currentTimeMillis;
+ yield* ensureRates();
+ yield* ensureScanCacheLoaded;
+
+ const hostId = NodeOS.hostname();
+ // The home resolvers ask for `Path` themselves; satisfy them from the
+ // instance we already hold so `readSummary` stays context-free.
+ const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path));
+ const windowStartMs = sinceMs - MTIME_SLACK_MS;
+
+ const aggregator = new UsageAggregator({
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ rates,
+ });
+
+ const sources: UsageSource[] = [];
+ const livePaths = new Set();
+ const walkedRoots: string[] = [];
+
+ for (const { provider, dir } of dirs) {
+ const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
+ const exists = yield* fileSystem
+ .exists(dir)
+ .pipe(Effect.catchCause(() => Effect.succeed(false)));
+ const scannedAt = yield* DateTime.now;
+
+ if (!exists) {
+ sources.push({
+ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
+ status: "missing",
+ lastScannedAt: DateTime.formatIso(scannedAt),
+ scannedFiles: 0,
+ skippedFiles: 0,
+ distinctSessions: 0,
+ message: "No transcript directory on this environment.",
+ });
+ continue;
+ }
+
+ walkedRoots.push(dir);
+ const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs));
+ 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);
+ const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider);
+ if (records.length === 0) {
+ skippedFiles += 1;
+ continue;
+ }
+ scannedFiles += 1;
+ for (const record of 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);
+ }
+ }
+ }
+
+ sources.push({
+ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
+ status: "ok",
+ lastScannedAt: DateTime.formatIso(scannedAt),
+ scannedFiles,
+ skippedFiles,
+ distinctSessions: sessionIds.size,
+ message: null,
+ });
+ }
+
+ const pruned = pruneScanCache(fileCache, {
+ livePaths,
+ walkedRoots,
+ windowStartMs,
+ retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * DAY_MS,
+ });
+ 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),
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ buckets: aggregated.buckets,
+ sources,
+ pricing: {
+ status: ratesStatus,
+ source: LITELLM_RATES_URL,
+ fetchedAt:
+ ratesFetchedAtMs === null
+ ? null
+ : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)),
+ knownModels: rates.size,
+ },
+ scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),
+ } satisfies UsageSummary;
+ });
+
+ return { readSummary } as const;
+});
+
+export const UsageServiceLive = Layer.effect(UsageService, make);
diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts
new file mode 100644
index 000000000..b9a4a70b9
--- /dev/null
+++ b/apps/server/src/usage/usageAggregation.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { UsageAggregator } from "./usageAggregation.ts";
+import type { RateTable } from "./usagePricing.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,
+ },
+ ],
+]);
+
+function record(overrides: Partial = {}): UsageRecord {
+ return {
+ provider: "claude",
+ // 2026-08-07T04:05Z is still Aug 6 in Los Angeles.
+ timestampMs: Date.parse("2026-08-07T04:05:13.944Z"),
+ model: "claude-fable-5",
+ sessionId: "session-a",
+ totals: {
+ uncachedInputTokens: 100,
+ cachedInputTokens: 1000,
+ cacheCreationTokens: 10,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ },
+ reportedCostUsd: null,
+ dedupeKey: null,
+ ...overrides,
+ };
+}
+
+function aggregate(records: readonly UsageRecord[], timeZone = "UTC") {
+ const aggregator = new UsageAggregator({
+ timeZone,
+ sinceDay: "2026-08-01",
+ untilDay: "2026-08-31",
+ rates,
+ });
+ for (const item of records) aggregator.add(item);
+ return aggregator.finish();
+}
+
+describe("UsageAggregator", () => {
+ it("keeps only the first record for a repeated dedupe key", () => {
+ const result = aggregate([
+ record({ dedupeKey: "msg_1:req_1" }),
+ record({ dedupeKey: "msg_1:req_1" }),
+ record({ dedupeKey: "msg_1:req_1" }),
+ ]);
+
+ expect(result.duplicatesDropped).toBe(2);
+ expect(result.buckets[0]?.records).toBe(1);
+ expect(result.buckets[0]?.totals.outputTokens).toBe(50);
+ });
+
+ it("still sums records that carry no dedupe key", () => {
+ const result = aggregate([record(), record()]);
+
+ expect(result.duplicatesDropped).toBe(0);
+ expect(result.buckets[0]?.totals.outputTokens).toBe(100);
+ });
+
+ it("buckets by the day in the requested time zone", () => {
+ const utc = aggregate([record()], "UTC");
+ const losAngeles = aggregate([record()], "America/Los_Angeles");
+
+ expect(utc.buckets[0]?.day).toBe("2026-08-07");
+ expect(losAngeles.buckets[0]?.day).toBe("2026-08-06");
+ });
+
+ it("prices against the rate table", () => {
+ const result = aggregate([record()]);
+
+ // 100*1e-5 + 1000*1e-6 + 10*1.25e-5 + 50*5e-5
+ expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9);
+ expect(result.buckets[0]?.costSource).toBe("modelPriced");
+ // Cached input at full input rates minus what it actually cost.
+ expect(result.buckets[0]?.cacheSavingsUsd).toBeCloseTo(0.009, 9);
+ });
+
+ it("counts tokens but not cost for a model with no rate", () => {
+ const result = aggregate([record({ model: "kimi-k3" })]);
+
+ expect(result.buckets[0]?.costUsd).toBe(0);
+ expect(result.buckets[0]?.costSource).toBe("unpriced");
+ expect(result.buckets[0]?.unpricedRecords).toBe(1);
+ expect(result.buckets[0]?.totals.outputTokens).toBe(50);
+ });
+
+ it("prefers a reported cost over the rate table", () => {
+ const result = aggregate([record({ reportedCostUsd: 1.25 })]);
+
+ expect(result.buckets[0]?.costUsd).toBe(1.25);
+ expect(result.buckets[0]?.costSource).toBe("providerReported");
+ });
+
+ it("drops records outside the window and reports whether one contributed", () => {
+ const aggregator = new UsageAggregator({
+ timeZone: "UTC",
+ sinceDay: "2026-08-01",
+ untilDay: "2026-08-31",
+ rates,
+ });
+
+ expect(aggregator.add(record({ dedupeKey: "msg_1:req_1" }))).toBe(true);
+ expect(aggregator.add(record({ dedupeKey: "msg_1:req_1" }))).toBe(false);
+ // The mtime prefilter admits boundary files whose records fall outside.
+ expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-31T23:59:59Z") }))).toBe(false);
+ expect(aggregator.add(record({ timestampMs: Date.parse("2026-09-01T00:00:00Z") }))).toBe(false);
+ expect(aggregator.add(record({ timestampMs: Date.parse("2026-08-01T00:00:00Z") }))).toBe(true);
+
+ expect(aggregator.finish().outOfWindow).toBe(2);
+ });
+
+ it("separates providers and models into their own buckets", () => {
+ const result = aggregate([
+ record(),
+ record({ provider: "codex", model: "gpt-5.6-sol" }),
+ record({ model: "claude-opus-5" }),
+ ]);
+
+ expect(result.buckets).toHaveLength(3);
+ });
+});
diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts
new file mode 100644
index 000000000..02a7a75f5
--- /dev/null
+++ b/apps/server/src/usage/usageAggregation.ts
@@ -0,0 +1,185 @@
+// @effect-diagnostics globalDate:off
+/**
+ * Folds parsed transcript records into `(day, 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`
+ * construction is allowed here; nothing in this module reads the clock.
+ *
+ * Pure, so the bucketing and de-duplication rules are testable without touching
+ * the filesystem or the network.
+ *
+ * @module usageAggregation
+ */
+import type { UsageBucket, UsageDay, UsageTokenTotals } from "@threadlines/contracts";
+
+import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.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).
+ */
+export function makeDayFormatter(timeZone: string): (timestampMs: number) => string {
+ let format: Intl.DateTimeFormat;
+ try {
+ format = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ } catch {
+ // An unknown zone should degrade to UTC rather than fail the whole scan.
+ format = new Intl.DateTimeFormat("en-CA", {
+ timeZone: "UTC",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ }
+ return (timestampMs) => format.format(new Date(timestampMs));
+}
+
+interface MutableBucket {
+ totals: UsageTokenTotals;
+ costUsd: number;
+ cacheSavingsUsd: number;
+ records: number;
+ unpricedRecords: number;
+ providerReportedRecords: number;
+ sessions: Set;
+}
+
+export interface AggregateOptions {
+ readonly timeZone: string;
+ readonly sinceDay: string;
+ readonly untilDay: string;
+ readonly rates: RateTable;
+}
+
+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. */
+ readonly outOfWindow: number;
+}
+
+/**
+ * Accumulates records across many files.
+ *
+ * De-duplication is global across the whole scan, not per file: Claude Code
+ * copies a message's records forward when a session is resumed or relocated, so
+ * the same `dedupeKey` legitimately appears in several transcripts.
+ */
+export class UsageAggregator {
+ readonly #buckets = new Map();
+ readonly #seen = new Set();
+ readonly #toDay: (timestampMs: number) => string;
+ readonly #options: AggregateOptions;
+ #duplicatesDropped = 0;
+ #outOfWindow = 0;
+
+ constructor(options: AggregateOptions) {
+ this.#options = options;
+ this.#toDay = makeDayFormatter(options.timeZone);
+ }
+
+ /**
+ * 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.
+ */
+ 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 day = this.#toDay(record.timestampMs);
+ if (day < this.#options.sinceDay || day > this.#options.untilDay) {
+ this.#outOfWindow += 1;
+ return false;
+ }
+
+ const key = `${day} ${record.provider} ${record.model}`;
+ let bucket = this.#buckets.get(key);
+ if (bucket === undefined) {
+ bucket = {
+ totals: EMPTY_TOTALS,
+ costUsd: 0,
+ cacheSavingsUsd: 0,
+ records: 0,
+ unpricedRecords: 0,
+ providerReportedRecords: 0,
+ sessions: new Set(),
+ };
+ this.#buckets.set(key, bucket);
+ }
+
+ const priced = priceUsage(
+ this.#options.rates,
+ record.model,
+ record.totals,
+ record.reportedCostUsd,
+ );
+
+ bucket.totals = addTotals(bucket.totals, record.totals);
+ bucket.costUsd += priced.costUsd;
+ bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals);
+ bucket.records += 1;
+ 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 buckets: UsageBucket[] = [];
+ for (const [key, bucket] of this.#buckets) {
+ const [day = "", provider = "", model = ""] = key.split(" ");
+ buckets.push({
+ day: day as UsageDay,
+ provider: provider as UsageBucket["provider"],
+ model,
+ totals: bucket.totals,
+ costUsd: bucket.costUsd,
+ cacheSavingsUsd: bucket.cacheSavingsUsd,
+ costSource: resolveCostSource(bucket),
+ records: bucket.records,
+ unpricedRecords: bucket.unpricedRecords,
+ sessions: bucket.sessions.size,
+ });
+ }
+ // Stable ordering keeps payloads diffable and snapshot tests meaningful.
+ buckets.sort(
+ (a, b) =>
+ a.day.localeCompare(b.day) ||
+ a.provider.localeCompare(b.provider) ||
+ a.model.localeCompare(b.model),
+ );
+
+ return {
+ buckets,
+ duplicatesDropped: this.#duplicatesDropped,
+ outOfWindow: this.#outOfWindow,
+ };
+ }
+}
+
+/**
+ * A bucket mixes records from one model, but their cost provenance can differ
+ * when only some records carried a reported cost. The weakest provenance in the
+ * bucket wins so the UI never overstates confidence.
+ */
+function resolveCostSource(bucket: MutableBucket): UsageBucket["costSource"] {
+ if (bucket.unpricedRecords === bucket.records) return "unpriced";
+ if (bucket.providerReportedRecords === bucket.records) return "providerReported";
+ return "modelPriced";
+}
diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts
new file mode 100644
index 000000000..de891e0b7
--- /dev/null
+++ b/apps/server/src/usage/usagePricing.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { lookupRate, parseRateTable } from "./usagePricing.ts";
+
+describe("parseRateTable", () => {
+ it("prefers the un-prefixed entry over routing-prefixed duplicates", () => {
+ // LiteLLM publishes the same model many times over with a routing prefix,
+ // sometimes at different rates. They all normalise to one key, so the
+ // canonical entry has to win regardless of enumeration order.
+ const table = parseRateTable({
+ "azure/gpt-5.6-sol": { input_cost_per_token: 9e-6, output_cost_per_token: 9e-5 },
+ "gpt-5.6-sol": { input_cost_per_token: 5e-6, output_cost_per_token: 3e-5 },
+ "openrouter/gpt-5.6-sol": { input_cost_per_token: 7e-6, output_cost_per_token: 7e-5 },
+ });
+
+ expect(lookupRate(table, "gpt-5.6-sol")?.inputCostPerToken).toBe(5e-6);
+ });
+
+ it("drops entries missing either half of the base rate", () => {
+ // A half-priced model under-reports silently, which is worse than saying
+ // the model is unpriced.
+ const table = parseRateTable({
+ "half-priced": { input_cost_per_token: 1e-6 },
+ priced: { input_cost_per_token: 1e-6, output_cost_per_token: 2e-6 },
+ });
+
+ expect(lookupRate(table, "half-priced")).toBeNull();
+ // Cache rates fall back to the plain input rate rather than to free.
+ expect(lookupRate(table, "priced")?.cacheReadCostPerToken).toBe(1e-6);
+ });
+
+ it("never prices an ambiguous family name", () => {
+ // "opus" spans generations; guessing one would be a fabricated number.
+ const table = parseRateTable({
+ opus: { input_cost_per_token: 1e-6, output_cost_per_token: 2e-6 },
+ });
+
+ expect(lookupRate(table, "opus")).toBeNull();
+ });
+});
diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts
new file mode 100644
index 000000000..a8b041db7
--- /dev/null
+++ b/apps/server/src/usage/usagePricing.ts
@@ -0,0 +1,158 @@
+/**
+ * Model rate lookup and cost arithmetic.
+ *
+ * Rates come from LiteLLM's `model_prices_and_context_window.json`, the same
+ * table `ccusage` prices against. Everything here is pure: fetching and caching
+ * the table lives in `UsageService`.
+ *
+ * Every figure produced here is an API list-price equivalent, not billed spend.
+ *
+ * @module usagePricing
+ */
+import type { UsageCostSource, UsageTokenTotals } from "@threadlines/contracts";
+
+/**
+ * The subset of a LiteLLM entry we price against. All values are USD per token.
+ *
+ * LiteLLM also publishes tiered variants (`*_above_200k_tokens`, `*_flex`,
+ * `*_priority`, `*_batches`) and separate 1h cache-write rates. We deliberately
+ * price at the base tier: the transcripts don't record which tier served a
+ * request, so anything else would be a guess dressed up as precision.
+ */
+export interface ModelRate {
+ readonly inputCostPerToken: number;
+ readonly outputCostPerToken: number;
+ readonly cacheReadCostPerToken: number;
+ readonly cacheCreationCostPerToken: number;
+}
+
+export type RateTable = ReadonlyMap;
+
+/** Raw shape of one LiteLLM entry, narrowed to the fields we read. */
+interface LiteLlmEntry {
+ readonly input_cost_per_token?: unknown;
+ readonly output_cost_per_token?: unknown;
+ readonly cache_read_input_token_cost?: unknown;
+ readonly cache_creation_input_token_cost?: unknown;
+}
+
+function finiteNumber(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+/**
+ * Projects the LiteLLM document into a rate table.
+ *
+ * Entries without both an input and an output rate are dropped: a half-priced
+ * model would silently under-report cost, which is worse than reporting the
+ * model as unpriced.
+ */
+export function parseRateTable(document: unknown): RateTable {
+ const table = new Map();
+ if (typeof document !== "object" || document === null) return table;
+
+ for (const [name, raw] of Object.entries(document as Record)) {
+ if (typeof raw !== "object" || raw === null) continue;
+ const entry = raw as LiteLlmEntry;
+ const input = finiteNumber(entry.input_cost_per_token);
+ const output = finiteNumber(entry.output_cost_per_token);
+ if (input === null || output === null) continue;
+
+ // LiteLLM publishes the same model many times over with a routing prefix
+ // (`azure/gpt-5.6-sol`, `openrouter/...`), sometimes at different rates.
+ // They all normalise to one key, so the un-prefixed entry wins outright
+ // rather than whichever happened to be enumerated last.
+ const normalized = normalizeModelName(name);
+ const prefixed = name.includes("/");
+ if (prefixed && table.has(normalized)) continue;
+
+ table.set(normalized, {
+ inputCostPerToken: input,
+ outputCostPerToken: output,
+ // Anthropic bills cache reads at a discount and cache writes at a
+ // premium. When a model omits them, cached input is priced as plain input
+ // rather than as free.
+ cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input,
+ cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input,
+ });
+ }
+ return table;
+}
+
+/**
+ * Canonicalises a model name for lookup.
+ *
+ * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-4-5` and
+ * `anthropic/claude-opus-4-5`) and lowercases, since transcripts are
+ * inconsistent about casing.
+ */
+export function normalizeModelName(model: string): string {
+ const trimmed = model.trim().toLowerCase();
+ const slash = trimmed.lastIndexOf("/");
+ return slash === -1 ? trimmed : trimmed.slice(slash + 1);
+}
+
+/**
+ * Models we never price, regardless of the table.
+ *
+ * `` marks locally generated messages that were never billed. Bare
+ * family names ("opus", "sonnet") are genuinely ambiguous across generations, so
+ * we report them as unpriced instead of guessing a generation.
+ */
+const UNPRICEABLE_MODELS = new Set([
+ "",
+ "synthetic",
+ "opus",
+ "sonnet",
+ "haiku",
+ "fable",
+]);
+
+export function lookupRate(table: RateTable, model: string): ModelRate | null {
+ const normalized = normalizeModelName(model);
+ if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null;
+ return table.get(normalized) ?? null;
+}
+
+export interface PricedUsage {
+ readonly costUsd: number;
+ readonly costSource: UsageCostSource;
+}
+
+/**
+ * Prices one record's tokens.
+ *
+ * `reasoningTokens` is intentionally not charged separately: it is already
+ * counted inside `outputTokens`.
+ */
+export function priceUsage(
+ table: RateTable,
+ model: string,
+ totals: UsageTokenTotals,
+ reportedCostUsd: number | null,
+): PricedUsage {
+ if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) {
+ return { costUsd: reportedCostUsd, costSource: "providerReported" };
+ }
+
+ const rate = lookupRate(table, model);
+ if (rate === null) return { costUsd: 0, costSource: "unpriced" };
+
+ const costUsd =
+ totals.uncachedInputTokens * rate.inputCostPerToken +
+ totals.cachedInputTokens * rate.cacheReadCostPerToken +
+ totals.cacheCreationTokens * rate.cacheCreationCostPerToken +
+ totals.outputTokens * rate.outputCostPerToken;
+
+ return { costUsd, costSource: "modelPriced" };
+}
+
+/**
+ * What the cached input would have cost at full input rates, minus what it
+ * actually cost. Drives the "cache savings" figure.
+ */
+export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number {
+ const rate = lookupRate(table, model);
+ if (rate === null) return 0;
+ return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken);
+}
diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts
new file mode 100644
index 000000000..3c5913eee
--- /dev/null
+++ b/apps/server/src/usage/usageScanCache.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ decodeScanCache,
+ dedupeWithinFile,
+ encodeScanCache,
+ pruneScanCache,
+ type ScanCache,
+} from "./usageScanCache.ts";
+import type { UsageRecord } from "./usageTranscripts.ts";
+
+function record(overrides: Partial = {}): UsageRecord {
+ return {
+ provider: "claude",
+ timestampMs: 1_786_000_000_000,
+ model: "claude-fable-5",
+ sessionId: "session-a",
+ totals: {
+ uncachedInputTokens: 2,
+ cachedInputTokens: 1000,
+ cacheCreationTokens: 10,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ },
+ reportedCostUsd: null,
+ dedupeKey: "msg_1:req_1",
+ ...overrides,
+ };
+}
+
+function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache {
+ const cache: ScanCache = new Map();
+ for (const [path, mtimeMs, records] of entries) {
+ cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records });
+ }
+ return cache;
+}
+
+describe("scan cache round trip", () => {
+ it("restores records unchanged", () => {
+ const original = cacheWith([
+ ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2", model: "claude-opus-5" })]],
+ ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],
+ ]);
+
+ const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))));
+
+ expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));
+ expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl"));
+ });
+
+ it("interns repeated model and session strings", () => {
+ const encoded = encodeScanCache(
+ cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2" }), record()]]]),
+ );
+
+ expect(encoded.models).toEqual(["claude-fable-5"]);
+ expect(encoded.sessions).toEqual(["session-a"]);
+ });
+
+ it("treats a corrupt or foreign document as an empty cache", () => {
+ // A bad cache should cost one cold scan, never a broken page.
+ expect(decodeScanCache(null).size).toBe(0);
+ expect(decodeScanCache("nonsense").size).toBe(0);
+ expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0);
+ });
+
+ it("rejects the whole cache when an intern table holds a non-string", () => {
+ // models: [1] would pass the undefined guard, put a number in a record's
+ // model, and crash normalizeModelName at aggregate time.
+ const poisoned = {
+ ...encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])),
+ models: [1],
+ };
+
+ expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0);
+ });
+
+ it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => {
+ // Keeping the surviving rows under the original (size, mtime) would read as
+ // a valid warm hit and the file would never be re-parsed.
+ const encoded = encodeScanCache(
+ cacheWith([
+ ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:req_2" })]],
+ ["/good.jsonl", 100, [record()]],
+ ]),
+ );
+ const rows = encoded.files["/a.jsonl"]!.r;
+ const poisoned = {
+ ...encoded,
+ files: {
+ ...encoded.files,
+ "/a.jsonl": {
+ ...encoded.files["/a.jsonl"]!,
+ r: [rows[0]!, [...rows[1]!.slice(0, 3), "not-a-number", ...rows[1]!.slice(4)]],
+ },
+ },
+ };
+
+ const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)));
+ expect([...restored.keys()]).toEqual(["/good.jsonl"]);
+ });
+});
+
+describe("pruneScanCache", () => {
+ const retentionCutoffMs = 1000;
+
+ it("drops entries older than retention", () => {
+ const cache = cacheWith([["/old.jsonl", 500, [record()]]]);
+
+ const removed = pruneScanCache(cache, {
+ livePaths: new Set(),
+ walkedRoots: ["/"],
+ windowStartMs: 400,
+ retentionCutoffMs,
+ });
+
+ expect(removed).toBe(1);
+ expect(cache.size).toBe(0);
+ });
+
+ it("drops in-window entries whose file has disappeared but keeps the ones seen", () => {
+ const cache = cacheWith([
+ ["/gone.jsonl", 5000, [record()]],
+ ["/live.jsonl", 5000, [record()]],
+ ]);
+
+ pruneScanCache(cache, {
+ livePaths: new Set(["/live.jsonl"]),
+ walkedRoots: ["/"],
+ windowStartMs: 4000,
+ retentionCutoffMs,
+ });
+
+ expect([...cache.keys()]).toEqual(["/live.jsonl"]);
+ });
+
+ it("keeps entries outside the walked window that are still within retention", () => {
+ // Viewing 7 days must not evict the 90-day entries, which that walk never
+ // looked for and so cannot prove are gone.
+ const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]);
+
+ const removed = pruneScanCache(cache, {
+ livePaths: new Set(),
+ walkedRoots: ["/"],
+ windowStartMs: 4000,
+ retentionCutoffMs,
+ });
+
+ expect(removed).toBe(0);
+ });
+
+ it("keeps in-window entries for a provider whose directory was not walked", () => {
+ // A missing provider root or failed settings read leaves livePaths without
+ // that provider's files. Its warm entries must survive the pass.
+ const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]);
+
+ const removed = pruneScanCache(cache, {
+ livePaths: new Set(),
+ walkedRoots: ["/claude/projects"],
+ windowStartMs: 4000,
+ retentionCutoffMs,
+ });
+
+ expect(removed).toBe(0);
+ });
+});
+
+describe("dedupeWithinFile", () => {
+ it("keeps the first record per dedupe key", () => {
+ const kept = dedupeWithinFile([
+ record({ totals: { ...record().totals, outputTokens: 1 } }),
+ record({ totals: { ...record().totals, outputTokens: 999 } }),
+ record({ dedupeKey: "msg_2:req_2" }),
+ ]);
+
+ expect(kept).toHaveLength(2);
+ expect(kept[0]?.totals.outputTokens).toBe(1);
+ });
+
+ it("keeps every record that has no dedupe key", () => {
+ // Codex records are unique per rollout and carry no key.
+ expect(
+ dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]),
+ ).toHaveLength(2);
+ });
+});
diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts
new file mode 100644
index 000000000..29de3fa14
--- /dev/null
+++ b/apps/server/src/usage/usageScanCache.ts
@@ -0,0 +1,254 @@
+/**
+ * Durable per-file scan cache.
+ *
+ * Transcripts are append-only and a file that has not changed can never yield
+ * different usage, so parsed records are keyed by `(size, mtime)` and reused.
+ * Without this every server restart re-parses the whole window.
+ *
+ * Caching *per file* rather than per day is deliberate. It is timezone
+ * independent, so changing the reporting zone does not invalidate anything, and
+ * it keeps cross-file de-duplication exact: cached entries are de-duplicated
+ * within their own file only, and the aggregator still applies the global
+ * dedupe pass over the small surviving key set.
+ *
+ * @module usageScanCache
+ */
+import type { UsageProviderKind } from "@threadlines/contracts";
+
+import type { UsageRecord } from "./usageTranscripts.ts";
+
+/**
+ * Bump whenever a parser change alters what a given file parses to. Stale
+ * entries would otherwise keep serving the old interpretation forever, since a
+ * file that has not changed is never re-read.
+ */
+export const USAGE_SCAN_CACHE_VERSION = 1 as const;
+
+export interface CachedFile {
+ readonly size: number;
+ readonly mtimeMs: number;
+ readonly provider: UsageProviderKind;
+ readonly records: readonly UsageRecord[];
+}
+
+export type ScanCache = Map;
+
+/**
+ * Row layout for the serialised form. Positional and interned rather than
+ * object-per-record: on a long window that is the difference between a cache
+ * file measured in tens of megabytes and one a fraction of that size.
+ */
+type SerializedRecord = readonly [
+ timestampMs: number,
+ modelIndex: number,
+ sessionIndex: number,
+ uncachedInputTokens: number,
+ cachedInputTokens: number,
+ cacheCreationTokens: number,
+ outputTokens: number,
+ reasoningTokens: number,
+ dedupeKey: string | null,
+ reportedCostUsd: number | null,
+];
+
+interface SerializedFile {
+ readonly s: number;
+ readonly m: number;
+ readonly p: UsageProviderKind;
+ readonly r: readonly SerializedRecord[];
+}
+
+export interface SerializedCache {
+ readonly version: number;
+ readonly models: readonly string[];
+ readonly sessions: readonly string[];
+ readonly files: Readonly>;
+}
+
+/** Serialises the cache, interning the repeated model and session strings. */
+export function encodeScanCache(cache: ScanCache): SerializedCache {
+ const models: string[] = [];
+ const sessions: string[] = [];
+ const modelIndex = new Map();
+ const sessionIndex = new Map();
+
+ const intern = (table: string[], index: Map, value: string): number => {
+ const existing = index.get(value);
+ if (existing !== undefined) return existing;
+ const next = table.length;
+ table.push(value);
+ index.set(value, next);
+ return next;
+ };
+
+ const files: Record = {};
+ for (const [path, entry] of cache) {
+ files[path] = {
+ s: entry.size,
+ m: entry.mtimeMs,
+ p: entry.provider,
+ r: entry.records.map((record) => [
+ record.timestampMs,
+ intern(models, modelIndex, record.model),
+ intern(sessions, sessionIndex, record.sessionId),
+ record.totals.uncachedInputTokens,
+ record.totals.cachedInputTokens,
+ record.totals.cacheCreationTokens,
+ record.totals.outputTokens,
+ record.totals.reasoningTokens,
+ record.dedupeKey,
+ record.reportedCostUsd,
+ ]),
+ };
+ }
+
+ return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files };
+}
+
+function isArray(value: unknown): value is readonly unknown[] {
+ return Array.isArray(value);
+}
+
+/**
+ * Rebuilds the cache from a parsed document.
+ *
+ * Anything malformed yields an empty cache rather than an error: a corrupt
+ * cache should cost one cold scan, never a broken page.
+ */
+export function decodeScanCache(document: unknown): ScanCache {
+ const cache: ScanCache = new Map();
+ if (typeof document !== "object" || document === null) return cache;
+
+ const root = document as Partial;
+ if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache;
+ if (!isArray(root.models) || !isArray(root.sessions)) 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
+ // undefined guard below, land in a record's model, and crash the aggregate at
+ // normalizeModelName. 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;
+ const models = root.models as readonly string[];
+ const sessions = root.sessions as readonly string[];
+
+ for (const [path, raw] of Object.entries(root.files)) {
+ if (typeof raw !== "object" || raw === null) continue;
+ const entry = raw as Partial;
+ if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;
+ if (entry.p !== "claude" && entry.p !== "codex") continue;
+ if (!isArray(entry.r)) continue;
+
+ const provider: UsageProviderKind = entry.p;
+ const records: UsageRecord[] = [];
+ // Any corrupt row disqualifies the whole entry. Keeping the survivors under
+ // the original (size, mtime) would read as a valid warm hit and the file
+ // would never be re-parsed, silently losing the dropped rows' usage.
+ let corrupt = false;
+ for (const row of entry.r) {
+ if (!isArray(row) || row.length < 10) {
+ corrupt = true;
+ break;
+ }
+ const [
+ timestampMs,
+ modelIndex,
+ sessionIndex,
+ uncached,
+ cached,
+ cacheCreation,
+ output,
+ reasoning,
+ dedupeKey,
+ reportedCostUsd,
+ ] = row as SerializedRecord;
+
+ const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;
+ if (
+ typeof timestampMs !== "number" ||
+ !Number.isFinite(timestampMs) ||
+ model === undefined ||
+ !Number.isFinite(uncached) ||
+ !Number.isFinite(cached) ||
+ !Number.isFinite(cacheCreation) ||
+ !Number.isFinite(output) ||
+ !Number.isFinite(reasoning)
+ ) {
+ corrupt = true;
+ break;
+ }
+
+ records.push({
+ provider,
+ timestampMs,
+ model,
+ sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "",
+ totals: {
+ uncachedInputTokens: uncached,
+ cachedInputTokens: cached,
+ cacheCreationTokens: cacheCreation,
+ outputTokens: output,
+ reasoningTokens: reasoning,
+ },
+ reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,
+ dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,
+ });
+ }
+
+ if (corrupt) continue;
+ cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records });
+ }
+
+ return cache;
+}
+
+export interface PruneOptions {
+ /** Files the walk just saw. Only meaningful inside the walked window. */
+ readonly livePaths: ReadonlySet;
+ /**
+ * Roots the walk actually completed. Absence from `livePaths` only proves a
+ * file is gone when its root was walked: a provider whose directory failed to
+ * resolve this pass must not have its warm entries purged.
+ */
+ readonly walkedRoots: readonly string[];
+ /** Start of the walked window; entries older than this were not looked for. */
+ readonly windowStartMs: number;
+ /** Entries older than this are dropped regardless. */
+ readonly retentionCutoffMs: number;
+}
+
+/**
+ * Drops aged-out entries, and entries for files that have disappeared.
+ *
+ * The walk only covers the requested window, so absence from `livePaths` only
+ * proves deletion for entries *inside* that window. Pruning everything the walk
+ * missed would evict the 90-day entries every time someone looked at 7 days.
+ */
+export function pruneScanCache(cache: ScanCache, options: PruneOptions): number {
+ let removed = 0;
+ for (const [path, entry] of cache) {
+ const agedOut = entry.mtimeMs < options.retentionCutoffMs;
+ const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root));
+ const deleted =
+ underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path);
+ if (agedOut || deleted) {
+ cache.delete(path);
+ removed += 1;
+ }
+ }
+ return removed;
+}
+
+/** Within-file de-duplication, applied before an entry is cached. */
+export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] {
+ const seen = new Set();
+ const kept: UsageRecord[] = [];
+ for (const record of records) {
+ if (record.dedupeKey !== null) {
+ if (seen.has(record.dedupeKey)) continue;
+ seen.add(record.dedupeKey);
+ }
+ kept.push(record);
+ }
+ return kept;
+}
diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts
new file mode 100644
index 000000000..e4ba441ad
--- /dev/null
+++ b/apps/server/src/usage/usageTranscriptReader.ts
@@ -0,0 +1,142 @@
+// @effect-diagnostics nodeBuiltinImport:off
+/**
+ * Raw filesystem access for transcript scanning.
+ *
+ * Isolated here so the rest of the usage code stays on Effect's `FileSystem`.
+ * The direct `node:fs` streaming is deliberate: a cold 90-day window is on the
+ * order of a gigabyte across thousands of files, and `readline` over a read
+ * stream is roughly an order of magnitude cheaper than materialising each file.
+ * The equivalent Effect stream pipeline is idiomatic but not fast enough to sit
+ * behind a page load.
+ *
+ * @module usageTranscriptReader
+ */
+import * as NodeFS from "node:fs";
+import * as NodeFSP from "node:fs/promises";
+import * as NodePath from "node:path";
+import * as NodeReadline from "node:readline";
+
+import type { UsageProviderKind } from "@threadlines/contracts";
+
+import {
+ initialCodexScanState,
+ mightCarryUsage,
+ parseClaudeLine,
+ parseCodexLine,
+ type UsageRecord,
+} from "./usageTranscripts.ts";
+
+export interface TranscriptFile {
+ readonly path: string;
+ readonly size: number;
+ readonly mtimeMs: number;
+}
+
+/**
+ * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`.
+ *
+ * Errors on individual entries are swallowed: session files rotate and get
+ * removed while the walk is in flight, and a partial listing is far better than
+ * failing the page.
+ */
+export async function listTranscriptFiles(
+ root: string,
+ sinceMs: number,
+): Promise {
+ const found: TranscriptFile[] = [];
+
+ const walk = async (dir: string): Promise => {
+ let entries;
+ try {
+ entries = await NodeFSP.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const child = NodePath.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ await walk(child);
+ continue;
+ }
+ if (!entry.name.endsWith(".jsonl")) continue;
+ try {
+ const stats = await NodeFSP.stat(child);
+ if (stats.mtimeMs >= sinceMs) {
+ found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs });
+ }
+ } catch {
+ // Vanished between readdir and stat.
+ }
+ }
+ };
+
+ await walk(root);
+ return found;
+}
+
+/**
+ * Filesystem identity of a directory, as `device:inode`.
+ *
+ * Used to tell "two servers reading the same transcript directory" apart from
+ * "two machines whose hostname and home path happen to match". Returns an empty
+ * string when the directory cannot be stat'd.
+ */
+export async function readDirectoryVolumeId(path: string): Promise {
+ try {
+ const stats = await NodeFSP.stat(path);
+ return `${stats.dev}:${stats.ino}`;
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Streams one transcript and returns the usage records it contains, or `null`
+ * when the file could not be read.
+ *
+ * The distinction matters to the caller's cache: a genuinely empty transcript
+ * is a stable fact worth memoising, while a transient read failure memoised
+ * under the same `(size, mtime)` key would silently drop that file's usage
+ * until the file next changes.
+ *
+ * Codex carries the active model on `turn_context` lines and its fork marker on
+ * `session_meta`, neither of which holds usage of its own, so those still have
+ * to pass through the reducer to keep attribution and fork suppression correct.
+ */
+export async function readTranscriptRecords(
+ filePath: string,
+ provider: UsageProviderKind,
+): Promise {
+ const records: UsageRecord[] = [];
+ const codexState = initialCodexScanState();
+
+ try {
+ const lines = NodeReadline.createInterface({
+ input: NodeFS.createReadStream(filePath, { encoding: "utf8" }),
+ crlfDelay: Infinity,
+ });
+
+ for await (const line of lines) {
+ if (provider === "codex") {
+ if (
+ !mightCarryUsage(line, provider) &&
+ !line.includes('"turn_context"') &&
+ !line.includes('"session_meta"')
+ ) {
+ continue;
+ }
+ const record = parseCodexLine(line, codexState);
+ if (record !== null) records.push(record);
+ continue;
+ }
+
+ if (!mightCarryUsage(line, provider)) continue;
+ const record = parseClaudeLine(line);
+ if (record !== null) records.push(record);
+ }
+ } catch {
+ return null;
+ }
+
+ return records;
+}
diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts
new file mode 100644
index 000000000..48bc60477
--- /dev/null
+++ b/apps/server/src/usage/usageTranscripts.test.ts
@@ -0,0 +1,267 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ initialCodexScanState,
+ parseClaudeLine,
+ parseCodexLine,
+ totalTokens,
+} from "./usageTranscripts.ts";
+
+/** Shaped after a real Claude Code assistant record. */
+function claudeLine(overrides: {
+ messageId: string;
+ contentType: string;
+ requestId?: string;
+ model?: string;
+ outputTokens?: number;
+}): string {
+ return JSON.stringify({
+ type: "assistant",
+ timestamp: "2026-08-10T03:50:03.336Z",
+ sessionId: "bf5b7ee7-6a3b-41b2-b47c-ba3ef4375b46",
+ requestId: overrides.requestId ?? "req_011CdtPMPBEdPXGLpcmo3hze",
+ cwd: "/Users/will/badcode",
+ message: {
+ id: overrides.messageId,
+ role: "assistant",
+ model: overrides.model ?? "claude-fable-5",
+ content: [{ type: overrides.contentType }],
+ usage: {
+ input_tokens: 2,
+ cache_creation_input_tokens: 15501,
+ cache_read_input_tokens: 17070,
+ output_tokens: overrides.outputTokens ?? 499,
+ service_tier: "standard",
+ cache_creation: { ephemeral_1h_input_tokens: 15501, ephemeral_5m_input_tokens: 0 },
+ },
+ },
+ });
+}
+
+describe("parseClaudeLine", () => {
+ it("extracts token totals and a dedupe key", () => {
+ const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" }));
+
+ expect(record?.provider).toBe("claude");
+ expect(record?.model).toBe("claude-fable-5");
+ expect(record?.sessionId).toBe("bf5b7ee7-6a3b-41b2-b47c-ba3ef4375b46");
+ expect(record?.totals).toEqual({
+ uncachedInputTokens: 2,
+ cachedInputTokens: 17070,
+ cacheCreationTokens: 15501,
+ outputTokens: 499,
+ reasoningTokens: 0,
+ });
+ expect(record?.dedupeKey).toBe("msg_1:req_011CdtPMPBEdPXGLpcmo3hze");
+ });
+
+ it("gives every content block of one message the same dedupe key", () => {
+ // The CLI writes one record per content block, each repeating the parent
+ // message's full usage. Summing them would overcount several times over.
+ const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" }));
+ const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" }));
+
+ expect(text?.dedupeKey).toBe(toolUse?.dedupeKey);
+ expect(text?.totals).toEqual(toolUse?.totals);
+ });
+
+ it("reads a reported cost only when the CLI wrote one", () => {
+ const withoutCost = parseClaudeLine(claudeLine({ messageId: "msg_3", contentType: "text" }));
+ const withCost = parseClaudeLine(
+ JSON.stringify({
+ ...(JSON.parse(claudeLine({ messageId: "msg_3", contentType: "text" })) as object),
+ costUSD: 0.42,
+ }),
+ );
+
+ expect(withoutCost?.reportedCostUsd).toBeNull();
+ expect(withCost?.reportedCostUsd).toBe(0.42);
+ });
+
+ it("ignores records that are not assistant messages", () => {
+ expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull();
+ expect(parseClaudeLine("not json")).toBeNull();
+ });
+});
+
+describe("parseCodexLine", () => {
+ const sessionMeta = JSON.stringify({
+ type: "session_meta",
+ timestamp: "2026-08-10T04:41:36.122Z",
+ payload: {
+ session_id: "019fe9f9-df22-7720-8727-262a9a10e5e9",
+ id: "019fe9f9-df22-7720-8727-262a9a10e5e9",
+ // Ordinary rollouts carry `source` as a plain string and a null fork id.
+ source: "vscode",
+ forked_from_id: null,
+ },
+ });
+ // `turn_context` payloads carry `model` at the top level and no payload type.
+ const turnContext = JSON.stringify({
+ type: "turn_context",
+ timestamp: "2026-08-10T04:41:38.694Z",
+ payload: { model: "gpt-5.6-sol", cwd: "/Users/will/badcode" },
+ });
+ const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) =>
+ JSON.stringify({
+ type: "event_msg",
+ timestamp: "2026-08-10T04:41:46.840Z",
+ payload: {
+ type: "token_count",
+ info: {
+ total_token_usage: { input_tokens: inputTokens, output_tokens: output },
+ last_token_usage: {
+ input_tokens: inputTokens,
+ cached_input_tokens: cached,
+ cache_write_input_tokens: 0,
+ output_tokens: output,
+ reasoning_output_tokens: reasoning,
+ },
+ },
+ },
+ });
+
+ it("attributes usage to the model from the preceding turn context", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(sessionMeta, state);
+ parseCodexLine(turnContext, state);
+ const record = parseCodexLine(tokenCount(20642, 11008, 420, 169), state);
+
+ expect(record?.provider).toBe("codex");
+ expect(record?.model).toBe("gpt-5.6-sol");
+ expect(record?.sessionId).toBe("019fe9f9-df22-7720-8727-262a9a10e5e9");
+ // Codex reports input_tokens inclusive of the cached portion.
+ expect(record?.totals.uncachedInputTokens).toBe(20642 - 11008);
+ expect(record?.totals.cachedInputTokens).toBe(11008);
+ expect(record?.totals.reasoningTokens).toBe(169);
+ });
+
+ it("skips a repeated token_count so deltas are not double counted", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(turnContext, state);
+ const first = parseCodexLine(tokenCount(100, 0, 10, 0), state);
+ const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state);
+
+ expect(first).not.toBeNull();
+ expect(repeat).toBeNull();
+ });
+
+ it("does not let a pre-model event poison the duplicate signature", () => {
+ // A token_count before its turn_context is dropped; the identical event
+ // re-emitted once the model is known must still be counted.
+ const state = initialCodexScanState();
+ expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
+ parseCodexLine(turnContext, state);
+ expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();
+ });
+
+ // A forked or subagent rollout opens with the parent's history copied in and
+ // every line re-stamped to the fork instant, then the ancestors' session
+ // metas. Threadlines forks Codex threads natively, so counting those copies
+ // again would multiply the prefix turns by the number of forks taken.
+ describe("forked rollouts", () => {
+ const meta = (overrides: {
+ id: string;
+ timestamp: string;
+ forkedFromId?: string;
+ spawnParentId?: string;
+ }) =>
+ JSON.stringify({
+ type: "session_meta",
+ timestamp: overrides.timestamp,
+ payload: {
+ id: overrides.id,
+ forked_from_id: overrides.forkedFromId ?? null,
+ ...(overrides.spawnParentId === undefined
+ ? { source: "vscode" }
+ : {
+ source: {
+ subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } },
+ },
+ }),
+ },
+ });
+ const stamped = (timestamp: string, line: string) => {
+ const parsed = JSON.parse(line) as { timestamp: string };
+ parsed.timestamp = timestamp;
+ return JSON.stringify(parsed);
+ };
+
+ it("keeps the child session id over copied ancestor metas", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(meta({ id: "child", timestamp: "2026-07-21T05:11:54.120Z" }), state);
+ parseCodexLine(meta({ id: "parent", timestamp: "2026-07-21T05:11:54.121Z" }), state);
+ parseCodexLine(turnContext, state);
+ const record = parseCodexLine(tokenCount(100, 0, 10, 0), state);
+
+ expect(record?.sessionId).toBe("child");
+ });
+
+ it("drops the re-stamped copied burst and keeps the first real event", () => {
+ // Timings taken from a real Threadlines Codex fork: the copied prefix
+ // lands within a millisecond of the fork, the child's own turn 2.7s later.
+ const state = initialCodexScanState();
+ const forkInstant = "2026-07-21T05:11:54.120Z";
+ parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state);
+ parseCodexLine(meta({ id: "parent", timestamp: "2026-07-21T05:11:54.121Z" }), state);
+ parseCodexLine(stamped("2026-07-21T05:11:54.121Z", turnContext), state);
+
+ expect(
+ parseCodexLine(stamped("2026-07-21T05:11:54.121Z", tokenCount(17821, 1408, 20, 0)), state),
+ ).toBeNull();
+ expect(
+ parseCodexLine(stamped("2026-07-21T05:11:54.140Z", tokenCount(17830, 1408, 25, 0)), state),
+ ).toBeNull();
+
+ const real = parseCodexLine(
+ stamped("2026-07-21T05:11:56.826Z", tokenCount(17859, 1408, 11, 0)),
+ state,
+ );
+ expect(real?.totals.outputTokens).toBe(11);
+
+ // Suppression never restarts, even for closely spaced later events.
+ expect(
+ parseCodexLine(stamped("2026-07-21T05:11:56.926Z", tokenCount(17900, 1408, 40, 0)), state),
+ ).not.toBeNull();
+ });
+
+ it("recognizes subagent spawns without forked_from_id", () => {
+ const state = initialCodexScanState();
+ const spawnInstant = "2026-08-01T20:36:12.311Z";
+ parseCodexLine(
+ meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }),
+ state,
+ );
+ parseCodexLine(stamped(spawnInstant, turnContext), state);
+ expect(
+ parseCodexLine(stamped("2026-08-01T20:36:12.312Z", tokenCount(100, 0, 10, 0)), state),
+ ).toBeNull();
+ });
+
+ it("does not suppress anything in a rollout that is not a fork", () => {
+ const state = initialCodexScanState();
+ parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T20:36:12.311Z" }), state);
+ parseCodexLine(stamped("2026-08-01T20:36:12.411Z", turnContext), state);
+ const record = parseCodexLine(
+ stamped("2026-08-01T20:36:12.511Z", tokenCount(100, 0, 10, 0)),
+ state,
+ );
+
+ expect(record).not.toBeNull();
+ });
+ });
+});
+
+describe("totalTokens", () => {
+ it("does not add reasoning on top of output", () => {
+ expect(
+ totalTokens({
+ uncachedInputTokens: 10,
+ cachedInputTokens: 20,
+ cacheCreationTokens: 30,
+ outputTokens: 40,
+ reasoningTokens: 25,
+ }),
+ ).toBe(100);
+ });
+});
diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts
new file mode 100644
index 000000000..d4aea728a
--- /dev/null
+++ b/apps/server/src/usage/usageTranscripts.ts
@@ -0,0 +1,313 @@
+/**
+ * Pure parsers for the provider CLIs' on-disk session transcripts.
+ *
+ * Both parsers are line-at-a-time reducers so callers can stream large files
+ * without materialising them. Neither touches the filesystem.
+ *
+ * @module usageTranscripts
+ */
+import type { UsageProviderKind, UsageTokenTotals } from "@threadlines/contracts";
+
+export interface UsageRecord {
+ readonly provider: UsageProviderKind;
+ readonly timestampMs: number;
+ readonly model: string;
+ readonly sessionId: string;
+ readonly totals: UsageTokenTotals;
+ readonly reportedCostUsd: number | null;
+ /**
+ * Key for cross-file de-duplication, or `null` when the record is inherently
+ * unique and needs no dedup.
+ */
+ readonly dedupeKey: string | null;
+}
+
+export const EMPTY_TOTALS: UsageTokenTotals = {
+ uncachedInputTokens: 0,
+ cachedInputTokens: 0,
+ cacheCreationTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+};
+
+function int(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
+}
+
+function parseTimestampMs(value: unknown): number | null {
+ if (typeof value !== "string") return null;
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals {
+ return {
+ uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens,
+ cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens,
+ cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
+ outputTokens: a.outputTokens + b.outputTokens,
+ reasoningTokens: a.reasoningTokens + b.reasoningTokens,
+ };
+}
+
+export function totalTokens(totals: UsageTokenTotals): number {
+ // reasoningTokens is a subset of outputTokens and must not be added again.
+ return (
+ totals.uncachedInputTokens +
+ totals.cachedInputTokens +
+ totals.cacheCreationTokens +
+ totals.outputTokens
+ );
+}
+
+/**
+ * Cheap substring gate applied before `JSON.parse`.
+ *
+ * Transcripts are mostly tool output; only a minority of lines carry usage, and
+ * the lines that do not are frequently the largest. Skipping them without
+ * parsing is worth roughly an order of magnitude on a 30-day window.
+ */
+export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean {
+ return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"');
+}
+
+/* -------------------------------------------------------------------------- */
+/* Claude Code */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Parses one line of a Claude Code transcript.
+ *
+ * The CLI writes one record per assistant *content block*, and every one of
+ * those records repeats the same complete `usage` object for the parent
+ * message. Summing them overcounts badly, so the caller must drop repeats by
+ * `dedupeKey` and keep the first.
+ */
+export function parseClaudeLine(line: string): UsageRecord | 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"] !== "assistant") return null;
+
+ const message = record["message"];
+ if (typeof message !== "object" || message === null) return null;
+ const messageRecord = message as Record;
+
+ const usage = messageRecord["usage"];
+ if (typeof usage !== "object" || usage === null) return null;
+ const usageRecord = usage as Record;
+
+ const timestampMs = parseTimestampMs(record["timestamp"]);
+ if (timestampMs === null) return null;
+
+ const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "";
+ if (model.length === 0) return null;
+
+ const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null;
+ const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null;
+ // Matches ccusage: prefer the message/request pair, fall back to whichever
+ // half exists. Records with neither cannot be de-duplicated.
+ const dedupeKey =
+ messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`;
+
+ // Older CLI builds wrote a `costUSD` per record; current ones do not. Read it
+ // when present rather than assuming either way.
+ const cost = record["costUSD"];
+
+ return {
+ provider: "claude",
+ timestampMs,
+ model,
+ sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "",
+ totals: {
+ uncachedInputTokens: int(usageRecord["input_tokens"]),
+ cachedInputTokens: int(usageRecord["cache_read_input_tokens"]),
+ // The nested `cache_creation` object splits this into 5m and 1h ephemeral
+ // buckets, which bill at different multipliers. The rate table publishes
+ // one cache-write rate, so the split would not change the arithmetic;
+ // the flat total is what we price.
+ cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]),
+ outputTokens: int(usageRecord["output_tokens"]),
+ // Anthropic folds thinking tokens into output and does not break them out.
+ reasoningTokens: 0,
+ },
+ reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
+ dedupeKey,
+ };
+}
+
+/* -------------------------------------------------------------------------- */
+/* Codex */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Rolling state for a single Codex rollout file.
+ *
+ * Codex `token_count` events carry no model, so the model is carried forward
+ * from the most recent `turn_context`. Sessions that switch models mid-run
+ * attribute correctly from the switch onward.
+ */
+export interface CodexScanState {
+ model: string;
+ sessionId: string;
+ lastUsageSignature: string | null;
+ sawSessionMeta: boolean;
+ /** While true, leading usage events are re-stamped copies of parent history. */
+ suppressingForkCopies: boolean;
+ forkCopyAnchorMs: number;
+}
+
+export function initialCodexScanState(): CodexScanState {
+ return {
+ model: "",
+ sessionId: "",
+ lastUsageSignature: null,
+ sawSessionMeta: false,
+ suppressingForkCopies: false,
+ forkCopyAnchorMs: 0,
+ };
+}
+
+/**
+ * A forked or subagent rollout opens with the parent's full history copied in,
+ * every line re-stamped to the fork instant. Those copies are written in one
+ * synchronous burst (observed gaps 0-40ms in real Threadlines forks), while the
+ * child's first genuine usage event only lands after a real model turn
+ * (observed seconds). One second of separation splits the two cleanly;
+ * `ccusage` uses the same threshold.
+ *
+ * This matters more for Threadlines than for a plain CLI install: forking a
+ * Codex thread is a first-class feature here, so forked rollouts are common and
+ * naive summing would multiply their prefix turns by the fork count.
+ */
+const FORK_COPY_MAX_GAP_MS = 1000;
+
+/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */
+function isForkedSessionMeta(payload: Record): boolean {
+ // Present but `null` on ordinary rollouts, so the type check is load-bearing.
+ if (typeof payload["forked_from_id"] === "string") return true;
+ const source = payload["source"];
+ // `source` is a plain string ("vscode", "cli") on ordinary rollouts.
+ if (typeof source !== "object" || source === null) return false;
+ const subagent = (source as Record)["subagent"];
+ if (typeof subagent !== "object" || subagent === null) return false;
+ const spawn = (subagent as Record)["thread_spawn"];
+ if (typeof spawn !== "object" || spawn === null) return false;
+ return typeof (spawn as Record)["parent_thread_id"] === "string";
+}
+
+/**
+ * Feeds one line of a Codex rollout into `state`, returning a record when the
+ * line was a usage event.
+ *
+ * Deltas come from `last_token_usage`. Summing those across a session
+ * reconciles with the session's final `total_token_usage`, provided consecutive
+ * duplicate events are dropped, which this does.
+ */
+export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | 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;
+ const payload = record["payload"];
+ if (typeof payload !== "object" || payload === null) return null;
+ const payloadRecord = payload as Record;
+ const payloadType = payloadRecord["type"];
+
+ if (record["type"] === "session_meta") {
+ // Only the first meta describes this file's own session. A forked rollout
+ // repeats the ancestors' metas right after it; letting those through would
+ // reassign every subsequent record to an ancestor session.
+ if (state.sawSessionMeta) return null;
+ state.sawSessionMeta = true;
+ const id = payloadRecord["id"] ?? payloadRecord["session_id"];
+ if (typeof id === "string") state.sessionId = id;
+ const metaTimestampMs = parseTimestampMs(record["timestamp"]);
+ if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) {
+ state.suppressingForkCopies = true;
+ state.forkCopyAnchorMs = metaTimestampMs;
+ }
+ return null;
+ }
+
+ if (record["type"] === "turn_context") {
+ // `turn_context` payloads carry `model` at the top level and no `type` of
+ // their own, so the envelope type is the only reliable discriminator.
+ if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"];
+ return null;
+ }
+
+ if (payloadType !== "token_count") return null;
+
+ const info = payloadRecord["info"];
+ if (typeof info !== "object" || info === null) return null;
+ const last = (info as Record)["last_token_usage"];
+ if (typeof last !== "object" || last === null) return null;
+ const lastRecord = last as Record;
+
+ // Only an event that is otherwise eligible may consume the duplicate
+ // signature. A token_count arriving before its turn_context (no model yet)
+ // must not poison it, or the re-emitted copy after the model is known would
+ // be skipped as a duplicate and those tokens never counted.
+ const timestampMs = parseTimestampMs(record["timestamp"]);
+ if (timestampMs === null) return null;
+ if (state.model.length === 0) return null;
+
+ // Codex re-emits an unchanged token_count on some stream boundaries. Summing
+ // those would double count, so identical consecutive payloads are skipped.
+ const signature = JSON.stringify(lastRecord);
+ if (signature === state.lastUsageSignature) return null;
+ state.lastUsageSignature = signature;
+
+ // In a forked rollout the copied parent history was already counted from the
+ // parent's own file. Drop the leading burst; the first usage event separated
+ // from its predecessor by a real turn's worth of time ends it for good.
+ if (state.suppressingForkCopies) {
+ if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) {
+ state.forkCopyAnchorMs = timestampMs;
+ return null;
+ }
+ state.suppressingForkCopies = false;
+ }
+
+ const inputTokens = int(lastRecord["input_tokens"]);
+ const cachedInputTokens = int(lastRecord["cached_input_tokens"]);
+ const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]);
+ const outputTokens = int(lastRecord["output_tokens"]);
+
+ const totals: UsageTokenTotals = {
+ // Codex reports `input_tokens` inclusive of the cached portion.
+ uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens),
+ cachedInputTokens,
+ cacheCreationTokens,
+ outputTokens,
+ // Reported inside output_tokens, surfaced separately for the token mix.
+ reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])),
+ };
+
+ if (totalTokens(totals) === 0) return null;
+
+ return {
+ provider: "codex",
+ timestampMs,
+ model: state.model,
+ sessionId: state.sessionId,
+ totals,
+ // Codex does not report cost in the rollout.
+ reportedCostUsd: null,
+ // Events surviving the fork-copy suppression above are unique to this
+ // rollout, so they need no global dedup.
+ dedupeKey: null,
+ };
+}
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index c83205fc3..154f3f264 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -68,6 +68,7 @@ import { coalesceLatestAggregateEvents } from "./orchestration/shellStreamCoales
import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
import { ThreadSearch } from "./orchestration/Services/ThreadSearch.ts";
+import { UsageService } from "./usage/UsageService.ts";
import {
observeRpcEffect,
observeRpcStream,
@@ -233,6 +234,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
Effect.gen(function* () {
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
const threadSearch = yield* ThreadSearch;
+ const usage = yield* UsageService;
const orchestrationEngine = yield* OrchestrationEngineService;
const checkpointDiffQuery = yield* CheckpointDiffQuery;
const checkpointRevert = yield* CheckpointRevert;
@@ -1416,6 +1418,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
"rpc.aggregate": "server",
},
),
+ [WS_METHODS.usageSummary]: (input) =>
+ observeRpcEffect(WS_METHODS.usageSummary, usage.readSummary(input), {
+ "rpc.aggregate": "usage",
+ }),
[WS_METHODS.sourceControlLookupRepository]: (input) =>
observeRpcEffect(
WS_METHODS.sourceControlLookupRepository,
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 86661a7ac..150ac186b 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -23,4 +23,5 @@ export * from "./orchestration.ts";
export * from "./editor.ts";
export * from "./project.ts";
export * from "./filesystem.ts";
+export * from "./usage.ts";
export * from "./rpc.ts";
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 02960820c..dcbb4034b 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -14,6 +14,7 @@ import {
FilesystemBrowseResult,
FilesystemBrowseError,
} from "./filesystem.ts";
+import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
import {
GitActionProgressEvent,
VcsCommitDetailsInput,
@@ -345,6 +346,9 @@ export const WS_METHODS = {
serverGetProviderInstructionFiles: "server.getProviderInstructionFiles",
serverWriteProviderInstructionFile: "server.writeProviderInstructionFile",
+ // Usage reporting methods
+ usageSummary: "usage.summary",
+
// Source control methods
sourceControlLookupRepository: "sourceControl.lookupRepository",
sourceControlListRepositories: "sourceControl.listRepositories",
@@ -688,6 +692,12 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make(
},
);
+export const WsUsageSummaryRpc = Rpc.make(WS_METHODS.usageSummary, {
+ payload: UsageSummaryInput,
+ success: UsageSummary,
+ error: UsageReadError,
+});
+
export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
payload: ProjectSearchEntriesInput,
success: ProjectSearchEntriesResult,
@@ -1144,6 +1154,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerReadProviderExtensionMcpResourceRpc,
WsServerGetProviderInstructionFilesRpc,
WsServerWriteProviderInstructionFileRpc,
+ WsUsageSummaryRpc,
WsSourceControlLookupRepositoryRpc,
WsSourceControlListRepositoriesRpc,
WsSourceControlCloneRepositoryRpc,
diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts
new file mode 100644
index 000000000..efb3ab53f
--- /dev/null
+++ b/packages/contracts/src/usage.ts
@@ -0,0 +1,215 @@
+/**
+ * Usage reporting contract.
+ *
+ * Each environment scans the provider CLIs' own on-disk session transcripts
+ * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather
+ * than Threadlines' orchestration projections, so usage stays complete even for
+ * turns that were driven by the CLI, an editor extension, or another tool
+ * entirely. `ccusage` takes the same approach.
+ *
+ * Environments return pre-aggregated `(day, provider, model)` buckets. Raw
+ * transcript records never cross the wire.
+ *
+ * Cost semantics: every `costUsd` in this module is the *API list-price
+ * equivalent* of the tokens, not billed spend. Subscription plans bill on their
+ * own terms and the transcripts carry no invoice, so a figure here answers
+ * "what would this have cost on the API" and nothing else. UI copy must say so.
+ *
+ * @module usage
+ */
+import * as Schema from "effect/Schema";
+
+import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
+
+/**
+ * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. A
+ * client merging several environments renders partial coverage when one reports
+ * an older version rather than failing the whole page.
+ */
+export const USAGE_CONTRACT_VERSION = 1 as const;
+
+export const UsageProviderKind = Schema.Literals(["claude", "codex"]);
+export type UsageProviderKind = typeof UsageProviderKind.Type;
+
+/**
+ * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`.
+ *
+ * Days are bucketed server-side so a turn always lands on the day the user
+ * experienced it, not the UTC day.
+ */
+const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+
+export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe(
+ Schema.brand("UsageDay"),
+);
+export type UsageDay = typeof UsageDay.Type;
+
+/**
+ * Window lengths the UI offers. The scan cache retains 90 days, so a longer
+ * window would cold-scan every time.
+ */
+export const USAGE_WINDOW_DAY_OPTIONS = [7, 30, 90] as const;
+
+export const UsageWindowDays = Schema.Literals(USAGE_WINDOW_DAY_OPTIONS);
+export type UsageWindowDays = typeof UsageWindowDays.Type;
+
+/** Longest window the server will scan, in days. Matches the cache retention. */
+export const USAGE_MAX_WINDOW_DAYS = 90 as const;
+
+/**
+ * Why a bucket's cost is what it is.
+ *
+ * - `providerReported` — the transcript carried an explicit cost figure.
+ * - `modelPriced` — we matched the model against the LiteLLM rate table.
+ * - `unpriced` — tokens are known, rates are not. Counted in token totals,
+ * excluded from cost.
+ */
+export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]);
+export type UsageCostSource = typeof UsageCostSource.Type;
+
+/**
+ * Token counts for a bucket.
+ *
+ * `cachedInputTokens` and `cacheCreationTokens` are disjoint from
+ * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens`
+ * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic
+ * folds thinking into output), so it must never be added on top.
+ */
+export const UsageTokenTotals = Schema.Struct({
+ uncachedInputTokens: NonNegativeInt,
+ cachedInputTokens: NonNegativeInt,
+ cacheCreationTokens: NonNegativeInt,
+ outputTokens: NonNegativeInt,
+ reasoningTokens: NonNegativeInt,
+});
+export type UsageTokenTotals = typeof UsageTokenTotals.Type;
+
+/**
+ * One `(day, provider, model)` cell.
+ *
+ * `costUsd` is the API list-price equivalent of these tokens, not money spent.
+ * `unpricedRecords` counts records whose tokens are included in the token
+ * totals but which contributed nothing to `costUsd`.
+ */
+export const UsageBucket = Schema.Struct({
+ day: UsageDay,
+ provider: UsageProviderKind,
+ model: TrimmedNonEmptyString,
+ totals: UsageTokenTotals,
+ costUsd: Schema.Number,
+ /**
+ * What the cached input would have cost at full input rates minus what it
+ * actually cost. Requires the rate table, so it is computed alongside cost
+ * rather than derived on the client.
+ */
+ cacheSavingsUsd: Schema.Number,
+ costSource: UsageCostSource,
+ /** Distinct assistant responses, after de-duplication. */
+ records: NonNegativeInt,
+ unpricedRecords: NonNegativeInt,
+ /** Distinct transcript sessions that contributed to this cell. */
+ sessions: NonNegativeInt,
+});
+export type UsageBucket = typeof UsageBucket.Type;
+
+/**
+ * Identifies the physical transcript directory a source read from.
+ *
+ * Two environments on one machine (worktree servers, for example) resolve the
+ * same provider home and would otherwise double count. The client drops
+ * duplicate fingerprints before merging.
+ */
+export const UsageSourceFingerprint = Schema.Struct({
+ hostId: TrimmedNonEmptyString,
+ provider: UsageProviderKind,
+ resolvedHomePath: TrimmedNonEmptyString,
+ /**
+ * Filesystem identity of the transcript directory, as `device:inode`.
+ *
+ * Hostname and path alone are not enough: every Mac resolves
+ * `/Users//.claude`, so two machines that happen to share a hostname
+ * would look like one source and have one of them silently dropped. The
+ * device/inode pair is stable for two servers reading the same directory and
+ * effectively never collides across machines. Empty when it cannot be read.
+ */
+ volumeId: Schema.String,
+});
+export type UsageSourceFingerprint = typeof UsageSourceFingerprint.Type;
+
+export const UsageSourceStatus = Schema.Literals(["ok", "missing", "partial", "failed"]);
+export type UsageSourceStatus = typeof UsageSourceStatus.Type;
+
+export const UsageSource = Schema.Struct({
+ fingerprint: UsageSourceFingerprint,
+ status: UsageSourceStatus,
+ /**
+ * When this directory was last walked, as an ISO instant. Warm scans reuse
+ * memoised per-file results, so this is the only honest freshness signal the
+ * client has for a source.
+ */
+ lastScannedAt: Schema.String,
+ scannedFiles: NonNegativeInt,
+ skippedFiles: NonNegativeInt,
+ /**
+ * Distinct transcript sessions seen under this directory. Buckets also carry
+ * per-bucket session counts, but a session spans days and models, so summing
+ * those overcounts; this is the figure clients should total.
+ */
+ distinctSessions: NonNegativeInt,
+ message: Schema.NullOr(TrimmedNonEmptyString),
+});
+export type UsageSource = typeof UsageSource.Type;
+
+export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]);
+export type UsagePricingStatus = typeof UsagePricingStatus.Type;
+
+/**
+ * Provenance for the rate table, so the UI can be honest about how good the
+ * cost figures are.
+ */
+export const UsagePricing = Schema.Struct({
+ status: UsagePricingStatus,
+ source: TrimmedNonEmptyString,
+ fetchedAt: Schema.NullOr(Schema.String),
+ knownModels: NonNegativeInt,
+});
+export type UsagePricing = typeof UsagePricing.Type;
+
+export const UsageSummaryInput = Schema.Struct({
+ /** Inclusive first day of the window, in `timeZone`. */
+ sinceDay: UsageDay,
+ /** Inclusive last day of the window, in `timeZone`. */
+ untilDay: UsageDay,
+ /**
+ * IANA zone the client wants days bucketed in. A fixed offset would be wrong
+ * for any window that crosses a DST boundary, and the viewer's machine is
+ * often not the machine holding the transcripts.
+ */
+ timeZone: TrimmedNonEmptyString,
+});
+export type UsageSummaryInput = typeof UsageSummaryInput.Type;
+
+export const UsageSummary = Schema.Struct({
+ contractVersion: Schema.Number,
+ readAt: Schema.String,
+ timeZone: TrimmedNonEmptyString,
+ sinceDay: UsageDay,
+ untilDay: UsageDay,
+ buckets: Schema.Array(UsageBucket),
+ sources: Schema.Array(UsageSource),
+ pricing: UsagePricing,
+ /** Wall-clock cost of the scan, surfaced in diagnostics. */
+ scanDurationMs: NonNegativeInt,
+});
+export type UsageSummary = typeof UsageSummary.Type;
+
+export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", {
+ reason: Schema.Literals(["scanFailed", "invalidWindow"]),
+ /** Stable, bounded description. The underlying failure travels in `cause`. */
+ detail: TrimmedNonEmptyString,
+ cause: Schema.optional(Schema.Defect()),
+}) {
+ override get message(): string {
+ return `Usage read failed (${this.reason}): ${this.detail}`;
+ }
+}
diff --git a/packages/shared/package.json b/packages/shared/package.json
index b64a2b328..316bfeb7c 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -174,6 +174,14 @@
"./uuid": {
"types": "./src/uuid.ts",
"import": "./src/uuid.ts"
+ },
+ "./usageMerge": {
+ "types": "./src/usageMerge.ts",
+ "import": "./src/usageMerge.ts"
+ },
+ "./usageFormat": {
+ "types": "./src/usageFormat.ts",
+ "import": "./src/usageFormat.ts"
}
},
"scripts": {
diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts
new file mode 100644
index 000000000..0a72389cd
--- /dev/null
+++ b/packages/shared/src/usageFormat.ts
@@ -0,0 +1,117 @@
+// @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl.
+/**
+ * Display formatting and window construction for the usage page.
+ *
+ * Costs formatted here are API list-price equivalents, not billed spend; the
+ * surrounding UI copy has to say so.
+ *
+ * @module usageFormat
+ */
+import { UsageDay, type UsageSummaryInput, type UsageWindowDays } from "@threadlines/contracts";
+
+const CURRENCY = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
+const INTEGER = new Intl.NumberFormat("en-US");
+
+const DAY_MS = 86_400_000;
+
+export function formatUsd(value: number): string {
+ return CURRENCY.format(value);
+}
+
+export function formatCount(value: number): string {
+ return INTEGER.format(Math.round(value));
+}
+
+/**
+ * Compacts a token count to three significant figures with a unit suffix, so
+ * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`).
+ */
+export function formatTokens(value: number): string {
+ const abs = Math.abs(value);
+ if (abs >= 1e12) return `${trim(value / 1e12)}T`;
+ if (abs >= 1e9) return `${trim(value / 1e9)}B`;
+ if (abs >= 1e6) return `${trim(value / 1e6)}M`;
+ if (abs >= 1e3) return `${trim(value / 1e3)}K`;
+ return INTEGER.format(Math.round(value));
+}
+
+function trim(value: number): string {
+ const abs = Math.abs(value);
+ const digits = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
+ return value.toFixed(digits).replace(/\.0+$/, "");
+}
+
+export function formatPercent(share: number, digits = 1): string {
+ return `${(share * 100).toFixed(digits)}%`;
+}
+
+const MONTHS = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+] as const;
+
+/** `2026-08-07` to `Aug 7`. */
+export function formatDayShort(day: string): string {
+ const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
+ if (year === undefined || month === undefined || dayOfMonth === undefined) return day;
+ return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`;
+}
+
+/** Inclusive day list between two `YYYY-MM-DD` bounds. */
+export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] {
+ const days: string[] = [];
+ const start = Date.parse(`${sinceDay}T00:00:00Z`);
+ const end = Date.parse(`${untilDay}T00:00:00Z`);
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return days;
+
+ for (let cursor = start; cursor <= end; cursor += DAY_MS) {
+ days.push(new Date(cursor).toISOString().slice(0, 10));
+ }
+ return days;
+}
+
+/**
+ * The window the page requests for a 7/30/90-day selection, expressed in the
+ * viewer's own time zone so days line up with what they actually experienced.
+ *
+ * The zone travels with the request rather than being inferred server-side: the
+ * machine holding the transcripts is often not the machine looking at them.
+ */
+export function makeUsageWindow(windowDays: UsageWindowDays, now = new Date()): UsageSummaryInput {
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
+ const format = new Intl.DateTimeFormat("en-CA", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+ const untilDay = format.format(now);
+ // Subtracting fixed milliseconds from `now` lands on the wrong calendar day
+ // around a DST transition. Only "today" needs the zone; the window start is
+ // pure calendar arithmetic on that day, done in UTC where days are uniform.
+ const [year = 0, month = 1, dayOfMonth = 1] = untilDay
+ .split("-")
+ .map((part) => Number.parseInt(part, 10));
+ const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (windowDays - 1)));
+ return {
+ sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
+ untilDay: UsageDay.make(untilDay),
+ timeZone,
+ };
+}
diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts
new file mode 100644
index 000000000..3d5b18e2a
--- /dev/null
+++ b/packages/shared/src/usageMerge.test.ts
@@ -0,0 +1,255 @@
+import {
+ USAGE_CONTRACT_VERSION,
+ type EnvironmentId,
+ type UsageBucket,
+ type UsageDay,
+ type UsageProviderKind,
+ type UsageSummary,
+} from "@threadlines/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
+
+function bucket(overrides: Partial = {}): UsageBucket {
+ return {
+ day: "2026-08-07" as UsageDay,
+ provider: "claude",
+ model: "claude-fable-5",
+ totals: {
+ uncachedInputTokens: 100,
+ cachedInputTokens: 1000,
+ cacheCreationTokens: 10,
+ outputTokens: 50,
+ reasoningTokens: 0,
+ },
+ costUsd: 10,
+ cacheSavingsUsd: 2,
+ costSource: "modelPriced",
+ records: 5,
+ unpricedRecords: 0,
+ sessions: 1,
+ ...overrides,
+ };
+}
+
+interface SourceSpec {
+ readonly provider: UsageProviderKind;
+ readonly hostId: string;
+ readonly homePath: string;
+ readonly volumeId?: string;
+ readonly distinctSessions?: number;
+ readonly lastScannedAt?: string;
+}
+
+function summary(
+ buckets: readonly UsageBucket[],
+ sources: readonly SourceSpec[],
+ contractVersion: number = USAGE_CONTRACT_VERSION,
+): UsageSummary {
+ return {
+ contractVersion,
+ readAt: "2026-08-07T00:00:00.000Z",
+ timeZone: "UTC",
+ sinceDay: "2026-08-01" as UsageDay,
+ untilDay: "2026-08-31" as UsageDay,
+ buckets,
+ sources: sources.map((source) => ({
+ fingerprint: {
+ hostId: source.hostId,
+ provider: source.provider,
+ resolvedHomePath: source.homePath,
+ volumeId: source.volumeId ?? `vol-${source.hostId}`,
+ },
+ status: "ok" as const,
+ lastScannedAt: source.lastScannedAt ?? "2026-08-07T00:00:00.000Z",
+ scannedFiles: 1,
+ skippedFiles: 0,
+ distinctSessions: source.distinctSessions ?? 1,
+ message: null,
+ })),
+ pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 },
+ scanDurationMs: 1,
+ };
+}
+
+function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage {
+ return { environmentId: id as EnvironmentId, label: id, summary: usageSummary };
+}
+
+const claudeSource = (hostId: string): SourceSpec => ({
+ provider: "claude",
+ hostId,
+ homePath: "/Users/will/.claude/projects",
+});
+
+describe("mergeUsage", () => {
+ it("sums environments that read different transcript directories", () => {
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment(
+ "env-b",
+ summary([bucket({ costUsd: 4 })], [{ ...claudeSource("windows"), distinctSessions: 3 }]),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(14);
+ expect(merged.records).toBe(10);
+ expect(merged.sessions).toBe(4);
+ expect(merged.contributingEnvironments).toEqual(["env-a", "env-b"]);
+ expect(merged.duplicateSources).toEqual([]);
+ });
+
+ it("counts a directory once when two environments on one machine scan it", () => {
+ // Worktree servers resolve the same provider home; summing both would
+ // double every token on that machine.
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment("env-b", summary([bucket()], [claudeSource("mac")])),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(10);
+ expect(merged.sessions).toBe(1);
+ expect(merged.contributingEnvironments).toEqual(["env-a"]);
+ expect(merged.duplicateSources).toEqual(["env-b: /Users/will/.claude/projects"]);
+ });
+
+ it("keeps two machines apart when hostname and home path collide", () => {
+ // Every Mac with the default computer name resolves the same path, so only
+ // the filesystem identity can tell them apart.
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary([bucket()], [{ ...claudeSource("macbook-pro"), volumeId: "1:2" }]),
+ ),
+ environment(
+ "env-b",
+ summary([bucket()], [{ ...claudeSource("macbook-pro"), volumeId: "3:4" }]),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(20);
+ expect(merged.duplicateSources).toEqual([]);
+ });
+
+ it("drops only the duplicated provider, not the whole environment", () => {
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment(
+ "env-b",
+ summary(
+ [bucket(), bucket({ provider: "codex", model: "gpt-5.6-sol", costUsd: 3 })],
+ [claudeSource("mac"), { provider: "codex", hostId: "mac", homePath: "/x/.codex" }],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(13);
+ expect(merged.providers.map((provider) => provider.provider)).toEqual(["claude", "codex"]);
+ });
+
+ it("excludes an environment on an older contract instead of failing", () => {
+ const merged = mergeUsage(
+ [
+ environment("env-a", summary([bucket()], [claudeSource("mac")])),
+ environment(
+ "env-b",
+ summary([bucket()], [claudeSource("windows")], USAGE_CONTRACT_VERSION - 1),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(10);
+ expect(merged.staleEnvironments).toEqual(["env-b"]);
+ });
+
+ it("splits cost quality across the three provenances", () => {
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary(
+ [
+ bucket({ records: 2, costSource: "providerReported" }),
+ bucket({
+ day: "2026-08-08" as UsageDay,
+ records: 6,
+ unpricedRecords: 3,
+ costSource: "modelPriced",
+ }),
+ ],
+ [claudeSource("mac")],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.records).toBe(8);
+ expect(merged.costQuality.providerReportedShare).toBeCloseTo(2 / 8, 9);
+ expect(merged.costQuality.unpricedShare).toBeCloseTo(3 / 8, 9);
+ expect(merged.costQuality.modelPricedShare).toBeCloseTo(3 / 8, 9);
+ expect(merged.costQuality.cacheSavingsUsd).toBe(4);
+ });
+
+ it("reports the oldest scan across contributing sources", () => {
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary([bucket()], [{ ...claudeSource("mac"), lastScannedAt: "2026-08-07T09:00:00Z" }]),
+ ),
+ environment(
+ "env-b",
+ summary(
+ [bucket()],
+ [{ ...claudeSource("windows"), lastScannedAt: "2026-08-07T06:00:00Z" }],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.oldestScanAt).toBe("2026-08-07T06:00:00Z");
+ });
+
+ it("totals tokens without adding reasoning on top of output", () => {
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ summary(
+ [
+ bucket({
+ totals: {
+ uncachedInputTokens: 1,
+ cachedInputTokens: 2,
+ cacheCreationTokens: 3,
+ outputTokens: 4,
+ reasoningTokens: 2,
+ },
+ }),
+ ],
+ [claudeSource("mac")],
+ ),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.totalTokens).toBe(10);
+ expect(merged.reasoningTokens).toBe(2);
+ });
+});
diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts
new file mode 100644
index 000000000..be249e4c8
--- /dev/null
+++ b/packages/shared/src/usageMerge.ts
@@ -0,0 +1,372 @@
+/**
+ * Merges per-environment usage summaries into the single view the page renders.
+ *
+ * Pure, so the de-duplication and derivation rules can be tested without a
+ * connected environment.
+ *
+ * Every `costUsd` here is an API list-price equivalent, not billed spend.
+ *
+ * @module usageMerge
+ */
+import type {
+ EnvironmentId,
+ UsageBucket,
+ UsageProviderKind,
+ UsageSourceFingerprint,
+ UsageSummary,
+} from "@threadlines/contracts";
+
+export interface EnvironmentUsage {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly summary: UsageSummary;
+}
+
+export interface ProviderTotals {
+ readonly provider: UsageProviderKind;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+ readonly records: number;
+ readonly costShare: number;
+ readonly tokenShare: number;
+}
+
+export interface ModelTotals {
+ readonly model: string;
+ readonly provider: UsageProviderKind;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+ readonly records: number;
+ readonly costShare: number;
+}
+
+export interface DailyTotals {
+ readonly day: string;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+ readonly byProvider: ReadonlyMap;
+}
+
+export interface CostQuality {
+ readonly providerReportedShare: number;
+ readonly modelPricedShare: number;
+ readonly unpricedShare: number;
+ readonly cacheSavingsUsd: number;
+}
+
+export interface MergedUsage {
+ readonly costUsd: number;
+ readonly uncachedInputTokens: number;
+ readonly cachedInputTokens: number;
+ readonly cacheCreationTokens: number;
+ readonly outputTokens: number;
+ readonly reasoningTokens: number;
+ readonly totalTokens: number;
+ readonly records: number;
+ readonly sessions: number;
+ readonly providers: readonly ProviderTotals[];
+ readonly models: readonly ModelTotals[];
+ readonly daily: readonly DailyTotals[];
+ readonly costQuality: CostQuality;
+ /** Environments whose data was dropped as a duplicate of another's. */
+ readonly duplicateSources: readonly string[];
+ readonly contributingEnvironments: readonly EnvironmentId[];
+ readonly staleEnvironments: readonly EnvironmentId[];
+ /** Oldest `lastScannedAt` across contributing sources, or `null` when none. */
+ readonly oldestScanAt: string | null;
+}
+
+/**
+ * Two sources are the same physical transcript directory only when host,
+ * provider, path and filesystem identity all agree.
+ *
+ * `volumeId` is what stops two machines that happen to share a hostname and a
+ * home path, which is every Mac with the default computer name, from collapsing
+ * into one source and having one of them silently dropped.
+ */
+function fingerprintKey(fingerprint: UsageSourceFingerprint): string {
+ return [
+ fingerprint.hostId,
+ fingerprint.provider,
+ fingerprint.resolvedHomePath,
+ fingerprint.volumeId,
+ ].join(" ");
+}
+
+/**
+ * Decides which environment owns each physical transcript directory.
+ *
+ * Several environments on one machine (worktree servers, for instance) resolve
+ * the same provider home and would otherwise double count every token. The
+ * first environment in a stable order claims a fingerprint; the rest have that
+ * provider's buckets dropped. Environments are sorted by id so the winner does
+ * not change between renders.
+ */
+function claimSources(environments: readonly EnvironmentUsage[]): {
+ readonly ownerByFingerprint: ReadonlyMap;
+ readonly duplicates: readonly string[];
+} {
+ const ownerByFingerprint = new Map();
+ const duplicates: string[] = [];
+
+ const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId));
+
+ for (const environment of ordered) {
+ for (const source of environment.summary.sources) {
+ if (source.status === "missing") continue;
+ const key = fingerprintKey(source.fingerprint);
+ if (ownerByFingerprint.has(key)) {
+ duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`);
+ continue;
+ }
+ ownerByFingerprint.set(key, environment.environmentId);
+ }
+ }
+
+ return { ownerByFingerprint, duplicates };
+}
+
+/** Sources this environment owns after fingerprint claims, plus their buckets. */
+function ownedContribution(
+ environment: EnvironmentUsage,
+ ownerByFingerprint: ReadonlyMap,
+): {
+ readonly buckets: readonly UsageBucket[];
+ readonly sessions: number;
+ readonly oldestScanAt: string | null;
+} {
+ const ownedProviders = new Set();
+ let sessions = 0;
+ let oldestScanAt: string | null = null;
+ for (const source of environment.summary.sources) {
+ if (source.status === "missing") continue;
+ const key = fingerprintKey(source.fingerprint);
+ if (ownerByFingerprint.get(key) !== environment.environmentId) continue;
+ ownedProviders.add(source.fingerprint.provider);
+ // Distinct within a directory. Summing per-bucket session counts instead
+ // would count a session once per day and model it spans.
+ sessions += source.distinctSessions;
+ // ISO instants with the same precision sort lexicographically.
+ if (oldestScanAt === null || source.lastScannedAt < oldestScanAt) {
+ oldestScanAt = source.lastScannedAt;
+ }
+ }
+ return {
+ buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)),
+ sessions,
+ oldestScanAt,
+ };
+}
+
+function bucketTokens(bucket: UsageBucket): number {
+ // reasoningTokens is a subset of outputTokens and must not be added again.
+ return (
+ bucket.totals.uncachedInputTokens +
+ bucket.totals.cachedInputTokens +
+ bucket.totals.cacheCreationTokens +
+ bucket.totals.outputTokens
+ );
+}
+
+const EMPTY_MERGED: MergedUsage = {
+ costUsd: 0,
+ uncachedInputTokens: 0,
+ cachedInputTokens: 0,
+ cacheCreationTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+ totalTokens: 0,
+ records: 0,
+ sessions: 0,
+ providers: [],
+ models: [],
+ daily: [],
+ costQuality: {
+ providerReportedShare: 0,
+ modelPricedShare: 0,
+ unpricedShare: 0,
+ cacheSavingsUsd: 0,
+ },
+ duplicateSources: [],
+ contributingEnvironments: [],
+ staleEnvironments: [],
+ oldestScanAt: null,
+};
+
+/**
+ * Merges every connected environment's summary.
+ *
+ * `expectedContractVersion` guards against an environment running older server
+ * code: rather than blocking the page, its data is excluded and its id is
+ * reported so the UI can say coverage is partial.
+ */
+export function mergeUsage(
+ environments: readonly EnvironmentUsage[],
+ expectedContractVersion: number,
+): MergedUsage {
+ if (environments.length === 0) return EMPTY_MERGED;
+
+ const current: EnvironmentUsage[] = [];
+ const staleEnvironments: EnvironmentId[] = [];
+ for (const environment of environments) {
+ if (environment.summary.contractVersion === expectedContractVersion) {
+ current.push(environment);
+ } else {
+ staleEnvironments.push(environment.environmentId);
+ }
+ }
+
+ const { ownerByFingerprint, duplicates } = claimSources(current);
+
+ let costUsd = 0;
+ let uncachedInputTokens = 0;
+ let cachedInputTokens = 0;
+ let cacheCreationTokens = 0;
+ let outputTokens = 0;
+ let reasoningTokens = 0;
+ let records = 0;
+ let sessions = 0;
+ let cacheSavingsUsd = 0;
+ let providerReportedRecords = 0;
+ let unpricedRecords = 0;
+ let oldestScanAt: string | null = null;
+
+ const providerAccumulator = new Map<
+ UsageProviderKind,
+ { costUsd: number; totalTokens: number; records: number }
+ >();
+ const modelAccumulator = new Map<
+ string,
+ { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number }
+ >();
+ const dailyAccumulator = new Map<
+ string,
+ {
+ costUsd: number;
+ totalTokens: number;
+ byProvider: Map;
+ }
+ >();
+ const contributingEnvironments: EnvironmentId[] = [];
+
+ for (const environment of current) {
+ const contribution = ownedContribution(environment, ownerByFingerprint);
+ if (contribution.buckets.length > 0) contributingEnvironments.push(environment.environmentId);
+ sessions += contribution.sessions;
+ if (
+ contribution.oldestScanAt !== null &&
+ (oldestScanAt === null || contribution.oldestScanAt < oldestScanAt)
+ ) {
+ oldestScanAt = contribution.oldestScanAt;
+ }
+
+ for (const bucket of contribution.buckets) {
+ const tokens = bucketTokens(bucket);
+
+ costUsd += bucket.costUsd;
+ cacheSavingsUsd += bucket.cacheSavingsUsd;
+ uncachedInputTokens += bucket.totals.uncachedInputTokens;
+ cachedInputTokens += bucket.totals.cachedInputTokens;
+ cacheCreationTokens += bucket.totals.cacheCreationTokens;
+ outputTokens += bucket.totals.outputTokens;
+ reasoningTokens += bucket.totals.reasoningTokens;
+ records += bucket.records;
+ unpricedRecords += bucket.unpricedRecords;
+ if (bucket.costSource === "providerReported") providerReportedRecords += bucket.records;
+
+ const provider = providerAccumulator.get(bucket.provider) ?? {
+ costUsd: 0,
+ totalTokens: 0,
+ records: 0,
+ };
+ provider.costUsd += bucket.costUsd;
+ provider.totalTokens += tokens;
+ provider.records += bucket.records;
+ providerAccumulator.set(bucket.provider, provider);
+
+ const modelKey = `${bucket.provider} ${bucket.model}`;
+ const model = modelAccumulator.get(modelKey) ?? {
+ provider: bucket.provider,
+ costUsd: 0,
+ totalTokens: 0,
+ records: 0,
+ };
+ model.costUsd += bucket.costUsd;
+ model.totalTokens += tokens;
+ model.records += bucket.records;
+ modelAccumulator.set(modelKey, model);
+
+ const day = dailyAccumulator.get(bucket.day) ?? {
+ costUsd: 0,
+ totalTokens: 0,
+ byProvider: new Map(),
+ };
+ day.costUsd += bucket.costUsd;
+ day.totalTokens += tokens;
+ const dayProvider = day.byProvider.get(bucket.provider) ?? { costUsd: 0, totalTokens: 0 };
+ dayProvider.costUsd += bucket.costUsd;
+ dayProvider.totalTokens += tokens;
+ day.byProvider.set(bucket.provider, dayProvider);
+ dailyAccumulator.set(bucket.day, day);
+ }
+ }
+
+ const totalTokens = uncachedInputTokens + cachedInputTokens + cacheCreationTokens + outputTokens;
+
+ const providers: ProviderTotals[] = [...providerAccumulator.entries()]
+ .map(([provider, totals]) => ({
+ provider,
+ costUsd: totals.costUsd,
+ totalTokens: totals.totalTokens,
+ records: totals.records,
+ costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
+ tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens,
+ }))
+ .sort((a, b) => b.costUsd - a.costUsd);
+
+ const models: ModelTotals[] = [...modelAccumulator.entries()]
+ .map(([key, totals]) => ({
+ model: key.slice(key.indexOf(" ") + 1),
+ provider: totals.provider,
+ costUsd: totals.costUsd,
+ totalTokens: totals.totalTokens,
+ records: totals.records,
+ costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd,
+ }))
+ .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens);
+
+ const daily: DailyTotals[] = [...dailyAccumulator.entries()]
+ .map(([day, totals]) => ({
+ day,
+ costUsd: totals.costUsd,
+ totalTokens: totals.totalTokens,
+ byProvider: totals.byProvider,
+ }))
+ .sort((a, b) => a.day.localeCompare(b.day));
+
+ return {
+ costUsd,
+ uncachedInputTokens,
+ cachedInputTokens,
+ cacheCreationTokens,
+ outputTokens,
+ reasoningTokens,
+ totalTokens,
+ records,
+ sessions,
+ providers,
+ models,
+ daily,
+ costQuality: {
+ providerReportedShare: records === 0 ? 0 : providerReportedRecords / records,
+ unpricedShare: records === 0 ? 0 : unpricedRecords / records,
+ modelPricedShare:
+ records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records,
+ cacheSavingsUsd,
+ },
+ duplicateSources: duplicates,
+ contributingEnvironments,
+ staleEnvironments,
+ oldestScanAt,
+ };
+}
From cff436bf2e3b062680566d053fcd4680a94044b2 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 02:06:39 -0400
Subject: [PATCH 02/14] Add the usage page and its entry points
/usage renders the merged cross-machine picture: totals strip, a flat
stacked-bar daily chart (empty days kept - gaps are information), a
per-model table, and a machines section that names every environment
the app knows about, including offline ones as "Not reporting" and
stale scans as "Out of date". Every cost is labeled API list-price
equivalent; subscription plans bill separately.
Entry points: a live sidebar-footer meter with today's compact token
total (falls back to a plain "Usage" label without data), a one-line
usage row in settings > providers, and a command palette action. The
meter owns its own query so refetches re-render one line of chrome,
sharing a key family with the page so neither double-fetches. Servers
predating usage.summary read as unreachable rather than erroring.
---
apps/web/src/components/CommandPalette.tsx | 12 +
apps/web/src/components/Sidebar.tsx | 4 +
.../settings/SettingsPanels.browser.tsx | 237 +++++++-------
.../components/settings/SettingsPanels.tsx | 37 ++-
.../components/sidebar/SidebarUsageMeter.tsx | 49 +++
.../components/usage/UsageView.browser.tsx | 285 +++++++++++++++++
apps/web/src/components/usage/UsageView.tsx | 290 ++++++++++++++++++
.../src/components/usage/usageView.logic.ts | 167 ++++++++++
apps/web/src/environmentApi.ts | 3 +
apps/web/src/lib/usageReactQuery.ts | 201 ++++++++++++
apps/web/src/localApi.test.ts | 3 +
apps/web/src/routeTree.gen.ts | 21 ++
apps/web/src/routes/_chat.usage.tsx | 7 +
apps/web/src/rpc/wsRpcClient.ts | 6 +
packages/contracts/src/ipc.ts | 9 +
packages/shared/src/usageFormat.test.ts | 73 +++++
packages/shared/src/usageFormat.ts | 26 ++
17 files changed, 1319 insertions(+), 111 deletions(-)
create mode 100644 apps/web/src/components/sidebar/SidebarUsageMeter.tsx
create mode 100644 apps/web/src/components/usage/UsageView.browser.tsx
create mode 100644 apps/web/src/components/usage/UsageView.tsx
create mode 100644 apps/web/src/components/usage/usageView.logic.ts
create mode 100644 apps/web/src/lib/usageReactQuery.ts
create mode 100644 apps/web/src/routes/_chat.usage.tsx
create mode 100644 packages/shared/src/usageFormat.test.ts
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 5fefbbc97..f4ba2f994 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -24,6 +24,7 @@ import {
CornerLeftUpIcon,
FolderIcon,
FolderPlusIcon,
+ GaugeIcon,
HomeIcon,
LinkIcon,
MessageSquareIcon,
@@ -1607,6 +1608,17 @@ function OpenCommandPaletteDialog() {
});
}
+ actionItems.push({
+ kind: "action",
+ value: "action:usage",
+ searchTerms: ["usage", "tokens", "cost", "spend", "spending", "price", "billing"],
+ title: "Open usage",
+ icon: ,
+ run: async () => {
+ await navigate({ to: "/usage" });
+ },
+ });
+
actionItems.push({
kind: "action",
value: "action:settings",
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index faec5baf7..972a4c2b0 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -110,6 +110,7 @@ import { SidebarHoverCardGroup } from "./sidebar/hoverCard";
import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard";
import { resolveThreadActionProjectRef, startNewGeneralChatThread } from "../lib/chatThreadActions";
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
+import { SidebarUsageMeter } from "./sidebar/SidebarUsageMeter";
import { SidebarVersionTag } from "./sidebar/SidebarVersionTag";
import { readEnvironmentApi } from "../environmentApi";
import { useSettings } from "~/hooks/useSettings";
@@ -313,6 +314,9 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() {
+
+
+
new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
function renderWithTestRouter(children: ReactNode) {
const rootRoute = createRootRoute({
component: () => children,
@@ -52,7 +70,6 @@ function renderWithTestRouter(children: ReactNode) {
routeTree: rootRoute.addChildren([indexRoute]),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
-
return render( );
}
@@ -787,10 +804,10 @@ describe("GeneralSettingsPanel observability", () => {
});
vi.stubGlobal("fetch", fetchMock);
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Connect your phone or tablet")).toBeInTheDocument();
@@ -835,10 +852,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -875,10 +892,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await vi.waitFor(() => {
@@ -915,10 +932,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -964,10 +981,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -1001,10 +1018,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Create link", exact: true }).click();
@@ -1075,10 +1092,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect
@@ -1168,10 +1185,10 @@ describe("GeneralSettingsPanel observability", () => {
});
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("http://192.168.86.39:3773/")).toBeInTheDocument();
@@ -1194,9 +1211,9 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
mounted = await renderWithTestRouter(
-
+
- ,
+ ,
);
await expect.element(page.getByText("About")).toBeInTheDocument();
@@ -1217,9 +1234,9 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
mounted = await renderWithTestRouter(
-
+
- ,
+ ,
);
await expect
@@ -1252,9 +1269,9 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
mounted = await renderWithTestRouter(
-
+
- ,
+ ,
);
const analyticsSwitch = page.getByRole("switch", {
@@ -1364,10 +1381,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Connected devices")).toBeInTheDocument();
@@ -1457,10 +1474,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument();
@@ -1523,10 +1540,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Loading devices...")).toBeInTheDocument();
@@ -1552,10 +1569,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
const networkAccessToggle = page.getByLabelText("Allow phone and tablet access");
@@ -1606,10 +1623,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Add computer", exact: true }).click();
@@ -1699,10 +1716,10 @@ describe("GeneralSettingsPanel observability", () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
const openLogsButton = page.getByLabelText("Open logs folder");
@@ -1714,10 +1731,10 @@ describe("GeneralSettingsPanel observability", () => {
it("shows Claude configuration fields in provider settings", async () => {
setServerConfigSnapshot(createBaseServerConfig());
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1754,10 +1771,10 @@ describe("GeneralSettingsPanel observability", () => {
],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText(/Credential configured · Claude Max/)).toBeInTheDocument();
@@ -1779,10 +1796,10 @@ describe("GeneralSettingsPanel observability", () => {
],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1839,10 +1856,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createCodexProviderWithResetCredits()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Choose a reset credit for Codex usage").click();
@@ -1874,10 +1891,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1916,10 +1933,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByLabelText("Toggle Claude details").click();
@@ -1953,10 +1970,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createOutdatedProvider("codex")],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available — view details" }).click();
@@ -1988,10 +2005,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createMissingClaudeProvider({ canInstall: true })],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
// The button replaces the manual recipe; the diagnosis sentence stays.
@@ -2037,10 +2054,10 @@ describe("GeneralSettingsPanel observability", () => {
],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Installing… added 1 package")).toBeVisible();
@@ -2065,10 +2082,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createMissingClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
// No derived install command: the full guide sentence and its link stay.
@@ -2099,10 +2116,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createVerifiedNativeOutdatedClaudeProvider()],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available — view details" }).click();
@@ -2158,10 +2175,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [provider],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available — view details" }).click();
@@ -2192,10 +2209,10 @@ describe("GeneralSettingsPanel observability", () => {
providers: [createOutdatedProvider("codex", longUpdateCommand)],
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await page.getByRole("button", { name: "Update available — view details" }).click();
@@ -2264,10 +2281,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
it("shows skeleton sections while the first source control scan is pending", async () => {
setSourceControlDiscoveryStub(() => new Promise(() => {}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Version Control")).toBeInTheDocument();
@@ -2284,10 +2301,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
sourceControlProviders: [],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByText("Nothing detected yet")).toBeInTheDocument();
@@ -2318,10 +2335,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
sourceControlProviders: [],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
@@ -2364,10 +2381,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect
@@ -2396,10 +2413,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
sourceControlProviders: [],
}));
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
const toggle = page.getByRole("button", { name: "Toggle Git details" });
@@ -2437,10 +2454,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
};
});
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
@@ -2451,10 +2468,10 @@ describe("SourceControlSettingsPanel discovery states", () => {
mounted = null;
document.body.innerHTML = "";
- mounted = await render(
-
+ mounted = await renderWithTestRouter(
+
- ,
+ ,
);
await expect.element(page.getByRole("switch", { name: "Git availability" })).toBeDisabled();
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index f3ba3f524..43f2ab714 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -6,8 +6,9 @@ import {
RefreshCwIcon,
Trash2Icon,
} from "lucide-react";
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
+import { formatTokens, formatUsd } from "@threadlines/shared/usageFormat";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AUTO_ARCHIVE_INACTIVE_THREADS_DAY_OPTIONS,
@@ -37,6 +38,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import { readEnvironmentApi } from "../../environmentApi";
import { setDesktopUpdateStateQueryData } from "../../lib/desktopUpdateReactQuery";
+import { usageSummaryQueryOptions, useUsageEnvironmentTargets } from "../../lib/usageReactQuery";
import {
resolveDefaultTextGenerationBackupModelSelectionState,
resolveAppModelSelectionState,
@@ -133,6 +135,8 @@ const MAINTAINED_PROVIDER_DRIVER_KINDS = DRIVER_OPTIONS.map((definition) => defi
const INACTIVE_THREAD_ARCHIVE_COMMAND_DELAY_MS = 25;
const ARCHIVED_THREAD_DELETE_COMMAND_DELAY_MS = 25;
const DEFAULT_ARCHIVED_THREAD_DELETE_AGE_DAYS: ArchivedThreadDeleteAgeDays = 90;
+/** A month reads as "recently" without being a single noisy week. */
+const USAGE_SETTINGS_WINDOW_DAYS = 30;
function waitForInactiveThreadArchiveCommandSlot(): Promise {
return new Promise((resolve) =>
@@ -1060,6 +1064,36 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" |
);
}
+/**
+ * The bridge from provider setup to what those providers have actually cost.
+ * One plain row, no chrome: it is a signpost, not a control.
+ */
+function ProviderUsageLinkRow() {
+ const targets = useUsageEnvironmentTargets();
+ const usageQuery = useQuery(
+ usageSummaryQueryOptions({ targets, windowDays: USAGE_SETTINGS_WINDOW_DAYS }),
+ );
+ const merged = usageQuery.data?.merged ?? null;
+
+ return (
+
+ Usage
+ {merged ? (
+
+ {formatTokens(merged.totalTokens)} tokens · {formatUsd(merged.costUsd)} API-equivalent
+
+ ) : (
+
+ )}
+ View usage
+
+ );
+}
+
export function ProviderSettingsPanel({
focusedInstanceId = null,
}: {
@@ -1387,6 +1421,7 @@ export function ProviderSettingsPanel({
Account, usage, and configuration apply to the paired computer. Favorites and model
ordering are saved on this device.
+
{rows.map((row) => {
const driverOption = getDriverOption(row.driver);
const liveProvider = serverProviders.find(
diff --git a/apps/web/src/components/sidebar/SidebarUsageMeter.tsx b/apps/web/src/components/sidebar/SidebarUsageMeter.tsx
new file mode 100644
index 000000000..f982414fd
--- /dev/null
+++ b/apps/web/src/components/sidebar/SidebarUsageMeter.tsx
@@ -0,0 +1,49 @@
+import { formatTokensCompact } from "@threadlines/shared/usageFormat";
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
+import { GaugeIcon } from "lucide-react";
+import { useCallback } from "react";
+
+import { usageTodayQueryOptions, useUsageEnvironmentTargets } from "../../lib/usageReactQuery";
+import { SidebarMenuButton, useSidebar } from "../ui/sidebar";
+
+/**
+ * Today's token total in the sidebar footer.
+ *
+ * Self-contained on purpose: the query lives here rather than in the sidebar so
+ * a usage refetch re-renders one line of chrome instead of the whole inbox.
+ * Without an answer it stays the plain label, because a wrong number in the
+ * footer is worse than no number.
+ */
+export function SidebarUsageMeter() {
+ const navigate = useNavigate();
+ const { isMobile, setOpenMobile } = useSidebar();
+ const targets = useUsageEnvironmentTargets();
+ const usageQuery = useQuery(usageTodayQueryOptions({ targets }));
+ const totalTokens = usageQuery.data?.merged.totalTokens ?? null;
+
+ const openUsage = useCallback(() => {
+ if (isMobile) {
+ setOpenMobile(false);
+ }
+ void navigate({ to: "/usage" });
+ }, [isMobile, navigate, setOpenMobile]);
+
+ return (
+
+
+ {totalTokens === null ? (
+ Usage
+ ) : (
+
+ {formatTokensCompact(totalTokens)} today
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/usage/UsageView.browser.tsx b/apps/web/src/components/usage/UsageView.browser.tsx
new file mode 100644
index 000000000..c5dba9c1a
--- /dev/null
+++ b/apps/web/src/components/usage/UsageView.browser.tsx
@@ -0,0 +1,285 @@
+import "../../index.css";
+
+import {
+ EnvironmentId,
+ USAGE_CONTRACT_VERSION,
+ type UsageBucket,
+ type UsageDay,
+ type UsageProviderKind,
+ type UsageSummary,
+ type UsageSummaryInput,
+} from "@threadlines/contracts";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from "@tanstack/react-router";
+import type { ReactNode } from "react";
+import { page } from "vite-plus/test/browser";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { render } from "vitest-browser-react";
+
+import {
+ __resetEnvironmentApiOverridesForTests,
+ __setEnvironmentApiOverrideForTests,
+} from "../../environmentApi";
+import {
+ resetPrimaryEnvironmentDescriptorForTests,
+ writePrimaryEnvironmentDescriptor,
+} from "../../environments/primary";
+import {
+ resetSavedEnvironmentRegistryStoreForTests,
+ useSavedEnvironmentRegistryStore,
+} from "../../environments/runtime";
+import { SidebarProvider } from "../ui/sidebar";
+import { SidebarUsageMeter } from "../sidebar/SidebarUsageMeter";
+import { UsageView } from "./UsageView";
+
+const REPORTING_ENVIRONMENT_ID = EnvironmentId.make("env-studio");
+const OFFLINE_ENVIRONMENT_ID = EnvironmentId.make("env-laptop");
+
+function tokens(total: number): UsageBucket["totals"] {
+ return {
+ uncachedInputTokens: Math.round(total / 2),
+ cachedInputTokens: Math.round(total / 2),
+ cacheCreationTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+ };
+}
+
+function bucket(input: {
+ readonly day: string;
+ readonly provider: UsageProviderKind;
+ readonly model: string;
+ readonly costUsd: number;
+ readonly totalTokens: number;
+}): UsageBucket {
+ return {
+ day: input.day as UsageDay,
+ provider: input.provider,
+ model: input.model,
+ totals: tokens(input.totalTokens),
+ costUsd: input.costUsd,
+ cacheSavingsUsd: 1.5,
+ costSource: "modelPriced",
+ records: 4,
+ unpricedRecords: 0,
+ sessions: 2,
+ };
+}
+
+function source(
+ provider: UsageProviderKind,
+ resolvedHomePath: string,
+): UsageSummary["sources"][number] {
+ return {
+ fingerprint: {
+ hostId: "studio",
+ provider,
+ resolvedHomePath,
+ volumeId: `1:${provider}`,
+ },
+ status: "ok",
+ lastScannedAt: new Date().toISOString(),
+ scannedFiles: 12,
+ skippedFiles: 0,
+ distinctSessions: 3,
+ message: null,
+ };
+}
+
+function summaryFor(
+ input: UsageSummaryInput,
+ buckets: (day: string) => readonly UsageBucket[],
+): UsageSummary {
+ return {
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: new Date().toISOString(),
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ buckets: buckets(input.untilDay),
+ sources: [source("claude", "/Users/dev/.claude"), source("codex", "/Users/dev/.codex")],
+ pricing: {
+ status: "cached",
+ source: "litellm",
+ fetchedAt: new Date().toISOString(),
+ knownModels: 400,
+ },
+ scanDurationMs: 42,
+ };
+}
+
+function registerEnvironments(summary: (input: UsageSummaryInput) => Promise): void {
+ writePrimaryEnvironmentDescriptor({
+ environmentId: REPORTING_ENVIRONMENT_ID,
+ label: "Studio Mac",
+ platform: { os: "darwin", arch: "arm64" },
+ serverVersion: "0.1.0",
+ capabilities: { repositoryIdentity: false },
+ });
+ // A saved backend that is not connected: the page must name it rather than
+ // quietly leaving it out of the totals.
+ useSavedEnvironmentRegistryStore.setState({
+ byId: {
+ [OFFLINE_ENVIRONMENT_ID]: {
+ environmentId: OFFLINE_ENVIRONMENT_ID,
+ label: "Laptop",
+ wsBaseUrl: "ws://laptop.local",
+ httpBaseUrl: "http://laptop.local",
+ createdAt: new Date().toISOString(),
+ lastConnectedAt: null,
+ },
+ },
+ });
+ __setEnvironmentApiOverrideForTests(REPORTING_ENVIRONMENT_ID, {
+ usage: { summary },
+ } as never);
+}
+
+function renderWithProviders(children: ReactNode) {
+ const rootRoute = createRootRoute({ component: () => children });
+ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" });
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ resetPrimaryEnvironmentDescriptorForTests();
+ resetSavedEnvironmentRegistryStoreForTests();
+ __resetEnvironmentApiOverridesForTests();
+});
+
+afterEach(() => {
+ resetPrimaryEnvironmentDescriptorForTests();
+ resetSavedEnvironmentRegistryStoreForTests();
+ __resetEnvironmentApiOverridesForTests();
+});
+
+describe("UsageView", () => {
+ it("renders merged totals, model rows, and every machine including the silent one", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (day) => [
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 12.5,
+ totalTokens: 2_000_000,
+ }),
+ bucket({
+ day,
+ provider: "codex",
+ model: "gpt-5.6-sol",
+ costUsd: 4.5,
+ totalTokens: 400_000,
+ }),
+ ]),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders( );
+
+ await expect.element(page.getByText("$17.00")).toBeInTheDocument();
+ await expect.element(page.getByText("2.40M")).toBeInTheDocument();
+ await expect
+ .element(page.getByText("API list-price equivalent. Subscription plans bill separately."))
+ .toBeInTheDocument();
+
+ const modelRows = page.getByTestId("usage-model-row").elements();
+ expect(modelRows).toHaveLength(2);
+ // Sorted by cost, so the expensive model leads.
+ expect(modelRows[0]?.textContent).toContain("claude-fable-5");
+ expect(modelRows[1]?.textContent).toContain("gpt-5.6-sol");
+
+ const machineRows = page.getByTestId("usage-machine-row").elements();
+ expect(machineRows).toHaveLength(2);
+ const laptopRow = machineRows.find((row) => row.textContent?.includes("Laptop"));
+ expect(laptopRow?.textContent).toContain("Not reporting");
+ const studioRow = machineRows.find((row) => row.textContent?.includes("Studio Mac"));
+ expect(studioRow?.textContent).toContain("/Users/dev/.claude");
+ });
+
+ it("asks for a longer window when the day selector changes", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (day) => [
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 1,
+ totalTokens: 1_000,
+ }),
+ ]),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders( );
+ await expect.element(page.getByTestId("usage-model-row").first()).toBeInTheDocument();
+ expect(windowLengthOf(summary.mock.calls.at(-1)?.[0])).toBe(7);
+
+ await page.getByTestId("usage-window-30").click();
+
+ await vi.waitFor(() => {
+ expect(windowLengthOf(summary.mock.calls.at(-1)?.[0])).toBe(30);
+ });
+ });
+});
+
+describe("SidebarUsageMeter", () => {
+ it("shows today's compact token total once it arrives", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (day) => [
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 3,
+ totalTokens: 2_400_000,
+ }),
+ ]),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ // Compact form: trailing zeros are table alignment, not chip copy.
+ await expect.element(page.getByTestId("sidebar-usage-meter")).toHaveTextContent("2.4M today");
+ });
+
+ it("stays the plain label when no environment answers", async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await expect.element(page.getByTestId("sidebar-usage-meter")).toHaveTextContent("Usage");
+ });
+});
+
+function windowLengthOf(input: UsageSummaryInput | undefined): number {
+ if (!input) return 0;
+ const since = Date.parse(`${input.sinceDay}T00:00:00Z`);
+ const until = Date.parse(`${input.untilDay}T00:00:00Z`);
+ return Math.round((until - since) / 86_400_000) + 1;
+}
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
new file mode 100644
index 000000000..8cbc6fc21
--- /dev/null
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -0,0 +1,290 @@
+import { USAGE_WINDOW_DAY_OPTIONS, type UsageWindowDays } from "@threadlines/contracts";
+import {
+ formatCount,
+ formatDayShort,
+ formatTokens,
+ formatUsd,
+ formatPercent,
+} from "@threadlines/shared/usageFormat";
+import { useQuery } from "@tanstack/react-query";
+import { RotateCwIcon } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick";
+import { cn } from "../../lib/utils";
+import { usageSummaryQueryOptions, useUsageEnvironmentTargets } from "../../lib/usageReactQuery";
+import { formatRelativeTimeLabel } from "../../timestampFormat";
+import {
+ buildUsageChartBars,
+ buildUsageMachineRows,
+ formatCostQualityFootnote,
+ shouldLabelUsageTick,
+ USAGE_MACHINE_STATE_LABELS,
+ USAGE_PROVIDER_BAR_CLASSES,
+ USAGE_PROVIDER_LABELS,
+ USAGE_PROVIDER_ORDER,
+ type UsageMachineRow,
+} from "./usageView.logic";
+
+const SECTION_LABEL_CLASS =
+ "font-mono text-[10px] uppercase tracking-wider text-muted-foreground/55 select-none";
+const NUMBER_CLASS = "font-mono tabular-nums";
+/** Scan freshness is a relative label, so it needs a slow clock of its own. */
+const FRESHNESS_TICK_MS = 30_000;
+
+/**
+ * What the provider CLIs' own transcripts say this account has spent, merged
+ * across every computer Threadlines knows about.
+ *
+ * The figures are API list prices for the tokens, never billed spend, and the
+ * page says so under the title: a subscription bills on its own terms and the
+ * transcripts carry no invoice.
+ */
+export function UsageView() {
+ const [windowDays, setWindowDays] = useState(7);
+ // One slow clock for the whole page: scan freshness is the only honest signal
+ // about a warm scan, and it must not go stale on screen.
+ useRelativeTimeTick(FRESHNESS_TICK_MS);
+ const targets = useUsageEnvironmentTargets();
+ const usageQuery = useQuery(usageSummaryQueryOptions({ targets, windowDays }));
+ const data = usageQuery.data ?? null;
+ const merged = data?.merged ?? null;
+
+ const bars = useMemo(
+ () =>
+ data
+ ? buildUsageChartBars({
+ daily: data.merged.daily,
+ sinceDay: data.window.sinceDay,
+ untilDay: data.window.untilDay,
+ })
+ : [],
+ [data],
+ );
+ const machines = useMemo(
+ () =>
+ data
+ ? buildUsageMachineRows({
+ environments: data.environments,
+ staleEnvironments: data.merged.staleEnvironments,
+ })
+ : [],
+ [data],
+ );
+
+ return (
+
+
+
Usage
+
+ {USAGE_WINDOW_DAY_OPTIONS.map((option) => (
+ setWindowDays(option)}
+ >
+ {option}d
+
+ ))}
+
+
void usageQuery.refetch()}
+ >
+
+ Refresh
+
+
+
+ API list-price equivalent. Subscription plans bill separately.
+
+
+ {targets.length === 0 ? (
+
+ No computers are connected, so there is nothing to report yet.
+
+ ) : !merged || !data ? (
+
+ {usageQuery.isError ? "Usage could not be read." : "Reading provider transcripts…"}
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
Daily
+
+ {USAGE_PROVIDER_ORDER.map((provider) => (
+
+
+ {USAGE_PROVIDER_LABELS[provider]}
+
+ ))}
+
+
+
+ {bars.map((bar) => (
+
+ {bar.segments.map((segment) => (
+
+ ))}
+
+ ))}
+
+
+ {bars.map((bar, index) => (
+
+ {shouldLabelUsageTick(index, bars.length) ? formatDayShort(bar.day) : ""}
+
+ ))}
+
+
+
+
+ Models
+ {merged.models.length === 0 ? (
+
+ No recorded activity in this window.
+
+ ) : (
+
+ {merged.models.map((model) => (
+
+
+ {model.model}
+
+
+ {formatTokens(model.totalTokens)}
+
+
+ {formatUsd(model.costUsd)}
+
+
+ {formatPercent(model.costShare, 0)}
+
+
+ ))}
+
+ )}
+
+
+
+ Machines
+
+ {machines.map((machine) => (
+
+ ))}
+
+ {merged.duplicateSources.length > 0 ? (
+
+ Counted once: {merged.duplicateSources.join(", ")} is the same folder another
+ computer already reported.
+
+ ) : null}
+
+
+
+ {formatCostQualityFootnote(merged.costQuality)}
+
+ >
+ )}
+
+ );
+}
+
+function UsageStat({ label, value }: { readonly label: string; readonly value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function UsageMachineRowView({ machine }: { readonly machine: UsageMachineRow }) {
+ return (
+
+
+ {machine.label}
+
+ {USAGE_MACHINE_STATE_LABELS[machine.state]}
+
+
+ {machine.detail ? (
+ {machine.detail}
+ ) : null}
+ {machine.sources.map((source) => (
+
+
+ {USAGE_PROVIDER_LABELS[source.fingerprint.provider]}
+
+
+ {source.fingerprint.resolvedHomePath}
+
+
+ {source.status === "missing"
+ ? "not found"
+ : `scanned ${formatRelativeTimeLabel(source.lastScannedAt)}`}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/usage/usageView.logic.ts b/apps/web/src/components/usage/usageView.logic.ts
new file mode 100644
index 000000000..4fc84630d
--- /dev/null
+++ b/apps/web/src/components/usage/usageView.logic.ts
@@ -0,0 +1,167 @@
+import type { UsageProviderKind, UsageSource } from "@threadlines/contracts";
+import {
+ enumerateDays,
+ formatDayShort,
+ formatPercent,
+ formatTokens,
+ formatUsd,
+} from "@threadlines/shared/usageFormat";
+import type { CostQuality, DailyTotals } from "@threadlines/shared/usageMerge";
+
+import type { UsageEnvironmentReport } from "~/lib/usageReactQuery";
+
+export const USAGE_PROVIDER_LABELS: Record = {
+ claude: "Claude",
+ codex: "Codex",
+};
+
+/**
+ * Two tones from the existing palette rather than brand colors: the app has one
+ * graph accent, so the second provider takes a neutral.
+ */
+export const USAGE_PROVIDER_BAR_CLASSES: Record = {
+ claude: "bg-primary-graph",
+ codex: "bg-foreground/35",
+};
+
+/** Stacking order, so a provider keeps the same position in every bar. */
+export const USAGE_PROVIDER_ORDER: readonly UsageProviderKind[] = ["claude", "codex"];
+
+export interface UsageChartSegment {
+ readonly provider: UsageProviderKind;
+ readonly totalTokens: number;
+ /** Share of the tallest day in the window, as a percentage of bar height. */
+ readonly heightPercent: number;
+}
+
+export interface UsageChartBar {
+ readonly day: string;
+ readonly totalTokens: number;
+ readonly costUsd: number;
+ readonly segments: readonly UsageChartSegment[];
+ /** Native-tooltip text; the page deliberately has no tooltip system. */
+ readonly title: string;
+}
+
+/**
+ * One bar per calendar day of the requested window, including days with no
+ * activity — gaps are information, and dropping them would compress the axis
+ * into a lie about when the work happened.
+ */
+export function buildUsageChartBars(input: {
+ readonly daily: readonly DailyTotals[];
+ readonly sinceDay: string;
+ readonly untilDay: string;
+}): readonly UsageChartBar[] {
+ const byDay = new Map(input.daily.map((entry) => [entry.day, entry]));
+ const peakTokens = input.daily.reduce((peak, entry) => Math.max(peak, entry.totalTokens), 0);
+
+ return enumerateDays(input.sinceDay, input.untilDay).map((day) => {
+ const entry = byDay.get(day);
+ const segments = USAGE_PROVIDER_ORDER.flatMap((provider) => {
+ const providerTotals = entry?.byProvider.get(provider);
+ if (!providerTotals || providerTotals.totalTokens === 0) return [];
+ return [
+ {
+ provider,
+ totalTokens: providerTotals.totalTokens,
+ heightPercent: peakTokens === 0 ? 0 : (providerTotals.totalTokens / peakTokens) * 100,
+ },
+ ];
+ });
+
+ return {
+ day,
+ totalTokens: entry?.totalTokens ?? 0,
+ costUsd: entry?.costUsd ?? 0,
+ segments,
+ title: describeUsageDay(day, entry ?? null, segments),
+ };
+ });
+}
+
+function describeUsageDay(
+ day: string,
+ entry: DailyTotals | null,
+ segments: readonly UsageChartSegment[],
+): string {
+ if (!entry || entry.totalTokens === 0) {
+ return `${formatDayShort(day)}: no recorded usage`;
+ }
+ const breakdown = segments
+ .map(
+ (segment) =>
+ `${USAGE_PROVIDER_LABELS[segment.provider]} ${formatTokens(segment.totalTokens)}`,
+ )
+ .join(", ");
+ return `${formatDayShort(day)}: ${formatTokens(entry.totalTokens)} tokens, ${formatUsd(entry.costUsd)} API-equivalent (${breakdown})`;
+}
+
+/**
+ * Roughly six date ticks, whatever the window length, so 90 days does not turn
+ * the axis into a smear.
+ */
+export function shouldLabelUsageTick(index: number, count: number): boolean {
+ if (count === 0) return false;
+ const step = Math.max(1, Math.ceil(count / 6));
+ return index % step === 0 || index === count - 1;
+}
+
+export type UsageMachineState = "reporting" | "stale" | "not-reporting";
+
+export interface UsageMachineRow {
+ readonly environmentId: string;
+ readonly label: string;
+ readonly state: UsageMachineState;
+ readonly detail: string | null;
+ readonly sources: readonly UsageSource[];
+}
+
+/**
+ * A row per environment the app knows about, including the ones that answered
+ * nothing. Silently dropping an unreachable machine would make the totals look
+ * complete when they are not.
+ */
+export function buildUsageMachineRows(input: {
+ readonly environments: readonly UsageEnvironmentReport[];
+ readonly staleEnvironments: readonly string[];
+}): readonly UsageMachineRow[] {
+ const stale = new Set(input.staleEnvironments);
+
+ return [...input.environments]
+ .sort((left, right) => left.label.localeCompare(right.label))
+ .map((environment) => {
+ if (!environment.summary) {
+ return {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ state: "not-reporting" as const,
+ detail: environment.error,
+ sources: [],
+ };
+ }
+ const isStale = stale.has(environment.environmentId);
+ return {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ state: isStale ? ("stale" as const) : ("reporting" as const),
+ detail: isStale ? "Running an older usage format; excluded from the totals." : null,
+ sources: environment.summary.sources,
+ };
+ });
+}
+
+export const USAGE_MACHINE_STATE_LABELS: Record = {
+ reporting: "Reporting",
+ stale: "Out of date",
+ "not-reporting": "Not reporting",
+};
+
+/** How trustworthy the cost column is, in one line. */
+export function formatCostQualityFootnote(costQuality: CostQuality): string {
+ return [
+ `Priced from provider records ${formatPercent(costQuality.providerReportedShare, 0)}`,
+ `model rates ${formatPercent(costQuality.modelPricedShare, 0)}`,
+ `unpriced ${formatPercent(costQuality.unpricedShare, 0)}`,
+ ].join(" · ");
+}
diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts
index 49aedab3e..feae4ed51 100644
--- a/apps/web/src/environmentApi.ts
+++ b/apps/web/src/environmentApi.ts
@@ -45,6 +45,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
filesystem: {
browse: rpcClient.filesystem.browse,
},
+ usage: {
+ summary: rpcClient.usage.summary,
+ },
sourceControl: {
lookupRepository: rpcClient.sourceControl.lookupRepository,
listRepositories: rpcClient.sourceControl.listRepositories,
diff --git a/apps/web/src/lib/usageReactQuery.ts b/apps/web/src/lib/usageReactQuery.ts
new file mode 100644
index 000000000..33528aed0
--- /dev/null
+++ b/apps/web/src/lib/usageReactQuery.ts
@@ -0,0 +1,201 @@
+import {
+ USAGE_CONTRACT_VERSION,
+ type EnvironmentId,
+ type UsageSummary,
+ type UsageSummaryInput,
+ type UsageWindowDays,
+} from "@threadlines/contracts";
+import { makeTodayUsageWindow, makeUsageWindow } from "@threadlines/shared/usageFormat";
+import {
+ mergeUsage,
+ type EnvironmentUsage,
+ type MergedUsage,
+} from "@threadlines/shared/usageMerge";
+import { queryOptions } from "@tanstack/react-query";
+import { useMemo } from "react";
+
+import { readEnvironmentApi } from "~/environmentApi";
+import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "~/environments/primary";
+import {
+ useSavedEnvironmentRegistryStore,
+ useSavedEnvironmentRuntimeStore,
+} from "~/environments/runtime";
+import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";
+
+/** An environment the page will ask for usage, connected or not. */
+export interface UsageEnvironmentTarget {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+}
+
+/**
+ * What one environment contributed.
+ *
+ * A machine that could not be reached keeps its row with `summary: null` rather
+ * than disappearing: a total that quietly omits a computer is worse than one
+ * that says which computer is missing.
+ */
+export interface UsageEnvironmentReport {
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ readonly summary: UsageSummary | null;
+ readonly error: string | null;
+}
+
+export interface UsageAcrossEnvironments {
+ readonly window: UsageSummaryInput;
+ readonly merged: MergedUsage;
+ readonly environments: readonly UsageEnvironmentReport[];
+}
+
+/** A few minutes: a transcript scan is disk work, and usage is not live data. */
+const USAGE_STALE_TIME_MS = 5 * 60_000;
+
+function normalizeTargets(
+ targets: ReadonlyArray,
+): ReadonlyArray {
+ return [...targets].sort((left, right) => left.environmentId.localeCompare(right.environmentId));
+}
+
+async function readUsageAcrossEnvironments(
+ targets: ReadonlyArray,
+ window: UsageSummaryInput,
+): Promise {
+ const reads = await Promise.allSettled(
+ targets.map(async (target) => {
+ const api = readEnvironmentApi(target.environmentId);
+ if (!api?.usage) {
+ throw new Error(`Environment ${target.environmentId} is unavailable.`);
+ }
+ return api.usage.summary(window);
+ }),
+ );
+
+ const environments: UsageEnvironmentReport[] = targets.map((target, index) => {
+ const read = reads[index];
+ if (read?.status === "fulfilled") {
+ return {
+ environmentId: target.environmentId,
+ label: target.label,
+ summary: read.value,
+ error: null,
+ };
+ }
+ return {
+ environmentId: target.environmentId,
+ label: target.label,
+ summary: null,
+ error: read?.status === "rejected" ? describeUsageReadFailure(read.reason) : "Unavailable",
+ };
+ });
+
+ const contributions: EnvironmentUsage[] = environments.flatMap((environment) =>
+ environment.summary
+ ? [
+ {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ summary: environment.summary,
+ },
+ ]
+ : [],
+ );
+
+ return {
+ window,
+ merged: mergeUsage(contributions, USAGE_CONTRACT_VERSION),
+ environments,
+ };
+}
+
+function describeUsageReadFailure(reason: unknown): string {
+ if (reason instanceof Error && reason.message.trim().length > 0) {
+ return reason.message;
+ }
+ return "Unavailable";
+}
+
+/**
+ * The page's query. Every environment is asked in parallel and the failures are
+ * kept alongside the successes, so the merge can be honest about coverage.
+ */
+export function usageSummaryQueryOptions(input: {
+ readonly targets: ReadonlyArray;
+ readonly windowDays: UsageWindowDays;
+}) {
+ const targets = normalizeTargets(input.targets);
+ return queryOptions({
+ queryKey: [
+ "usage",
+ "summary",
+ input.windowDays,
+ targets.map((target) => target.environmentId),
+ ] as const,
+ // The window is built inside the fetch rather than in the key so a session
+ // left open past midnight rolls onto the new day at the next refetch.
+ queryFn: () => readUsageAcrossEnvironments(targets, makeUsageWindow(input.windowDays)),
+ enabled: targets.length > 0,
+ staleTime: USAGE_STALE_TIME_MS,
+ });
+}
+
+/**
+ * Today alone, for the sidebar meter. Same key family as the page so the two
+ * never collide, and a different window so neither refetches for the other.
+ */
+export function usageTodayQueryOptions(input: {
+ readonly targets: ReadonlyArray;
+}) {
+ const targets = normalizeTargets(input.targets);
+ return queryOptions({
+ queryKey: ["usage", "summary", "today", targets.map((target) => target.environmentId)] as const,
+ queryFn: () => readUsageAcrossEnvironments(targets, makeTodayUsageWindow()),
+ enabled: targets.length > 0,
+ staleTime: USAGE_STALE_TIME_MS,
+ });
+}
+
+/**
+ * Every environment the app knows about: this device plus each saved backend,
+ * whether or not it is currently connected.
+ */
+export function useUsageEnvironmentTargets(): ReadonlyArray {
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const primaryLabel = readPrimaryEnvironmentDescriptor()?.label ?? null;
+ const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId);
+ const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId);
+
+ return useMemo(() => {
+ const targets: UsageEnvironmentTarget[] = [];
+ const seen = new Set();
+
+ if (primaryEnvironmentId) {
+ seen.add(primaryEnvironmentId);
+ targets.push({
+ environmentId: primaryEnvironmentId,
+ label: resolveEnvironmentOptionLabel({
+ isPrimary: true,
+ environmentId: primaryEnvironmentId,
+ runtimeLabel: primaryLabel,
+ }),
+ });
+ }
+
+ for (const record of Object.values(savedEnvironmentsById)) {
+ if (seen.has(record.environmentId)) continue;
+ seen.add(record.environmentId);
+ targets.push({
+ environmentId: record.environmentId,
+ label: resolveEnvironmentOptionLabel({
+ isPrimary: false,
+ environmentId: record.environmentId,
+ runtimeLabel:
+ savedEnvironmentRuntimeById[record.environmentId]?.descriptor?.label ?? null,
+ savedLabel: record.label,
+ }),
+ });
+ }
+
+ return normalizeTargets(targets);
+ }, [primaryEnvironmentId, primaryLabel, savedEnvironmentRuntimeById, savedEnvironmentsById]);
+}
diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts
index 6991a5f06..2f7b7be7b 100644
--- a/apps/web/src/localApi.test.ts
+++ b/apps/web/src/localApi.test.ts
@@ -68,6 +68,9 @@ const rpcClientMock = {
filesystem: {
browse: vi.fn(),
},
+ usage: {
+ summary: vi.fn(),
+ },
sourceControl: {
lookupRepository: vi.fn(),
listRepositories: vi.fn(),
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index f4c57490c..b8c92dbf6 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ChatIndexRouteImport } from './routes/_chat.index'
import { Route as ChatChatsRouteImport } from './routes/_chat.chats'
+import { Route as ChatUsageRouteImport } from './routes/_chat.usage'
import { Route as SettingsIndexRouteImport } from './routes/settings.index'
import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
@@ -51,6 +52,11 @@ const ChatChatsRoute = ChatChatsRouteImport.update({
path: '/chats',
getParentRoute: () => ChatRoute,
} as any)
+const ChatUsageRoute = ChatUsageRouteImport.update({
+ id: '/usage',
+ path: '/usage',
+ getParentRoute: () => ChatRoute,
+} as any)
const SettingsIndexRoute = SettingsIndexRouteImport.update({
id: '/',
path: '/',
@@ -118,6 +124,7 @@ export interface FileRoutesByFullPath {
'/pair': typeof PairRoute
'/settings': typeof SettingsRouteWithChildren
'/chats': typeof ChatChatsRoute
+ '/usage': typeof ChatUsageRoute
'/settings/archived': typeof SettingsArchivedRoute
'/settings/connections': typeof SettingsConnectionsRoute
'/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -134,6 +141,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/pair': typeof PairRoute
'/chats': typeof ChatChatsRoute
+ '/usage': typeof ChatUsageRoute
'/settings/archived': typeof SettingsArchivedRoute
'/settings/connections': typeof SettingsConnectionsRoute
'/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -154,6 +162,7 @@ export interface FileRoutesById {
'/pair': typeof PairRoute
'/settings': typeof SettingsRouteWithChildren
'/_chat/chats': typeof ChatChatsRoute
+ '/_chat/usage': typeof ChatUsageRoute
'/settings/archived': typeof SettingsArchivedRoute
'/settings/connections': typeof SettingsConnectionsRoute
'/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -175,6 +184,7 @@ export interface FileRouteTypes {
| '/pair'
| '/settings'
| '/chats'
+ | '/usage'
| '/settings/archived'
| '/settings/connections'
| '/settings/diagnostics'
@@ -191,6 +201,7 @@ export interface FileRouteTypes {
to:
| '/pair'
| '/chats'
+ | '/usage'
| '/settings/archived'
| '/settings/connections'
| '/settings/diagnostics'
@@ -210,6 +221,7 @@ export interface FileRouteTypes {
| '/pair'
| '/settings'
| '/_chat/chats'
+ | '/_chat/usage'
| '/settings/archived'
| '/settings/connections'
| '/settings/diagnostics'
@@ -268,6 +280,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChatChatsRouteImport
parentRoute: typeof ChatRoute
}
+ '/_chat/usage': {
+ id: '/_chat/usage'
+ path: '/usage'
+ fullPath: '/usage'
+ preLoaderRoute: typeof ChatUsageRouteImport
+ parentRoute: typeof ChatRoute
+ }
'/settings/': {
id: '/settings/'
path: '/'
@@ -357,6 +376,7 @@ declare module '@tanstack/react-router' {
interface ChatRouteChildren {
ChatChatsRoute: typeof ChatChatsRoute
+ ChatUsageRoute: typeof ChatUsageRoute
ChatIndexRoute: typeof ChatIndexRoute
ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute
ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute
@@ -364,6 +384,7 @@ interface ChatRouteChildren {
const ChatRouteChildren: ChatRouteChildren = {
ChatChatsRoute: ChatChatsRoute,
+ ChatUsageRoute: ChatUsageRoute,
ChatIndexRoute: ChatIndexRoute,
ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute,
ChatDraftDraftIdRoute: ChatDraftDraftIdRoute,
diff --git a/apps/web/src/routes/_chat.usage.tsx b/apps/web/src/routes/_chat.usage.tsx
new file mode 100644
index 000000000..e894bfc95
--- /dev/null
+++ b/apps/web/src/routes/_chat.usage.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { UsageView } from "../components/usage/UsageView";
+
+export const Route = createFileRoute("/_chat/usage")({
+ component: UsageView,
+});
diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts
index e44357e21..b6788c5c1 100644
--- a/apps/web/src/rpc/wsRpcClient.ts
+++ b/apps/web/src/rpc/wsRpcClient.ts
@@ -113,6 +113,9 @@ export interface WsRpcClient {
readonly filesystem: {
readonly browse: RpcUnaryMethod;
};
+ readonly usage: {
+ readonly summary: RpcUnaryMethod;
+ };
readonly sourceControl: {
readonly lookupRepository: RpcUnaryMethod;
readonly listRepositories: RpcUnaryMethod;
@@ -438,6 +441,9 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
filesystem: {
browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
},
+ usage: {
+ summary: (input) => transport.request((client) => client[WS_METHODS.usageSummary](input)),
+ },
sourceControl: {
lookupRepository: (input) =>
transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 99c33ecaa..3ed6eae82 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -52,6 +52,7 @@ import type {
VcsStatusResult,
} from "./git.ts";
import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
+import type { UsageSummary, UsageSummaryInput } from "./usage.ts";
import type {
ProjectFaviconInput,
ProjectFaviconResult,
@@ -1178,6 +1179,14 @@ export interface EnvironmentApi {
filesystem: {
browse: (input: FilesystemBrowseInput) => Promise;
};
+ /**
+ * Token/cost reporting for this environment's provider transcripts. Optional
+ * so a client can still talk to a server predating the usage scan; callers
+ * treat its absence the same way they treat an unreachable environment.
+ */
+ usage?: {
+ summary: (input: UsageSummaryInput) => Promise;
+ };
sourceControl: {
lookupRepository: (
input: SourceControlRepositoryLookupInput,
diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts
new file mode 100644
index 000000000..ac021fbee
--- /dev/null
+++ b/packages/shared/src/usageFormat.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ enumerateDays,
+ formatDayShort,
+ formatTokens,
+ formatTokensCompact,
+ makeTodayUsageWindow,
+ makeUsageWindow,
+} from "./usageFormat.ts";
+
+describe("formatTokens", () => {
+ it("compacts to three significant figures with a unit suffix", () => {
+ expect(formatTokens(999)).toBe("999");
+ expect(formatTokens(1_040)).toBe("1.04K");
+ expect(formatTokens(804_000)).toBe("804K");
+ expect(formatTokens(2_400_000)).toBe("2.40M");
+ expect(formatTokens(2_000_000)).toBe("2M");
+ expect(formatTokens(19_900_000_000)).toBe("19.9B");
+ });
+});
+
+describe("formatTokensCompact", () => {
+ it("drops trailing zeros but keeps significant fraction digits", () => {
+ expect(formatTokensCompact(2_400_000)).toBe("2.4M");
+ expect(formatTokensCompact(2_000_000)).toBe("2M");
+ expect(formatTokensCompact(1_040)).toBe("1.04K");
+ expect(formatTokensCompact(999)).toBe("999");
+ });
+});
+
+describe("formatDayShort", () => {
+ it("renders a calendar day without shifting it into another zone", () => {
+ expect(formatDayShort("2026-08-07")).toBe("Aug 7");
+ expect(formatDayShort("2026-01-01")).toBe("Jan 1");
+ });
+});
+
+describe("enumerateDays", () => {
+ it("includes both bounds and spans month ends", () => {
+ expect(enumerateDays("2026-01-30", "2026-02-02")).toEqual([
+ "2026-01-30",
+ "2026-01-31",
+ "2026-02-01",
+ "2026-02-02",
+ ]);
+ });
+
+ it("returns nothing when the range is inverted", () => {
+ expect(enumerateDays("2026-02-02", "2026-01-30")).toEqual([]);
+ });
+});
+
+describe("makeUsageWindow", () => {
+ it("ends today and spans the requested number of days inclusively", () => {
+ const window = makeUsageWindow(7, new Date("2026-03-10T18:00:00Z"));
+ expect(enumerateDays(window.sinceDay, window.untilDay)).toHaveLength(7);
+ expect(window.timeZone.length).toBeGreaterThan(0);
+ });
+
+ it("walks back across a month boundary", () => {
+ const window = makeUsageWindow(7, new Date("2026-03-03T18:00:00Z"));
+ expect(enumerateDays(window.sinceDay, window.untilDay)).toHaveLength(7);
+ expect(window.sinceDay < window.untilDay).toBe(true);
+ });
+});
+
+describe("makeTodayUsageWindow", () => {
+ it("covers a single day", () => {
+ const window = makeTodayUsageWindow(new Date("2026-03-10T18:00:00Z"));
+ expect(window.sinceDay).toBe(window.untilDay);
+ });
+});
diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts
index 0a72389cd..a19d02851 100644
--- a/packages/shared/src/usageFormat.ts
+++ b/packages/shared/src/usageFormat.ts
@@ -47,6 +47,17 @@ function trim(value: number): string {
return value.toFixed(digits).replace(/\.0+$/, "");
}
+/**
+ * {@link formatTokens} without trailing zeros ("2.4M", never "2.40M"), for
+ * one-off figures like the sidebar meter. Tables keep the padded form so
+ * fraction digits line up.
+ */
+export function formatTokensCompact(value: number): string {
+ return formatTokens(value)
+ .replace(/(\.\d*?)0+(?=[A-Z]?$)/, "$1")
+ .replace(/\.(?=[A-Z]?$)/, "");
+}
+
export function formatPercent(share: number, digits = 1): string {
return `${(share * 100).toFixed(digits)}%`;
}
@@ -94,6 +105,21 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri
* machine holding the transcripts is often not the machine looking at them.
*/
export function makeUsageWindow(windowDays: UsageWindowDays, now = new Date()): UsageSummaryInput {
+ return buildUsageWindow(windowDays, now);
+}
+
+/**
+ * Today alone, for the compact live figure in the sidebar footer.
+ *
+ * Separate from {@link makeUsageWindow} because one day is not one of the
+ * windows the page offers, and widening that function's type would invite
+ * arbitrary windows the scan cache is not sized for.
+ */
+export function makeTodayUsageWindow(now = new Date()): UsageSummaryInput {
+ return buildUsageWindow(1, now);
+}
+
+function buildUsageWindow(windowDays: number, now: Date): UsageSummaryInput {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const format = new Intl.DateTimeFormat("en-CA", {
timeZone,
From c15c18169f95e104891bdf673ec0d4eed1a46d11 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 02:29:21 -0400
Subject: [PATCH 03/14] Redesign the usage page around the numbers
The page now leads with the figures: a two-column hero with the
API-equivalent cost as the one display-size element (asterisked to its
"if billed at full API rates" footnote) over per-provider share bars,
beside a smooth overlapping-area daily chart with a cost/tokens toggle.
Claude and Codex get the app's first brand hues (--provider-claude
terracotta, --provider-codex neutral, both themes). Below: a five-stat
band with context sublines (per active day, share of observed input,
cache writes, reasoning, savings multiple) and the models table with
provider icons, zero-token placeholder rows filtered out.
Window switching is now instant: one 90-day scan per environment set,
and 7/30/90 (default 30) are derived client-side by narrowing buckets
and re-merging, so the selector never refetches or shows loading. The
settings row shares the same cache. The sidebar meter says what it is:
Usage, then today's compact figure.
---
.../components/settings/SettingsPanels.tsx | 18 +-
.../components/sidebar/SidebarUsageMeter.tsx | 10 +-
.../components/usage/UsageView.browser.tsx | 145 ++++--
apps/web/src/components/usage/UsageView.tsx | 428 +++++++++++++-----
.../src/components/usage/usageView.logic.ts | 343 +++++++++++---
apps/web/src/index.css | 11 +
apps/web/src/lib/usageReactQuery.ts | 60 ++-
packages/shared/src/usageFormat.test.ts | 9 +
packages/shared/src/usageFormat.ts | 22 +-
packages/shared/src/usageMerge.test.ts | 63 ++-
packages/shared/src/usageMerge.ts | 23 +
11 files changed, 892 insertions(+), 240 deletions(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 43f2ab714..e8ad3eb52 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -20,6 +20,7 @@ import {
type ProviderInstanceConfig,
type ProviderInstanceId,
type ScopedThreadRef,
+ type UsageWindowDays,
} from "@threadlines/contracts";
import { scopeThreadRef } from "@threadlines/client-runtime";
import { DEFAULT_UNIFIED_SETTINGS } from "@threadlines/contracts/settings";
@@ -38,7 +39,11 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import { readEnvironmentApi } from "../../environmentApi";
import { setDesktopUpdateStateQueryData } from "../../lib/desktopUpdateReactQuery";
-import { usageSummaryQueryOptions, useUsageEnvironmentTargets } from "../../lib/usageReactQuery";
+import {
+ deriveUsageWindow,
+ usageSummaryQueryOptions,
+ useUsageEnvironmentTargets,
+} from "../../lib/usageReactQuery";
import {
resolveDefaultTextGenerationBackupModelSelectionState,
resolveAppModelSelectionState,
@@ -136,7 +141,7 @@ const INACTIVE_THREAD_ARCHIVE_COMMAND_DELAY_MS = 25;
const ARCHIVED_THREAD_DELETE_COMMAND_DELAY_MS = 25;
const DEFAULT_ARCHIVED_THREAD_DELETE_AGE_DAYS: ArchivedThreadDeleteAgeDays = 90;
/** A month reads as "recently" without being a single noisy week. */
-const USAGE_SETTINGS_WINDOW_DAYS = 30;
+const USAGE_SETTINGS_WINDOW_DAYS: UsageWindowDays = 30;
function waitForInactiveThreadArchiveCommandSlot(): Promise {
return new Promise((resolve) =>
@@ -1070,10 +1075,13 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" |
*/
function ProviderUsageLinkRow() {
const targets = useUsageEnvironmentTargets();
- const usageQuery = useQuery(
- usageSummaryQueryOptions({ targets, windowDays: USAGE_SETTINGS_WINDOW_DAYS }),
+ // The same scan the usage page reads, narrowed here rather than re-fetched.
+ const usageQuery = useQuery(usageSummaryQueryOptions({ targets }));
+ const scan = usageQuery.data ?? null;
+ const merged = useMemo(
+ () => (scan ? deriveUsageWindow(scan, USAGE_SETTINGS_WINDOW_DAYS).merged : null),
+ [scan],
);
- const merged = usageQuery.data?.merged ?? null;
return (
- {totalTokens === null ? (
- Usage
- ) : (
-
+ Usage
+ {totalTokens === null ? null : (
+
{formatTokensCompact(totalTokens)} today
)}
diff --git a/apps/web/src/components/usage/UsageView.browser.tsx b/apps/web/src/components/usage/UsageView.browser.tsx
index c5dba9c1a..05792fea6 100644
--- a/apps/web/src/components/usage/UsageView.browser.tsx
+++ b/apps/web/src/components/usage/UsageView.browser.tsx
@@ -9,6 +9,7 @@ import {
type UsageSummary,
type UsageSummaryInput,
} from "@threadlines/contracts";
+import { enumerateDays } from "@threadlines/shared/usageFormat";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
RouterProvider,
@@ -57,6 +58,7 @@ function bucket(input: {
readonly model: string;
readonly costUsd: number;
readonly totalTokens: number;
+ readonly cacheSavingsUsd?: number;
}): UsageBucket {
return {
day: input.day as UsageDay,
@@ -64,7 +66,7 @@ function bucket(input: {
model: input.model,
totals: tokens(input.totalTokens),
costUsd: input.costUsd,
- cacheSavingsUsd: 1.5,
+ cacheSavingsUsd: input.cacheSavingsUsd ?? 1.5,
costSource: "modelPriced",
records: 4,
unpricedRecords: 0,
@@ -94,7 +96,7 @@ function source(
function summaryFor(
input: UsageSummaryInput,
- buckets: (day: string) => readonly UsageBucket[],
+ buckets: (days: readonly string[]) => readonly UsageBucket[],
): UsageSummary {
return {
contractVersion: USAGE_CONTRACT_VERSION,
@@ -102,7 +104,7 @@ function summaryFor(
timeZone: input.timeZone,
sinceDay: input.sinceDay,
untilDay: input.untilDay,
- buckets: buckets(input.untilDay),
+ buckets: buckets(enumerateDays(input.sinceDay, input.untilDay)),
sources: [source("claude", "/Users/dev/.claude"), source("codex", "/Users/dev/.codex")],
pricing: {
status: "cached",
@@ -172,35 +174,64 @@ afterEach(() => {
});
describe("UsageView", () => {
- it("renders merged totals, model rows, and every machine including the silent one", async () => {
+ it("renders the hero, the stat band, the priced models, and the silent machine", async () => {
const summary = vi.fn(async (input: UsageSummaryInput) =>
- summaryFor(input, (day) => [
- bucket({
- day,
- provider: "claude",
- model: "claude-fable-5",
- costUsd: 12.5,
- totalTokens: 2_000_000,
- }),
- bucket({
- day,
- provider: "codex",
- model: "gpt-5.6-sol",
- costUsd: 4.5,
- totalTokens: 400_000,
- }),
- ]),
+ summaryFor(input, (days) => {
+ const day = days[days.length - 1] ?? input.untilDay;
+ return [
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 12.5,
+ totalTokens: 2_000_000,
+ }),
+ bucket({
+ day,
+ provider: "codex",
+ model: "gpt-5.6-sol",
+ costUsd: 4.5,
+ totalTokens: 400_000,
+ }),
+ // Placeholder rows carry records but no tokens and no cost; the table
+ // has nothing to say about them.
+ bucket({
+ day,
+ provider: "codex",
+ model: "",
+ costUsd: 0,
+ totalTokens: 0,
+ cacheSavingsUsd: 0,
+ }),
+ ];
+ }),
);
registerEnvironments(summary);
renderWithProviders( );
- await expect.element(page.getByText("$17.00")).toBeInTheDocument();
- await expect.element(page.getByText("2.40M")).toBeInTheDocument();
+ // The one display-size figure on the page, asterisked to its footnote.
+ await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$17.00*");
await expect
- .element(page.getByText("API list-price equivalent. Subscription plans bill separately."))
+ .element(page.getByText("* if billed at full API rates. Subscription plans bill separately."))
.toBeInTheDocument();
+ const providerRows = page.getByTestId("usage-provider-row").elements();
+ expect(providerRows).toHaveLength(2);
+ expect(providerRows[0]?.textContent).toContain("Claude Code");
+ expect(providerRows[0]?.textContent).toContain("$12.50");
+ expect(providerRows[0]?.textContent).toContain("73.5% of cost · 2M tokens");
+
+ const stats = page.getByTestId("usage-stat").elements();
+ expect(stats.map((stat) => stat.textContent)).toEqual([
+ // Compact figures here: the stat band is one-off numbers, not a column.
+ "Processed tokens2.4M2.4M per active day",
+ "Cached input1.2M50.0% of observed input",
+ "Uncached input1.2M0 cache writes",
+ "Output0",
+ "Cache savings$3.000.2x the API-equivalent cost",
+ ]);
+
const modelRows = page.getByTestId("usage-model-row").elements();
expect(modelRows).toHaveLength(2);
// Sorted by cost, so the expensive model leads.
@@ -215,44 +246,60 @@ describe("UsageView", () => {
expect(studioRow?.textContent).toContain("/Users/dev/.claude");
});
- it("asks for a longer window when the day selector changes", async () => {
+ it("opens on 30 days and switches windows without another scan", async () => {
const summary = vi.fn(async (input: UsageSummaryInput) =>
- summaryFor(input, (day) => [
- bucket({
- day,
- provider: "claude",
- model: "claude-fable-5",
- costUsd: 1,
- totalTokens: 1_000,
- }),
- ]),
+ summaryFor(input, (days) =>
+ days.map((day) =>
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 1,
+ totalTokens: 1_000,
+ }),
+ ),
+ ),
);
registerEnvironments(summary);
renderWithProviders( );
- await expect.element(page.getByTestId("usage-model-row").first()).toBeInTheDocument();
- expect(windowLengthOf(summary.mock.calls.at(-1)?.[0])).toBe(7);
- await page.getByTestId("usage-window-30").click();
+ await vi.waitFor(() => {
+ expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(30);
+ });
+ // One scan, and it covers the longest window the selector offers.
+ expect(summary).toHaveBeenCalledTimes(1);
+ expect(windowLengthOf(summary.mock.calls[0]?.[0])).toBe(90);
+ await page.getByTestId("usage-window-7").click();
await vi.waitFor(() => {
- expect(windowLengthOf(summary.mock.calls.at(-1)?.[0])).toBe(30);
+ expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(7);
});
+
+ await page.getByTestId("usage-window-90").click();
+ await vi.waitFor(() => {
+ expect(page.getByTestId("usage-chart-day").elements()).toHaveLength(90);
+ });
+
+ // Narrowing and widening are arithmetic on the scan already in hand.
+ expect(summary).toHaveBeenCalledTimes(1);
});
});
describe("SidebarUsageMeter", () => {
- it("shows today's compact token total once it arrives", async () => {
+ it("names itself and carries today's compact total once it arrives", async () => {
const summary = vi.fn(async (input: UsageSummaryInput) =>
- summaryFor(input, (day) => [
- bucket({
- day,
- provider: "claude",
- model: "claude-fable-5",
- costUsd: 3,
- totalTokens: 2_400_000,
- }),
- ]),
+ summaryFor(input, (days) =>
+ days.map((day) =>
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 3,
+ totalTokens: 2_400_000,
+ }),
+ ),
+ ),
);
registerEnvironments(summary);
@@ -263,7 +310,9 @@ describe("SidebarUsageMeter", () => {
);
// Compact form: trailing zeros are table alignment, not chip copy.
- await expect.element(page.getByTestId("sidebar-usage-meter")).toHaveTextContent("2.4M today");
+ await expect
+ .element(page.getByTestId("sidebar-usage-meter"))
+ .toHaveTextContent("Usage2.4M today");
});
it("stays the plain label when no environment answers", async () => {
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
index 8cbc6fc21..f869c3633 100644
--- a/apps/web/src/components/usage/UsageView.tsx
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -1,8 +1,12 @@
-import { USAGE_WINDOW_DAY_OPTIONS, type UsageWindowDays } from "@threadlines/contracts";
+import {
+ USAGE_WINDOW_DAY_OPTIONS,
+ type UsageProviderKind,
+ type UsageWindowDays,
+} from "@threadlines/contracts";
import {
formatCount,
- formatDayShort,
formatTokens,
+ formatTokensCompact,
formatUsd,
formatPercent,
} from "@threadlines/shared/usageFormat";
@@ -12,18 +16,32 @@ import { useMemo, useState } from "react";
import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick";
import { cn } from "../../lib/utils";
-import { usageSummaryQueryOptions, useUsageEnvironmentTargets } from "../../lib/usageReactQuery";
+import {
+ deriveUsageWindow,
+ usageSummaryQueryOptions,
+ useUsageEnvironmentTargets,
+} from "../../lib/usageReactQuery";
import { formatRelativeTimeLabel } from "../../timestampFormat";
+import { ClaudeAI, OpenAI, type Icon } from "../Icons";
import {
- buildUsageChartBars,
+ buildUsageAreaChart,
buildUsageMachineRows,
+ buildUsageStats,
formatCostQualityFootnote,
- shouldLabelUsageTick,
+ formatUsageDateRange,
+ USAGE_CHART_MODE_LABELS,
+ USAGE_CHART_MODES,
+ USAGE_CHART_TITLES,
USAGE_MACHINE_STATE_LABELS,
- USAGE_PROVIDER_BAR_CLASSES,
+ USAGE_PROVIDER_COLORS,
USAGE_PROVIDER_LABELS,
- USAGE_PROVIDER_ORDER,
+ USAGE_PROVIDER_READING_ORDER,
+ USAGE_PROVIDER_SHORT_LABELS,
+ visibleUsageModels,
+ type UsageAreaChart,
+ type UsageChartMode,
type UsageMachineRow,
+ type UsageStat,
} from "./usageView.logic";
const SECTION_LABEL_CLASS =
@@ -31,54 +49,85 @@ const SECTION_LABEL_CLASS =
const NUMBER_CLASS = "font-mono tabular-nums";
/** Scan freshness is a relative label, so it needs a slow clock of its own. */
const FRESHNESS_TICK_MS = 30_000;
+/** The window a first-time reader gets: a week is too short to see a pattern. */
+const DEFAULT_WINDOW_DAYS: UsageWindowDays = 30;
+
+/** The same glyphs the model picker draws, keyed by the usage contract's names. */
+const USAGE_PROVIDER_ICONS: Record = {
+ claude: ClaudeAI,
+ codex: OpenAI,
+};
/**
* What the provider CLIs' own transcripts say this account has spent, merged
* across every computer Threadlines knows about.
*
* The figures are API list prices for the tokens, never billed spend, and the
- * page says so under the title: a subscription bills on its own terms and the
+ * page says so under the headline: a subscription bills on its own terms and the
* transcripts carry no invoice.
+ *
+ * One scan covers the longest window on offer and the selector narrows it here,
+ * so switching between 7, 30 and 90 days is a recompute rather than a wait.
*/
export function UsageView() {
- const [windowDays, setWindowDays] = useState(7);
+ const [windowDays, setWindowDays] = useState(DEFAULT_WINDOW_DAYS);
+ const [chartMode, setChartMode] = useState("cost");
// One slow clock for the whole page: scan freshness is the only honest signal
// about a warm scan, and it must not go stale on screen.
useRelativeTimeTick(FRESHNESS_TICK_MS);
const targets = useUsageEnvironmentTargets();
- const usageQuery = useQuery(usageSummaryQueryOptions({ targets, windowDays }));
- const data = usageQuery.data ?? null;
- const merged = data?.merged ?? null;
+ const usageQuery = useQuery(usageSummaryQueryOptions({ targets }));
+ const scan = usageQuery.data ?? null;
- const bars = useMemo(
+ const view = useMemo(
+ () => (scan ? deriveUsageWindow(scan, windowDays) : null),
+ [scan, windowDays],
+ );
+ const merged = view?.merged ?? null;
+
+ const chart = useMemo(
() =>
- data
- ? buildUsageChartBars({
- daily: data.merged.daily,
- sinceDay: data.window.sinceDay,
- untilDay: data.window.untilDay,
+ view
+ ? buildUsageAreaChart({
+ daily: view.merged.daily,
+ sinceDay: view.window.sinceDay,
+ untilDay: view.window.untilDay,
+ mode: chartMode,
})
- : [],
- [data],
+ : null,
+ [chartMode, view],
);
+ // The machines answered for the whole scan, not for the selected window, so
+ // they are read from the scan and never move when the selector does.
const machines = useMemo(
() =>
- data
+ scan
? buildUsageMachineRows({
- environments: data.environments,
- staleEnvironments: data.merged.staleEnvironments,
+ environments: scan.environments,
+ staleEnvironments: scan.merged.staleEnvironments,
})
: [],
- [data],
+ [scan],
);
+ const models = useMemo(() => (merged ? visibleUsageModels(merged) : []), [merged]);
+ const stats = useMemo(() => (merged ? buildUsageStats(merged) : []), [merged]);
return (
-
-
Usage
+
+
Usage
+ {view ? (
+
+ {formatUsageDateRange(view.window.sinceDay, view.window.untilDay)}
+
+ ) : null}
+
{USAGE_WINDOW_DAY_OPTIONS.map((option) => (
-
- API list-price equivalent. Subscription plans bill separately.
-
{targets.length === 0 ? (
No computers are connected, so there is nothing to report yet.
- ) : !merged || !data ? (
+ ) : !merged || !view || !chart ? (
{usageQuery.isError ? "Usage could not be read." : "Reading provider transcripts…"}
) : (
<>
-
-
-
-
-
-
-
-
-
-
Daily
-
- {USAGE_PROVIDER_ORDER.map((provider) => (
-
-
- {USAGE_PROVIDER_LABELS[provider]}
-
- ))}
+
+
+
API-equivalent cost
+
+ {formatUsd(merged.costUsd)}
+ *
+
+
+ * if billed at full API rates. Subscription plans bill separately.
+
+
+ {USAGE_PROVIDER_READING_ORDER.flatMap((provider) => {
+ const totals = merged.providers.find((entry) => entry.provider === provider);
+ return totals ? [ ] : [];
+ })}
-
- {bars.map((bar) => (
-
- {bar.segments.map((segment) => (
-
- ))}
-
- ))}
-
-
- {bars.map((bar, index) => (
-
- {shouldLabelUsageTick(index, bars.length) ? formatDayShort(bar.day) : ""}
-
- ))}
-
-
+
+
+
+
+
+ {stats.map((stat) => (
+
+ ))}
+
- Models
- {merged.models.length === 0 ? (
+
+ Models · {formatCount(merged.sessions)} sessions
+
+ {models.length === 0 ? (
No recorded activity in this window.
) : (
- {merged.models.map((model) => (
-
-
- {model.model}
-
-
- {formatTokens(model.totalTokens)}
-
-
- {formatUsd(model.costUsd)}
-
-
{
+ const ProviderIcon = USAGE_PROVIDER_ICONS[model.provider];
+ return (
+
- {formatPercent(model.costShare, 0)}
-
-
- ))}
+
+
+
+ {model.model}
+
+
+
+ {formatTokens(model.totalTokens)}
+
+
+ {formatUsd(model.costUsd)}
+
+
+ {formatPercent(model.costShare, 0)}
+
+
+ );
+ })}
)}
@@ -241,11 +268,182 @@ export function UsageView() {
);
}
-function UsageStat({ label, value }: { readonly label: string; readonly value: string }) {
+/** Provider name, its slice of the cost, and how wide that slice is. */
+function UsageProviderRow({
+ totals,
+}: {
+ readonly totals: {
+ readonly provider: UsageProviderKind;
+ readonly costUsd: number;
+ readonly costShare: number;
+ readonly totalTokens: number;
+ };
+}) {
+ const ProviderIcon = USAGE_PROVIDER_ICONS[totals.provider];
return (
-
-
{label}
-
{value}
+
+
+
+
+ {USAGE_PROVIDER_LABELS[totals.provider]}
+
+
+ {formatUsd(totals.costUsd)}
+
+
+
+
+ {formatPercent(totals.costShare)} of cost · {formatTokensCompact(totals.totalTokens)} tokens
+
+
+ );
+}
+
+/**
+ * Overlapping areas, one per provider, drawn from the zero baseline.
+ *
+ * The SVG carries only the curves: gridlines, labels and the hover targets are
+ * HTML, so nothing has to survive the horizontal stretch that lets the plot fill
+ * whatever width the hero gives it.
+ */
+function UsageChart({
+ chart,
+ mode,
+ onModeChange,
+}: {
+ readonly chart: UsageAreaChart;
+ readonly mode: UsageChartMode;
+ readonly onModeChange: (mode: UsageChartMode) => void;
+}) {
+ return (
+
+
+
{USAGE_CHART_TITLES[mode]}
+
+
+ {USAGE_CHART_MODES.map((option, index) => (
+
+ {index > 0 ? | : null}
+ onModeChange(option)}
+ >
+ {USAGE_CHART_MODE_LABELS[option]}
+
+
+ ))}
+
+
+ {USAGE_PROVIDER_READING_ORDER.map((provider) => (
+
+
+ {USAGE_PROVIDER_LABELS[provider]}
+
+ ))}
+
+
+
+
+ {chart.gridlines.map((gridline) => (
+
+
+ {gridline.label}
+
+
+ ))}
+
+ {chart.series.map((series) => (
+
+
+
+
+ ))}
+
+ {/* Transparent per-day columns: the whole tooltip system, in one attribute. */}
+
+ {chart.columns.map((column) => (
+
+ ))}
+
+ {chart.isEmpty ? (
+
+ No recorded activity in this window.
+
+ ) : null}
+
+
+ {chart.axisLabels.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ );
+}
+
+function UsageStatCell({ stat }: { readonly stat: UsageStat }) {
+ return (
+
+ {stat.label}
+
+ {stat.value}
+
+ {stat.context ? (
+ {stat.context}
+ ) : null}
);
}
@@ -273,7 +471,7 @@ function UsageMachineRowView({ machine }: { readonly machine: UsageMachineRow })
className="flex min-w-0 items-baseline gap-2 text-xs text-muted-foreground/55"
>
- {USAGE_PROVIDER_LABELS[source.fingerprint.provider]}
+ {USAGE_PROVIDER_SHORT_LABELS[source.fingerprint.provider]}
{source.fingerprint.resolvedHomePath}
diff --git a/apps/web/src/components/usage/usageView.logic.ts b/apps/web/src/components/usage/usageView.logic.ts
index 4fc84630d..9622d9082 100644
--- a/apps/web/src/components/usage/usageView.logic.ts
+++ b/apps/web/src/components/usage/usageView.logic.ts
@@ -4,107 +4,336 @@ import {
formatDayShort,
formatPercent,
formatTokens,
+ formatTokensCompact,
formatUsd,
} from "@threadlines/shared/usageFormat";
-import type { CostQuality, DailyTotals } from "@threadlines/shared/usageMerge";
+import type { CostQuality, DailyTotals, MergedUsage } from "@threadlines/shared/usageMerge";
import type { UsageEnvironmentReport } from "~/lib/usageReactQuery";
+/** Product names, as the providers write them. */
export const USAGE_PROVIDER_LABELS: Record = {
+ claude: "Claude Code",
+ codex: "Codex",
+};
+
+/** For the narrow columns where the product name would not fit. */
+export const USAGE_PROVIDER_SHORT_LABELS: Record = {
claude: "Claude",
codex: "Codex",
};
/**
- * Two tones from the existing palette rather than brand colors: the app has one
- * graph accent, so the second provider takes a neutral.
+ * The only brand hues in the app, defined in `index.css` for both themes.
+ *
+ * Held as CSS variables rather than utility classes because the chart paints
+ * them into SVG fill/stroke attributes as well as into HTML.
*/
-export const USAGE_PROVIDER_BAR_CLASSES: Record = {
- claude: "bg-primary-graph",
- codex: "bg-foreground/35",
+export const USAGE_PROVIDER_COLORS: Record = {
+ claude: "var(--provider-claude)",
+ codex: "var(--provider-codex)",
};
-/** Stacking order, so a provider keeps the same position in every bar. */
-export const USAGE_PROVIDER_ORDER: readonly UsageProviderKind[] = ["claude", "codex"];
+/**
+ * Paint order, not stacking order: the series overlap, and Claude is usually the
+ * larger of the two, so it draws last and keeps its outline unbroken.
+ */
+export const USAGE_PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
+
+/** Reading order for legends and rows: the same order every time. */
+export const USAGE_PROVIDER_READING_ORDER: readonly UsageProviderKind[] = ["claude", "codex"];
+
+export type UsageChartMode = "cost" | "tokens";
+
+export const USAGE_CHART_MODES: readonly UsageChartMode[] = ["cost", "tokens"];
+
+export const USAGE_CHART_MODE_LABELS: Record = {
+ cost: "Cost",
+ tokens: "Tokens",
+};
+
+export const USAGE_CHART_TITLES: Record = {
+ cost: "Daily cost",
+ tokens: "Daily tokens",
+};
+
+/**
+ * Chart user units. Height matches the rendered pixel height so vertical
+ * geometry is 1:1; width is nominal, since the SVG stretches horizontally to
+ * whatever the hero column gives it.
+ */
+const CHART_WIDTH = 1000;
+const CHART_HEIGHT = 200;
+const GRIDLINE_COUNT = 4;
-export interface UsageChartSegment {
+export interface UsageChartSeries {
readonly provider: UsageProviderKind;
- readonly totalTokens: number;
- /** Share of the tallest day in the window, as a percentage of bar height. */
- readonly heightPercent: number;
+ readonly color: string;
+ /** Closed shape from the zero baseline, for the translucent fill. */
+ readonly areaPath: string;
+ /** The same curve without the baseline, for the stroke on top. */
+ readonly linePath: string;
}
-export interface UsageChartBar {
+export interface UsageChartGridline {
+ readonly value: number;
+ readonly label: string;
+ /** Distance from the top of the plot, as a percentage. */
+ readonly topPercent: number;
+}
+
+export interface UsageChartColumn {
readonly day: string;
- readonly totalTokens: number;
- readonly costUsd: number;
- readonly segments: readonly UsageChartSegment[];
/** Native-tooltip text; the page deliberately has no tooltip system. */
readonly title: string;
}
+export interface UsageAreaChart {
+ readonly viewBoxWidth: number;
+ readonly viewBoxHeight: number;
+ readonly series: readonly UsageChartSeries[];
+ readonly gridlines: readonly UsageChartGridline[];
+ /** Window start, midpoint and end. Three ticks, whatever the window length. */
+ readonly axisLabels: readonly string[];
+ readonly columns: readonly UsageChartColumn[];
+ readonly isEmpty: boolean;
+}
+
+function chartValue(
+ totals: { readonly costUsd: number; readonly totalTokens: number } | undefined,
+ mode: UsageChartMode,
+): number {
+ if (!totals) return 0;
+ return mode === "cost" ? totals.costUsd : totals.totalTokens;
+}
+
/**
- * One bar per calendar day of the requested window, including days with no
- * activity — gaps are information, and dropping them would compress the axis
- * into a lie about when the work happened.
+ * One smooth area per provider across every calendar day of the window,
+ * including the days with no activity — gaps are information, and dropping them
+ * would compress the axis into a lie about when the work happened.
*/
-export function buildUsageChartBars(input: {
+export function buildUsageAreaChart(input: {
readonly daily: readonly DailyTotals[];
readonly sinceDay: string;
readonly untilDay: string;
-}): readonly UsageChartBar[] {
+ readonly mode: UsageChartMode;
+}): UsageAreaChart {
const byDay = new Map(input.daily.map((entry) => [entry.day, entry]));
- const peakTokens = input.daily.reduce((peak, entry) => Math.max(peak, entry.totalTokens), 0);
-
- return enumerateDays(input.sinceDay, input.untilDay).map((day) => {
- const entry = byDay.get(day);
- const segments = USAGE_PROVIDER_ORDER.flatMap((provider) => {
- const providerTotals = entry?.byProvider.get(provider);
- if (!providerTotals || providerTotals.totalTokens === 0) return [];
- return [
- {
- provider,
- totalTokens: providerTotals.totalTokens,
- heightPercent: peakTokens === 0 ? 0 : (providerTotals.totalTokens / peakTokens) * 100,
- },
- ];
+ const days = enumerateDays(input.sinceDay, input.untilDay);
+
+ const valuesByProvider = new Map(
+ USAGE_PROVIDER_ORDER.map((provider) => [
+ provider,
+ days.map((day) => chartValue(byDay.get(day)?.byProvider.get(provider), input.mode)),
+ ]),
+ );
+
+ const peak = [...valuesByProvider.values()]
+ .flat()
+ .reduce((highest, value) => Math.max(highest, value), 0);
+ const axisMax = niceAxisMax(peak);
+
+ const series: UsageChartSeries[] = [];
+ for (const provider of USAGE_PROVIDER_ORDER) {
+ const values = valuesByProvider.get(provider) ?? [];
+ if (values.every((value) => value === 0)) continue;
+ const points = monotonePoints(values, axisMax);
+ series.push({
+ provider,
+ color: USAGE_PROVIDER_COLORS[provider],
+ areaPath: `${points} L ${CHART_WIDTH} ${CHART_HEIGHT} L 0 ${CHART_HEIGHT} Z`,
+ linePath: points,
});
+ }
- return {
+ return {
+ viewBoxWidth: CHART_WIDTH,
+ viewBoxHeight: CHART_HEIGHT,
+ series,
+ gridlines: axisMax === 0 ? [] : buildGridlines(axisMax, input.mode),
+ axisLabels: axisTickDays(days).map((day) => formatDayShort(day).toUpperCase()),
+ columns: days.map((day) => ({
day,
- totalTokens: entry?.totalTokens ?? 0,
- costUsd: entry?.costUsd ?? 0,
- segments,
- title: describeUsageDay(day, entry ?? null, segments),
+ title: describeUsageDay(day, byDay.get(day) ?? null),
+ })),
+ isEmpty: series.length === 0,
+ };
+}
+
+/** Start, middle and end, de-duplicated for windows too short to have three. */
+function axisTickDays(days: readonly string[]): readonly string[] {
+ if (days.length === 0) return [];
+ const ticks = [days[0], days[Math.floor((days.length - 1) / 2)], days[days.length - 1]];
+ return [...new Set(ticks.filter((day): day is string => day !== undefined))];
+}
+
+function buildGridlines(axisMax: number, mode: UsageChartMode): readonly UsageChartGridline[] {
+ return Array.from({ length: GRIDLINE_COUNT }, (_unused, index) => {
+ const fraction = (GRIDLINE_COUNT - index) / GRIDLINE_COUNT;
+ const value = axisMax * fraction;
+ return {
+ value,
+ label: mode === "cost" ? formatUsd(value) : formatTokens(value),
+ topPercent: (1 - fraction) * 100,
};
});
}
-function describeUsageDay(
- day: string,
- entry: DailyTotals | null,
- segments: readonly UsageChartSegment[],
-): string {
+const AXIS_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] as const;
+
+/**
+ * The smallest round ceiling above the peak that also divides evenly into
+ * {@link GRIDLINE_COUNT} bands, so every gridline label is a number a reader can
+ * hold in their head.
+ */
+export function niceAxisMax(peak: number): number {
+ if (!(peak > 0)) return 0;
+ const band = peak / GRIDLINE_COUNT;
+ const magnitude = 10 ** Math.floor(Math.log10(band));
+ const normalized = band / magnitude;
+ const step = AXIS_STEPS.find((candidate) => normalized <= candidate + 1e-9) ?? 10;
+ return step * magnitude * GRIDLINE_COUNT;
+}
+
+/**
+ * A monotone cubic through the daily values, as an SVG path.
+ *
+ * Fritsch-Carlson tangents rather than a plain Catmull-Rom: a smooth curve that
+ * overshoots would draw days below zero and peaks the data never reached, which
+ * on a spend chart is not a stylistic quibble.
+ */
+function monotonePoints(values: readonly number[], axisMax: number): string {
+ if (values.length === 0) return "M 0 " + String(CHART_HEIGHT);
+ const stepX = values.length > 1 ? CHART_WIDTH / (values.length - 1) : 0;
+ const toY = (value: number) =>
+ axisMax === 0 ? CHART_HEIGHT : CHART_HEIGHT - (value / axisMax) * CHART_HEIGHT;
+ const ys = values.map(toY);
+ if (ys.length === 1) {
+ // One day: a flat segment, so the fill still has a shape to close.
+ return `M 0 ${round(ys[0] ?? CHART_HEIGHT)} L ${CHART_WIDTH} ${round(ys[0] ?? CHART_HEIGHT)}`;
+ }
+
+ const tangents = monotoneTangents(ys, stepX);
+ let path = `M 0 ${round(ys[0] ?? 0)}`;
+ for (let index = 0; index < ys.length - 1; index += 1) {
+ const x0 = index * stepX;
+ const x1 = (index + 1) * stepX;
+ const y0 = ys[index] ?? 0;
+ const y1 = ys[index + 1] ?? 0;
+ const m0 = tangents[index] ?? 0;
+ const m1 = tangents[index + 1] ?? 0;
+ const control = stepX / 3;
+ path += ` C ${round(x0 + control)} ${round(y0 + m0 * control)} ${round(x1 - control)} ${round(y1 - m1 * control)} ${round(x1)} ${round(y1)}`;
+ }
+ return path;
+}
+
+function monotoneTangents(ys: readonly number[], stepX: number): readonly number[] {
+ const secants = ys.slice(0, -1).map((y, index) => ((ys[index + 1] ?? y) - y) / stepX);
+ return ys.map((_unused, index) => {
+ const before = secants[index - 1];
+ const after = secants[index];
+ if (before === undefined) return after ?? 0;
+ if (after === undefined) return before;
+ // A local extremum: a flat tangent is the only one that cannot overshoot.
+ if (before * after <= 0) return 0;
+ const average = (before + after) / 2;
+ const limit = 3 * Math.min(Math.abs(before), Math.abs(after));
+ return Math.sign(average) * Math.min(Math.abs(average), limit);
+ });
+}
+
+function round(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+function describeUsageDay(day: string, entry: DailyTotals | null): string {
if (!entry || entry.totalTokens === 0) {
return `${formatDayShort(day)}: no recorded usage`;
}
- const breakdown = segments
- .map(
- (segment) =>
- `${USAGE_PROVIDER_LABELS[segment.provider]} ${formatTokens(segment.totalTokens)}`,
- )
- .join(", ");
+ const breakdown = USAGE_PROVIDER_READING_ORDER.flatMap((provider) => {
+ const totals = entry.byProvider.get(provider);
+ if (!totals || totals.totalTokens === 0) return [];
+ return [`${USAGE_PROVIDER_LABELS[provider]} ${formatTokens(totals.totalTokens)}`];
+ }).join(", ");
return `${formatDayShort(day)}: ${formatTokens(entry.totalTokens)} tokens, ${formatUsd(entry.costUsd)} API-equivalent (${breakdown})`;
}
+/** `2026-07-12` and `2026-08-10` to `Jul 12 to Aug 10`. */
+export function formatUsageDateRange(sinceDay: string, untilDay: string): string {
+ return `${formatDayShort(sinceDay)} to ${formatDayShort(untilDay)}`;
+}
+
+export interface UsageStat {
+ readonly label: string;
+ readonly value: string;
+ /** One line of context, or nothing when the figure has none worth adding. */
+ readonly context: string | null;
+}
+
+/**
+ * The band under the hero: what the tokens were, not just how many.
+ *
+ * Every sub-line is a ratio the totals alone do not give, so a reader can tell a
+ * heavy day from a heavy month, and cache reads from cache writes.
+ */
+export function buildUsageStats(merged: MergedUsage): readonly UsageStat[] {
+ // A day with any tokens at all. Dividing by calendar days instead would make
+ // a week off look like a drop in intensity rather than a week off.
+ const activeDays = merged.daily.filter((entry) => entry.totalTokens > 0).length;
+ const observedInput =
+ merged.cachedInputTokens + merged.uncachedInputTokens + merged.cacheCreationTokens;
+
+ return [
+ {
+ label: "Processed tokens",
+ value: formatTokensCompact(merged.totalTokens),
+ context:
+ activeDays === 0
+ ? null
+ : `${formatTokensCompact(merged.totalTokens / activeDays)} per active day`,
+ },
+ {
+ label: "Cached input",
+ value: formatTokensCompact(merged.cachedInputTokens),
+ context:
+ observedInput === 0
+ ? null
+ : `${formatPercent(merged.cachedInputTokens / observedInput)} of observed input`,
+ },
+ {
+ label: "Uncached input",
+ value: formatTokensCompact(merged.uncachedInputTokens),
+ context: `${formatTokensCompact(merged.cacheCreationTokens)} cache writes`,
+ },
+ {
+ label: "Output",
+ value: formatTokensCompact(merged.outputTokens),
+ context:
+ merged.reasoningTokens === 0
+ ? null
+ : `includes ${formatTokensCompact(merged.reasoningTokens)} reasoning`,
+ },
+ {
+ label: "Cache savings",
+ value: formatUsd(merged.costQuality.cacheSavingsUsd),
+ context:
+ merged.costUsd === 0
+ ? null
+ : `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the API-equivalent cost`,
+ },
+ ];
+}
+
/**
- * Roughly six date ticks, whatever the window length, so 90 days does not turn
- * the axis into a smear.
+ * Models worth a row.
+ *
+ * A scan turns up placeholder model names (`` among them) carrying
+ * neither tokens nor cost. They are real records, but a row of zeros tells a
+ * reader nothing except that the table is noisy.
*/
-export function shouldLabelUsageTick(index: number, count: number): boolean {
- if (count === 0) return false;
- const step = Math.max(1, Math.ceil(count / 6));
- return index % step === 0 || index === count - 1;
+export function visibleUsageModels(merged: MergedUsage): MergedUsage["models"] {
+ return merged.models.filter((model) => model.totalTokens > 0 || model.costUsd > 0);
}
export type UsageMachineState = "reporting" | "stale" | "not-reporting";
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 3a69ae638..1cc0aa3e2 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -70,6 +70,8 @@
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
+ --color-provider-claude: var(--provider-claude);
+ --color-provider-codex: var(--provider-codex);
--color-primary-graph: var(--primary-graph);
--color-primary-readable: var(--primary-readable);
--color-primary-foreground: var(--primary-foreground);
@@ -207,6 +209,13 @@
--primary: var(--app-accent-blue);
--primary-readable: oklch(0.488 0.217 264);
--primary-graph: oklch(0.488 0.217 264);
+ /* The two providers charted on the usage page, and the only brand hues in the
+ app. Anthropic's terracotta reads as itself in both modes; Codex has no
+ usable brand color, so it takes a near-foreground neutral, which is also how
+ its own mark is drawn. Darkened on light, lifted on dark, so a 1.5px chart
+ stroke stays legible against either canvas. */
+ --provider-claude: #c96442;
+ --provider-codex: oklch(0.32 0 0);
--primary-foreground: var(--color-white);
--secondary: --alpha(var(--color-black) / 4%);
--secondary-foreground: var(--foreground);
@@ -265,6 +274,8 @@
--primary: var(--app-accent-blue);
--primary-readable: oklch(0.65 0.19 264);
--primary-graph: oklch(0.7 0.17 264);
+ --provider-claude: #e08b66;
+ --provider-codex: oklch(0.9 0 0);
--primary-foreground: var(--color-white);
--secondary: --alpha(var(--color-white) / 8%);
--secondary-foreground: var(--foreground);
diff --git a/apps/web/src/lib/usageReactQuery.ts b/apps/web/src/lib/usageReactQuery.ts
index 33528aed0..98822d228 100644
--- a/apps/web/src/lib/usageReactQuery.ts
+++ b/apps/web/src/lib/usageReactQuery.ts
@@ -1,17 +1,24 @@
import {
USAGE_CONTRACT_VERSION,
+ USAGE_MAX_WINDOW_DAYS,
+ UsageDay,
type EnvironmentId,
type UsageSummary,
type UsageSummaryInput,
type UsageWindowDays,
} from "@threadlines/contracts";
-import { makeTodayUsageWindow, makeUsageWindow } from "@threadlines/shared/usageFormat";
import {
+ makeTodayUsageWindow,
+ makeUsageWindow,
+ windowStartDay,
+} from "@threadlines/shared/usageFormat";
+import {
+ filterSummaryWindow,
mergeUsage,
type EnvironmentUsage,
type MergedUsage,
} from "@threadlines/shared/usageMerge";
-import { queryOptions } from "@tanstack/react-query";
+import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { useMemo } from "react";
import { readEnvironmentApi } from "~/environmentApi";
@@ -118,27 +125,70 @@ function describeUsageReadFailure(reason: unknown): string {
/**
* The page's query. Every environment is asked in parallel and the failures are
* kept alongside the successes, so the merge can be honest about coverage.
+ *
+ * Always the longest window the page offers, whatever the selector says. A scan
+ * is disk work measured in seconds, and every shorter selection is a subset of
+ * this one, so the 7- and 30-day views are derived rather than fetched.
*/
export function usageSummaryQueryOptions(input: {
readonly targets: ReadonlyArray;
- readonly windowDays: UsageWindowDays;
}) {
const targets = normalizeTargets(input.targets);
return queryOptions({
queryKey: [
"usage",
"summary",
- input.windowDays,
+ USAGE_MAX_WINDOW_DAYS,
targets.map((target) => target.environmentId),
] as const,
// The window is built inside the fetch rather than in the key so a session
// left open past midnight rolls onto the new day at the next refetch.
- queryFn: () => readUsageAcrossEnvironments(targets, makeUsageWindow(input.windowDays)),
+ queryFn: () => readUsageAcrossEnvironments(targets, makeUsageWindow(USAGE_MAX_WINDOW_DAYS)),
enabled: targets.length > 0,
staleTime: USAGE_STALE_TIME_MS,
+ // A refresh keeps the page on screen: the numbers move, the layout does not.
+ placeholderData: keepPreviousData,
});
}
+/**
+ * Narrows a scanned result to the selected window, client-side.
+ *
+ * Anchored on the scan's own last day rather than on "now", so the derived
+ * window always lines up with the days the scan actually covered.
+ */
+export function deriveUsageWindow(
+ scan: UsageAcrossEnvironments,
+ windowDays: UsageWindowDays,
+): UsageAcrossEnvironments {
+ const untilDay = scan.window.untilDay;
+ const sinceDay = UsageDay.make(windowStartDay(untilDay, windowDays));
+ if (sinceDay <= scan.window.sinceDay) return scan;
+
+ const environments: UsageEnvironmentReport[] = scan.environments.map((environment) =>
+ environment.summary
+ ? { ...environment, summary: filterSummaryWindow(environment.summary, sinceDay, untilDay) }
+ : environment,
+ );
+ const contributions: EnvironmentUsage[] = environments.flatMap((environment) =>
+ environment.summary
+ ? [
+ {
+ environmentId: environment.environmentId,
+ label: environment.label,
+ summary: environment.summary,
+ },
+ ]
+ : [],
+ );
+
+ return {
+ window: { ...scan.window, sinceDay },
+ merged: mergeUsage(contributions, USAGE_CONTRACT_VERSION),
+ environments,
+ };
+}
+
/**
* Today alone, for the sidebar meter. Same key family as the page so the two
* never collide, and a different window so neither refetches for the other.
diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts
index ac021fbee..5323add58 100644
--- a/packages/shared/src/usageFormat.test.ts
+++ b/packages/shared/src/usageFormat.test.ts
@@ -7,6 +7,7 @@ import {
formatTokensCompact,
makeTodayUsageWindow,
makeUsageWindow,
+ windowStartDay,
} from "./usageFormat.ts";
describe("formatTokens", () => {
@@ -51,6 +52,14 @@ describe("enumerateDays", () => {
});
});
+describe("windowStartDay", () => {
+ it("counts the end day itself and walks back across a month boundary", () => {
+ expect(windowStartDay("2026-08-10", 30)).toBe("2026-07-12");
+ expect(windowStartDay("2026-03-03", 7)).toBe("2026-02-25");
+ expect(windowStartDay("2026-08-10", 1)).toBe("2026-08-10");
+ });
+});
+
describe("makeUsageWindow", () => {
it("ends today and spans the requested number of days inclusively", () => {
const window = makeUsageWindow(7, new Date("2026-03-10T18:00:00Z"));
diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts
index a19d02851..96dc25035 100644
--- a/packages/shared/src/usageFormat.ts
+++ b/packages/shared/src/usageFormat.ts
@@ -97,6 +97,22 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri
return days;
}
+/**
+ * Inclusive first day of a window of `windowDays` ending on `untilDay`.
+ *
+ * The page fetches one long window and narrows it on the client, so the shorter
+ * selections have to be anchored to the day the scan actually ended on rather
+ * than to a fresh "now" that may already have rolled over.
+ */
+export function windowStartDay(untilDay: string, windowDays: number): string {
+ const [year = 0, month = 1, dayOfMonth = 1] = untilDay
+ .split("-")
+ .map((part) => Number.parseInt(part, 10));
+ // Calendar arithmetic in UTC, where every day is the same length.
+ const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (windowDays - 1)));
+ return start.toISOString().slice(0, 10);
+}
+
/**
* The window the page requests for a 7/30/90-day selection, expressed in the
* viewer's own time zone so days line up with what they actually experienced.
@@ -131,12 +147,8 @@ function buildUsageWindow(windowDays: number, now: Date): UsageSummaryInput {
// Subtracting fixed milliseconds from `now` lands on the wrong calendar day
// around a DST transition. Only "today" needs the zone; the window start is
// pure calendar arithmetic on that day, done in UTC where days are uniform.
- const [year = 0, month = 1, dayOfMonth = 1] = untilDay
- .split("-")
- .map((part) => Number.parseInt(part, 10));
- const start = new Date(Date.UTC(year, month - 1, dayOfMonth - (windowDays - 1)));
return {
- sinceDay: UsageDay.make(start.toISOString().slice(0, 10)),
+ sinceDay: UsageDay.make(windowStartDay(untilDay, windowDays)),
untilDay: UsageDay.make(untilDay),
timeZone,
};
diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts
index 3d5b18e2a..38bacaa79 100644
--- a/packages/shared/src/usageMerge.test.ts
+++ b/packages/shared/src/usageMerge.test.ts
@@ -8,7 +8,7 @@ import {
} from "@threadlines/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
+import { filterSummaryWindow, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
function bucket(overrides: Partial = {}): UsageBucket {
return {
@@ -253,3 +253,64 @@ describe("mergeUsage", () => {
expect(merged.reasoningTokens).toBe(2);
});
});
+
+describe("filterSummaryWindow", () => {
+ it("keeps the days on both bounds and drops the ones outside", () => {
+ const scanned = summary(
+ [
+ bucket({ day: "2026-08-04" as UsageDay }),
+ bucket({ day: "2026-08-05" as UsageDay }),
+ bucket({ day: "2026-08-07" as UsageDay }),
+ bucket({ day: "2026-08-08" as UsageDay }),
+ ],
+ [claudeSource("mac")],
+ );
+
+ const narrowed = filterSummaryWindow(
+ scanned,
+ "2026-08-05" as UsageDay,
+ "2026-08-07" as UsageDay,
+ );
+
+ expect(narrowed.buckets.map((entry) => entry.day)).toEqual(["2026-08-05", "2026-08-07"]);
+ expect(narrowed.sinceDay).toBe("2026-08-05");
+ expect(narrowed.untilDay).toBe("2026-08-07");
+ });
+
+ it("passes the scan's own sources and pricing through untouched", () => {
+ const scanned = summary([bucket()], [claudeSource("mac")]);
+
+ const narrowed = filterSummaryWindow(
+ scanned,
+ "2026-08-07" as UsageDay,
+ "2026-08-07" as UsageDay,
+ );
+
+ expect(narrowed.sources).toBe(scanned.sources);
+ expect(narrowed.pricing).toBe(scanned.pricing);
+ expect(narrowed.readAt).toBe(scanned.readAt);
+ });
+
+ it("re-merges to the totals the narrower window alone would have produced", () => {
+ const scanned = summary(
+ [
+ bucket({ day: "2026-08-01" as UsageDay, costUsd: 10 }),
+ bucket({ day: "2026-08-07" as UsageDay, costUsd: 4 }),
+ ],
+ [claudeSource("mac")],
+ );
+
+ const merged = mergeUsage(
+ [
+ environment(
+ "env-a",
+ filterSummaryWindow(scanned, "2026-08-05" as UsageDay, "2026-08-07" as UsageDay),
+ ),
+ ],
+ USAGE_CONTRACT_VERSION,
+ );
+
+ expect(merged.costUsd).toBe(4);
+ expect(merged.daily.map((entry) => entry.day)).toEqual(["2026-08-07"]);
+ });
+});
diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts
index be249e4c8..e3ffa99c5 100644
--- a/packages/shared/src/usageMerge.ts
+++ b/packages/shared/src/usageMerge.ts
@@ -11,11 +11,34 @@
import type {
EnvironmentId,
UsageBucket,
+ UsageDay,
UsageProviderKind,
UsageSourceFingerprint,
UsageSummary,
} from "@threadlines/contracts";
+/**
+ * Narrows a scanned summary to a shorter day range.
+ *
+ * The page scans once for the longest window it offers and derives the 7- and
+ * 30-day views from that result, so switching windows is arithmetic rather than
+ * another disk walk. Sources, pricing and freshness describe the scan itself and
+ * pass through untouched: they do not become less true for a shorter window.
+ */
+export function filterSummaryWindow(
+ summary: UsageSummary,
+ sinceDay: UsageDay,
+ untilDay: UsageDay,
+): UsageSummary {
+ return {
+ ...summary,
+ sinceDay,
+ untilDay,
+ // `YYYY-MM-DD` days with fixed width compare correctly as strings.
+ buckets: summary.buckets.filter((bucket) => bucket.day >= sinceDay && bucket.day <= untilDay),
+ };
+}
+
export interface EnvironmentUsage {
readonly environmentId: EnvironmentId;
readonly label: string;
From 2d0bd5869c2efac84a294c9d45b9ff6222bffb05 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 02:43:55 -0400
Subject: [PATCH 04/14] Let the usage page breathe on a phone
The header wraps as a whole row instead of breaking the date range
across three lines, model rows drop the share column below sm so the
model name keeps its room, and stat sublines wrap instead of clipping.
Desktop keeps the single-row band and the four-column table.
---
apps/web/src/components/usage/UsageView.tsx | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
index f869c3633..8b6c2569f 100644
--- a/apps/web/src/components/usage/UsageView.tsx
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -117,11 +117,12 @@ export function UsageView() {
className="mx-auto flex h-full w-full max-w-[1200px] flex-col overflow-y-auto px-6 py-8"
data-testid="usage-view"
>
-
+ {/* Wraps as a whole row on narrow screens; the range never breaks internally. */}
+
Usage
{view ? (
{formatUsageDateRange(view.window.sinceDay, view.window.untilDay)}
@@ -215,7 +216,7 @@ export function UsageView() {
return (
@@ -232,8 +233,14 @@ export function UsageView() {
{formatUsd(model.costUsd)}
+ {/* Share is the first column to go on a phone: the model
+ name is the row's point, and share is derivable from
+ cost at a glance. */}
{formatPercent(model.costShare, 0)}
@@ -442,7 +449,9 @@ function UsageStatCell({ stat }: { readonly stat: UsageStat }) {
{stat.value}
{stat.context ? (
- {stat.context}
+ // Wraps on a phone, truncates on desktop where the band is one row and
+ // a two-line cell would stagger its neighbours.
+ {stat.context}
) : null}
);
From 9964347a8c8f2c0e349bc199bad25ff3b896d8a6 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:26:01 -0400
Subject: [PATCH 05/14] Let the cost|tokens toggle drive the whole hero, add a
days breakdown
The toggle used to switch only the chart while the headline stayed in
dollars. Now the hero follows it: the big figure, its label, and the
provider rows all show the selected measure, with the share bars sized by
token share in tokens mode. The cost asterisk moves to a superscript and
keeps its footnote only in cost mode, where the caveat means something.
The breakdown table gains a Model|Day toggle: day rows list each active
day newest first with tokens, cost, and share of the window's cost.
---
.../components/usage/UsageView.browser.tsx | 54 ++++++++
apps/web/src/components/usage/UsageView.tsx | 120 +++++++++++++++---
.../src/components/usage/usageView.logic.ts | 41 ++++++
3 files changed, 200 insertions(+), 15 deletions(-)
diff --git a/apps/web/src/components/usage/UsageView.browser.tsx b/apps/web/src/components/usage/UsageView.browser.tsx
index 05792fea6..1789c68b5 100644
--- a/apps/web/src/components/usage/UsageView.browser.tsx
+++ b/apps/web/src/components/usage/UsageView.browser.tsx
@@ -246,6 +246,60 @@ describe("UsageView", () => {
expect(studioRow?.textContent).toContain("/Users/dev/.claude");
});
+ it("switches the hero to tokens with the chart mode and the breakdown to days", async () => {
+ const summary = vi.fn(async (input: UsageSummaryInput) =>
+ summaryFor(input, (days) => {
+ const day = days[days.length - 1] ?? input.untilDay;
+ return [
+ bucket({
+ day,
+ provider: "claude",
+ model: "claude-fable-5",
+ costUsd: 12.5,
+ totalTokens: 2_000_000,
+ }),
+ bucket({
+ day,
+ provider: "codex",
+ model: "gpt-5.6-sol",
+ costUsd: 4.5,
+ totalTokens: 400_000,
+ }),
+ ];
+ }),
+ );
+ registerEnvironments(summary);
+
+ renderWithProviders( );
+ await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$17.00*");
+
+ await page.getByTestId("usage-chart-mode-tokens").click();
+
+ // The whole hero follows the toggle: headline figure, label, provider rows.
+ // The cost asterisk and its footnote only make sense against dollars.
+ await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("2.4M");
+ await expect.element(page.getByText("Processed tokens").first()).toBeInTheDocument();
+ expect(
+ page
+ .getByText("* if billed at full API rates. Subscription plans bill separately.")
+ .elements(),
+ ).toHaveLength(0);
+ const providerRows = page.getByTestId("usage-provider-row").elements();
+ expect(providerRows[0]?.textContent).toContain("2M");
+ expect(providerRows[0]?.textContent).toContain("83.3% of tokens · $12.50");
+
+ await page.getByTestId("usage-breakdown-days").click();
+
+ // Only the day with activity earns a row, and it carries both measures.
+ const dayRows = page.getByTestId("usage-day-row").elements();
+ expect(dayRows).toHaveLength(1);
+ expect(dayRows[0]?.textContent).toContain("$17.00");
+ expect(page.getByTestId("usage-model-row").elements()).toHaveLength(0);
+
+ await page.getByTestId("usage-breakdown-models").click();
+ expect(page.getByTestId("usage-model-row").elements()).toHaveLength(2);
+ });
+
it("opens on 30 days and switches windows without another scan", async () => {
const summary = vi.fn(async (input: UsageSummaryInput) =>
summaryFor(input, (days) =>
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
index 8b6c2569f..d23afaee4 100644
--- a/apps/web/src/components/usage/UsageView.tsx
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -25,13 +25,17 @@ import { formatRelativeTimeLabel } from "../../timestampFormat";
import { ClaudeAI, OpenAI, type Icon } from "../Icons";
import {
buildUsageAreaChart,
+ buildUsageDayRows,
buildUsageMachineRows,
buildUsageStats,
formatCostQualityFootnote,
formatUsageDateRange,
+ USAGE_BREAKDOWN_LABELS,
+ USAGE_BREAKDOWNS,
USAGE_CHART_MODE_LABELS,
USAGE_CHART_MODES,
USAGE_CHART_TITLES,
+ USAGE_HERO_LABELS,
USAGE_MACHINE_STATE_LABELS,
USAGE_PROVIDER_COLORS,
USAGE_PROVIDER_LABELS,
@@ -39,6 +43,7 @@ import {
USAGE_PROVIDER_SHORT_LABELS,
visibleUsageModels,
type UsageAreaChart,
+ type UsageBreakdown,
type UsageChartMode,
type UsageMachineRow,
type UsageStat,
@@ -72,6 +77,7 @@ const USAGE_PROVIDER_ICONS: Record = {
export function UsageView() {
const [windowDays, setWindowDays] = useState(DEFAULT_WINDOW_DAYS);
const [chartMode, setChartMode] = useState("cost");
+ const [breakdown, setBreakdown] = useState("models");
// One slow clock for the whole page: scan freshness is the only honest signal
// about a warm scan, and it must not go stale on screen.
useRelativeTimeTick(FRESHNESS_TICK_MS);
@@ -110,6 +116,7 @@ export function UsageView() {
[scan],
);
const models = useMemo(() => (merged ? visibleUsageModels(merged) : []), [merged]);
+ const dayRows = useMemo(() => (merged ? buildUsageDayRows(merged) : []), [merged]);
const stats = useMemo(() => (merged ? buildUsageStats(merged) : []), [merged]);
return (
@@ -173,21 +180,33 @@ export function UsageView() {
<>
-
API-equivalent cost
+
{USAGE_HERO_LABELS[chartMode]}
- {formatUsd(merged.costUsd)}
- *
+ {chartMode === "cost" ? (
+ <>
+ {formatUsd(merged.costUsd)}
+ *
+ >
+ ) : (
+ formatTokensCompact(merged.totalTokens)
+ )}
-
- * if billed at full API rates. Subscription plans bill separately.
-
+ {/* The asterisk and its footnote are the cost figure's caveat; a
+ token count needs no disclaimer. */}
+ {chartMode === "cost" ? (
+
+ * if billed at full API rates. Subscription plans bill separately.
+
+ ) : null}
{USAGE_PROVIDER_READING_ORDER.flatMap((provider) => {
const totals = merged.providers.find((entry) => entry.provider === provider);
- return totals ? [ ] : [];
+ return totals
+ ? [ ]
+ : [];
})}
@@ -202,10 +221,71 @@ export function UsageView() {
-
- Models · {formatCount(merged.sessions)} sessions
-
- {models.length === 0 ? (
+
+
+ {breakdown === "models" ? "Models" : "Days"} · {formatCount(merged.sessions)}{" "}
+ sessions
+
+
+
+ {USAGE_BREAKDOWNS.map((option, index) => (
+
+ {index > 0 ? | : null}
+ setBreakdown(option)}
+ >
+ {USAGE_BREAKDOWN_LABELS[option]}
+
+
+ ))}
+
+
+ {breakdown === "days" ? (
+ dayRows.length === 0 ? (
+
+ No recorded activity in this window.
+
+ ) : (
+
+ {dayRows.map((row) => (
+
+
+ {row.label}
+
+
+ {formatTokens(row.totalTokens)}
+
+
+ {formatUsd(row.costUsd)}
+
+
+ {formatPercent(row.costShare, 0)}
+
+
+ ))}
+
+ )
+ ) : models.length === 0 ? (
No recorded activity in this window.
@@ -275,18 +355,26 @@ export function UsageView() {
);
}
-/** Provider name, its slice of the cost, and how wide that slice is. */
+/**
+ * Provider name, its slice of the selected measure, and how wide that slice is.
+ * The row follows the page's cost|tokens mode: the leading figure and the bar
+ * show the selected measure, and the sub-line carries the other one.
+ */
function UsageProviderRow({
totals,
+ mode,
}: {
readonly totals: {
readonly provider: UsageProviderKind;
readonly costUsd: number;
readonly costShare: number;
readonly totalTokens: number;
+ readonly tokenShare: number;
};
+ readonly mode: UsageChartMode;
}) {
const ProviderIcon = USAGE_PROVIDER_ICONS[totals.provider];
+ const share = mode === "cost" ? totals.costShare : totals.tokenShare;
return (
@@ -295,20 +383,22 @@ function UsageProviderRow({
{USAGE_PROVIDER_LABELS[totals.provider]}
- {formatUsd(totals.costUsd)}
+ {mode === "cost" ? formatUsd(totals.costUsd) : formatTokensCompact(totals.totalTokens)}
- {formatPercent(totals.costShare)} of cost · {formatTokensCompact(totals.totalTokens)} tokens
+ {mode === "cost"
+ ? `${formatPercent(totals.costShare)} of cost · ${formatTokensCompact(totals.totalTokens)} tokens`
+ : `${formatPercent(totals.tokenShare)} of tokens · ${formatUsd(totals.costUsd)}`}
);
diff --git a/apps/web/src/components/usage/usageView.logic.ts b/apps/web/src/components/usage/usageView.logic.ts
index 9622d9082..7aa3eb49a 100644
--- a/apps/web/src/components/usage/usageView.logic.ts
+++ b/apps/web/src/components/usage/usageView.logic.ts
@@ -57,6 +57,47 @@ export const USAGE_CHART_TITLES: Record = {
tokens: "Daily tokens",
};
+/** The hero follows the chart's cost|tokens mode; these are its labels. */
+export const USAGE_HERO_LABELS: Record = {
+ cost: "API-equivalent cost",
+ tokens: "Processed tokens",
+};
+
+export type UsageBreakdown = "models" | "days";
+
+export const USAGE_BREAKDOWNS: readonly UsageBreakdown[] = ["models", "days"];
+
+export const USAGE_BREAKDOWN_LABELS: Record = {
+ models: "Model",
+ days: "Day",
+};
+
+export interface UsageDayRow {
+ readonly day: string;
+ readonly label: string;
+ readonly totalTokens: number;
+ readonly costUsd: number;
+ readonly costShare: number;
+}
+
+/**
+ * The days breakdown, newest first. Days without activity are dropped: unlike
+ * the chart, where a gap is information, a table of zero rows buries the days
+ * that had something to say.
+ */
+export function buildUsageDayRows(merged: MergedUsage): readonly UsageDayRow[] {
+ return merged.daily
+ .filter((entry) => entry.totalTokens > 0 || entry.costUsd > 0)
+ .map((entry) => ({
+ day: entry.day,
+ label: formatDayShort(entry.day),
+ totalTokens: entry.totalTokens,
+ costUsd: entry.costUsd,
+ costShare: merged.costUsd === 0 ? 0 : entry.costUsd / merged.costUsd,
+ }))
+ .toReversed();
+}
+
/**
* Chart user units. Height matches the rendered pixel height so vertical
* geometry is 1:1; width is nominal, since the SVG stretches horizontally to
From 0bb8441b5b7987414f504d4af6cd0cc58eb984e1 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:26:46 -0400
Subject: [PATCH 06/14] Give the cost asterisk room to breathe
---
apps/web/src/components/usage/UsageView.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
index d23afaee4..8b8c5db8d 100644
--- a/apps/web/src/components/usage/UsageView.tsx
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -188,7 +188,9 @@ export function UsageView() {
{chartMode === "cost" ? (
<>
{formatUsd(merged.costUsd)}
- *
+
+ *
+
>
) : (
formatTokensCompact(merged.totalTokens)
From c575059e8e11886ff4c3186a017f740113c73f0a Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:32:00 -0400
Subject: [PATCH 07/14] Track chart hovers with a day line and a pinned card
The native title tooltip made the reader wait and gave one line of text.
Hovering the plot now draws a hairline through the hovered day and pins a
card beside it: the day, one row per charted provider with its figure in
the chart's mode, and the total. The card flips sides past the midline so
it never leaves the plot, and everything it shows is precomputed on the
column, so hover work is a lookup.
---
.../components/usage/UsageView.browser.tsx | 12 ++++
apps/web/src/components/usage/UsageView.tsx | 64 +++++++++++++++++--
.../src/components/usage/usageView.logic.ts | 48 ++++++++------
3 files changed, 101 insertions(+), 23 deletions(-)
diff --git a/apps/web/src/components/usage/UsageView.browser.tsx b/apps/web/src/components/usage/UsageView.browser.tsx
index 1789c68b5..2f8c80948 100644
--- a/apps/web/src/components/usage/UsageView.browser.tsx
+++ b/apps/web/src/components/usage/UsageView.browser.tsx
@@ -244,6 +244,18 @@ describe("UsageView", () => {
expect(laptopRow?.textContent).toContain("Not reporting");
const studioRow = machineRows.find((row) => row.textContent?.includes("Studio Mac"));
expect(studioRow?.textContent).toContain("/Users/dev/.claude");
+
+ // Hovering a day raises the tracking card: both providers, then the total.
+ await page.getByTestId("usage-chart-day").nth(29).hover();
+ const card = page.getByTestId("usage-chart-card");
+ await expect.element(card).toBeVisible();
+ const cardText = card.element().textContent ?? "";
+ expect(cardText).toContain("Claude Code");
+ expect(cardText).toContain("$12.50");
+ expect(cardText).toContain("Codex");
+ expect(cardText).toContain("$4.50");
+ expect(cardText).toContain("Total");
+ expect(cardText).toContain("$17.00");
});
it("switches the hero to tokens with the chart mode and the breakdown to days", async () => {
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
index 8b8c5db8d..b99d5cf7c 100644
--- a/apps/web/src/components/usage/UsageView.tsx
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -422,6 +422,16 @@ function UsageChart({
readonly mode: UsageChartMode;
readonly onModeChange: (mode: UsageChartMode) => void;
}) {
+ // The hovered day, held as an index so a window switch under the cursor
+ // cannot leave the card describing a day the chart no longer shows.
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+ const hoveredColumn = hoveredIndex === null ? null : (chart.columns[hoveredIndex] ?? null);
+ const hoveredCenterPercent =
+ hoveredIndex === null || chart.columns.length === 0
+ ? 0
+ : ((hoveredIndex + 0.5) / chart.columns.length) * 100;
+ // The card sits on whichever side has room, flipping past the midline.
+ const hoveredOnLeftHalf = hoveredCenterPercent <= 50;
return (
@@ -502,17 +512,61 @@ function UsageChart({
))}
- {/* Transparent per-day columns: the whole tooltip system, in one attribute. */}
-
- {chart.columns.map((column) => (
+ {/* Transparent per-day columns drive the tracking line and the card. */}
+
setHoveredIndex(null)}>
+ {chart.columns.map((column, index) => (
setHoveredIndex(index)}
/>
))}
+ {hoveredColumn ? (
+ <>
+
+
+
{hoveredColumn.label}
+
+ {hoveredColumn.entries.map((entry) => {
+ const ProviderIcon = USAGE_PROVIDER_ICONS[entry.provider];
+ return (
+
+
+
+ {USAGE_PROVIDER_LABELS[entry.provider]}
+
+
+ {entry.valueLabel}
+
+
+ );
+ })}
+
+
+ Total
+
+ {hoveredColumn.totalLabel}
+
+
+
+ >
+ ) : null}
{chart.isEmpty ? (
No recorded activity in this window.
diff --git a/apps/web/src/components/usage/usageView.logic.ts b/apps/web/src/components/usage/usageView.logic.ts
index 7aa3eb49a..c11d9a9b8 100644
--- a/apps/web/src/components/usage/usageView.logic.ts
+++ b/apps/web/src/components/usage/usageView.logic.ts
@@ -123,10 +123,19 @@ export interface UsageChartGridline {
readonly topPercent: number;
}
+export interface UsageChartColumnEntry {
+ readonly provider: UsageProviderKind;
+ /** Formatted in the chart's mode, so the card needs no arithmetic. */
+ readonly valueLabel: string;
+}
+
+/** One hoverable day: everything the tracking card says about it. */
export interface UsageChartColumn {
readonly day: string;
- /** Native-tooltip text; the page deliberately has no tooltip system. */
- readonly title: string;
+ readonly label: string;
+ readonly entries: readonly UsageChartColumnEntry[];
+ readonly totalLabel: string;
+ readonly hasActivity: boolean;
}
export interface UsageAreaChart {
@@ -193,10 +202,25 @@ export function buildUsageAreaChart(input: {
series,
gridlines: axisMax === 0 ? [] : buildGridlines(axisMax, input.mode),
axisLabels: axisTickDays(days).map((day) => formatDayShort(day).toUpperCase()),
- columns: days.map((day) => ({
- day,
- title: describeUsageDay(day, byDay.get(day) ?? null),
- })),
+ columns: days.map((day) => {
+ const entry = byDay.get(day);
+ const formatValue = input.mode === "cost" ? formatUsd : formatTokensCompact;
+ return {
+ day,
+ label: formatDayShort(day),
+ // Every charted provider gets a row, zeros included: the card answers
+ // "who was quiet that day" as much as "who was busy". Reading order,
+ // not paint order, so the card matches the legend and the hero.
+ entries: USAGE_PROVIDER_READING_ORDER.filter((provider) =>
+ series.some((line) => line.provider === provider),
+ ).map((provider) => ({
+ provider,
+ valueLabel: formatValue(chartValue(entry?.byProvider.get(provider), input.mode)),
+ })),
+ totalLabel: formatValue(chartValue(entry, input.mode)),
+ hasActivity: (entry?.totalTokens ?? 0) > 0,
+ };
+ }),
isEmpty: series.length === 0,
};
}
@@ -288,18 +312,6 @@ function round(value: number): number {
return Math.round(value * 100) / 100;
}
-function describeUsageDay(day: string, entry: DailyTotals | null): string {
- if (!entry || entry.totalTokens === 0) {
- return `${formatDayShort(day)}: no recorded usage`;
- }
- const breakdown = USAGE_PROVIDER_READING_ORDER.flatMap((provider) => {
- const totals = entry.byProvider.get(provider);
- if (!totals || totals.totalTokens === 0) return [];
- return [`${USAGE_PROVIDER_LABELS[provider]} ${formatTokens(totals.totalTokens)}`];
- }).join(", ");
- return `${formatDayShort(day)}: ${formatTokens(entry.totalTokens)} tokens, ${formatUsd(entry.costUsd)} API-equivalent (${breakdown})`;
-}
-
/** `2026-07-12` and `2026-08-10` to `Jul 12 to Aug 10`. */
export function formatUsageDateRange(sinceDay: string, untilDay: string): string {
return `${formatDayShort(sinceDay)} to ${formatDayShort(untilDay)}`;
From af5e820c0e5e1568ea53b91620fac17e040938fc Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:35:23 -0400
Subject: [PATCH 08/14] Make the settings usage link a tile that names its
window
The plain text row read as page furniture and never said the figures
covered 30 days. It is now a clickable card in the same family as the
provider entries: a window-stamped label over the token and cost figures,
with the view-usage affordance on the right.
---
.../components/settings/SettingsPanels.tsx | 40 ++++++++++++++-----
1 file changed, 29 insertions(+), 11 deletions(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index e8ad3eb52..fc25cd300 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1,6 +1,7 @@
import {
ArchiveIcon,
ArchiveX,
+ ChevronRightIcon,
LoaderIcon,
PlusIcon,
RefreshCwIcon,
@@ -1070,8 +1071,9 @@ export function GeneralSettingsPanel({ surface = "full" }: { surface?: "full" |
}
/**
- * The bridge from provider setup to what those providers have actually cost.
- * One plain row, no chrome: it is a signpost, not a control.
+ * The bridge from provider setup to what those providers have actually cost:
+ * a clickable tile in the same card family as the provider entries below it,
+ * named for its window so the figures cannot be mistaken for all-time totals.
*/
function ProviderUsageLinkRow() {
const targets = useUsageEnvironmentTargets();
@@ -1085,19 +1087,35 @@ function ProviderUsageLinkRow() {
return (
- Usage
- {merged ? (
-
- {formatTokens(merged.totalTokens)} tokens · {formatUsd(merged.costUsd)} API-equivalent
+
+
+
+ Usage · Last {USAGE_SETTINGS_WINDOW_DAYS} days
+
+ {merged ? (
+
+
+ {formatTokens(merged.totalTokens)}
+
+ tokens ·
+
+ {formatUsd(merged.costUsd)}
+
+ API-equivalent
+
+ ) : (
+ Reading provider transcripts…
+ )}
- ) : (
-
- )}
- View usage
+
+ View usage
+
+
+
);
}
From 64ab823cf1d2abdaee3ab236c483696a16ea31a1 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:38:12 -0400
Subject: [PATCH 09/14] Give the settings usage tile a block box so its border
draws whole
---
apps/web/src/components/settings/SettingsPanels.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index fc25cd300..d45c2c53b 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1087,7 +1087,7 @@ function ProviderUsageLinkRow() {
return (
From 93c31a0a8b6e3f88ecd9eb0535dceeef8e64c6a4 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:41:55 -0400
Subject: [PATCH 10/14] Keep the usage meter in the corner on the settings
pages too
The settings sidebar replaced it with nothing, so the one screen where
quota bars live lost the live token count. Same component, same position:
bottom-left means "today's usage" on both surfaces.
---
apps/web/src/components/settings/SettingsSidebarNav.tsx | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index 059cc3fc5..a97af5bd3 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -11,6 +11,7 @@ import {
SidebarMenuItem,
SidebarSeparator,
} from "../ui/sidebar";
+import { SidebarUsageMeter } from "../sidebar/SidebarUsageMeter";
import { SidebarVersionTag } from "../sidebar/SidebarVersionTag";
import { isHostedStaticApp } from "../../hostedPairing";
import {
@@ -87,6 +88,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
+ {/* The same meter the main sidebar keeps above Settings, so the
+ bottom-left corner means "today's usage" on both surfaces. */}
+
+
+
Date: Mon, 10 Aug 2026 03:43:21 -0400
Subject: [PATCH 11/14] Set the usage tile figures in one typeface so the line
sits level
---
.../components/settings/SettingsPanels.tsx | 19 +++++++++----------
1 file changed, 9 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index d45c2c53b..1952c0b0e 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1092,20 +1092,19 @@ function ProviderUsageLinkRow() {
to="/usage"
>
-
+
Usage · Last {USAGE_SETTINGS_WINDOW_DAYS} days
+ {/* One typeface for the whole line: mono figures beside sans words
+ put two vertical metrics on one baseline and the row reads as
+ misaligned. Emphasis comes from color alone. */}
{merged ? (
-
-
- {formatTokens(merged.totalTokens)}
-
- tokens ·
-
- {formatUsd(merged.costUsd)}
-
- API-equivalent
+
+ {formatTokens(merged.totalTokens)}
+ tokens ·
+ {formatUsd(merged.costUsd)}
+ API-equivalent
) : (
Reading provider transcripts…
From 56dfd1c94e5779d0bbf5308620ce0ae3647cef13 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:45:11 -0400
Subject: [PATCH 12/14] Sit the view-usage affordance level with the figures
line
---
apps/web/src/components/settings/SettingsPanels.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 1952c0b0e..851facc76 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1110,7 +1110,9 @@ function ProviderUsageLinkRow() {
Reading provider transcripts…
)}
-
+ {/* Bottom-aligned, not centered: the affordance sits level with the
+ figures line rather than floating between the two left rows. */}
+
View usage
From 2b966da8210e3722ca75b1206e2189f5b66962b1 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 03:51:44 -0400
Subject: [PATCH 13/14] Even out the usage tile line and put provider glyphs in
the chart legend
The tile's mixed-brightness line made the dim words drop their bottom
anti-aliased pixel and read a hair higher than the numbers; the whole
line is now one color and the caps label above carries the hierarchy.
The chart legend swaps its swatch dots for the provider icons, whose
built-in brand fills already match the series hues.
---
.../components/settings/SettingsPanels.tsx | 14 +++++-----
apps/web/src/components/usage/UsageView.tsx | 26 ++++++++++---------
2 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 851facc76..55ff539da 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1096,15 +1096,13 @@ function ProviderUsageLinkRow() {
Usage · Last {USAGE_SETTINGS_WINDOW_DAYS} days
- {/* One typeface for the whole line: mono figures beside sans words
- put two vertical metrics on one baseline and the row reads as
- misaligned. Emphasis comes from color alone. */}
+ {/* One typeface AND one color: mixing brightness on a line of small
+ mono type erodes the dim glyphs' anti-aliased bottom edge, so the
+ muted words read a pixel higher than the numbers beside them. The
+ caps label above carries the hierarchy instead. */}
{merged ? (
-
- {formatTokens(merged.totalTokens)}
- tokens ·
- {formatUsd(merged.costUsd)}
- API-equivalent
+
+ {formatTokens(merged.totalTokens)} tokens · {formatUsd(merged.costUsd)} API-equivalent
) : (
Reading provider transcripts…
diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx
index b99d5cf7c..34c042d9a 100644
--- a/apps/web/src/components/usage/UsageView.tsx
+++ b/apps/web/src/components/usage/UsageView.tsx
@@ -459,19 +459,21 @@ function UsageChart({
))}
- {USAGE_PROVIDER_READING_ORDER.map((provider) => (
-
+ {USAGE_PROVIDER_READING_ORDER.map((provider) => {
+ const ProviderIcon = USAGE_PROVIDER_ICONS[provider];
+ return (
- {USAGE_PROVIDER_LABELS[provider]}
-
- ))}
+ key={provider}
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60"
+ >
+ {/* The glyphs' built-in brand fills already match the series
+ hues, so they replace the swatch dots without a legend key
+ being lost. */}
+
+ {USAGE_PROVIDER_LABELS[provider]}
+
+ );
+ })}
From 9cd2ceabaa004910bb42193266030855436cdcfd Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 10:13:28 -0400
Subject: [PATCH 14/14] Drive the headless-token disclosure from React alone
The advanced section was a native details element that React also controlled: the browser toggled open instantly, and any render committed before the onToggle state update re-applied the stale open and snapped it shut - stuck closed, since the revert also fired onToggle(false). Summary clicks now prevent the native toggle and flip React state, the only driver. Fixes the CI-only failure on the verified-usage settings test.
---
.../components/settings/ProviderInstanceCard.tsx | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx
index b5931c1d9..f92f2b17e 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.tsx
+++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx
@@ -582,10 +582,19 @@ function ClaudeLongLivedAuthSection(props: {
return (
setIsExpanded(event.currentTarget.open)}
>
-
+ {
+ event.preventDefault();
+ setIsExpanded((expanded) => !expanded);
+ }}
+ >
Advanced: headless chat token