From 3b88e5b351f73c1070330135dcb48f6900499bfc Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 28 Aug 2026 13:12:52 +1000 Subject: [PATCH 01/78] feat(web): usage chart zooms by drag and accepts custom date ranges The usage window was locked to four presets, so a spike on the chart could not be inspected without eyeballing dates. Dragging across any daily chart now commits the selection as the date window, double-click returns to the preset, and date fields beside the presets accept any custom range directly. The server already accepted arbitrary day windows; this is web-only. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/usage/UsagePage.tsx | 106 ++++++++++++++++-- .../usage/UsageProviderChart.test.ts | 28 ++++- .../components/usage/UsageProviderChart.tsx | 104 ++++++++++++++++- docs/user/usage.md | 3 + packages/shared/src/usageFormat.test.ts | 19 ++++ packages/shared/src/usageFormat.ts | 15 +++ 6 files changed, 258 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7474bb9d6120..1b4d04469400 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -17,6 +17,7 @@ import { formatPercent, formatTokens, formatUsd, + makeCustomWindow, makeWindow, } from "@t3tools/shared/usageFormat"; import { Button } from "../ui/button"; @@ -42,14 +43,17 @@ const WINDOW_OPTIONS = [ ] as const; export function UsagePage() { + // `days` remembers the last preset even while a custom (brushed or typed) + // range is active, so a reset lands back where the user started. const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, + custom: false, window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); - const { days: windowDays, window } = windowSelection; - const isPast24Hours = windowDays === 1; + const { days: windowDays, custom: isCustomWindow, window } = windowSelection; + const isPast24Hours = !isCustomWindow && windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); // Hold the content until every environment is terminal. Rendering merged @@ -89,10 +93,24 @@ export function UsagePage() { const selectWindow = (days: number) => { setWindowSelection({ days, + custom: false, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectCustomWindow = (sinceDay: string, untilDay: string) => { + setWindowSelection({ + days: windowDays, + custom: true, + window: makeCustomWindow(sinceDay, untilDay), + }); + }; const refreshWindow = () => { + // A custom range is a fixed span of past days; rescanning is all a + // refresh can mean for it. + if (isCustomWindow) { + refresh(); + return; + } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay === window.sinceDay && @@ -102,7 +120,7 @@ export function UsagePage() { ) { refresh(); } else { - setWindowSelection({ days: windowDays, window: nextWindow }); + setWindowSelection({ days: windowDays, custom: false, window: nextWindow }); } }; const windowLabel = @@ -136,10 +154,15 @@ export function UsagePage() { ))} + { const value = next[0]; if (value) selectWindow(Number(value)); @@ -175,7 +198,12 @@ export function UsagePage() { Tokens - { + if (value !== "custom" && value !== null) selectWindow(Number(value)); + }} + > - {WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label} + {isCustomWindow + ? "Custom" + : WINDOW_OPTIONS.find((option) => option.days === windowDays)?.label} @@ -282,10 +312,17 @@ export function UsagePage() {
-

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

+
+

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

+ {isPast24Hours ? null : ( + + drag to zoom ยท double-click resets + + )} +
selectWindow(windowDays), + })} />
@@ -464,6 +507,49 @@ export function UsagePage() { ); } +/** + * Free date-range bounds beside the presets. Native date inputs; committing + * either bound deselects every preset. Hidden below lg with the rest of the + * expanded toolbar. + */ +function UsageDateRangeInputs({ + sinceDay, + untilDay, + onChange, +}: { + readonly sinceDay: string; + readonly untilDay: string; + readonly onChange: (sinceDay: string, untilDay: string) => void; +}) { + const inputClass = + "h-7 rounded-md border border-border bg-transparent px-2 text-xs text-foreground [color-scheme:inherit] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"; + return ( +
+ { + if (event.target.value) onChange(event.target.value, untilDay); + }} + /> + to + { + if (event.target.value) onChange(sinceDay, event.target.value); + }} + /> +
+ ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index a4114cfdfb57..8998939859b5 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildDayColumns, niceScale } from "./UsageProviderChart"; +import { brushSelection, buildDayColumns, niceScale } from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; describe("niceScale", () => { @@ -133,3 +133,29 @@ describe("hourly chart columns", () => { ).toEqual([0, 4, 0]); }); }); + +describe("brushSelection", () => { + const days = ["2026-08-01", "2026-08-02", "2026-08-03", "2026-08-04"]; + + it("returns inclusive bounds for a forward drag", () => { + expect(brushSelection(days, 1, 3)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("normalises a backward drag", () => { + expect(brushSelection(days, 3, 1)).toEqual({ + sinceDay: "2026-08-02", + untilDay: "2026-08-04", + }); + }); + + it("treats a plain click as no selection", () => { + expect(brushSelection(days, 2, 2)).toBeNull(); + }); + + it("rejects endpoints outside the day list", () => { + expect(brushSelection(days, 0, 9)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 26d49e664804..4e560a018504 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,7 +1,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; + +import { cn } from "../../lib/utils"; import { formatDayShort, formatHourShort, @@ -19,6 +21,13 @@ const PLOT_TOP = 8; export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { + /** + * Present only when the window can zoom (daily resolution). Receives the + * inclusive day bounds of a completed drag selection. + */ + readonly onZoomToDays?: (sinceDay: string, untilDay: string) => void; + /** Restores the preset window on double-click. */ + readonly onResetZoom?: () => void; readonly providers: readonly UsageProviderKind[]; readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; @@ -187,7 +196,26 @@ export function buildDayColumns( return buildPeriodColumns(days, byDay, metric); } +/** + * Inclusive day bounds of a brush selection, or null for a plain click. + * Endpoints may arrive in either drag direction. + */ +export function brushSelection( + days: readonly string[], + startIndex: number, + endIndex: number, +): { readonly sinceDay: string; readonly untilDay: string } | null { + if (startIndex === endIndex) return null; + const [first, last] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; + const sinceDay = days[first]; + const untilDay = days[last]; + if (sinceDay === undefined || untilDay === undefined) return null; + return { sinceDay, untilDay }; +} + export function UsageProviderChart({ + onZoomToDays, + onResetZoom, providers, days, daily, @@ -207,6 +235,9 @@ export function UsageProviderChart({ [daily, hourly, resolution], ); const [hoverIndex, setHoverIndex] = useState(null); + // Drag-selection endpoints, as period indices. Only daily windows zoom. + const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); + const zoomable = resolution === "day" && onZoomToDays !== undefined; const plotRef = useRef(null); const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); @@ -306,23 +337,67 @@ export function UsageProviderChart({ return () => observer.disconnect(); }, [hoverIndex, positionTooltip]); + const indexAt = useCallback( + (clientX: number): number | null => { + const plot = plotRef.current; + if (plot === null || periods.length === 0) return null; + const bounds = plot.getBoundingClientRect(); + if (bounds.width === 0) return null; + const localX = Math.min(bounds.width, Math.max(0, clientX - bounds.left)); + const fraction = localX / bounds.width; + const index = Math.round(fraction * (periods.length - 1)); + return Math.min(periods.length - 1, Math.max(0, index)); + }, + [periods.length], + ); + const handleMove = useCallback( (event: React.MouseEvent) => { const plot = plotRef.current; if (plot === null || periods.length === 0) return; const bounds = plot.getBoundingClientRect(); if (bounds.width === 0) return; + const index = indexAt(event.clientX); + if (index === null) return; + if (brush !== null) { + // While selecting, the crosshair and tooltip give way to the band. + if (index !== brush.end) setBrush({ start: brush.start, end: index }); + return; + } const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); - const fraction = localX / bounds.width; - const index = Math.round(fraction * (periods.length - 1)); hoverPositionRef.current = { x: localX, y: localY }; positionTooltip(); - setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); + setHoverIndex(index); }, - [periods.length, positionTooltip], + [brush, indexAt, periods.length, positionTooltip], ); + const beginBrush = useCallback( + (event: React.MouseEvent) => { + if (!zoomable || event.button !== 0) return; + const index = indexAt(event.clientX); + if (index === null) return; + event.preventDefault(); + hoverPositionRef.current = null; + setHoverIndex(null); + setBrush({ start: index, end: index }); + }, + [indexAt, zoomable], + ); + + // A drag may end anywhere on the page, so the commit listens on the window. + useEffect(() => { + if (brush === null || onZoomToDays === undefined) return; + const commit = () => { + const selection = brushSelection(days, brush.start, brush.end); + setBrush(null); + if (selection !== null) onZoomToDays(selection.sinceDay, selection.untilDay); + }; + window.addEventListener("mouseup", commit); + return () => window.removeEventListener("mouseup", commit); + }, [brush, days, onZoomToDays]); + const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; const formatPeriod = (period: string) => @@ -350,8 +425,10 @@ export function UsageProviderChart({
{ hoverPositionRef.current = null; setHoverIndex(null); @@ -401,6 +478,21 @@ export function UsageProviderChart({ /> ))} + {brush === null || brush.start === brush.end ? null : ( + + )} + {hoverIndex === null ? null : ( { } }); }); + +describe("makeCustomWindow", () => { + it("builds a daily window over the inclusive range", () => { + const window = makeCustomWindow("2026-08-03", "2026-08-11"); + + expect(window.sinceDay).toBe("2026-08-03"); + expect(window.untilDay).toBe("2026-08-11"); + expect(window.resolution).toBe("day"); + expect(window.sinceTime).toBeUndefined(); + }); + + it("swaps out-of-order bounds so a raw drag never produces an invalid window", () => { + const window = makeCustomWindow("2026-08-11", "2026-08-03"); + + expect(window.sinceDay).toBe("2026-08-03"); + expect(window.untilDay).toBe("2026-08-11"); + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd751829dd87..9f8fd7500253 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -170,6 +170,21 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone); } +/** + * A daily window over an explicit inclusive day range, in the viewer's zone. + * Bounds arrive from date inputs or a chart brush; out-of-order bounds are + * swapped rather than rejected so callers can pass a drag's raw endpoints. + */ +export function makeCustomWindow(sinceDay: string, untilDay: string): UsageSummaryInput { + const [first, last] = sinceDay <= untilDay ? [sinceDay, untilDay] : [untilDay, sinceDay]; + return { + sinceDay: UsageDay.make(first), + untilDay: UsageDay.make(last), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + resolution: "day", + }; +} + /** * The window the page requests, expressed in the viewer's own time zone so days * line up with what they actually experienced. From 065b362f14c890dbb0454fcfb25342fbcb9e5c40 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 20:03:15 +1000 Subject: [PATCH 02/78] fix(web): bound and complete usage range interactions Cap custom windows before enumeration, keep date controls reachable in compact layouts, and complete brush gestures synchronously through pointer capture. --- .../src/components/usage/UsagePage.test.tsx | 8 +++ apps/web/src/components/usage/UsagePage.tsx | 30 +++++--- .../usage/UsageProviderChart.test.ts | 9 ++- .../components/usage/UsageProviderChart.tsx | 72 +++++++++++++------ packages/shared/src/usageFormat.test.ts | 7 ++ packages/shared/src/usageFormat.ts | 13 +++- 6 files changed, 107 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 8e86b521e890..7139570dc7af 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -39,6 +39,7 @@ vi.mock("react", async (importOriginal) => { vi.mock("../../env", () => ({ isElectron: false })); vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/input", () => ({ Input: "input" })); vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); vi.mock("../ui/select", () => ({ Select: "div", @@ -135,6 +136,13 @@ beforeEach(() => { }); describe("UsagePage hourly breakdown", () => { + it("keeps custom date fields available in both desktop and compact layouts", () => { + const markup = renderToStaticMarkup(); + + expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); + expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); + }); + it("keeps recent activity visible first without empty hourly rows", () => { const markup = renderToStaticMarkup(); const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1b4d04469400..df602cf1f8dd 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -21,6 +21,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; @@ -238,6 +239,12 @@ export function UsagePage() { + {settling ? ( <> {environments.length > 1 ? : null} @@ -509,26 +516,29 @@ export function UsagePage() { /** * Free date-range bounds beside the presets. Native date inputs; committing - * either bound deselects every preset. Hidden below lg with the rest of the - * expanded toolbar. + * either bound deselects every preset. Compact layouts render the same control + * above the page content so custom ranges remain reachable without crowding + * the header. */ function UsageDateRangeInputs({ + className, sinceDay, untilDay, onChange, }: { + readonly className?: string; readonly sinceDay: string; readonly untilDay: string; readonly onChange: (sinceDay: string, untilDay: string) => void; }) { - const inputClass = - "h-7 rounded-md border border-border bg-transparent px-2 text-xs text-foreground [color-scheme:inherit] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"; return ( -
- + { @@ -536,10 +546,12 @@ function UsageDateRangeInputs({ }} /> to - { diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 8998939859b5..ec2b23f73372 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { brushSelection, buildDayColumns, niceScale } from "./UsageProviderChart"; +import { brushSelection, buildDayColumns, periodIndexAt, niceScale } from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; describe("niceScale", () => { @@ -159,3 +159,10 @@ describe("brushSelection", () => { expect(brushSelection(days, 0, 9)).toBeNull(); }); }); + +describe("periodIndexAt", () => { + it("clamps a captured pointer to either chart edge", () => { + expect(periodIndexAt(-50, 100, 400, 5)).toBe(0); + expect(periodIndexAt(750, 100, 400, 5)).toBe(4); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 4e560a018504..db2e265b1699 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; @@ -213,6 +213,20 @@ export function brushSelection( return { sinceDay, untilDay }; } +/** Period index beneath a pointer, clamped when pointer capture moves outside the plot. */ +export function periodIndexAt( + clientX: number, + plotLeft: number, + plotWidth: number, + periodCount: number, +): number | null { + if (plotWidth <= 0 || periodCount <= 0) return null; + const localX = Math.min(plotWidth, Math.max(0, clientX - plotLeft)); + const fraction = localX / plotWidth; + const index = Math.round(fraction * (periodCount - 1)); + return Math.min(periodCount - 1, Math.max(0, index)); +} + export function UsageProviderChart({ onZoomToDays, onResetZoom, @@ -237,6 +251,7 @@ export function UsageProviderChart({ const [hoverIndex, setHoverIndex] = useState(null); // Drag-selection endpoints, as period indices. Only daily windows zoom. const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); + const brushRef = useRef<{ readonly start: number; readonly end: number } | null>(null); const zoomable = resolution === "day" && onZoomToDays !== undefined; const plotRef = useRef(null); const tooltipRef = useRef(null); @@ -342,11 +357,7 @@ export function UsageProviderChart({ const plot = plotRef.current; if (plot === null || periods.length === 0) return null; const bounds = plot.getBoundingClientRect(); - if (bounds.width === 0) return null; - const localX = Math.min(bounds.width, Math.max(0, clientX - bounds.left)); - const fraction = localX / bounds.width; - const index = Math.round(fraction * (periods.length - 1)); - return Math.min(periods.length - 1, Math.max(0, index)); + return periodIndexAt(clientX, bounds.left, bounds.width, periods.length); }, [periods.length], ); @@ -359,9 +370,14 @@ export function UsageProviderChart({ if (bounds.width === 0) return; const index = indexAt(event.clientX); if (index === null) return; - if (brush !== null) { + const activeBrush = brushRef.current; + if (activeBrush !== null) { // While selecting, the crosshair and tooltip give way to the band. - if (index !== brush.end) setBrush({ start: brush.start, end: index }); + if (index !== activeBrush.end) { + const nextBrush = { start: activeBrush.start, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + } return; } const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); @@ -370,33 +386,45 @@ export function UsageProviderChart({ positionTooltip(); setHoverIndex(index); }, - [brush, indexAt, periods.length, positionTooltip], + [indexAt, periods.length, positionTooltip], ); const beginBrush = useCallback( - (event: React.MouseEvent) => { + (event: React.PointerEvent) => { if (!zoomable || event.button !== 0) return; const index = indexAt(event.clientX); if (index === null) return; event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - setBrush({ start: index, end: index }); + const nextBrush = { start: index, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); }, [indexAt, zoomable], ); - // A drag may end anywhere on the page, so the commit listens on the window. - useEffect(() => { - if (brush === null || onZoomToDays === undefined) return; - const commit = () => { - const selection = brushSelection(days, brush.start, brush.end); + const finishBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if (activeBrush === null || onZoomToDays === undefined) return; + const end = indexAt(event.clientX) ?? activeBrush.end; + const selection = brushSelection(days, activeBrush.start, end); + brushRef.current = null; setBrush(null); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } if (selection !== null) onZoomToDays(selection.sinceDay, selection.untilDay); - }; - window.addEventListener("mouseup", commit); - return () => window.removeEventListener("mouseup", commit); - }, [brush, days, onZoomToDays]); + }, + [days, indexAt, onZoomToDays], + ); + + const cancelBrush = useCallback(() => { + brushRef.current = null; + setBrush(null); + }, []); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; @@ -427,7 +455,9 @@ export function UsageProviderChart({ ref={plotRef} className={cn("relative h-56 flex-1", zoomable && "cursor-crosshair")} onMouseMove={handleMove} - onMouseDown={beginBrush} + onPointerDown={beginBrush} + onPointerUp={finishBrush} + onPointerCancel={cancelBrush} onDoubleClick={onResetZoom} onMouseLeave={() => { hoverPositionRef.current = null; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 1759503b4a83..c87da20df5a0 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -89,4 +89,11 @@ describe("makeCustomWindow", () => { expect(window.sinceDay).toBe("2026-08-03"); expect(window.untilDay).toBe("2026-08-11"); }); + + it("caps typed ranges at the largest supported 90-day window", () => { + const window = makeCustomWindow("0001-01-01", "9999-12-31"); + + expect(window.sinceDay).toBe("0001-01-01"); + expect(window.untilDay).toBe("0001-03-31"); + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 9f8fd7500253..a75b0c433d19 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -14,6 +14,8 @@ const CURRENCY = new Intl.NumberFormat("en-US", { }); const INTEGER = new Intl.NumberFormat("en-US"); +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_CUSTOM_WINDOW_DAYS = 90; export function formatUsd(value: number): string { return CURRENCY.format(value); @@ -174,9 +176,18 @@ export function formatRelativeHourShort( * A daily window over an explicit inclusive day range, in the viewer's zone. * Bounds arrive from date inputs or a chart brush; out-of-order bounds are * swapped rather than rejected so callers can pass a drag's raw endpoints. + * Typed spans are capped at the same 90 days as the largest preset so day + * enumeration remains bounded. */ export function makeCustomWindow(sinceDay: string, untilDay: string): UsageSummaryInput { - const [first, last] = sinceDay <= untilDay ? [sinceDay, untilDay] : [untilDay, sinceDay]; + const [first, requestedLast] = sinceDay <= untilDay ? [sinceDay, untilDay] : [untilDay, sinceDay]; + const firstMs = Date.parse(`${first}T00:00:00Z`); + const requestedLastMs = Date.parse(`${requestedLast}T00:00:00Z`); + const maximumLastMs = firstMs + (MAX_CUSTOM_WINDOW_DAYS - 1) * DAY_MS; + const last = + requestedLastMs > maximumLastMs + ? new Date(maximumLastMs).toISOString().slice(0, 10) + : requestedLast; return { sinceDay: UsageDay.make(first), untilDay: UsageDay.make(last), From 7239eac0ef9646668ba846103918ac2f3c33b23d Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 20:13:14 +1000 Subject: [PATCH 03/78] fix(web): track usage brush with pointer events --- .../components/usage/UsageProviderChart.tsx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index db2e265b1699..3f4d4c0e5f79 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -368,18 +368,9 @@ export function UsageProviderChart({ if (plot === null || periods.length === 0) return; const bounds = plot.getBoundingClientRect(); if (bounds.width === 0) return; + if (brushRef.current !== null) return; const index = indexAt(event.clientX); if (index === null) return; - const activeBrush = brushRef.current; - if (activeBrush !== null) { - // While selecting, the crosshair and tooltip give way to the band. - if (index !== activeBrush.end) { - const nextBrush = { start: activeBrush.start, end: index }; - brushRef.current = nextBrush; - setBrush(nextBrush); - } - return; - } const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); hoverPositionRef.current = { x: localX, y: localY }; @@ -389,6 +380,19 @@ export function UsageProviderChart({ [indexAt, periods.length, positionTooltip], ); + const trackBrush = useCallback( + (event: React.PointerEvent) => { + const activeBrush = brushRef.current; + if (activeBrush === null || !event.currentTarget.hasPointerCapture(event.pointerId)) return; + const index = indexAt(event.clientX); + if (index === null || index === activeBrush.end) return; + const nextBrush = { start: activeBrush.start, end: index }; + brushRef.current = nextBrush; + setBrush(nextBrush); + }, + [indexAt], + ); + const beginBrush = useCallback( (event: React.PointerEvent) => { if (!zoomable || event.button !== 0) return; @@ -453,9 +457,10 @@ export function UsageProviderChart({
Date: Tue, 1 Sep 2026 21:16:02 +1000 Subject: [PATCH 04/78] fix(web): preserve touch scrolling while brushing usage --- .../components/usage/UsageProviderChart.tsx | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 3f4d4c0e5f79..b9343645a1e9 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -251,7 +251,11 @@ export function UsageProviderChart({ const [hoverIndex, setHoverIndex] = useState(null); // Drag-selection endpoints, as period indices. Only daily windows zoom. const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); - const brushRef = useRef<{ readonly start: number; readonly end: number } | null>(null); + const brushRef = useRef<{ + readonly pointerId: number; + readonly start: number; + readonly end: number; + } | null>(null); const zoomable = resolution === "day" && onZoomToDays !== undefined; const plotRef = useRef(null); const tooltipRef = useRef(null); @@ -383,10 +387,16 @@ export function UsageProviderChart({ const trackBrush = useCallback( (event: React.PointerEvent) => { const activeBrush = brushRef.current; - if (activeBrush === null || !event.currentTarget.hasPointerCapture(event.pointerId)) return; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + !event.currentTarget.hasPointerCapture(event.pointerId) + ) { + return; + } const index = indexAt(event.clientX); if (index === null || index === activeBrush.end) return; - const nextBrush = { start: activeBrush.start, end: index }; + const nextBrush = { ...activeBrush, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, @@ -395,14 +405,16 @@ export function UsageProviderChart({ const beginBrush = useCallback( (event: React.PointerEvent) => { - if (!zoomable || event.button !== 0) return; + if (!zoomable || event.button !== 0 || !event.isPrimary || brushRef.current !== null) return; const index = indexAt(event.clientX); if (index === null) return; - event.preventDefault(); + // `touch-pan-y` owns vertical gestures. Avoid canceling that browser + // default while still suppressing text selection for mouse and pen. + if (event.pointerType !== "touch") event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - const nextBrush = { start: index, end: index }; + const nextBrush = { pointerId: event.pointerId, start: index, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, @@ -412,7 +424,14 @@ export function UsageProviderChart({ const finishBrush = useCallback( (event: React.PointerEvent) => { const activeBrush = brushRef.current; - if (activeBrush === null || onZoomToDays === undefined) return; + if ( + activeBrush === null || + activeBrush.pointerId !== event.pointerId || + !event.currentTarget.hasPointerCapture(event.pointerId) || + onZoomToDays === undefined + ) { + return; + } const end = indexAt(event.clientX) ?? activeBrush.end; const selection = brushSelection(days, activeBrush.start, end); brushRef.current = null; @@ -425,7 +444,8 @@ export function UsageProviderChart({ [days, indexAt, onZoomToDays], ); - const cancelBrush = useCallback(() => { + const cancelBrush = useCallback((event: React.PointerEvent) => { + if (brushRef.current?.pointerId !== event.pointerId) return; brushRef.current = null; setBrush(null); }, []); @@ -457,7 +477,7 @@ export function UsageProviderChart({
Date: Fri, 28 Aug 2026 12:33:56 +1000 Subject: [PATCH 05/78] feat(usage): break down usage by project The usage page mixed every session on the machine into one pool, so there was no way to see which project the spend belonged to. Buckets now carry the title of the T3 project whose folder the session ran in (resolved per environment from the transcript cwd, including sessions driven outside T3 Code), the breakdown gains a Project view, and a project picker narrows the whole page to one project. Sessions outside every project group under "Outside projects"; Grok logs record no folder and always count there. Older environments merge unchanged with their buckets unattributed. Co-Authored-By: Claude Fable 5 --- apps/server/src/server.ts | 7 +- apps/server/src/usage/UsageService.ts | 26 ++- .../server/src/usage/usageAggregation.test.ts | 59 +++++- apps/server/src/usage/usageAggregation.ts | 70 ++++++- apps/server/src/usage/usageScanCache.test.ts | 1 + apps/server/src/usage/usageScanCache.ts | 23 ++- .../server/src/usage/usageTranscripts.test.ts | 8 +- apps/server/src/usage/usageTranscripts.ts | 13 ++ .../src/components/usage/UsagePage.test.tsx | 43 ++++- apps/web/src/components/usage/UsagePage.tsx | 178 ++++++++++++++++-- apps/web/src/state/usage.ts | 14 +- docs/user/usage.md | 5 + packages/contracts/src/usage.ts | 22 ++- packages/shared/src/usageMerge.test.ts | 58 +++++- packages/shared/src/usageMerge.ts | 85 ++++++++- 15 files changed, 567 insertions(+), 45 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 631902ac087d..2355fb99d791 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import { ProjectionProjectRepositoryLive } from "./persistence/Layers/ProjectionProjects.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -178,7 +179,11 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provideMerge(ServerSettingsLayerLive), ); -const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); +const UsageLayerLive = UsageService.layer.pipe( + // The repository resolves each session's cwd to the project it ran in. + Layer.provide(ProjectionProjectRepositoryLive), + Layer.provide(ServerSettingsLayerLive), +); const ResourceDiagnosticsLayerLive = Layer.mergeAll( ResourceTelemetryLayerLive, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 16a7478d954e..6f2c2bb5ff5e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -38,10 +38,11 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; import { parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -128,6 +129,7 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; + const projectRepository = yield* ProjectionProjectRepository; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -242,6 +244,27 @@ export const make = Effect.gen(function* () { ]; }); + /** + * Builds the cwd โ†’ project-title resolver for one scan. + * + * Projects are re-read every scan so a project created or renamed since the + * last refresh attributes correctly. A repository failure degrades to "no + * attribution" rather than failing the page. + */ + const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* () { + const projects = yield* projectRepository + .listAll() + .pipe(Effect.catchCause(() => Effect.succeed([]))); + return makeProjectResolver( + projects.map((project) => ({ + workspaceRoot: project.workspaceRoot, + title: project.title, + deleted: project.deletedAt !== null, + })), + path.sep, + ); + }); + /** * Loads the persisted scan cache exactly once per process. * @@ -436,6 +459,7 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, + resolveProject: yield* resolveProjects(), }); const sources: UsageSource[] = []; diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 8da4e920ac06..adc66cfbdc49 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { UsageAggregator } from "./usageAggregation.ts"; +import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -23,6 +23,7 @@ function record(overrides: Partial = {}): UsageRecord { timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), model: "claude-fable-5", sessionId: "session-a", + cwd: "", totals: { uncachedInputTokens: 100, cachedInputTokens: 1000, @@ -94,6 +95,27 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(100); }); + it("splits buckets by resolved project and omits the field when unresolved", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd === "/work/app" ? "App" : ""), + }); + aggregator.add(record({ cwd: "/work/app" })); + aggregator.add(record({ cwd: "/work/app" })); + aggregator.add(record({ cwd: "/elsewhere" })); + const { buckets } = aggregator.finish(); + + // Same day, provider and model, so only the project splits the cell. + expect(buckets).toHaveLength(2); + expect(buckets[0]?.project).toBeUndefined(); + expect(buckets[0]?.records).toBe(1); + expect(buckets[1]?.project).toBe("App"); + expect(buckets[1]?.records).toBe(2); + }); + it("buckets by the day in the requested time zone", () => { const utc = aggregate([record()], "UTC"); const losAngeles = aggregate([record()], "America/Los_Angeles"); @@ -202,3 +224,38 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(3); }); }); + +describe("makeProjectResolver", () => { + const resolver = makeProjectResolver( + [ + { workspaceRoot: "/work/app", title: "App", deleted: false }, + { workspaceRoot: "/work/app/vendored", title: "Vendored", deleted: false }, + { workspaceRoot: "/work/legacy", title: "Legacy Was Deleted", deleted: true }, + { workspaceRoot: "/work/legacy", title: "Legacy", deleted: false }, + { workspaceRoot: "/work/untitled", title: " ", deleted: false }, + ], + "/", + ); + + it("matches the root itself and any path under it", () => { + expect(resolver("/work/app")).toBe("App"); + expect(resolver("/work/app/src/deep")).toBe("App"); + }); + + it("requires a path-segment boundary, not a bare prefix", () => { + expect(resolver("/work/app-sibling")).toBe(""); + }); + + it("prefers the deepest matching root", () => { + expect(resolver("/work/app/vendored/lib")).toBe("Vendored"); + }); + + it("prefers a live project over a deleted one sharing the root", () => { + expect(resolver("/work/legacy/src")).toBe("Legacy"); + }); + + it("never attributes to a blank title or an empty cwd", () => { + expect(resolver("/work/untitled/src")).toBe(""); + expect(resolver("")).toBe(""); + }); +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index e100be76e979..352339aa1842 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -1,7 +1,7 @@ // @effect-diagnostics globalDate:off /** - * Folds parsed transcript records into `(day, hourStart?, provider, model)` - * buckets. + * Folds parsed transcript records into `(day, hourStart?, project, provider, + * model)` buckets. * * `Intl.DateTimeFormat` is the only reliable way to resolve a wall-clock day in * an arbitrary IANA zone, and it takes a `Date`. That is why the raw `Date` @@ -46,6 +46,54 @@ export function makeDayFormatter(timeZone: string): (timestampMs: number) => str const HOUR_MS = 60 * 60 * 1000; +export interface ProjectRoot { + readonly workspaceRoot: string; + readonly title: string; + /** Soft-deleted projects still attribute: the spend happened while they existed. */ + readonly deleted: boolean; +} + +/** + * Builds the cwd โ†’ project-title resolver used by {@link AggregateOptions}. + * + * Deepest root wins, so a session in a project nested inside another + * attributes to the inner one. Live projects outrank deleted ones sharing a + * root, since deleting and re-creating a project leaves both rows. Results are + * memoised per cwd; a scan sees few distinct cwds but many records. + */ +export function makeProjectResolver( + projects: readonly ProjectRoot[], + separator: string, +): (cwd: string) => string { + const roots = projects + .map((project) => ({ + root: + project.workspaceRoot.length > 1 && project.workspaceRoot.endsWith(separator) + ? project.workspaceRoot.slice(0, -1) + : project.workspaceRoot, + title: project.title.trim(), + deleted: project.deleted, + })) + .filter((entry) => entry.root.length > 0 && entry.title.length > 0) + .sort((a, b) => b.root.length - a.root.length || Number(a.deleted) - Number(b.deleted)); + + const byCwd = new Map(); + return (cwd) => { + if (cwd.length === 0) return ""; + const cached = byCwd.get(cwd); + if (cached !== undefined) return cached; + let resolved = ""; + for (const { root, title } of roots) { + if (cwd === root || (cwd.startsWith(root) && cwd[root.length] === separator)) { + resolved = title; + break; + } + } + byCwd.set(cwd, resolved); + return resolved; + }; +} + interface MutableBucket { totals: UsageTokenTotals; costUsd: number; @@ -64,6 +112,12 @@ export interface AggregateOptions { readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; + /** + * Maps a record's working directory to the title of the project it ran in, + * or `""` when it ran outside every project. Omitting it leaves every bucket + * unattributed. + */ + readonly resolveProject?: (cwd: string) => string; } export interface AggregateResult { @@ -145,7 +199,12 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; + // The key is parsed back apart on NUL, which project titles must not carry. + const project = + this.#options.resolveProject === undefined + ? "" + : this.#options.resolveProject(record.cwd).replaceAll("\u0000", ""); + const key = `${day}\u0000${hourStart}\u0000${project}\u0000${record.provider}\u0000${record.model}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -180,10 +239,12 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + const [day = "", hourStart = "", project = "", provider = "", model = ""] = + key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), + ...(project === "" ? {} : { project }), provider: provider as UsageBucket["provider"], model, totals: bucket.totals, @@ -200,6 +261,7 @@ export class UsageAggregator { (a, b) => a.day.localeCompare(b.day) || (a.hourStart ?? "").localeCompare(b.hourStart ?? "") || + (a.project ?? "").localeCompare(b.project ?? "") || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model), ); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fdb0aabafa40..42a230fdeb95 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -16,6 +16,7 @@ function record(overrides: Partial = {}): UsageRecord { timestampMs: 1_786_000_000_000, model: "claude-fable-5", sessionId: "session-a", + cwd: "/home/theo/project", totals: { uncachedInputTokens: 2, cachedInputTokens: 1000, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 102058a07d35..024771e4cf5a 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -26,7 +26,9 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. -export const USAGE_SCAN_CACHE_VERSION = 3 as const; +// v4: records carry the session's cwd for project attribution; v3 entries +// would pin every cached file to "no project" forever. +export const USAGE_SCAN_CACHE_VERSION = 4 as const; export interface CachedFile { readonly size: number; @@ -61,6 +63,7 @@ type SerializedRecord = readonly [ reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, + cwdIndex: number, ]; interface SerializedFile { @@ -82,15 +85,18 @@ interface SerializedCache { readonly version: number; readonly models: readonly string[]; readonly sessions: readonly string[]; + readonly cwds: readonly string[]; readonly files: Readonly>; } -/** Serialises the cache, interning the repeated model and session strings. */ +/** Serialises the cache, interning the repeated model, session and cwd strings. */ export function encodeScanCache(cache: ScanCache): SerializedCache { const models: string[] = []; const sessions: string[] = []; + const cwds: string[] = []; const modelIndex = new Map(); const sessionIndex = new Map(); + const cwdIndex = new Map(); const intern = (table: string[], index: Map, value: string): number => { const existing = index.get(value); @@ -112,6 +118,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.totals.reasoningTokens, record.dedupeKey, record.reportedCostUsd, + intern(cwds, cwdIndex, record.cwd), ]; const files: Record = {}; @@ -129,7 +136,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { }; } - return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, files }; + return { version: USAGE_SCAN_CACHE_VERSION, models, sessions, cwds, files }; } function isRecordArray(value: unknown): value is readonly unknown[] { @@ -148,7 +155,9 @@ export function decodeScanCache(document: unknown): ScanCache { const root = document as Partial; if (root.version !== USAGE_SCAN_CACHE_VERSION) return cache; - if (!isRecordArray(root.models) || !isRecordArray(root.sessions)) return cache; + if (!isRecordArray(root.models) || !isRecordArray(root.sessions) || !isRecordArray(root.cwds)) { + return cache; + } if (typeof root.files !== "object" || root.files === null) return cache; // The intern tables must be all strings: a numeric entry would pass the @@ -156,8 +165,10 @@ export function decodeScanCache(document: unknown): ScanCache { // at lookupRate. A corrupt table rejects the whole cache. if (!root.models.every((value) => typeof value === "string")) return cache; if (!root.sessions.every((value) => typeof value === "string")) return cache; + if (!root.cwds.every((value) => typeof value === "string")) return cache; const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; + const cwds = root.cwds as readonly string[]; // Any corrupt row disqualifies the whole entry. Keeping the survivors // under the original (size, mtime) would read as a valid warm hit and the @@ -168,7 +179,7 @@ export function decodeScanCache(document: unknown): ScanCache { ): UsageRecord[] | null => { const records: UsageRecord[] = []; for (const row of rows) { - if (!isRecordArray(row) || row.length < 10) return null; + if (!isRecordArray(row) || row.length < 11) return null; const [ timestampMs, modelIndex, @@ -180,6 +191,7 @@ export function decodeScanCache(document: unknown): ScanCache { reasoning, dedupeKey, reportedCostUsd, + cwdIndex, ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; @@ -201,6 +213,7 @@ export function decodeScanCache(document: unknown): ScanCache { timestampMs, model, sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", + cwd: (typeof cwdIndex === "number" ? cwds[cwdIndex] : undefined) ?? "", totals: { uncachedInputTokens: uncached, cachedInputTokens: cached, diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index b09db613ed85..3b03b7fc6deb 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -51,6 +51,7 @@ describe("parseClaudeLine", () => { reasoningTokens: 0, }); expect(record?.dedupeKey).toBe("msg_1:"); + expect(record?.cwd).toBe("/home/theo/project"); }); it("gives every content block of one message the same dedupe key", () => { @@ -73,7 +74,11 @@ describe("parseCodexLine", () => { const sessionMeta = JSON.stringify({ type: "session_meta", timestamp: "2026-08-01T05:17:41.289Z", - payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + payload: { + type: "session_meta", + id: "019fbbc1-b12c-7360-a685-28c181f0025f", + cwd: "/home/theo/project", + }, }); const turnContext = JSON.stringify({ type: "turn_context", @@ -107,6 +112,7 @@ describe("parseCodexLine", () => { expect(record?.provider).toBe("codex"); expect(record?.model).toBe("gpt-5.6-sol"); expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f"); + expect(record?.cwd).toBe("/home/theo/project"); // Codex reports input_tokens inclusive of the cached portion. expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008); expect(record?.totals.cachedInputTokens).toBe(11008); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 2aea60709666..25941f300206 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -13,6 +13,11 @@ export interface UsageRecord { readonly timestampMs: number; readonly model: string; readonly sessionId: string; + /** + * Working directory the session ran in, or `""` when the transcript does not + * record one (Grok). Drives project attribution at aggregation time. + */ + readonly cwd: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; /** @@ -136,6 +141,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { timestampMs, model, sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + cwd: typeof record["cwd"] === "string" ? record["cwd"] : "", totals: { uncachedInputTokens: int(usageRecord["input_tokens"]), cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), @@ -163,6 +169,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { export interface CodexScanState { model: string; sessionId: string; + cwd: string; lastUsageSignature: string | null; sawSessionMeta: boolean; /** While true, leading usage events are re-stamped copies of parent history. */ @@ -174,6 +181,7 @@ export function initialCodexScanState(): CodexScanState { return { model: "", sessionId: "", + cwd: "", lastUsageSignature: null, sawSessionMeta: false, suppressingForkCopies: false, @@ -233,6 +241,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; if (typeof id === "string") state.sessionId = id; + if (typeof payloadRecord["cwd"] === "string") state.cwd = payloadRecord["cwd"]; const metaTimestampMs = parseTimestampMs(record["timestamp"]); if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) { state.suppressingForkCopies = true; @@ -301,6 +310,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord timestampMs, model: state.model, sessionId: state.sessionId, + cwd: state.cwd, totals, // Codex does not report cost in the rollout. reportedCostUsd: null, @@ -431,6 +441,8 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { timestampMs, model: "grok", sessionId, + // Grok session logs record no working directory. + cwd: "", totals: grokTotalsToUsage(topLevel), reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), // No prompt id means we cannot tell two same-second updates apart. @@ -477,6 +489,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { timestampMs, model: entry.model, sessionId, + cwd: "", totals, reportedCostUsd, dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 7139570dc7af..fe676afb7779 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), metric: "cost" as "cost" | "tokens", - breakdown: "time" as "model" | "time", + breakdown: "time" as "model" | "project" | "time", })); vi.mock("react", async (importOriginal) => { @@ -104,6 +104,23 @@ const modelTotals = Object.freeze([ }, ]); +const projectTotals = Object.freeze([ + { + project: "Expensive Project", + costUsd: 9, + totalTokens: 200, + records: 2, + costShare: 9 / 16, + }, + { + project: null, + costUsd: 7, + totalTokens: 900, + records: 1, + costShare: 7 / 16, + }, +]); + beforeEach(() => { testState.metric = "cost"; testState.breakdown = "time"; @@ -111,6 +128,7 @@ beforeEach(() => { merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), models: modelTotals, + projects: projectTotals, hourly: [ { day: "2026-08-10", @@ -163,6 +181,29 @@ describe("UsagePage hourly breakdown", () => { }); }); +describe("UsagePage project breakdown", () => { + it("ranks projects by cost and labels unattributed work", () => { + testState.breakdown = "project"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/Expensive Project.*Outside projects/); + expect(body).toContain("$9.00"); + expect(body).toContain("$7.00"); + }); + + it("ranks projects by tokens when the token metric is selected", () => { + testState.metric = "tokens"; + testState.breakdown = "project"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/Outside projects.*Expensive Project/); + }); +}); + describe("UsagePage model breakdown", () => { it("sorts models by cost when the cost metric is selected", () => { testState.breakdown = "model"; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index dbbd3ce863bb..68986ba580ee 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -2,7 +2,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; +import type { DailyTotals, HourlyTotals, ProjectTotals } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; import { useCommitOnBlur } from "../../hooks/useCommitOnBlur"; @@ -54,10 +54,12 @@ export function UsagePage() { window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); - const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const [breakdown, setBreakdown] = useState<"model" | "project" | "time">("model"); + // A project title, null for work outside every project, undefined for all. + const [projectFilter, setProjectFilter] = useState(undefined); const { days: windowDays, custom: isCustomWindow, window } = windowSelection; const isPast24Hours = !isCustomWindow && windowDays === 1; - const { merged, environments, isPending, isPartial, refresh } = useUsage(window); + const { merged, environments, isPending, isPartial, refresh } = useUsage(window, projectFilter); // Hold the content until every environment is terminal. Rendering merged // totals while devices are still answering makes every number on the page @@ -90,8 +92,23 @@ export function UsagePage() { : merged.models, [breakdown, merged.models, metric], ); + const breakdownProjects = useMemo( + () => + metric === "tokens" + ? merged.projects.toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ) + : merged.projects, + [merged.projects, metric], + ); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; + // Session figures are per transcript directory; a project filter cannot + // split them, so they only render unfiltered. + const sessionsKnown = projectFilter === undefined; + // Only offer the picker once a second grouping exists; a lone group can + // only ever filter to itself. + const showProjectPicker = merged.projects.length > 1 || projectFilter !== undefined; const selectWindow = (days: number) => { setWindowSelection({ @@ -142,6 +159,13 @@ export function UsagePage() {
+ {showProjectPicker ? ( + + ) : null}
+ {showProjectPicker ? ( + + ) : null} onChange(projectFilterFromValue(value ?? ""))} + > + + + {label} + + + + All projects + {projects.map((project) => + project.project === null ? ( + + Outside projects + + ) : ( + + {project.project} + + ), + )} + + + ); +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index ba78a61d8a88..664acadf3170 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -71,7 +71,11 @@ export interface UsageView { readonly refresh: () => void; } -export function useUsage(input: UsageSummaryInput): UsageView { +export function useUsage( + input: UsageSummaryInput, + /** A project title, `null` for outside-projects buckets, `undefined` for no filter. */ + projectFilter?: string | null, +): UsageView { const windowKey = useMemo( () => JSON.stringify({ @@ -118,8 +122,12 @@ export function useUsage(input: UsageSummaryInput): UsageView { }, ], ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [environments]); + return mergeUsage( + answered, + USAGE_CONTRACT_VERSION, + projectFilter === undefined ? undefined : { projectFilter }, + ); + }, [environments, projectFilter]); const answeredCount = environments.filter((environment) => environment.summary !== null).length; const stillReporting = environments.filter( diff --git a/docs/user/usage.md b/docs/user/usage.md index e5be1e784ea3..ae886604ab49 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -14,3 +14,8 @@ headline and chart, and refreshing rescans every connected environment. Any daily chart zooms: drag across it to make the selection the new date window, and double-click to return to the preset. The date fields beside the presets accept any custom range directly. + +Usage is attributed to the project whose folder a session ran in, including sessions driven +outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker +narrows the whole page to one project; work that ran outside every project is grouped under +"Outside projects". Grok Build sessions record no folder and always count there. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 8c099ddb33aa..5e02b84c9810 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -21,14 +21,15 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 5 as const; +export const USAGE_CONTRACT_VERSION = 6 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * - * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets - * remain valid, so mixed-version environments keep those totals instead of - * treating every older server as stale. + * v5 only adds `grok` to {@link UsageProviderKind}; v6 only adds the optional + * bucket `project`. v4 Claude/Codex buckets remain valid, so mixed-version + * environments keep those totals instead of treating every older server as + * stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; @@ -80,8 +81,9 @@ export const UsageTokenTotals = Schema.Struct({ export type UsageTokenTotals = typeof UsageTokenTotals.Type; /** - * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start - * instant of a rolling bucket and is present only for hourly requests. + * One `(day, hourStart?, project, provider, model)` cell. `hourStart` is the + * UTC start instant of a rolling bucket and is present only for hourly + * requests. * * `costUsd` is the raw API-equivalent cost of these tokens. It is not money * spent: subscription plans bill separately. `unpricedRecords` counts records @@ -91,6 +93,14 @@ export type UsageTokenTotals = typeof UsageTokenTotals.Type; export const UsageBucket = Schema.Struct({ day: UsageDay, hourStart: Schema.optional(TrimmedNonEmptyString), + /** + * Title of the T3 project whose workspace root contains the session's + * working directory, resolved per environment at scan time. Absent when the + * session ran outside every project on that environment, or when the + * transcript carries no working directory (Grok, and summaries from servers + * predating this field). + */ + project: Schema.optional(TrimmedNonEmptyString), provider: UsageProviderKind, model: TrimmedNonEmptyString, totals: UsageTokenTotals, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ff..c161fd305a37 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -1,5 +1,6 @@ import { USAGE_CONTRACT_VERSION, + USAGE_MERGE_COMPATIBLE_SINCE, type EnvironmentId, type UsageBucket, type UsageDay, @@ -158,7 +159,7 @@ describe("mergeUsage", () => { summary( [bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 2, + USAGE_MERGE_COMPATIBLE_SINCE - 1, ), ), ], @@ -338,4 +339,59 @@ describe("mergeUsage", () => { expect(merged.daily).toHaveLength(1); expect(merged.daily[0]?.costUsd).toBe(10); }); + + it("rolls buckets up by project, with unattributed buckets under null", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ project: "App", costUsd: 6 }), + bucket({ project: "App", costUsd: 2, model: "claude-opus-5" }), + bucket({ costUsd: 2 }), + ], + [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.projects.map((project) => [project.project, project.costUsd])).toEqual([ + ["App", 8], + [null, 2], + ]); + expect(merged.projects[0]?.costShare).toBeCloseTo(0.8, 9); + }); + + it("filters every figure except the project list when a project is selected", () => { + const environments = [ + environment( + "env-a", + summary( + [ + bucket({ project: "App", costUsd: 6 }), + bucket({ costUsd: 2, provider: "codex", model: "gpt-5.6-sol" }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ]; + + const filtered = mergeUsage(environments, USAGE_CONTRACT_VERSION, { projectFilter: "App" }); + expect(filtered.costUsd).toBe(6); + expect(filtered.providers.map((provider) => provider.provider)).toEqual(["claude"]); + // Session counts are per source directory and cannot be split by project. + expect(filtered.sessions).toBe(0); + // The picker keeps its full option list while the filter narrows the rest. + expect(filtered.projects.map((project) => project.project)).toEqual(["App", null]); + + const outside = mergeUsage(environments, USAGE_CONTRACT_VERSION, { projectFilter: null }); + expect(outside.costUsd).toBe(2); + expect(outside.providers.map((provider) => provider.provider)).toEqual(["codex"]); + }); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 428599d51c74..9f842da865f3 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -40,6 +40,15 @@ export interface ModelTotals { readonly costShare: number; } +/** One project's slice of the window. `project` is null for buckets that ran outside every project. */ +export interface ProjectTotals { + readonly project: string | null; + readonly costUsd: number; + readonly totalTokens: number; + readonly records: number; + readonly costShare: number; +} + export interface DailyTotals { readonly day: string; readonly costUsd: number; @@ -74,6 +83,11 @@ export interface MergedUsage { readonly sessions: number; readonly providers: readonly ProviderTotals[]; readonly models: readonly ModelTotals[]; + /** + * Always computed from the unfiltered buckets, so a project picker keeps its + * full option list while a filter is applied. + */ + readonly projects: readonly ProjectTotals[]; readonly daily: readonly DailyTotals[]; readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; @@ -189,6 +203,7 @@ const EMPTY_MERGED: MergedUsage = { sessions: 0, providers: [], models: [], + projects: [], daily: [], hourly: [], costQuality: { @@ -202,6 +217,18 @@ const EMPTY_MERGED: MergedUsage = { staleEnvironments: [], }; +export interface MergeUsageOptions { + /** + * Restrict every figure except `projects` to buckets from one project: + * a title selects that project, `null` selects buckets that ran outside + * every project, and `undefined` applies no filter. + * + * Sessions are counted per source directory, not per project, so a filtered + * merge reports `sessions` as 0 rather than a number it cannot know. + */ + readonly projectFilter?: string | null; +} + /** * Merges every connected environment's summary. * @@ -214,8 +241,10 @@ const EMPTY_MERGED: MergedUsage = { export function mergeUsage( environments: readonly EnvironmentUsage[], expectedContractVersion: number, + options?: MergeUsageOptions, ): MergedUsage { if (environments.length === 0) return EMPTY_MERGED; + const projectFilter = options?.projectFilter; const current: EnvironmentUsage[] = []; const staleEnvironments: EnvironmentId[] = []; @@ -249,6 +278,13 @@ export function mergeUsage( string, { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } >(); + // Keyed by title, with null (outside every project) under a NUL sentinel no + // title can contain. Accumulated before the project filter applies. + const projectAccumulator = new Map< + string, + { costUsd: number; totalTokens: number; records: number } + >(); + let unfilteredCostUsd = 0; const dailyAccumulator = new Map< string, { @@ -273,21 +309,39 @@ export function mergeUsage( const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - for (const [providerKind, providerSessions] of sessionsByProvider) { - sessions += providerSessions; - if (providerSessions === 0) continue; - const provider = providerAccumulator.get(providerKind) ?? { + // Session counts are per source directory; a project filter cannot split + // them, so a filtered merge leaves every session figure at 0. + if (projectFilter === undefined) { + for (const [providerKind, providerSessions] of sessionsByProvider) { + sessions += providerSessions; + if (providerSessions === 0) continue; + const provider = providerAccumulator.get(providerKind) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + sessions: 0, + }; + provider.sessions += providerSessions; + providerAccumulator.set(providerKind, provider); + } + } + + for (const bucket of buckets) { + const tokens = bucketTokens(bucket); + + unfilteredCostUsd += bucket.costUsd; + const projectKey = bucket.project ?? "\0"; + const project = projectAccumulator.get(projectKey) ?? { costUsd: 0, totalTokens: 0, records: 0, - sessions: 0, }; - provider.sessions += providerSessions; - providerAccumulator.set(providerKind, provider); - } + project.costUsd += bucket.costUsd; + project.totalTokens += tokens; + project.records += bucket.records; + projectAccumulator.set(projectKey, project); - for (const bucket of buckets) { - const tokens = bucketTokens(bucket); + if (projectFilter !== undefined && (bucket.project ?? null) !== projectFilter) continue; costUsd += bucket.costUsd; cacheSavingsUsd += bucket.cacheSavingsUsd; @@ -383,6 +437,16 @@ export function mergeUsage( })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); + const projects: ProjectTotals[] = [...projectAccumulator.entries()] + .map(([key, totals]) => ({ + project: key === "\0" ? null : key, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: unfilteredCostUsd === 0 ? 0 : totals.costUsd / unfilteredCostUsd, + })) + .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); + const daily: DailyTotals[] = [...dailyAccumulator.entries()] .map(([day, totals]) => ({ day, @@ -408,6 +472,7 @@ export function mergeUsage( sessions, providers, models, + projects, daily, hourly, costQuality: { From 52167be704677357fdea01be8e7a0827cef8fe30 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 20:11:30 +1000 Subject: [PATCH 06/78] fix(usage): keep project attribution stable --- apps/server/src/usage/UsageService.ts | 1 + .../server/src/usage/usageAggregation.test.ts | 67 +++++++++++++++---- apps/server/src/usage/usageAggregation.ts | 59 ++++++++++------ apps/server/src/usage/usageScanCache.test.ts | 16 +++++ apps/server/src/usage/usageScanCache.ts | 11 ++- .../src/components/usage/UsagePage.test.tsx | 23 ++++++- apps/web/src/components/usage/UsagePage.tsx | 45 ++++++++----- apps/web/src/state/usage.ts | 2 +- packages/contracts/src/usage.ts | 14 ++-- packages/shared/src/usageMerge.test.ts | 35 +++++++++- packages/shared/src/usageMerge.ts | 45 ++++++++++--- 11 files changed, 244 insertions(+), 74 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6f2c2bb5ff5e..b7bf06e1112e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -257,6 +257,7 @@ export const make = Effect.gen(function* () { .pipe(Effect.catchCause(() => Effect.succeed([]))); return makeProjectResolver( projects.map((project) => ({ + projectId: project.projectId, workspaceRoot: project.workspaceRoot, title: project.title, deleted: project.deletedAt !== null, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index adc66cfbdc49..15946251dfab 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,3 +1,4 @@ +import { ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; @@ -96,12 +97,13 @@ describe("UsageAggregator", () => { }); it("splits buckets by resolved project and omits the field when unresolved", () => { + const projectId = ProjectId.make("project-app"); const aggregator = new UsageAggregator({ timeZone: "UTC", sinceDay: "2026-08-01", untilDay: "2026-08-31", rates, - resolveProject: (cwd) => (cwd === "/work/app" ? "App" : ""), + resolveProject: (cwd) => (cwd === "/work/app" ? { projectId, title: "App" } : null), }); aggregator.add(record({ cwd: "/work/app" })); aggregator.add(record({ cwd: "/work/app" })); @@ -113,6 +115,7 @@ describe("UsageAggregator", () => { expect(buckets[0]?.project).toBeUndefined(); expect(buckets[0]?.records).toBe(1); expect(buckets[1]?.project).toBe("App"); + expect(buckets[1]?.projectId).toBe(projectId); expect(buckets[1]?.records).toBe(2); }); @@ -226,36 +229,74 @@ describe("UsageAggregator", () => { }); describe("makeProjectResolver", () => { + const appId = ProjectId.make("project-app"); + const vendoredId = ProjectId.make("project-vendored"); + const legacyDeletedId = ProjectId.make("project-legacy-deleted"); + const legacyId = ProjectId.make("project-legacy"); + const untitledId = ProjectId.make("project-untitled"); const resolver = makeProjectResolver( [ - { workspaceRoot: "/work/app", title: "App", deleted: false }, - { workspaceRoot: "/work/app/vendored", title: "Vendored", deleted: false }, - { workspaceRoot: "/work/legacy", title: "Legacy Was Deleted", deleted: true }, - { workspaceRoot: "/work/legacy", title: "Legacy", deleted: false }, - { workspaceRoot: "/work/untitled", title: " ", deleted: false }, + { projectId: appId, workspaceRoot: "/work/app", title: "App", deleted: false }, + { + projectId: vendoredId, + workspaceRoot: "/work/app/vendored", + title: "Vendored", + deleted: false, + }, + { + projectId: legacyDeletedId, + workspaceRoot: "/work/legacy", + title: "Legacy Was Deleted", + deleted: true, + }, + { + projectId: legacyId, + workspaceRoot: "/work/legacy", + title: "Legacy", + deleted: false, + }, + { + projectId: untitledId, + workspaceRoot: "/work/untitled", + title: " ", + deleted: false, + }, ], "/", ); it("matches the root itself and any path under it", () => { - expect(resolver("/work/app")).toBe("App"); - expect(resolver("/work/app/src/deep")).toBe("App"); + expect(resolver("/work/app")).toEqual({ projectId: appId, title: "App" }); + expect(resolver("/work/app/src/deep")).toEqual({ projectId: appId, title: "App" }); }); it("requires a path-segment boundary, not a bare prefix", () => { - expect(resolver("/work/app-sibling")).toBe(""); + expect(resolver("/work/app-sibling")).toBeNull(); }); it("prefers the deepest matching root", () => { - expect(resolver("/work/app/vendored/lib")).toBe("Vendored"); + expect(resolver("/work/app/vendored/lib")).toEqual({ + projectId: vendoredId, + title: "Vendored", + }); }); it("prefers a live project over a deleted one sharing the root", () => { - expect(resolver("/work/legacy/src")).toBe("Legacy"); + expect(resolver("/work/legacy/src")).toEqual({ projectId: legacyId, title: "Legacy" }); }); it("never attributes to a blank title or an empty cwd", () => { - expect(resolver("/work/untitled/src")).toBe(""); - expect(resolver("")).toBe(""); + expect(resolver("/work/untitled/src")).toBeNull(); + expect(resolver("")).toBeNull(); + }); + + it("matches descendants when the project root is the filesystem root", () => { + const rootId = ProjectId.make("project-root"); + const rootResolver = makeProjectResolver( + [{ projectId: rootId, workspaceRoot: "/", title: "Root", deleted: false }], + "/", + ); + + expect(rootResolver("/work/app")).toEqual({ projectId: rootId, title: "Root" }); }); }); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 352339aa1842..4633c8216836 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -12,7 +12,13 @@ * * @module usageAggregation */ -import type { UsageBucket, UsageDay, UsageResolution, UsageTokenTotals } from "@t3tools/contracts"; +import type { + ProjectId, + UsageBucket, + UsageDay, + UsageResolution, + UsageTokenTotals, +} from "@t3tools/contracts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; @@ -47,14 +53,20 @@ export function makeDayFormatter(timeZone: string): (timestampMs: number) => str const HOUR_MS = 60 * 60 * 1000; export interface ProjectRoot { + readonly projectId: ProjectId; readonly workspaceRoot: string; readonly title: string; /** Soft-deleted projects still attribute: the spend happened while they existed. */ readonly deleted: boolean; } +export interface ProjectAttribution { + readonly projectId: ProjectId; + readonly title: string; +} + /** - * Builds the cwd โ†’ project-title resolver used by {@link AggregateOptions}. + * Builds the cwd โ†’ project resolver used by {@link AggregateOptions}. * * Deepest root wins, so a session in a project nested inside another * attributes to the inner one. Live projects outrank deleted ones sharing a @@ -64,9 +76,10 @@ export interface ProjectRoot { export function makeProjectResolver( projects: readonly ProjectRoot[], separator: string, -): (cwd: string) => string { +): (cwd: string) => ProjectAttribution | null { const roots = projects .map((project) => ({ + projectId: project.projectId, root: project.workspaceRoot.length > 1 && project.workspaceRoot.endsWith(separator) ? project.workspaceRoot.slice(0, -1) @@ -77,15 +90,17 @@ export function makeProjectResolver( .filter((entry) => entry.root.length > 0 && entry.title.length > 0) .sort((a, b) => b.root.length - a.root.length || Number(a.deleted) - Number(b.deleted)); - const byCwd = new Map(); + const byCwd = new Map(); return (cwd) => { - if (cwd.length === 0) return ""; - const cached = byCwd.get(cwd); - if (cached !== undefined) return cached; - let resolved = ""; - for (const { root, title } of roots) { - if (cwd === root || (cwd.startsWith(root) && cwd[root.length] === separator)) { - resolved = title; + if (cwd.length === 0) return null; + if (byCwd.has(cwd)) return byCwd.get(cwd) ?? null; + let resolved: ProjectAttribution | null = null; + for (const { projectId, root, title } of roots) { + if ( + cwd === root || + (root === separator ? cwd.startsWith(separator) : cwd.startsWith(`${root}${separator}`)) + ) { + resolved = { projectId, title }; break; } } @@ -113,11 +128,10 @@ export interface AggregateOptions { readonly sinceTimeMs?: number; readonly untilTimeMs?: number; /** - * Maps a record's working directory to the title of the project it ran in, - * or `""` when it ran outside every project. Omitting it leaves every bucket - * unattributed. + * Maps a record's working directory to the project it ran in, or `null` when + * it ran outside every project. Omitting it leaves every bucket unattributed. */ - readonly resolveProject?: (cwd: string) => string; + readonly resolveProject?: (cwd: string) => ProjectAttribution | null; } export interface AggregateResult { @@ -199,12 +213,11 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - // The key is parsed back apart on NUL, which project titles must not carry. - const project = - this.#options.resolveProject === undefined - ? "" - : this.#options.resolveProject(record.cwd).replaceAll("\u0000", ""); - const key = `${day}\u0000${hourStart}\u0000${project}\u0000${record.provider}\u0000${record.model}`; + // The key is parsed back apart on NUL, which project fields must not carry. + const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectId = resolvedProject?.projectId.replaceAll("\u0000", "") ?? ""; + const project = resolvedProject?.title.replaceAll("\u0000", "") ?? ""; + const key = `${day}\u0000${hourStart}\u0000${projectId}\u0000${project}\u0000${record.provider}\u0000${record.model}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -239,12 +252,13 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", project = "", provider = "", model = ""] = + const [day = "", hourStart = "", projectId = "", project = "", provider = "", model = ""] = key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), ...(project === "" ? {} : { project }), + ...(projectId === "" ? {} : { projectId: projectId as ProjectId }), provider: provider as UsageBucket["provider"], model, totals: bucket.totals, @@ -262,6 +276,7 @@ export class UsageAggregator { a.day.localeCompare(b.day) || (a.hourStart ?? "").localeCompare(b.hourStart ?? "") || (a.project ?? "").localeCompare(b.project ?? "") || + (a.projectId ?? "").localeCompare(b.projectId ?? "") || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model), ); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 42a230fdeb95..c189a0b44b27 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -187,6 +187,22 @@ describe("scan cache round trip", () => { const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); expect(restored.has("/a.jsonl")).toBe(false); }); + + it.each([0.5, 99])("drops an entry with invalid cwd index %s", (cwdIndex) => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const row = encoded.files["/a.jsonl"]!.r[0]!; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [[...row.slice(0, 10), cwdIndex]], + }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); }); describe("pruneScanCache", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 024771e4cf5a..6a976ed032ba 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -195,10 +195,17 @@ export function decodeScanCache(document: unknown): ScanCache { ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + const session = typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined; + const cwd = typeof cwdIndex === "number" ? cwds[cwdIndex] : undefined; if ( typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || model === undefined || + !Number.isInteger(modelIndex) || + session === undefined || + !Number.isInteger(sessionIndex) || + cwd === undefined || + !Number.isInteger(cwdIndex) || !Number.isFinite(uncached) || !Number.isFinite(cached) || !Number.isFinite(cacheCreation) || @@ -212,8 +219,8 @@ export function decodeScanCache(document: unknown): ScanCache { provider, timestampMs, model, - sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", - cwd: (typeof cwdIndex === "number" ? cwds[cwdIndex] : undefined) ?? "", + sessionId: session, + cwd, totals: { uncachedInputTokens: uncached, cachedInputTokens: cached, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index fe676afb7779..33ee465ce5a0 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,4 +1,4 @@ -import { USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { ProjectId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -7,6 +7,7 @@ const testState = vi.hoisted(() => ({ useUsage: vi.fn(), metric: "cost" as "cost" | "tokens", breakdown: "time" as "model" | "project" | "time", + projectFilter: undefined as string | null | undefined, })); vi.mock("react", async (importOriginal) => { @@ -30,7 +31,9 @@ vi.mock("react", async (importOriginal) => { ? testState.metric : initial === "model" ? testState.breakdown - : initial, + : initial === undefined + ? testState.projectFilter + : initial, vi.fn(), ]), }; @@ -106,6 +109,8 @@ const modelTotals = Object.freeze([ const projectTotals = Object.freeze([ { + projectId: ProjectId.make("project-expensive"), + projectKey: "id:project-expensive", project: "Expensive Project", costUsd: 9, totalTokens: 200, @@ -113,6 +118,8 @@ const projectTotals = Object.freeze([ costShare: 9 / 16, }, { + projectId: null, + projectKey: null, project: null, costUsd: 7, totalTokens: 900, @@ -124,6 +131,7 @@ const projectTotals = Object.freeze([ beforeEach(() => { testState.metric = "cost"; testState.breakdown = "time"; + testState.projectFilter = undefined; testState.useUsage.mockReturnValue({ merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), @@ -202,6 +210,17 @@ describe("UsagePage project breakdown", () => { expect(body).toMatch(/Outside projects.*Expensive Project/); }); + + it("shows only the selected project in the project breakdown", () => { + testState.breakdown = "project"; + testState.projectFilter = "id:project-expensive"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toContain("Expensive Project"); + expect(body).not.toContain("Outside projects"); + }); }); describe("UsagePage model breakdown", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 68986ba580ee..3383f1ae5515 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -55,7 +55,7 @@ export function UsagePage() { })); const [metric, setMetric] = useState("cost"); const [breakdown, setBreakdown] = useState<"model" | "project" | "time">("model"); - // A project title, null for work outside every project, undefined for all. + // A namespaced project key, null for work outside every project, undefined for all. const [projectFilter, setProjectFilter] = useState(undefined); const { days: windowDays, custom: isCustomWindow, window } = windowSelection; const isPast24Hours = !isCustomWindow && windowDays === 1; @@ -92,15 +92,22 @@ export function UsagePage() { : merged.models, [breakdown, merged.models, metric], ); - const breakdownProjects = useMemo( - () => - metric === "tokens" - ? merged.projects.toSorted( - (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, - ) - : merged.projects, - [merged.projects, metric], - ); + const breakdownProjects = useMemo(() => { + const scoped = + projectFilter === undefined + ? merged.projects + : merged.projects.filter((project) => project.projectKey === projectFilter); + return metric === "tokens" + ? scoped.toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ) + : scoped; + }, [merged.projects, metric, projectFilter]); + const selectedProjectLabel = + projectFilter === undefined + ? null + : (merged.projects.find((project) => project.projectKey === projectFilter)?.project ?? + "Outside projects"); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; // Session figures are per transcript directory; a project filter cannot @@ -303,7 +310,7 @@ export function UsagePage() { {(() => { const scope = sessionsKnown ? `${formatCount(merged.sessions)} sessions` - : (projectFilter ?? "Outside projects"); + : (selectedProjectLabel ?? "Outside projects"); return metric === "cost" ? `${scope} ยท API estimate` : scope; })()} @@ -459,7 +466,7 @@ export function UsagePage() { ) : ( breakdownProjects.map((project) => ( @@ -667,8 +674,8 @@ function UsageDateRangeInputs({ /** * Select values are plain strings, so the three filter states get distinct - * encodings: sentinels for "all" and "outside", a prefix for titles so a - * project literally named "all" cannot collide with the sentinel. + * encodings: sentinels for "all" and "outside", while attributed projects + * already carry a namespaced stable key from the merge layer. */ const ALL_PROJECTS_VALUE = "all"; const OUTSIDE_PROJECTS_VALUE = "outside"; @@ -696,7 +703,10 @@ function UsageProjectSelect({ readonly filter: string | null | undefined; readonly onChange: (filter: string | null | undefined) => void; }) { - const label = filter === undefined ? "All projects" : (filter ?? "Outside projects"); + const label = + filter === undefined + ? "All projects" + : (projects.find((project) => project.projectKey === filter)?.project ?? "Outside projects"); return ( = {}): UsageBucket { return { @@ -413,9 +414,10 @@ describe("mergeUsage", () => { ), ]; - const filtered = mergeUsage(environments, USAGE_CONTRACT_VERSION, { - projectFilter: "title:App", - }); + const unfiltered = mergeUsage(environments, USAGE_CONTRACT_VERSION); + const appKey = unfiltered.projects.find((project) => project.project === "App")?.projectKey; + if (appKey === undefined || appKey === null) throw new Error("app project key missing"); + const filtered = mergeUsage(environments, USAGE_CONTRACT_VERSION, { projectFilter: appKey }); expect(filtered.costUsd).toBe(6); expect(filtered.providers.map((provider) => provider.provider)).toEqual(["claude"]); // Session counts are per source directory and cannot be split by project. @@ -427,4 +429,53 @@ describe("mergeUsage", () => { expect(outside.costUsd).toBe(2); expect(outside.providers.map((provider) => provider.provider)).toEqual(["codex"]); }); + + it("namespaces stable project ids by environment", () => { + const sharedId = ProjectId.make("cloned-project"); + const environments = [ + environment( + "env-a", + summary( + [bucket({ projectId: sharedId, project: "App", costUsd: 6 })], + [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }], + ), + ), + environment( + "env-b", + summary( + [bucket({ projectId: sharedId, project: "App", costUsd: 2 })], + [{ provider: "claude", hostId: "linux", homePath: "/b/.claude" }], + ), + ), + ]; + + const merged = mergeUsage(environments, USAGE_CONTRACT_VERSION); + expect(merged.projects.map((project) => project.costUsd)).toEqual([6, 2]); + const firstKey = merged.projects[0]?.projectKey; + if (firstKey === undefined || firstKey === null) throw new Error("project key missing"); + expect(projectFilterForEnvironment(firstKey, "env-a" as EnvironmentId)).toBe(`id:${sharedId}`); + expect(projectFilterForEnvironment(firstKey, "env-b" as EnvironmentId)).toBe( + "environment-mismatch:", + ); + }); + + it("does not treat unknown attribution from old summaries as outside projects", () => { + const oldEnvironment = environment( + "env-old", + summary( + [bucket({ costUsd: 4 })], + [{ provider: "claude", hostId: "mac", homePath: "/old/.claude" }], + USAGE_PROJECT_ATTRIBUTION_SINCE - 1, + ), + ); + + const unfiltered = mergeUsage([oldEnvironment], USAGE_CONTRACT_VERSION); + expect(unfiltered.costUsd).toBe(4); + expect(unfiltered.projects).toEqual([]); + + const outside = mergeUsage([oldEnvironment], USAGE_CONTRACT_VERSION, { + projectFilter: null, + }); + expect(outside.costUsd).toBe(0); + }); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 93aa1ef87e26..ef1d31d35b4c 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -8,6 +8,7 @@ */ import { USAGE_MERGE_COMPATIBLE_SINCE, + USAGE_PROJECT_ATTRIBUTION_SINCE, type EnvironmentId, type ProjectId, type UsageBucket, @@ -233,12 +234,38 @@ export interface MergeUsageOptions { readonly projectFilter?: string | null; } -function bucketProjectKey(bucket: UsageBucket): string | null { +function localBucketProjectKey(bucket: UsageBucket): string | null { if (bucket.projectId !== undefined) return `id:${bucket.projectId}`; if (bucket.project !== undefined) return `title:${bucket.project}`; return null; } +function namespacedProjectKey(environmentId: EnvironmentId, localKey: string): string { + return JSON.stringify([environmentId, localKey]); +} + +/** Converts a merged project key back to the key understood by one server. */ +export function projectFilterForEnvironment( + filter: string | null | undefined, + environmentId: EnvironmentId, +): string | null | undefined { + if (filter === undefined || filter === null) return filter; + try { + const parsed: unknown = JSON.parse(filter); + if ( + Array.isArray(parsed) && + parsed.length === 2 && + parsed[0] === environmentId && + typeof parsed[1] === "string" + ) { + return parsed[1]; + } + } catch { + // A malformed or foreign key must select nothing in this environment. + } + return "environment-mismatch:"; +} + /** * Merges every connected environment's summary. * @@ -347,22 +374,35 @@ export function mergeUsage( const tokens = bucketTokens(bucket); unfilteredCostUsd += bucket.costUsd; - const projectKey = bucketProjectKey(bucket); - const accumulatorKey = projectKey ?? "\0"; - const project = projectAccumulator.get(accumulatorKey) ?? { - projectId: bucket.projectId ?? null, - projectKey, - project: bucket.project ?? null, - costUsd: 0, - totalTokens: 0, - records: 0, - }; - project.costUsd += bucket.costUsd; - project.totalTokens += tokens; - project.records += bucket.records; - projectAccumulator.set(accumulatorKey, project); + const localProjectKey = localBucketProjectKey(bucket); + const projectKey = + localProjectKey === null + ? environment.summary.contractVersion >= USAGE_PROJECT_ATTRIBUTION_SINCE + ? null + : undefined + : namespacedProjectKey(environment.environmentId, localProjectKey); + // Pre-project contracts cannot distinguish an outside-project bucket + // from one whose attribution is simply unavailable. Keep its usage in + // unfiltered totals, but never claim it belongs to the Outside slice. + if (projectKey === undefined) { + if (projectFilter !== undefined) continue; + } else { + const accumulatorKey = projectKey ?? "\0"; + const project = projectAccumulator.get(accumulatorKey) ?? { + projectId: bucket.projectId ?? null, + projectKey, + project: bucket.project ?? null, + costUsd: 0, + totalTokens: 0, + records: 0, + }; + project.costUsd += bucket.costUsd; + project.totalTokens += tokens; + project.records += bucket.records; + projectAccumulator.set(accumulatorKey, project); - if (projectFilter !== undefined && projectKey !== projectFilter) continue; + if (projectFilter !== undefined && projectKey !== projectFilter) continue; + } costUsd += bucket.costUsd; cacheSavingsUsd += bucket.cacheSavingsUsd; From 543cd6edb84f8b855885261f1c41e7156b41926d Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 21:40:23 +1000 Subject: [PATCH 08/78] fix(web): settle custom usage dates before scanning --- apps/web/src/components/usage/UsagePage.tsx | 32 ++++++++---- .../usage/UsageProviderChart.test.ts | 38 +++++++++++++- .../components/usage/UsageProviderChart.tsx | 51 ++++++++++--------- packages/shared/src/usageFormat.test.ts | 18 +++++++ packages/shared/src/usageFormat.ts | 28 +++++++++- 5 files changed, 130 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index df602cf1f8dd..dbbd3ce863bb 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -5,9 +5,11 @@ import { useMemo, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; +import { useCommitOnBlur } from "../../hooks/useCommitOnBlur"; import { cn } from "../../lib/utils"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { + compareUsageDays, enumerateDays, enumerateHourStarts, formatCount, @@ -531,6 +533,20 @@ function UsageDateRangeInputs({ readonly untilDay: string; readonly onChange: (sinceDay: string, untilDay: string) => void; }) { + // The shared buffered-input hook preserves a focused draft across upstream + // range changes and commits on both blur and Enter. Keep the hooks separate + // so each bound can validate against the last committed opposite bound. + const sinceInput = useCommitOnBlur(sinceDay, (next) => { + const comparison = compareUsageDays(next, untilDay); + if (comparison !== null && comparison <= 0) onChange(next, untilDay); + }); + const untilInput = useCommitOnBlur(untilDay, (next) => { + const comparison = compareUsageDays(sinceDay, next); + if (comparison !== null && comparison <= 0) onChange(sinceDay, next); + }); + const comparison = compareUsageDays(sinceInput.value, untilInput.value); + const invalid = comparison === null || comparison > 0; + return (
{ - if (event.target.value) onChange(event.target.value, untilDay); - }} + max={untilInput.value} + aria-invalid={invalid || undefined} + {...sinceInput} /> to { - if (event.target.value) onChange(sinceDay, event.target.value); - }} + min={sinceInput.value} + aria-invalid={invalid || undefined} + {...untilInput} />
); diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index ec2b23f73372..8b9001bc4d0f 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import { brushSelection, buildDayColumns, periodIndexAt, niceScale } from "./UsageProviderChart"; +import { + brushSelection, + buildDayColumns, + chartLabelIndices, + periodIndexAt, + niceScale, + spanSinglePeriodPoints, +} from "./UsageProviderChart"; import { providersWithUsage } from "./usageProviders"; describe("niceScale", () => { @@ -166,3 +173,32 @@ describe("periodIndexAt", () => { expect(periodIndexAt(750, 100, 400, 5)).toBe(4); }); }); + +describe("spanSinglePeriodPoints", () => { + it("repeats one point across the chart width", () => { + expect(spanSinglePeriodPoints([{ x: 0, y: 42 }])).toEqual([ + { x: 0, y: 42 }, + { x: 960, y: 42 }, + ]); + }); + + it("leaves multi-period points unchanged", () => { + const points = [ + { x: 0, y: 42 }, + { x: 960, y: 12 }, + ]; + + expect(spanSinglePeriodPoints(points)).toBe(points); + }); +}); + +describe("chartLabelIndices", () => { + it("deduplicates labels for one- and two-period windows", () => { + expect(chartLabelIndices(1)).toEqual([0]); + expect(chartLabelIndices(2)).toEqual([0, 1]); + }); + + it("keeps left, middle, and right labels for wider windows", () => { + expect(chartLabelIndices(5)).toEqual([0, 2, 4]); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index b9343645a1e9..b18d9b90df2a 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -53,6 +53,18 @@ interface Point { readonly y: number; } +/** Gives a one-period daily window enough horizontal span to draw a path. */ +export function spanSinglePeriodPoints(points: readonly Point[]): readonly Point[] { + const only = points.length === 1 ? points[0] : undefined; + return only === undefined ? points : [only, { ...only, x: VIEW_WIDTH }]; +} + +/** Selects distinct left, middle, and right labels for the available span. */ +export function chartLabelIndices(periodCount: number): readonly number[] { + if (periodCount <= 0) return []; + return [...new Set([0, Math.floor(periodCount / 2), periodCount - 1])]; +} + function valueFor( totals: DailyTotals | HourlyTotals | undefined, provider: UsageProviderKind, @@ -289,14 +301,11 @@ export function UsageProviderChart({ const built = providers.map((provider) => { const providerIndex = PROVIDER_ORDER.indexOf(provider); - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), - ); + const points = columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })); + const line = curvePath(smoothCurve(spanSinglePeriodPoints(points))); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -408,9 +417,6 @@ export function UsageProviderChart({ if (!zoomable || event.button !== 0 || !event.isPrimary || brushRef.current !== null) return; const index = indexAt(event.clientX); if (index === null) return; - // `touch-pan-y` owns vertical gestures. Avoid canceling that browser - // default while still suppressing text selection for mouse and pen. - if (event.pointerType !== "touch") event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); @@ -427,7 +433,6 @@ export function UsageProviderChart({ if ( activeBrush === null || activeBrush.pointerId !== event.pointerId || - !event.currentTarget.hasPointerCapture(event.pointerId) || onZoomToDays === undefined ) { return; @@ -477,12 +482,16 @@ export function UsageProviderChart({
{ hoverPositionRef.current = null; @@ -548,7 +557,7 @@ export function UsageProviderChart({ /> )} - {hoverIndex === null ? null : ( + {hoverIndex === null || periods.length === 1 ? null : (
- {periods[0] === undefined ? "" : formatPeriod(periods[0])} - - {periods[Math.floor(periods.length / 2)] === undefined - ? "" - : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")} - - - {periods[periods.length - 1] === undefined - ? "" - : formatPeriod(periods[periods.length - 1] ?? "")} - + {chartLabelIndices(periods.length).map((index) => ( + {formatPeriod(periods[index] ?? "")} + ))}
); diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index c87da20df5a0..a75b54d9ec60 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + compareUsageDays, enumerateHourStarts, formatDateTimeShort, formatHourShort, @@ -10,6 +11,19 @@ import { makeWindow, } from "./usageFormat.ts"; +describe("compareUsageDays", () => { + it("compares strict four-digit calendar dates numerically", () => { + expect(compareUsageDays("2026-08-03", "2026-08-11")).toBe(-1); + expect(compareUsageDays("2026-08-11", "2026-08-03")).toBe(1); + expect(compareUsageDays("2026-08-03", "2026-08-03")).toBe(0); + }); + + it("rejects variable-width years and impossible dates", () => { + expect(compareUsageDays("10000-01-01", "9999-12-31")).toBeNull(); + expect(compareUsageDays("2026-02-29", "2026-03-01")).toBeNull(); + }); +}); + describe("hourly usage formatting", () => { it("enumerates 24 fixed buckets across a rolling window", () => { const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z"); @@ -96,4 +110,8 @@ describe("makeCustomWindow", () => { expect(window.sinceDay).toBe("0001-01-01"); expect(window.untilDay).toBe("0001-03-31"); }); + + it("rejects bounds outside the strict day format", () => { + expect(() => makeCustomWindow("10000-01-01", "9999-12-31")).toThrow(RangeError); + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index a75b0c433d19..6b07b90a60b0 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -17,6 +17,29 @@ const INTEGER = new Intl.NumberFormat("en-US"); const DAY_MS = 24 * 60 * 60 * 1000; const MAX_CUSTOM_WINDOW_DAYS = 90; +function usageDayOrdinal(value: string): number | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (match === null) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (month < 1 || month > 12) return null; + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; + if (daysInMonth === undefined || day < 1 || day > daysInMonth) return null; + return year * 372 + (month - 1) * 31 + day; +} + +/** Compares two strict `YYYY-MM-DD` calendar days, or returns null if either is invalid. */ +export function compareUsageDays(left: string, right: string): -1 | 0 | 1 | null { + const leftOrdinal = usageDayOrdinal(left); + const rightOrdinal = usageDayOrdinal(right); + if (leftOrdinal === null || rightOrdinal === null) return null; + if (leftOrdinal < rightOrdinal) return -1; + if (leftOrdinal > rightOrdinal) return 1; + return 0; +} + export function formatUsd(value: number): string { return CURRENCY.format(value); } @@ -180,7 +203,10 @@ export function formatRelativeHourShort( * enumeration remains bounded. */ export function makeCustomWindow(sinceDay: string, untilDay: string): UsageSummaryInput { - const [first, requestedLast] = sinceDay <= untilDay ? [sinceDay, untilDay] : [untilDay, sinceDay]; + const comparison = compareUsageDays(sinceDay, untilDay); + if (comparison === null) + throw new RangeError("Usage window bounds must be valid YYYY-MM-DD dates"); + const [first, requestedLast] = comparison <= 0 ? [sinceDay, untilDay] : [untilDay, sinceDay]; const firstMs = Date.parse(`${first}T00:00:00Z`); const requestedLastMs = Date.parse(`${requestedLast}T00:00:00Z`); const maximumLastMs = firstMs + (MAX_CUSTOM_WINDOW_DAYS - 1) * DAY_MS; From da3518168de44ec22eeb2620ebd3ab004eae5368 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 21:41:47 +1000 Subject: [PATCH 09/78] fix(usage): keep project views self-contained --- apps/server/src/usage/UsageService.test.ts | 8 +++++ .../server/src/usage/usageAggregation.test.ts | 25 ++++++++----- apps/server/src/usage/usageAggregation.ts | 20 +++++++++-- apps/server/src/usage/usageScanCache.test.ts | 1 + apps/server/src/usage/usageScanCache.ts | 2 ++ .../server/src/usage/usageTranscripts.test.ts | 21 +++++++++++ apps/server/src/usage/usageTranscripts.ts | 1 + .../src/components/usage/UsagePage.test.tsx | 35 +++++++++++++++++-- apps/web/src/components/usage/UsagePage.tsx | 16 +++++++-- docs/user/usage.md | 3 +- packages/contracts/src/usage.ts | 18 ++++++---- packages/shared/src/usageMerge.test.ts | 31 ++++++++++++++-- packages/shared/src/usageMerge.ts | 18 ++++------ 13 files changed, 164 insertions(+), 35 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8fc86ee3d462..3e891159ab8d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -16,6 +16,8 @@ import * as Scheduler from "effect/Scheduler"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; +import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; @@ -85,6 +87,12 @@ const serviceLayers = (input: { Layer.provideMerge( Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), ), + Layer.provideMerge( + Layer.mergeAll( + ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + SqlitePersistenceMemory, + ), + ), ); function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 15946251dfab..0a4a7e6a2e15 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -96,7 +96,7 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(100); }); - it("splits buckets by resolved project and omits the field when unresolved", () => { + it("distinguishes project, outside, and unknown attribution", () => { const projectId = ProjectId.make("project-app"); const aggregator = new UsageAggregator({ timeZone: "UTC", @@ -108,15 +108,24 @@ describe("UsageAggregator", () => { aggregator.add(record({ cwd: "/work/app" })); aggregator.add(record({ cwd: "/work/app" })); aggregator.add(record({ cwd: "/elsewhere" })); + aggregator.add(record({ cwd: "", model: "grok-4" })); const { buckets } = aggregator.finish(); - // Same day, provider and model, so only the project splits the cell. - expect(buckets).toHaveLength(2); - expect(buckets[0]?.project).toBeUndefined(); - expect(buckets[0]?.records).toBe(1); - expect(buckets[1]?.project).toBe("App"); - expect(buckets[1]?.projectId).toBe(projectId); - expect(buckets[1]?.records).toBe(2); + expect(buckets).toHaveLength(3); + const outside = buckets.find((bucket) => bucket.projectAttribution === "outside"); + expect(outside?.project).toBeUndefined(); + expect(outside?.records).toBe(1); + const project = buckets.find((bucket) => bucket.projectAttribution === "project"); + expect(project?.project).toBe("App"); + expect(project?.projectId).toBe(projectId); + expect(project?.records).toBe(2); + expect(buckets.some((bucket) => bucket.projectAttribution === "unknown")).toBe(true); + }); + + it("marks every bucket unknown when no project resolver is available", () => { + const result = aggregate([record({ cwd: "/work/app" })]); + + expect(result.buckets[0]?.projectAttribution).toBe("unknown"); }); it("buckets by the day in the requested time zone", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 4633c8216836..a826242a0336 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -215,9 +215,15 @@ export class UsageAggregator { ).toISOString(); // The key is parsed back apart on NUL, which project fields must not carry. const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectAttribution = + resolvedProject !== null + ? "project" + : this.#options.resolveProject === undefined || record.cwd.length === 0 + ? "unknown" + : "outside"; const projectId = resolvedProject?.projectId.replaceAll("\u0000", "") ?? ""; const project = resolvedProject?.title.replaceAll("\u0000", "") ?? ""; - const key = `${day}\u0000${hourStart}\u0000${projectId}\u0000${project}\u0000${record.provider}\u0000${record.model}`; + const key = `${day}\u0000${hourStart}\u0000${projectAttribution}\u0000${projectId}\u0000${project}\u0000${record.provider}\u0000${record.model}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -252,13 +258,21 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", projectId = "", project = "", provider = "", model = ""] = - key.split("\u0000"); + const [ + day = "", + hourStart = "", + projectAttribution = "unknown", + projectId = "", + project = "", + provider = "", + model = "", + ] = key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), ...(project === "" ? {} : { project }), ...(projectId === "" ? {} : { projectId: projectId as ProjectId }), + projectAttribution: projectAttribution as UsageBucket["projectAttribution"], provider: provider as UsageBucket["provider"], model, totals: bucket.totals, diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index c189a0b44b27..45a5bd5496b6 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -81,6 +81,7 @@ describe("scan cache round trip", () => { codexState: { model: "gpt-5.2-codex", sessionId: "session-c", + cwd: "/home/theo/codex-project", lastUsageSignature: '{"input_tokens":1}', sawSessionMeta: true, suppressingForkCopies: false, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 6a976ed032ba..34eaf11f43a9 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -297,6 +297,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { if ( typeof state.model !== "string" || typeof state.sessionId !== "string" || + typeof state.cwd !== "string" || (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || typeof state.sawSessionMeta !== "boolean" || typeof state.suppressingForkCopies !== "boolean" || @@ -308,6 +309,7 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { return { model: state.model, sessionId: state.sessionId, + cwd: state.cwd, lastUsageSignature: state.lastUsageSignature ?? null, sawSessionMeta: state.sawSessionMeta, suppressingForkCopies: state.suppressingForkCopies, diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 3b03b7fc6deb..95794a4683e2 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -119,6 +119,27 @@ describe("parseCodexLine", () => { expect(record?.totals.reasoningTokens).toBe(116); }); + it("attributes resumed usage to the latest turn working directory", () => { + const state = initialCodexScanState(); + parseCodexLine(sessionMeta, state); + parseCodexLine( + JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T05:17:42.694Z", + payload: { + type: "turn_context", + model: "gpt-5.6-sol", + cwd: "/home/theo/next-project", + }, + }), + state, + ); + + const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); + + expect(record?.cwd).toBe("/home/theo/next-project"); + }); + it("skips a repeated token_count so deltas are not double counted", () => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 25941f300206..a8327656d157 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -252,6 +252,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord if (record["type"] === "turn_context") { if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"]; + if (typeof payloadRecord["cwd"] === "string") state.cwd = payloadRecord["cwd"]; return null; } diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 33ee465ce5a0..a8e55c02ab8c 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -115,7 +115,7 @@ const projectTotals = Object.freeze([ costUsd: 9, totalTokens: 200, records: 2, - costShare: 9 / 16, + costShare: 9 / 20, }, { projectId: null, @@ -124,7 +124,7 @@ const projectTotals = Object.freeze([ costUsd: 7, totalTokens: 900, records: 1, - costShare: 7 / 16, + costShare: 7 / 20, }, ]); @@ -199,6 +199,8 @@ describe("UsagePage project breakdown", () => { expect(body).toMatch(/Expensive Project.*Outside projects/); expect(body).toContain("$9.00"); expect(body).toContain("$7.00"); + expect(body).toContain("45.0%"); + expect(body).toContain("35.0%"); }); it("ranks projects by tokens when the token metric is selected", () => { @@ -220,6 +222,35 @@ describe("UsagePage project breakdown", () => { expect(body).toContain("Expensive Project"); expect(body).not.toContain("Outside projects"); + expect(body).toContain("100.0%"); + }); + + it("distinguishes unattributed usage from an empty window", () => { + testState.breakdown = "project"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { ...usage.merged, projects: [], records: 1 }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("No project attribution in this window."); + expect(markup).not.toContain("No activity in this window."); + }); + + it("keeps the empty-window message when there is no usage", () => { + testState.breakdown = "project"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { ...usage.merged, projects: [], records: 0 }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("No activity in this window."); + expect(markup).not.toContain("No project attribution in this window."); }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 63502a0d0835..65e1673e8b46 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -103,6 +103,10 @@ export function UsagePage() { ) : scoped; }, [merged.projects, metric, projectFilter]); + const breakdownProjectCostUsd = useMemo( + () => breakdownProjects.reduce((sum, project) => sum + project.costUsd, 0), + [breakdownProjects], + ); const projectLabelsRef = useRef(new Map()); for (const project of merged.projects) { if (project.projectKey !== null && project.project !== null) { @@ -469,7 +473,9 @@ export function UsagePage() { {breakdownProjects.length === 0 ? ( - No activity in this window. + {merged.records === 0 + ? "No activity in this window." + : "No project attribution in this window."} ) : ( @@ -491,7 +497,13 @@ export function UsagePage() { {formatUsd(project.costUsd)} - {formatPercent(project.costShare)} + {formatPercent( + projectFilter === undefined + ? project.costShare + : breakdownProjectCostUsd === 0 + ? 0 + : project.costUsd / breakdownProjectCostUsd, + )} {formatTokens(project.totalTokens)} diff --git a/docs/user/usage.md b/docs/user/usage.md index ae886604ab49..bd8dd7d1b00b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,4 +18,5 @@ to return to the preset. The date fields beside the presets accept any custom ra Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker narrows the whole page to one project; work that ran outside every project is grouped under -"Outside projects". Grok Build sessions record no folder and always count there. +"Outside projects". Grok Build sessions record no folder, so they remain in overall totals but are +omitted from the project breakdown and project filters. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 38d2ba2d2803..a0763873616e 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -21,19 +21,20 @@ import { NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas. * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 7 as const; +export const USAGE_CONTRACT_VERSION = 8 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * * v5 only adds `grok` to {@link UsageProviderKind}; v6 adds the optional bucket - * `project`; v7 adds its optional stable `projectId`. v4 Claude/Codex buckets - * remain valid, so mixed-version environments keep those totals instead of - * treating every older server as stale. + * `project`; v7 adds its optional stable `projectId`; v8 distinguishes outside + * projects from unknown attribution. v4 Claude/Codex buckets remain valid, so + * mixed-version environments keep those totals instead of treating every + * older server as stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; -/** First contract version whose absent project fields mean outside all projects. */ -export const USAGE_PROJECT_ATTRIBUTION_SINCE = 6 as const; +/** First contract version that explicitly distinguishes outside from unknown attribution. */ +export const USAGE_PROJECT_ATTRIBUTION_SINCE = 8 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; @@ -105,6 +106,11 @@ export const UsageBucket = Schema.Struct({ project: Schema.optional(TrimmedNonEmptyString), /** Stable identity for `project`; absent on summaries from pre-v7 servers. */ projectId: Schema.optional(ProjectId), + /** + * Whether the session ran in a project, outside every project, or carried no + * working directory. Optional only so current clients can read older summaries. + */ + projectAttribution: Schema.optional(Schema.Literals(["project", "outside", "unknown"])), provider: UsageProviderKind, model: TrimmedNonEmptyString, totals: UsageTokenTotals, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 1a5b452032e3..a522ba7be553 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -31,6 +31,7 @@ function bucket(overrides: Partial = {}): UsageBucket { records: 5, unpricedRecords: 0, sessions: 1, + projectAttribution: "outside", ...overrides, }; } @@ -342,7 +343,7 @@ describe("mergeUsage", () => { expect(merged.daily[0]?.costUsd).toBe(10); }); - it("rolls buckets up by project, with unattributed buckets under null", () => { + it("rolls buckets up by project, with explicit outside buckets under null", () => { const merged = mergeUsage( [ environment( @@ -463,7 +464,7 @@ describe("mergeUsage", () => { const oldEnvironment = environment( "env-old", summary( - [bucket({ costUsd: 4 })], + [bucket({ costUsd: 4, projectAttribution: undefined })], [{ provider: "claude", hostId: "mac", homePath: "/old/.claude" }], USAGE_PROJECT_ATTRIBUTION_SINCE - 1, ), @@ -478,4 +479,30 @@ describe("mergeUsage", () => { }); expect(outside.costUsd).toBe(0); }); + + it("does not treat current unknown attribution as outside projects", () => { + const currentEnvironment = environment( + "env-current", + summary( + [ + bucket({ + provider: "grok", + model: "grok-code-fast-1", + costUsd: 4, + projectAttribution: "unknown", + }), + ], + [{ provider: "grok", hostId: "mac", homePath: "/unknown" }], + ), + ); + + const unfiltered = mergeUsage([currentEnvironment], USAGE_CONTRACT_VERSION); + expect(unfiltered.costUsd).toBe(4); + expect(unfiltered.projects).toEqual([]); + + const outside = mergeUsage([currentEnvironment], USAGE_CONTRACT_VERSION, { + projectFilter: null, + }); + expect(outside.costUsd).toBe(0); + }); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index ef1d31d35b4c..68cf53858872 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -8,7 +8,6 @@ */ import { USAGE_MERGE_COMPATIBLE_SINCE, - USAGE_PROJECT_ATTRIBUTION_SINCE, type EnvironmentId, type ProjectId, type UsageBucket, @@ -234,10 +233,10 @@ export interface MergeUsageOptions { readonly projectFilter?: string | null; } -function localBucketProjectKey(bucket: UsageBucket): string | null { +function localBucketProjectKey(bucket: UsageBucket): string | null | undefined { if (bucket.projectId !== undefined) return `id:${bucket.projectId}`; if (bucket.project !== undefined) return `title:${bucket.project}`; - return null; + return bucket.projectAttribution === "outside" ? null : undefined; } function namespacedProjectKey(environmentId: EnvironmentId, localKey: string): string { @@ -376,14 +375,11 @@ export function mergeUsage( unfilteredCostUsd += bucket.costUsd; const localProjectKey = localBucketProjectKey(bucket); const projectKey = - localProjectKey === null - ? environment.summary.contractVersion >= USAGE_PROJECT_ATTRIBUTION_SINCE - ? null - : undefined - : namespacedProjectKey(environment.environmentId, localProjectKey); - // Pre-project contracts cannot distinguish an outside-project bucket - // from one whose attribution is simply unavailable. Keep its usage in - // unfiltered totals, but never claim it belongs to the Outside slice. + typeof localProjectKey === "string" + ? namespacedProjectKey(environment.environmentId, localProjectKey) + : localProjectKey; + // Unknown attribution stays in unfiltered totals but never claims to be + // part of the explicit Outside projects slice. if (projectKey === undefined) { if (projectFilter !== undefined) continue; } else { From 673f5e82356a2b636d31e0cbfe8c0e8c70e397af Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 17:35:25 +1000 Subject: [PATCH 10/78] feat(usage): thread and subagent drill-down behind the summary Add an on-demand thread breakdown RPC and a web Thread view with total daily cost and Claude subagent slices. Keep lower-cost usage in provider/project remainder rows so the bounded payload still reconciles. Cache-write component accounting remains a separate follow-up. Co-Authored-By: Claude Fable 5 --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/server.ts | 7 +- apps/server/src/usage/UsageService.ts | 202 +++++++++++- apps/server/src/usage/usageThreads.test.ts | 169 ++++++++++ apps/server/src/usage/usageThreads.ts | 301 ++++++++++++++++++ .../server/src/usage/usageTranscriptReader.ts | 99 ++++++ apps/server/src/ws.ts | 8 + apps/web/src/components/usage/UsagePage.tsx | 23 +- .../src/components/usage/UsageThreadTable.tsx | 277 ++++++++++++++++ apps/web/src/state/usage.ts | 60 ++++ docs/user/usage.md | 5 + packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/rpc.ts | 19 +- packages/contracts/src/usage.ts | 83 ++++- 14 files changed, 1250 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/usage/usageThreads.test.ts create mode 100644 apps/server/src/usage/usageThreads.ts create mode 100644 apps/web/src/components/usage/UsageThreadTable.tsx diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..692430088a53 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetUsageThreadBreakdown]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2355fb99d791..120da1bce440 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -27,6 +27,8 @@ import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { ProjectionProjectRepositoryLive } from "./persistence/Layers/ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "./persistence/Layers/ProjectionThreads.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -180,8 +182,11 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( ); const UsageLayerLive = UsageService.layer.pipe( - // The repository resolves each session's cwd to the project it ran in. + // Projects resolve each session's cwd to the project it ran in; threads and + // resume cursors attribute sessions to threads for the drill-down. Layer.provide(ProjectionProjectRepositoryLive), + Layer.provide(ProjectionThreadRepositoryLive), + Layer.provide(ProviderSessionRuntimeRepositoryLive), Layer.provide(ServerSettingsLayerLive), ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index b7bf06e1112e..17bd8bca913a 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -16,10 +16,13 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, + type ThreadId, type UsageProviderKind, type UsageSource, type UsageSummary, type UsageSummaryInput, + type UsageThreadBreakdown, + type UsageThreadBreakdownInput, UsageReadError, } from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; @@ -39,6 +42,8 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; +import { ProjectionThreadRepository } from "../persistence/Services/ProjectionThreads.ts"; +import { ProviderSessionRuntimeRepository } from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -48,7 +53,9 @@ import { listTranscriptFiles, readDirectoryVolumeId, readTranscriptRecords, + readTranscriptTitle, } from "./usageTranscriptReader.ts"; +import { foldThreadRows, ThreadUsageAccumulator, type ThreadRef } from "./usageThreads.ts"; import { decodeScanCache, dedupeWithinFile, @@ -74,6 +81,12 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +/** + * Thread rows sent per breakdown request. A window can hold thousands of + * sessions; everything past the cap is counted, not shipped. + */ +const THREAD_ROW_CAP = 40; + /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -95,6 +108,9 @@ export class UsageService extends Context.Service< UsageService, { readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + readonly readThreadBreakdown: ( + input: UsageThreadBreakdownInput, + ) => Effect.Effect; } >()("t3/usage/UsageService") {} @@ -119,6 +135,16 @@ export const layerTest = Layer.succeed( }, scanDurationMs: 0, }), + readThreadBreakdown: (input) => + Effect.succeed({ + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "1970-01-01T00:00:00.000Z", + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rows: [], + truncatedRows: 0, + scanDurationMs: 0, + }), }), ); @@ -130,6 +156,8 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; const projectRepository = yield* ProjectionProjectRepository; + const threadRepository = yield* ProjectionThreadRepository; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -595,7 +623,179 @@ export const make = Effect.gen(function* () { return yield* Deferred.await(deferred); }); - return { readSummary } as const; + /** + * Maps each thread's current provider session to the thread, from resume + * cursors. Historic sessions of the same thread attribute through the + * worktree map instead; sessions that never ran through T3 Code stay + * session-granular. + */ + const loadThreadAttribution = Effect.fn("UsageService.loadThreadAttribution")(function* () { + const sessionToThread = new Map(); + const worktreeToThread = new Map(); + const titles = new Map(); + + const projects = yield* projectRepository + .listAll() + .pipe(Effect.catchCause(() => Effect.succeed([]))); + const worktreeClaims = new Map(); + for (const project of projects) { + const threads = yield* threadRepository + .listByProjectId({ projectId: project.projectId }) + .pipe(Effect.catchCause(() => Effect.succeed([]))); + for (const thread of threads) { + const title = thread.title.trim(); + if (title.length > 0) titles.set(thread.threadId, title); + const worktree = thread.worktreePath?.trim() ?? ""; + // The project root is not a dedicated worktree: interactive sessions + // run there too, and several threads usually share it. + if (worktree.length === 0 || worktree === project.workspaceRoot) continue; + const ref: ThreadRef = { threadId: thread.threadId, title: title || thread.threadId }; + const claim = worktreeClaims.get(worktree); + if (claim === undefined) worktreeClaims.set(worktree, { ref, shared: false }); + else claim.shared = true; + } + } + for (const [worktree, claim] of worktreeClaims) { + if (!claim.shared) worktreeToThread.set(worktree, claim.ref); + } + + const runtimes = yield* runtimeRepository + .list() + .pipe(Effect.catchCause(() => Effect.succeed([]))); + for (const runtime of runtimes) { + const cursor = runtime.resumeCursor; + if (typeof cursor !== "object" || cursor === null) continue; + const cursorRecord = cursor as Record; + // Claude cursors carry the transcript uuid as `resume`; Codex cursors + // carry the rollout uuid as `threadId`. Other providers do not surface + // usage transcripts, so their cursors are irrelevant here. + const sessionId = + runtime.providerName === "claudeAgent" + ? cursorRecord["resume"] + : runtime.providerName === "codex" + ? cursorRecord["threadId"] + : undefined; + if (typeof sessionId !== "string" || sessionId.length === 0) continue; + const provider = runtime.providerName === "claudeAgent" ? "claude" : "codex"; + sessionToThread.set(`${provider}:${sessionId}`, { + threadId: runtime.threadId, + title: titles.get(runtime.threadId) ?? runtime.threadId, + }); + } + + return { sessionToThread, worktreeToThread }; + }); + + const readThreadBreakdown = Effect.fn("UsageService.readThreadBreakdown")(function* ( + input: UsageThreadBreakdownInput, + ) { + if (input.sinceDay > input.untilDay) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is after untilDay '${input.untilDay}'`, + }); + } + const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); + if (Option.isNone(windowStart)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: `sinceDay '${input.sinceDay}' is not a valid date`, + }); + } + + const startedAtMs = yield* Clock.currentTimeMillis; + yield* ensureRates(); + yield* ensureScanCacheLoaded; + + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS; + + const accumulator = new ThreadUsageAccumulator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rates, + resolveProject: yield* resolveProjects(), + }); + + // Preferred transcript per session for title extraction: the main file, + // never a subagent's. + const titleFiles = new Map< + string, + { readonly path: string; readonly provider: UsageProviderKind } + >(); + + for (const { provider, dir, fileName } of dirs) { + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) continue; + + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + for (const file of files) { + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + if (records.length === 0) continue; + const isSubagent = + provider === "claude" && path.basename(path.dirname(file.path)) === "subagents"; + const agentId = isSubagent ? path.basename(file.path, ".jsonl") : null; + for (const record of records) { + const sessionKey = + record.sessionId.length > 0 + ? `${provider}:${record.sessionId}` + : `${provider}:file:${file.path}`; + if (accumulator.add(record, { sessionKey, agentId }) && !isSubagent) { + if (!titleFiles.has(sessionKey)) { + titleFiles.set(sessionKey, { path: file.path, provider }); + } + } + } + } + } + + const attribution = yield* loadThreadAttribution(); + const folded = foldThreadRows(accumulator.finish(), attribution, { + cap: THREAD_ROW_CAP, + ...(input.project === undefined ? {} : { projectFilter: input.project }), + }); + + // Transcript titles only for unattributed rows that survived the cap. + const rows = yield* Effect.forEach( + folded.rows, + Effect.fnUntraced(function* ({ titleSessionKey, ...row }) { + if (row.title !== null) return { ...row, title: row.title }; + const source = titleFiles.get(titleSessionKey); + const transcriptTitle = + source === undefined + ? null + : yield* Effect.promise(() => readTranscriptTitle(source.path, source.provider)); + const fallback = row.key.startsWith("session:") ? shortSessionLabel(row.key) : row.key; + return { ...row, title: transcriptTitle ?? fallback }; + }), + { concurrency: 8 }, + ); + + const readAt = yield* DateTime.now; + const finishedAtMs = yield* Clock.currentTimeMillis; + return { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: DateTime.formatIso(readAt), + sinceDay: input.sinceDay, + untilDay: input.untilDay, + rows, + truncatedRows: folded.truncatedRows, + scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), + } satisfies UsageThreadBreakdown; + }); + + return { readSummary, readThreadBreakdown } as const; }); +/** `session:claude:8f14e45f-...` reads as `Session 8f14e45f`. */ +function shortSessionLabel(rowKey: string): string { + const sessionId = rowKey.slice(rowKey.lastIndexOf(":") + 1); + return sessionId.length > 8 ? `Session ${sessionId.slice(0, 8)}` : `Session ${sessionId}`; +} + export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts new file mode 100644 index 000000000000..1ebcf19f8e15 --- /dev/null +++ b/apps/server/src/usage/usageThreads.test.ts @@ -0,0 +1,169 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import type { RateTable } from "./usagePricing.ts"; +import { foldThreadRows, ThreadUsageAccumulator, type ThreadAttribution } from "./usageThreads.ts"; +import type { UsageRecord } from "./usageTranscripts.ts"; + +const rates: RateTable = new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheReadCostPerToken: 1e-6, + cacheCreationCostPerToken: 1.25e-5, + }, + ], +]); + +function record(overrides: Partial = {}): UsageRecord { + return { + provider: "claude", + timestampMs: Date.parse("2026-08-07T04:05:13.944Z"), + model: "claude-fable-5", + sessionId: "session-a", + cwd: "/work/app", + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 10, + outputTokens: 50, + reasoningTokens: 0, + }, + reportedCostUsd: null, + dedupeKey: null, + ...overrides, + }; +} + +function accumulate( + entries: readonly (readonly [UsageRecord, { sessionKey: string; agentId: string | null }])[], +) { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + for (const [item, context] of entries) accumulator.add(item, context); + return accumulator.finish(); +} + +const NO_ATTRIBUTION: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map(), +}; + +describe("ThreadUsageAccumulator", () => { + it("groups records by session and splits subagent slices out", () => { + const main = { sessionKey: "claude:session-a", agentId: null }; + const agent = { sessionKey: "claude:session-a", agentId: "agent-1" }; + const groups = accumulate([ + [record(), main], + [record(), agent], + [record({ sessionId: "session-b" }), { sessionKey: "claude:session-b", agentId: null }], + ]); + + expect(groups).toHaveLength(2); + const sessionA = groups.find((group) => group.sessionKey === "claude:session-a"); + expect(sessionA?.totals.outputTokens).toBe(100); + expect(sessionA?.agents.get("agent-1")?.totals.outputTokens).toBe(50); + }); + + it("dedupes globally across files with the summary's semantics", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ dedupeKey: "msg_1:" }), context], + [record({ dedupeKey: "msg_1:" }), context], + ]); + + expect(groups[0]?.totals.outputTokens).toBe(50); + }); + + it("records each day's estimated cost", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([[record(), context]]); + const day = groups[0]?.daily.get("2026-08-07"); + + expect(day).toBeCloseTo(100 * 1e-5 + 1000 * 1e-6 + 10 * 1.25e-5 + 50 * 5e-5, 12); + }); + + it("drops records outside the window", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ timestampMs: Date.parse("2026-07-01T00:00:00Z") }), context], + ]); + + expect(groups).toHaveLength(0); + }); +}); + +describe("foldThreadRows", () => { + const threadId = ThreadId.make("11111111-1111-4111-8111-111111111111"); + + it("folds sessions into one row per thread via cursor and worktree matches", () => { + const groups = accumulate([ + [record(), { sessionKey: "claude:session-a", agentId: null }], + [ + record({ sessionId: "session-b", cwd: "/work/app/.wt/thread-1" }), + { sessionKey: "claude:session-b", agentId: null }, + ], + [record({ sessionId: "session-c" }), { sessionKey: "claude:session-c", agentId: null }], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map([["claude:session-a", { threadId, title: "Fix the flaky test" }]]), + worktreeToThread: new Map([ + ["/work/app/.wt/thread-1", { threadId, title: "Fix the flaky test" }], + ]), + }; + + const { rows, truncatedRows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(truncatedRows).toBe(0); + expect(rows).toHaveLength(2); + const threadRow = rows.find((row) => row.threadId === threadId); + expect(threadRow?.title).toBe("Fix the flaky test"); + expect(threadRow?.sessions).toBe(2); + const standalone = rows.find((row) => row.threadId === null); + // Standalone rows leave the title to the caller's transcript read. + expect(standalone?.title).toBeNull(); + expect(standalone?.key).toBe("session:claude:session-c"); + }); + + it("caps rows by cost and counts the rest", () => { + const groups = accumulate( + Array.from({ length: 5 }, (_, index) => [ + record({ sessionId: `session-${index}` }), + { sessionKey: `claude:session-${index}`, agentId: null }, + ]), + ); + + const { rows, truncatedRows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 3 }); + + expect(rows).toHaveLength(3); + expect(truncatedRows).toBe(2); + }); + + it("filters by project before capping", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd === "/work/app" ? "App" : ""), + }); + accumulator.add(record(), { sessionKey: "claude:session-a", agentId: null }); + accumulator.add(record({ sessionId: "session-b", cwd: "/elsewhere" }), { + sessionKey: "claude:session-b", + agentId: null, + }); + const groups = accumulator.finish(); + + const app = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40, projectFilter: "App" }); + expect(app.rows.map((row) => row.key)).toEqual(["session:claude:session-a"]); + + const outside = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40, projectFilter: null }); + expect(outside.rows.map((row) => row.key)).toEqual(["session:claude:session-b"]); + }); +}); diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts new file mode 100644 index 000000000000..625689f25c97 --- /dev/null +++ b/apps/server/src/usage/usageThreads.ts @@ -0,0 +1,301 @@ +/** + * Pure grouping behind the thread drill-down: transcript records fold into + * per-session groups, and session groups fold into thread rows using the + * attribution the caller extracted from its own state (resume cursors and + * dedicated worktrees). + * + * Pure, so grouping, de-duplication and attribution are testable without the + * filesystem or the database. + * + * @module usageThreads + */ +import type { + ThreadId, + UsageAgentRow, + UsageProviderKind, + UsageThreadDayCost, + UsageThreadRow, + UsageTokenTotals, +} from "@t3tools/contracts"; +import { UsageDay } from "@t3tools/contracts"; + +import { makeDayFormatter } from "./usageAggregation.ts"; +import { priceUsage, type RateTable } from "./usagePricing.ts"; +import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; + +/** How the caller identifies the transcript a record came from. */ +export interface ThreadRecordContext { + /** `provider:sessionId`, or a file-derived fallback when the id is empty. */ + readonly sessionKey: string; + /** Claude subagent id when the record came from a `subagents/agent-*.jsonl` file. */ + readonly agentId: string | null; +} + +interface MutableAgentSlice { + totals: UsageTokenTotals; + costUsd: number; +} + +export interface SessionUsageGroup { + readonly sessionKey: string; + readonly provider: UsageProviderKind; + readonly sessionId: string; + readonly cwd: string; + readonly project: string; + readonly totals: UsageTokenTotals; + readonly costUsd: number; + readonly daily: ReadonlyMap; + readonly agents: ReadonlyMap; +} + +interface MutableSessionGroup { + provider: UsageProviderKind; + sessionId: string; + cwd: string; + totals: UsageTokenTotals; + costUsd: number; + daily: Map; + agents: Map; +} + +export interface ThreadUsageOptions { + readonly timeZone: string; + readonly sinceDay: string; + readonly untilDay: string; + readonly rates: RateTable; + /** Same resolver the summary uses; `""` means outside every project. */ + readonly resolveProject?: (cwd: string) => string; +} + +/** + * Folds records into per-session groups with per-day estimated costs. + * + * De-duplication is global across the scan with the same semantics as the + * summary aggregator, so a thread's number here always reconciles with its + * share of the summary. + */ +export class ThreadUsageAccumulator { + readonly #groups = new Map(); + readonly #seen = new Set(); + readonly #toDay: (timestampMs: number) => string; + readonly #options: ThreadUsageOptions; + + constructor(options: ThreadUsageOptions) { + this.#options = options; + this.#toDay = makeDayFormatter(options.timeZone); + } + + add(record: UsageRecord, context: ThreadRecordContext): boolean { + if (record.dedupeKey !== null) { + if (this.#seen.has(record.dedupeKey)) return false; + this.#seen.add(record.dedupeKey); + } + + const day = this.#toDay(record.timestampMs); + if (day < this.#options.sinceDay || day > this.#options.untilDay) return false; + + let group = this.#groups.get(context.sessionKey); + if (group === undefined) { + group = { + provider: record.provider, + sessionId: record.sessionId, + cwd: "", + totals: EMPTY_TOTALS, + costUsd: 0, + daily: new Map(), + agents: new Map(), + }; + this.#groups.set(context.sessionKey, group); + } + + if (group.cwd.length === 0 && record.cwd.length > 0) group.cwd = record.cwd; + + const priced = priceUsage( + this.#options.rates, + record.model, + record.totals, + record.reportedCostUsd, + ); + group.totals = addTotals(group.totals, record.totals); + group.costUsd += priced.costUsd; + group.daily.set(day, (group.daily.get(day) ?? 0) + priced.costUsd); + + if (context.agentId !== null) { + let agent = group.agents.get(context.agentId); + if (agent === undefined) { + agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + group.agents.set(context.agentId, agent); + } + agent.totals = addTotals(agent.totals, record.totals); + agent.costUsd += priced.costUsd; + } + return true; + } + + finish(): readonly SessionUsageGroup[] { + const resolve = this.#options.resolveProject; + return [...this.#groups.entries()].map(([sessionKey, group]) => ({ + sessionKey, + provider: group.provider, + sessionId: group.sessionId, + cwd: group.cwd, + project: resolve === undefined ? "" : resolve(group.cwd), + totals: group.totals, + costUsd: group.costUsd, + daily: group.daily, + agents: group.agents, + })); + } +} + +/** A thread a session can attribute to, from the environment's own state. */ +export interface ThreadRef { + readonly threadId: ThreadId; + readonly title: string; +} + +export interface ThreadAttribution { + /** `provider:sessionId` of each thread's current session, from resume cursors. */ + readonly sessionToThread: ReadonlyMap; + /** + * Dedicated worktree path โ†’ thread. Only paths claimed by exactly one + * thread belong here: a shared root would stamp one thread's identity onto + * every unrelated session running there. + */ + readonly worktreeToThread: ReadonlyMap; +} + +export interface FoldThreadRowsOptions { + /** A title, `null` for outside-projects sessions, `undefined` for no filter. */ + readonly projectFilter?: string | null | undefined; + /** Rows kept after sorting by cost; the rest are counted, not sent. */ + readonly cap: number; +} + +interface MutableThreadRow { + threadId: ThreadId | null; + title: string | null; + provider: UsageProviderKind; + project: string; + cwd: string; + totals: UsageTokenTotals; + costUsd: number; + sessions: number; + daily: Map; + agents: Map; + /** Session whose transcript can supply a title when no thread claims the row. */ + titleSessionKey: string; +} + +export interface FoldedThreadRows { + readonly rows: readonly (Omit & { + readonly title: string | null; + readonly titleSessionKey: string; + })[]; + readonly truncatedRows: number; +} + +/** + * Groups sessions into thread rows: resume-cursor matches first, then unique + * worktrees, else one row per session. Rows sort by cost and cap; a `null` + * title marks rows whose name must come from the transcript (the caller only + * reads titles for rows that survived the cap). + */ +export function foldThreadRows( + groups: readonly SessionUsageGroup[], + attribution: ThreadAttribution, + options: FoldThreadRowsOptions, +): FoldedThreadRows { + const byKey = new Map(); + + for (const group of groups) { + if (options.projectFilter !== undefined) { + const project = group.project.length === 0 ? null : group.project; + if (project !== options.projectFilter) continue; + } + + const ref = + attribution.sessionToThread.get(group.sessionKey) ?? + (group.cwd.length > 0 ? attribution.worktreeToThread.get(group.cwd) : undefined); + const rowKey = ref === undefined ? `session:${group.sessionKey}` : `thread:${ref.threadId}`; + + let row = byKey.get(rowKey); + if (row === undefined) { + row = { + threadId: ref?.threadId ?? null, + title: ref?.title ?? null, + provider: group.provider, + project: group.project, + cwd: group.cwd, + totals: EMPTY_TOTALS, + costUsd: 0, + sessions: 0, + daily: new Map(), + agents: new Map(), + titleSessionKey: group.sessionKey, + }; + byKey.set(rowKey, row); + } + + row.totals = addTotals(row.totals, group.totals); + row.costUsd += group.costUsd; + row.sessions += 1; + for (const [day, costUsd] of group.daily) { + row.daily.set(day, (row.daily.get(day) ?? 0) + costUsd); + } + for (const [agentId, slice] of group.agents) { + let agent = row.agents.get(agentId); + if (agent === undefined) { + agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + row.agents.set(agentId, agent); + } + agent.totals = addTotals(agent.totals, slice.totals); + agent.costUsd += slice.costUsd; + } + } + + const sorted = [...byKey.entries()].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + const kept = sorted.slice(0, options.cap); + + return { + rows: kept.map(([key, row]) => ({ + key, + threadId: row.threadId, + title: row.title, + titleSessionKey: row.titleSessionKey, + provider: row.provider, + ...(row.project === "" ? {} : { project: row.project }), + totals: row.totals, + costUsd: row.costUsd, + sessions: row.sessions, + agents: [...row.agents.entries()] + .map(([agentId, slice]) => ({ + agentId, + totals: slice.totals, + costUsd: slice.costUsd, + })) + .sort((a, b) => b.costUsd - a.costUsd) satisfies UsageAgentRow[], + daily: [...row.daily.entries()] + .map(([day, costUsd]) => ({ + day: day as UsageDay, + costUsd, + })) + .sort((a, b) => a.day.localeCompare(b.day)) satisfies UsageThreadDayCost[], + })), + truncatedRows: sorted.length - kept.length, + }; +} + +function totalOf(totals: UsageTokenTotals): number { + return ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + totals.outputTokens + ); +} diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e5ab6e0c9e0..522f02aa8554 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -311,3 +311,102 @@ export async function readTranscriptRecords( await handle.close().catch(() => undefined); } } + +/** Prefixes that mark an injected preamble, not something the user typed. */ +const NOT_TITLE_PREFIXES = ["<", "# AGENTS.md instructions", "Caveat: the messages below"]; + +const TITLE_MAX_LENGTH = 80; +const TITLE_MAX_LINES = 400; + +function cleanTitle(text: unknown): string | null { + if (typeof text !== "string") return null; + const collapsed = text.split(/\s+/).join(" ").trim(); + if (collapsed.length === 0) return null; + if (NOT_TITLE_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) return null; + return collapsed.length > TITLE_MAX_LENGTH + ? `${collapsed.slice(0, TITLE_MAX_LENGTH - 1)}\u2026` + : collapsed; +} + +function claudeTitleFromLine(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + if (record["type"] !== "user") return null; + const message = record["message"]; + if (typeof message !== "object" || message === null) return null; + const content = (message as Record)["content"]; + if (typeof content === "string") return cleanTitle(content); + if (!Array.isArray(content)) return null; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const entry = block as Record; + if (entry["type"] !== "text") continue; + const title = cleanTitle(entry["text"]); + if (title !== null) return title; + } + return null; +} + +function codexTitleFromLine(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const payload = (parsed as Record)["payload"]; + if (typeof payload !== "object" || payload === null) return null; + const record = payload as Record; + if (record["type"] !== "message" || record["role"] !== "user") return null; + const content = record["content"]; + if (!Array.isArray(content)) return null; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const title = cleanTitle((block as Record)["text"]); + if (title !== null) return title; + } + return null; +} + +/** + * First thing the user actually typed in a session, as a display title. + * + * Only called for the handful of unattributed rows that survived the response + * cap, so a second bounded read per row is fine. Returns null when the file + * cannot be read, holds no user text (Grok logs carry none we trust), or only + * injected preambles appear early on. + */ +export async function readTranscriptTitle( + filePath: string, + provider: UsageProviderKind, +): Promise { + if (provider === "grok") return null; + try { + const lines = NodeReadline.createInterface({ + input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + let seen = 0; + for await (const line of lines) { + seen += 1; + if (seen > TITLE_MAX_LINES) break; + const gate = provider === "claude" ? '"user"' : '"message"'; + if (!line.includes(gate)) continue; + const title = provider === "claude" ? claudeTitleFromLine(line) : codexTitleFromLine(line); + if (title !== null) { + lines.close(); + return title; + } + } + } catch { + return null; + } + return null; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index db3b74b7e4b6..443d5525c5a7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1754,6 +1754,14 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetUsageThreadBreakdown]: (input) => + observeRpcEffect( + WS_METHODS.serverGetUsageThreadBreakdown, + usage.readThreadBreakdown(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 65e1673e8b46..a7d84f9f3c2e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -36,6 +36,7 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { UsageThreadTable } from "./UsageThreadTable"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; const WINDOW_OPTIONS = [ @@ -54,7 +55,7 @@ export function UsagePage() { window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); - const [breakdown, setBreakdown] = useState<"model" | "project" | "time">("model"); + const [breakdown, setBreakdown] = useState<"model" | "project" | "thread" | "time">("model"); // A namespaced project key, null for work outside every project, undefined for all. const [projectFilter, setProjectFilter] = useState(undefined); const { days: windowDays, custom: isCustomWindow, window } = windowSelection; @@ -434,7 +435,12 @@ export function UsagePage() { value={[breakdown]} onValueChange={(next) => { const value = next[0]; - if (value === "model" || value === "project" || value === "time") { + if ( + value === "model" || + value === "project" || + value === "thread" || + value === "time" + ) { setBreakdown(value); } }} @@ -443,6 +449,7 @@ export function UsagePage() { [ { value: "model", label: "Model" }, { value: "project", label: "Project" }, + { value: "thread", label: "Thread" }, { value: "time", label: isPast24Hours ? "Hour" : "Day" }, ] as const ).map((option) => ( @@ -453,7 +460,17 @@ export function UsagePage() {
- {breakdown === "project" ? ( + {breakdown === "thread" ? ( + + ) : breakdown === "project" ? ( diff --git a/apps/web/src/components/usage/UsageThreadTable.tsx b/apps/web/src/components/usage/UsageThreadTable.tsx new file mode 100644 index 000000000000..3b2723fad19c --- /dev/null +++ b/apps/web/src/components/usage/UsageThreadTable.tsx @@ -0,0 +1,277 @@ +import type { + EnvironmentId, + UsageProviderKind, + UsageThreadBreakdownInput, + UsageThreadDayCost, + UsageThreadRow, +} from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + enumerateDays, + formatDayShort, + formatTokens, + formatUsd, +} from "@t3tools/shared/usageFormat"; + +import { useUsageThreads } from "../../state/usage"; +import { PROVIDER_PRESENTATION } from "./usageProviders"; + +/** + * On-demand thread drill-down behind the summary. Mounted only while the + * Thread breakdown view is open, which is what defers the RPC. + */ +export function UsageThreadTable({ + input, + environmentIds, +}: { + readonly input: UsageThreadBreakdownInput; + readonly environmentIds: readonly EnvironmentId[]; +}) { + const { rows, truncatedRows, isPending, failedEnvironments } = useUsageThreads( + input, + environmentIds, + ); + const [openRows, setOpenRows] = useState>(new Set()); + const totalCostUsd = useMemo(() => rows.reduce((sum, row) => sum + row.costUsd, 0), [rows]); + + const toggleRow = (key: string) => { + setOpenRows((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + if (isPending) { + return ( +
+ {[56, 42, 68, 35].map((width) => ( +
+ ))} +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => { + const open = openRows.has(row.key); + const tokens = + row.totals.uncachedInputTokens + + row.totals.cachedInputTokens + + row.totals.cacheCreationTokens + + row.totals.outputTokens; + return ( + toggleRow(row.key)} + /> + ); + }) + )} + {truncatedRows > 0 ? ( + + + + ) : null} + {failedEnvironments > 0 ? ( + + + + ) : null} + +
ThreadCostShareTokens
+ No activity in this window. +
+ {truncatedRows === 1 + ? "1 more thread not shown." + : `${truncatedRows} more threads not shown.`} +
+ {failedEnvironments === 1 + ? "1 environment could not report threads." + : `${failedEnvironments} environments could not report threads.`} +
+ ); +} + +function ThreadRowGroup({ + row, + open, + tokens, + share, + sinceDay, + untilDay, + onToggle, +}: { + readonly row: UsageThreadRow; + readonly open: boolean; + readonly tokens: number; + readonly share: number; + readonly sinceDay: string; + readonly untilDay: string; + readonly onToggle: () => void; +}) { + const Chevron = open ? ChevronDownIcon : ChevronRightIcon; + return ( + <> + + + + + + + {row.title} + + {row.agents.length > 0 ? ( + + {row.agents.length === 1 ? "1 subagent" : `${row.agents.length} subagents`} + + ) : null} + + + {formatUsd(row.costUsd)} + + {`${(share * 100).toFixed(1)}%`} + + + {formatTokens(tokens)} + + + {open ? ( + + + + {row.agents.map((agent) => { + const agentTokens = + agent.totals.uncachedInputTokens + + agent.totals.cachedInputTokens + + agent.totals.cacheCreationTokens + + agent.totals.outputTokens; + return ( +
+ + + agent + + {agent.agentId} + + + {formatUsd(agent.costUsd)} ยท {formatTokens(agentTokens)} tokens + +
+ ); + })} + + + ) : null} + + ); +} + +const CHART_WIDTH = 760; +const CHART_HEIGHT = 96; + +/** + * One thread's daily estimated cost. Static SVG, no animation. + */ +export function UsageThreadDailyChart({ + daily, + sinceDay, + untilDay, +}: { + readonly daily: readonly UsageThreadDayCost[]; + readonly sinceDay: string; + readonly untilDay: string; +}) { + const days = useMemo(() => enumerateDays(sinceDay, untilDay), [sinceDay, untilDay]); + const byDay = useMemo( + () => new Map(daily.map((entry) => [entry.day, entry])), + [daily], + ); + const peak = daily.reduce((max, entry) => Math.max(max, entry.costUsd), 0); + + if (peak === 0 || days.length === 0) { + return

No priced usage in this window.

; + } + + const bandWidth = CHART_WIDTH / days.length; + const barWidth = Math.max(1, bandWidth - (days.length > 120 ? 0.5 : 2)); + + return ( +
+
+ + Daily cost, {formatDayShort(sinceDay)} to {formatDayShort(untilDay)} + +
+ + {days.map((day, index) => { + const entry = byDay.get(day); + if (entry === undefined) return null; + const x = index * bandWidth; + const height = (entry.costUsd / peak) * (CHART_HEIGHT - 4); + return ( + + {`${formatDayShort(day)}: ${formatUsd(entry.costUsd)}`} + + + ); + })} + +
+ ); +} + +function ProviderMark({ provider }: { readonly provider: UsageProviderKind }) { + const Mark = PROVIDER_PRESENTATION[provider].mark; + return ; +} diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 38048d30ddff..4192f4315c78 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -12,6 +12,8 @@ import { type EnvironmentId, type UsageSummary, type UsageSummaryInput, + type UsageThreadBreakdownInput, + type UsageThreadRow, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -142,3 +144,61 @@ export function useUsage( refresh, }; } + +export interface UsageThreadsView { + readonly rows: readonly UsageThreadRow[]; + readonly truncatedRows: number; + /** True until every listed environment answered or failed. */ + readonly isPending: boolean; + readonly failedEnvironments: number; +} + +const usageThreadsAtom = Atom.family((requestKey: string) => + Atom.make((get): UsageThreadsView => { + const { input, environmentIds } = JSON.parse(requestKey) as { + input: UsageThreadBreakdownInput; + environmentIds: readonly EnvironmentId[]; + }; + + const rows: UsageThreadRow[] = []; + // Environments sharing a transcript directory report the same sessions; + // row keys are derived from provider session ids, so first-in wins. + const seen = new Set(); + let truncatedRows = 0; + let pending = 0; + let failed = 0; + for (const environmentId of environmentIds) { + const result = get(serverEnvironment.usageThreadBreakdown({ environmentId, input })); + if (result.waiting) pending += 1; + if (result._tag === "Failure") failed += 1; + const breakdown = Option.getOrNull(AsyncResult.value(result)); + if (breakdown === null) continue; + truncatedRows += breakdown.truncatedRows; + for (const row of breakdown.rows) { + const dedupeKey = `${row.provider}\u0000${row.key}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + rows.push(row); + } + } + rows.sort((a, b) => b.costUsd - a.costUsd); + + return { rows, truncatedRows, isPending: pending > 0, failedEnvironments: failed }; + }).pipe(Atom.withLabel(`web-usage:threads:${requestKey}`)), +); + +/** + * Thread drill-down across the environments that contributed to the summary. + * Mount the consuming component only while the thread view is open; fetching + * starts on first read. + */ +export function useUsageThreads( + input: UsageThreadBreakdownInput, + environmentIds: readonly EnvironmentId[], +): UsageThreadsView { + const requestKey = useMemo( + () => JSON.stringify({ input, environmentIds }), + [input, environmentIds], + ); + return useAtomValue(usageThreadsAtom(requestKey)); +} diff --git a/docs/user/usage.md b/docs/user/usage.md index bd8dd7d1b00b..b4de1e5d7fd8 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,6 +15,11 @@ headline and chart, and refreshing rescans every connected environment. Any daily chart zooms: drag across it to make the selection the new date window, and double-click to return to the preset. The date fields beside the presets accept any custom range directly. +The breakdown's **Thread** view drills into where the spend went: sessions group into the T3 Code +thread they belong to, with sessions that never ran through T3 Code listed under the first thing +you asked in them. Expanding a row shows its daily estimated cost, along with any Claude subagents +the thread spawned and their share. + Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker narrows the whole page to one project; work that ran outside every project is grouped under diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 176b2631e0c5..5ee5d17b7f17 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -697,6 +697,13 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, }), + // Fetched only when the thread view is opened; scans are cache-warm after + // the summary, so a minute of staleness matches it. + usageThreadBreakdown: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:usage-thread-breakdown", + tag: WS_METHODS.serverGetUsageThreadBreakdown, + staleTimeMs: 60_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7cd674485f94..4efe0d7e0697 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -192,7 +192,13 @@ import { ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; -import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; +import { + UsageReadError, + UsageSummary, + UsageSummaryInput, + UsageThreadBreakdown, + UsageThreadBreakdownInput, +} from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { SourceControlCloneRepositoryInput, @@ -291,6 +297,7 @@ export const WS_METHODS = { serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + serverGetUsageThreadBreakdown: "server.getUsageThreadBreakdown", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -453,6 +460,15 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); +export const WsServerGetUsageThreadBreakdownRpc = Rpc.make( + WS_METHODS.serverGetUsageThreadBreakdown, + { + payload: UsageThreadBreakdownInput, + success: UsageThreadBreakdown, + error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), + }, +); + export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, @@ -1044,6 +1060,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, + WsServerGetUsageThreadBreakdownRpc, WsServerSignalProcessRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index a0763873616e..15a33dc172c7 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -14,23 +14,23 @@ */ import * as Schema from "effect/Schema"; -import { NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 8 as const; +export const USAGE_CONTRACT_VERSION = 9 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * * v5 only adds `grok` to {@link UsageProviderKind}; v6 adds the optional bucket * `project`; v7 adds its optional stable `projectId`; v8 distinguishes outside - * projects from unknown attribution. v4 Claude/Codex buckets remain valid, so - * mixed-version environments keep those totals instead of treating every - * older server as stale. + * projects from unknown attribution; v9 adds the separate thread-breakdown RPC. + * v4 Claude/Codex buckets remain valid, so mixed-version environments keep + * those totals instead of treating every older server as stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; /** First contract version that explicitly distinguishes outside from unknown attribution. */ @@ -222,6 +222,79 @@ export const UsageSummary = Schema.Struct({ }); export type UsageSummary = typeof UsageSummary.Type; +export const UsageThreadBreakdownInput = Schema.Struct({ + /** Inclusive first day of the window, in `timeZone`. */ + sinceDay: UsageDay, + /** Inclusive last day of the window, in `timeZone`. */ + untilDay: UsageDay, + timeZone: TrimmedNonEmptyString, + /** + * Restrict to one project's sessions: a title selects that project, `null` + * selects sessions outside every project, absent applies no filter. + */ + project: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); +export type UsageThreadBreakdownInput = typeof UsageThreadBreakdownInput.Type; + +/** One Claude subagent's slice of its parent thread. */ +export const UsageAgentRow = Schema.Struct({ + agentId: TrimmedNonEmptyString, + totals: UsageTokenTotals, + costUsd: Schema.Number, +}); +export type UsageAgentRow = typeof UsageAgentRow.Type; + +/** + * One day of a thread's estimated cost. Days the thread was idle are omitted. + * Unpriced records contribute tokens to the row totals but nothing here. + */ +export const UsageThreadDayCost = Schema.Struct({ + day: UsageDay, + costUsd: Schema.Number, +}); +export type UsageThreadDayCost = typeof UsageThreadDayCost.Type; + +/** + * One thread's (or unattributed session group's) slice of the window. + * + * `threadId` is present when the sessions map to a T3 Code thread on this + * environment, via the thread's resume cursor or its dedicated worktree. + * Sessions that never ran through T3 Code stay session-granular with a title + * taken from the transcript. + */ +export const UsageThreadRow = Schema.Struct({ + /** Stable within one environment; opaque to clients. */ + key: TrimmedNonEmptyString, + threadId: Schema.NullOr(ThreadId), + title: TrimmedNonEmptyString, + provider: UsageProviderKind, + project: Schema.optional(TrimmedNonEmptyString), + totals: UsageTokenTotals, + costUsd: Schema.Number, + /** Distinct transcript sessions folded into this row. */ + sessions: NonNegativeInt, + agents: Schema.Array(UsageAgentRow), + daily: Schema.Array(UsageThreadDayCost), +}); +export type UsageThreadRow = typeof UsageThreadRow.Type; + +/** + * On-demand drill-down behind the usage summary. Rows are capped server-side + * (cost-descending) because a window can hold thousands of sessions and this + * payload rides the same WebSocket as everything else. + */ +export const UsageThreadBreakdown = Schema.Struct({ + contractVersion: Schema.Number, + readAt: Schema.String, + sinceDay: UsageDay, + untilDay: UsageDay, + rows: Schema.Array(UsageThreadRow), + /** Rows dropped by the cap, so the UI can say coverage is partial. */ + truncatedRows: NonNegativeInt, + scanDurationMs: NonNegativeInt, +}); +export type UsageThreadBreakdown = typeof UsageThreadBreakdown.Type; + export class UsageReadError extends Schema.TaggedErrorClass()("UsageReadError", { reason: Schema.Literals(["scanFailed", "invalidWindow"]), /** Stable, bounded description. The underlying failure travels in `cause`. */ From 0331e79946c405e7643ca5de891b695875959364 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 28 Aug 2026 21:42:03 +1000 Subject: [PATCH 11/78] fix(usage): reconcile thread drill-down totals --- apps/server/src/usage/UsageService.ts | 7 +- apps/server/src/usage/usageThreads.test.ts | 112 +++++++++++++++++- apps/server/src/usage/usageThreads.ts | 71 +++++++++-- .../src/components/usage/UsagePage.test.tsx | 25 +++- apps/web/src/components/usage/UsagePage.tsx | 2 +- .../src/components/usage/UsageThreadTable.tsx | 24 ++-- apps/web/src/state/usage.test.ts | 82 +++++++++++++ apps/web/src/state/usage.ts | 73 ++++++++---- docs/user/usage.md | 3 + packages/contracts/src/usage.ts | 11 +- packages/shared/src/usageMerge.test.ts | 11 ++ packages/shared/src/usageMerge.ts | 20 +++- 12 files changed, 386 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/state/usage.test.ts diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 17bd8bca913a..0ce3adf8a3f4 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -82,8 +82,8 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; const CACHE_RETENTION_DAYS = 90; /** - * Thread rows sent per breakdown request. A window can hold thousands of - * sessions; everything past the cap is counted, not shipped. + * Named thread rows sent per breakdown request. A window can hold thousands + * of sessions; lower-cost rows fold into provider/project remainders. */ const THREAD_ROW_CAP = 40; @@ -760,7 +760,8 @@ export const make = Effect.gen(function* () { ...(input.project === undefined ? {} : { projectFilter: input.project }), }); - // Transcript titles only for unattributed rows that survived the cap. + // Transcript titles only for retained unattributed rows. Grouped remainder + // rows already carry a generated title. const rows = yield* Effect.forEach( folded.rows, Effect.fnUntraced(function* ({ titleSessionKey, ...row }) { diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index 1ebcf19f8e15..f299083c3ec4 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -1,6 +1,7 @@ import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import { UsageAggregator } from "./usageAggregation.ts"; import type { RateTable } from "./usagePricing.ts"; import { foldThreadRows, ThreadUsageAccumulator, type ThreadAttribution } from "./usageThreads.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -131,7 +132,61 @@ describe("foldThreadRows", () => { expect(standalone?.key).toBe("session:claude:session-c"); }); - it("caps rows by cost and counts the rest", () => { + it("scopes one T3 thread by provider and project", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd.endsWith("one") ? "Project one" : "Project two"), + }); + const entries = [ + [record({ sessionId: "claude-one", cwd: "/work/one" }), "claude:claude-one"], + [record({ sessionId: "claude-two", cwd: "/work/two" }), "claude:claude-two"], + [ + record({ + provider: "codex", + model: "gpt-5.6-sol", + sessionId: "codex-one", + cwd: "/work/one", + }), + "codex:codex-one", + ], + ] as const; + for (const [item, sessionKey] of entries) { + accumulator.add(item, { sessionKey, agentId: null }); + } + const attribution: ThreadAttribution = { + sessionToThread: new Map( + entries.map(([, sessionKey]) => [sessionKey, { threadId, title: "Shared thread" }]), + ), + worktreeToThread: new Map(), + }; + + const { rows } = foldThreadRows(accumulator.finish(), attribution, { cap: 40 }); + + expect(rows).toHaveLength(3); + expect(rows.map((row) => [row.provider, row.project]).toSorted()).toEqual([ + ["claude", "Project one"], + ["claude", "Project two"], + ["codex", "Project one"], + ]); + expect(new Set(rows.map((row) => row.key)).size).toBe(3); + expect(rows.every((row) => row.threadId === threadId && row.title === "Shared thread")).toBe( + true, + ); + + const projectOne = foldThreadRows(accumulator.finish(), attribution, { + cap: 40, + projectFilter: "Project one", + }); + expect(projectOne.rows.map((row) => [row.provider, row.project]).toSorted()).toEqual([ + ["claude", "Project one"], + ["codex", "Project one"], + ]); + }); + + it("groups rows past the cap without losing their usage", () => { const groups = accumulate( Array.from({ length: 5 }, (_, index) => [ record({ sessionId: `session-${index}` }), @@ -141,8 +196,61 @@ describe("foldThreadRows", () => { const { rows, truncatedRows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 3 }); - expect(rows).toHaveLength(3); + expect(rows).toHaveLength(4); expect(truncatedRows).toBe(2); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.title).toBe("Other threads (2)"); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.groupedRows).toBe(2); + expect(rows.reduce((sum, row) => sum + row.totals.outputTokens, 0)).toBe(250); + }); + + it("reconciles every provider and project after lower-cost rows are grouped", () => { + const resolveProject = (cwd: string) => (cwd.endsWith("one") ? "Project one" : "Project two"); + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject, + }); + const summary = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + resolution: "day", + rates, + resolveProject, + }); + for (const [index, provider, project] of [ + [0, "claude", "one"], + [1, "claude", "one"], + [2, "claude", "two"], + [3, "codex", "one"], + [4, "codex", "two"], + ] as const) { + const item = record({ + provider, + model: provider === "claude" ? "claude-fable-5" : "gpt-5.6-sol", + sessionId: `session-${index}`, + cwd: `/work/${project}`, + }); + accumulator.add(item, { sessionKey: `${provider}:session-${index}`, agentId: null }); + summary.add(item); + } + const groups = accumulator.finish(); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 1 }); + const expected = new Map(); + for (const bucket of summary.finish().buckets) { + const key = `${bucket.provider}:${bucket.project ?? ""}`; + expected.set(key, (expected.get(key) ?? 0) + bucket.totals.outputTokens); + } + const actual = new Map(); + for (const row of rows) { + const key = `${row.provider}:${row.project ?? ""}`; + actual.set(key, (actual.get(key) ?? 0) + row.totals.outputTokens); + } + + expect(actual).toEqual(expected); }); it("filters by project before capping", () => { diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 625689f25c97..9905d1f55acb 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -181,6 +181,7 @@ interface MutableThreadRow { totals: UsageTokenTotals; costUsd: number; sessions: number; + groupedRows: number; daily: Map; agents: Map; /** Session whose transcript can supply a title when no thread claims the row. */ @@ -195,11 +196,18 @@ export interface FoldedThreadRows { readonly truncatedRows: number; } +function addDailyCosts(target: Map, source: ReadonlyMap): void { + for (const [day, costUsd] of source) { + target.set(day, (target.get(day) ?? 0) + costUsd); + } +} + /** * Groups sessions into thread rows: resume-cursor matches first, then unique - * worktrees, else one row per session. Rows sort by cost and cap; a `null` - * title marks rows whose name must come from the transcript (the caller only - * reads titles for rows that survived the cap). + * worktrees, else one row per session. Rows sort by cost. Rows beyond the cap + * fold into provider/project-specific remainders so the returned hierarchy + * still reconciles. A `null` title marks retained rows whose name must come + * from the transcript. */ export function foldThreadRows( groups: readonly SessionUsageGroup[], @@ -217,7 +225,10 @@ export function foldThreadRows( const ref = attribution.sessionToThread.get(group.sessionKey) ?? (group.cwd.length > 0 ? attribution.worktreeToThread.get(group.cwd) : undefined); - const rowKey = ref === undefined ? `session:${group.sessionKey}` : `thread:${ref.threadId}`; + const rowKey = + ref === undefined + ? `session:${group.sessionKey}` + : JSON.stringify(["thread", group.provider, group.project, ref.threadId]); let row = byKey.get(rowKey); if (row === undefined) { @@ -230,6 +241,7 @@ export function foldThreadRows( totals: EMPTY_TOTALS, costUsd: 0, sessions: 0, + groupedRows: 0, daily: new Map(), agents: new Map(), titleSessionKey: group.sessionKey, @@ -240,9 +252,7 @@ export function foldThreadRows( row.totals = addTotals(row.totals, group.totals); row.costUsd += group.costUsd; row.sessions += 1; - for (const [day, costUsd] of group.daily) { - row.daily.set(day, (row.daily.get(day) ?? 0) + costUsd); - } + addDailyCosts(row.daily, group.daily); for (const [agentId, slice] of group.agents) { let agent = row.agents.get(agentId); if (agent === undefined) { @@ -261,9 +271,51 @@ export function foldThreadRows( a[0].localeCompare(b[0]), ); const kept = sorted.slice(0, options.cap); + const omitted = sorted.slice(options.cap); + const remainders = new Map(); + for (const [, omittedRow] of omitted) { + const scopeKey = JSON.stringify([omittedRow.provider, omittedRow.project]); + let remainder = remainders.get(scopeKey); + if (remainder === undefined) { + const key = `remainder:${scopeKey}`; + remainder = { + threadId: null, + title: null, + provider: omittedRow.provider, + project: omittedRow.project, + cwd: "", + totals: EMPTY_TOTALS, + costUsd: 0, + sessions: 0, + groupedRows: 0, + daily: new Map(), + agents: new Map(), + titleSessionKey: key, + }; + remainders.set(scopeKey, remainder); + } + remainder.groupedRows += 1; + remainder.totals = addTotals(remainder.totals, omittedRow.totals); + remainder.costUsd += omittedRow.costUsd; + remainder.sessions += omittedRow.sessions; + addDailyCosts(remainder.daily, omittedRow.daily); + } + + const displayed = [ + ...kept, + ...[...remainders.entries()].map(([scopeKey, remainder]) => { + remainder.title = `Other threads (${remainder.groupedRows})`; + return [`remainder:${scopeKey}`, remainder] as const; + }), + ].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); return { - rows: kept.map(([key, row]) => ({ + rows: displayed.map(([key, row]) => ({ key, threadId: row.threadId, title: row.title, @@ -273,6 +325,7 @@ export function foldThreadRows( totals: row.totals, costUsd: row.costUsd, sessions: row.sessions, + ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), agents: [...row.agents.entries()] .map(([agentId, slice]) => ({ agentId, @@ -287,7 +340,7 @@ export function foldThreadRows( })) .sort((a, b) => a.day.localeCompare(b.day)) satisfies UsageThreadDayCost[], })), - truncatedRows: sorted.length - kept.length, + truncatedRows: omitted.length, }; } diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index a8e55c02ab8c..f8c4b553d435 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -5,8 +5,9 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), + usageThreadTable: vi.fn((_props: unknown) => null), metric: "cost" as "cost" | "tokens", - breakdown: "time" as "model" | "project" | "time", + breakdown: "time" as "model" | "project" | "thread" | "time", projectFilter: undefined as string | null | undefined, })); @@ -61,6 +62,7 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsageThreadTable", () => ({ UsageThreadTable: testState.usageThreadTable })); vi.mock("./usageProviders", async (importOriginal) => { const actual = await importOriginal(); return { @@ -132,6 +134,7 @@ beforeEach(() => { testState.metric = "cost"; testState.breakdown = "time"; testState.projectFilter = undefined; + testState.usageThreadTable.mockClear(); testState.useUsage.mockReturnValue({ merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), @@ -254,6 +257,26 @@ describe("UsagePage project breakdown", () => { }); }); +describe("UsagePage thread breakdown", () => { + it("requests thread rows in the selected project scope", () => { + testState.breakdown = "thread"; + testState.projectFilter = "id:project-expensive"; + + renderToStaticMarkup(); + + expect(testState.usageThreadTable).toHaveBeenCalledOnce(); + expect(testState.usageThreadTable.mock.calls[0]?.[0]).toMatchObject({ + input: { + sinceDay: "2026-08-10", + untilDay: "2026-08-11", + timeZone: "UTC", + project: "id:project-expensive", + }, + providerContributions: [], + }); + }); +}); + describe("UsagePage model breakdown", () => { it("sorts models by cost when the cost metric is selected", () => { testState.breakdown = "model"; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index a7d84f9f3c2e..fcbadb740aa4 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -468,7 +468,7 @@ export function UsagePage() { timeZone: window.timeZone, ...(projectFilter === undefined ? {} : { project: projectFilter }), }} - environmentIds={merged.contributingEnvironments} + providerContributions={merged.providerContributions} /> ) : breakdown === "project" ? ( diff --git a/apps/web/src/components/usage/UsageThreadTable.tsx b/apps/web/src/components/usage/UsageThreadTable.tsx index 3b2723fad19c..0d0f870b38f2 100644 --- a/apps/web/src/components/usage/UsageThreadTable.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.tsx @@ -1,9 +1,7 @@ import type { - EnvironmentId, UsageProviderKind, UsageThreadBreakdownInput, UsageThreadDayCost, - UsageThreadRow, } from "@t3tools/contracts"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -14,8 +12,9 @@ import { formatTokens, formatUsd, } from "@t3tools/shared/usageFormat"; +import type { EnvironmentProviderContribution } from "@t3tools/shared/usageMerge"; -import { useUsageThreads } from "../../state/usage"; +import { useUsageThreads, type UsageThreadRowWithEnvironment } from "../../state/usage"; import { PROVIDER_PRESENTATION } from "./usageProviders"; /** @@ -24,14 +23,14 @@ import { PROVIDER_PRESENTATION } from "./usageProviders"; */ export function UsageThreadTable({ input, - environmentIds, + providerContributions, }: { readonly input: UsageThreadBreakdownInput; - readonly environmentIds: readonly EnvironmentId[]; + readonly providerContributions: readonly EnvironmentProviderContribution[]; }) { const { rows, truncatedRows, isPending, failedEnvironments } = useUsageThreads( input, - environmentIds, + providerContributions, ); const [openRows, setOpenRows] = useState>(new Set()); const totalCostUsd = useMemo(() => rows.reduce((sum, row) => sum + row.costUsd, 0), [rows]); @@ -80,7 +79,8 @@ export function UsageThreadTable({ ) : ( rows.map((row) => { - const open = openRows.has(row.key); + const viewKey = `${row.environmentId}\u0000${row.key}`; + const open = openRows.has(viewKey); const tokens = row.totals.uncachedInputTokens + row.totals.cachedInputTokens + @@ -88,14 +88,14 @@ export function UsageThreadTable({ row.totals.outputTokens; return ( toggleRow(row.key)} + onToggle={() => toggleRow(viewKey)} /> ); }) @@ -104,8 +104,8 @@ export function UsageThreadTable({ ) : null} @@ -132,7 +132,7 @@ function ThreadRowGroup({ untilDay, onToggle, }: { - readonly row: UsageThreadRow; + readonly row: UsageThreadRowWithEnvironment; readonly open: boolean; readonly tokens: number; readonly share: number; diff --git a/apps/web/src/state/usage.test.ts b/apps/web/src/state/usage.test.ts new file mode 100644 index 000000000000..973b737cac3c --- /dev/null +++ b/apps/web/src/state/usage.test.ts @@ -0,0 +1,82 @@ +import type { + EnvironmentId, + UsageDay, + UsageProviderKind, + UsageThreadBreakdown, + UsageThreadRow, +} from "@t3tools/contracts"; +import type { EnvironmentProviderContribution } from "@t3tools/shared/usageMerge"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeUsageThreadBreakdowns } from "./usage"; + +function row(provider: UsageProviderKind, overrides: Partial = {}): UsageThreadRow { + return { + key: "same-key", + threadId: null, + title: `${provider} row`, + provider, + project: "App", + totals: { + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 30, + outputTokens: 40, + reasoningTokens: 5, + }, + costUsd: 1, + sessions: 1, + agents: [], + daily: [], + ...overrides, + }; +} + +function breakdown(rows: readonly UsageThreadRow[]): UsageThreadBreakdown { + return { + contractVersion: 7, + readAt: "2026-08-28T01:15:00.000Z", + sinceDay: "2026-08-01" as UsageDay, + untilDay: "2026-08-28" as UsageDay, + rows, + truncatedRows: rows.reduce((sum, item) => sum + (item.groupedRows ?? 0), 0), + scanDurationMs: 1, + }; +} + +describe("mergeUsageThreadBreakdowns", () => { + it("uses the summary's physical-source owner for each provider", () => { + const environmentA = "env-a" as EnvironmentId; + const environmentB = "env-b" as EnvironmentId; + const contributions: readonly EnvironmentProviderContribution[] = [ + { environmentId: environmentA, providers: ["claude"] }, + { environmentId: environmentB, providers: ["codex"] }, + ]; + const merged = mergeUsageThreadBreakdowns( + [ + { + environmentId: environmentA, + breakdown: breakdown([ + row("claude", { groupedRows: 2 }), + row("codex", { groupedRows: 7 }), + ]), + }, + { + environmentId: environmentB, + breakdown: breakdown([ + row("claude", { groupedRows: 11 }), + row("codex", { groupedRows: 3 }), + ]), + }, + ], + contributions, + ); + + expect(merged.rows.map((item) => [item.provider, item.environmentId])).toEqual([ + ["claude", environmentA], + ["codex", environmentB], + ]); + expect(merged.rows.reduce((sum, item) => sum + item.costUsd, 0)).toBe(2); + expect(merged.truncatedRows).toBe(5); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 4192f4315c78..88c9868e7161 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -12,6 +12,7 @@ import { type EnvironmentId, type UsageSummary, type UsageSummaryInput, + type UsageThreadBreakdown, type UsageThreadBreakdownInput, type UsageThreadRow, } from "@t3tools/contracts"; @@ -19,7 +20,12 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; -import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import { + mergeUsage, + type EnvironmentProviderContribution, + type EnvironmentUsage, + type MergedUsage, +} from "@t3tools/shared/usageMerge"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; @@ -145,45 +151,68 @@ export function useUsage( }; } +export interface UsageThreadRowWithEnvironment extends UsageThreadRow { + readonly environmentId: EnvironmentId; +} + export interface UsageThreadsView { - readonly rows: readonly UsageThreadRow[]; + readonly rows: readonly UsageThreadRowWithEnvironment[]; readonly truncatedRows: number; /** True until every listed environment answered or failed. */ readonly isPending: boolean; readonly failedEnvironments: number; } +export interface EnvironmentUsageThreadBreakdown { + readonly environmentId: EnvironmentId; + readonly breakdown: UsageThreadBreakdown; +} + +/** Applies the summary's physical-source ownership to thread rows. */ +export function mergeUsageThreadBreakdowns( + environments: readonly EnvironmentUsageThreadBreakdown[], + providerContributions: readonly EnvironmentProviderContribution[], +): Pick { + const providersByEnvironment = new Map( + providerContributions.map((entry) => [entry.environmentId, new Set(entry.providers)]), + ); + const rows: UsageThreadRowWithEnvironment[] = []; + let truncatedRows = 0; + + for (const environment of environments) { + const ownedProviders = providersByEnvironment.get(environment.environmentId); + if (ownedProviders === undefined) continue; + for (const row of environment.breakdown.rows) { + if (!ownedProviders.has(row.provider)) continue; + rows.push({ ...row, environmentId: environment.environmentId }); + truncatedRows += row.groupedRows ?? 0; + } + } + rows.sort((a, b) => b.costUsd - a.costUsd); + return { rows, truncatedRows }; +} + const usageThreadsAtom = Atom.family((requestKey: string) => Atom.make((get): UsageThreadsView => { - const { input, environmentIds } = JSON.parse(requestKey) as { + const { input, providerContributions } = JSON.parse(requestKey) as { input: UsageThreadBreakdownInput; - environmentIds: readonly EnvironmentId[]; + providerContributions: readonly EnvironmentProviderContribution[]; }; - const rows: UsageThreadRow[] = []; - // Environments sharing a transcript directory report the same sessions; - // row keys are derived from provider session ids, so first-in wins. - const seen = new Set(); - let truncatedRows = 0; + const breakdowns: EnvironmentUsageThreadBreakdown[] = []; let pending = 0; let failed = 0; - for (const environmentId of environmentIds) { + for (const { environmentId } of providerContributions) { const result = get(serverEnvironment.usageThreadBreakdown({ environmentId, input })); if (result.waiting) pending += 1; if (result._tag === "Failure") failed += 1; const breakdown = Option.getOrNull(AsyncResult.value(result)); if (breakdown === null) continue; - truncatedRows += breakdown.truncatedRows; - for (const row of breakdown.rows) { - const dedupeKey = `${row.provider}\u0000${row.key}`; - if (seen.has(dedupeKey)) continue; - seen.add(dedupeKey); - rows.push(row); - } + breakdowns.push({ environmentId, breakdown }); } - rows.sort((a, b) => b.costUsd - a.costUsd); + const merged = mergeUsageThreadBreakdowns(breakdowns, providerContributions); - return { rows, truncatedRows, isPending: pending > 0, failedEnvironments: failed }; + return { ...merged, isPending: pending > 0, failedEnvironments: failed }; }).pipe(Atom.withLabel(`web-usage:threads:${requestKey}`)), ); @@ -194,11 +223,11 @@ const usageThreadsAtom = Atom.family((requestKey: string) => */ export function useUsageThreads( input: UsageThreadBreakdownInput, - environmentIds: readonly EnvironmentId[], + providerContributions: readonly EnvironmentProviderContribution[], ): UsageThreadsView { const requestKey = useMemo( - () => JSON.stringify({ input, environmentIds }), - [input, environmentIds], + () => JSON.stringify({ input, providerContributions }), + [input, providerContributions], ); return useAtomValue(usageThreadsAtom(requestKey)); } diff --git a/docs/user/usage.md b/docs/user/usage.md index b4de1e5d7fd8..8e2707feca9b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -19,6 +19,9 @@ The breakdown's **Thread** view drills into where the spend went: sessions group thread they belong to, with sessions that never ran through T3 Code listed under the first thing you asked in them. Expanding a row shows its daily estimated cost, along with any Claude subagents the thread spawned and their share. +The view names the 40 highest-cost rows and groups lower-cost rows under **Other threads** by +provider and project. Those grouped rows stay in the totals, so the thread view still adds up to +the selected project or full summary. Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 15a33dc172c7..dfd0922605f4 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -273,15 +273,18 @@ export const UsageThreadRow = Schema.Struct({ costUsd: Schema.Number, /** Distinct transcript sessions folded into this row. */ sessions: NonNegativeInt, + /** Lower-cost thread rows represented by this grouped remainder row. */ + groupedRows: Schema.optional(NonNegativeInt), agents: Schema.Array(UsageAgentRow), daily: Schema.Array(UsageThreadDayCost), }); export type UsageThreadRow = typeof UsageThreadRow.Type; /** - * On-demand drill-down behind the usage summary. Rows are capped server-side - * (cost-descending) because a window can hold thousands of sessions and this - * payload rides the same WebSocket as everything else. + * On-demand drill-down behind the usage summary. Named rows are capped + * server-side because a window can hold thousands of sessions. Lower-cost + * rows fold into provider/project-specific remainder rows so totals reconcile + * without sending every transcript session over the WebSocket. */ export const UsageThreadBreakdown = Schema.Struct({ contractVersion: Schema.Number, @@ -289,7 +292,7 @@ export const UsageThreadBreakdown = Schema.Struct({ sinceDay: UsageDay, untilDay: UsageDay, rows: Schema.Array(UsageThreadRow), - /** Rows dropped by the cap, so the UI can say coverage is partial. */ + /** Underlying rows folded into the returned remainder rows. */ truncatedRows: NonNegativeInt, scanDurationMs: NonNegativeInt, }); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index a522ba7be553..f855a78bbbbf 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -96,6 +96,10 @@ describe("mergeUsage", () => { expect(merged.costUsd).toBe(20); expect(merged.records).toBe(10); expect(merged.duplicateSources).toHaveLength(0); + expect(merged.providerContributions).toEqual([ + { environmentId: "env-a", providers: ["claude"] }, + { environmentId: "env-b", providers: ["claude"] }, + ]); }); it("counts a shared transcript directory once", () => { @@ -114,6 +118,9 @@ describe("mergeUsage", () => { expect(merged.sessions).toBe(1); expect(merged.duplicateSources).toHaveLength(1); expect(merged.contributingEnvironments).toEqual(["env-a"]); + expect(merged.providerContributions).toEqual([ + { environmentId: "env-a", providers: ["claude"] }, + ]); }); it("drops only the duplicated provider, keeping the environment's other one", () => { @@ -148,6 +155,10 @@ describe("mergeUsage", () => { merged.providers.map((provider) => [provider.provider, provider.sessions]), ), ).toEqual({ claude: 1, codex: 1 }); + expect(merged.providerContributions).toEqual([ + { environmentId: "env-a", providers: ["claude"] }, + { environmentId: "env-b", providers: ["codex"] }, + ]); }); it("excludes an environment reporting an older contract version", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 68cf53858872..483996ea6f30 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -75,6 +75,11 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } +export interface EnvironmentProviderContribution { + readonly environmentId: EnvironmentId; + readonly providers: readonly UsageProviderKind[]; +} + export interface MergedUsage { readonly costUsd: number; readonly uncachedInputTokens: number; @@ -98,6 +103,8 @@ export interface MergedUsage { /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; + /** Provider rows this environment owns after physical-source de-duplication. */ + readonly providerContributions: readonly EnvironmentProviderContribution[]; readonly staleEnvironments: readonly EnvironmentId[]; } @@ -218,6 +225,7 @@ const EMPTY_MERGED: MergedUsage = { }, duplicateSources: [], contributingEnvironments: [], + providerContributions: [], staleEnvironments: [], }; @@ -347,10 +355,17 @@ export function mergeUsage( } >(); const contributingEnvironments: EnvironmentId[] = []; + const providerContributions: EnvironmentProviderContribution[] = []; for (const environment of current) { const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); - if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); + if (buckets.length > 0) { + contributingEnvironments.push(environment.environmentId); + providerContributions.push({ + environmentId: environment.environmentId, + providers: [...new Set(buckets.map((bucket) => bucket.provider))].sort(), + }); + } // Session counts are per source directory; a project filter cannot split // them, so a filtered merge leaves every session figure at 0. @@ -543,6 +558,9 @@ export function mergeUsage( }, duplicateSources: duplicates, contributingEnvironments, + providerContributions: providerContributions.sort((a, b) => + a.environmentId.localeCompare(b.environmentId), + ), staleEnvironments, }; } From 36fa10f4234c8dffa7077f8cc8f09551c45ac283 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 20:27:42 +1000 Subject: [PATCH 12/78] fix(usage): reconcile thread drill-down exactly --- apps/server/src/server.ts | 3 +- apps/server/src/usage/UsageService.test.ts | 17 +++ apps/server/src/usage/UsageService.ts | 74 ++++++++--- apps/server/src/usage/usageThreads.test.ts | 115 ++++++++++++++--- apps/server/src/usage/usageThreads.ts | 118 +++++++++++++----- .../src/usage/usageTranscriptReader.test.ts | 32 ++++- .../server/src/usage/usageTranscriptReader.ts | 36 ++++-- .../src/components/usage/UsagePage.test.tsx | 4 +- apps/web/src/components/usage/UsagePage.tsx | 4 +- .../usage/UsageThreadTable.test.tsx | 93 ++++++++++++++ .../src/components/usage/UsageThreadTable.tsx | 59 +++++---- apps/web/src/state/usage.test.ts | 29 ++++- apps/web/src/state/usage.ts | 66 +++++++--- docs/user/usage.md | 12 +- packages/contracts/src/usage.ts | 14 ++- 15 files changed, 550 insertions(+), 126 deletions(-) create mode 100644 apps/web/src/components/usage/UsageThreadTable.test.tsx diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 120da1bce440..d5b0b3b9280e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -28,7 +28,6 @@ import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderR import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { ProjectionProjectRepositoryLive } from "./persistence/Layers/ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "./persistence/Layers/ProjectionThreads.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; @@ -186,7 +185,7 @@ const UsageLayerLive = UsageService.layer.pipe( // resume cursors attribute sessions to threads for the drill-down. Layer.provide(ProjectionProjectRepositoryLive), Layer.provide(ProjectionThreadRepositoryLive), - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), Layer.provide(ServerSettingsLayerLive), ); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 3e891159ab8d..6991cb1867d2 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -232,3 +232,20 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); }); + +describe("isValidUsageDay", () => { + it("rejects impossible start and end dates instead of normalising them", () => { + assert.isTrue(UsageService.isValidUsageDay("2026-02-28")); + assert.isFalse(UsageService.isValidUsageDay("2026-02-29")); + assert.isFalse(UsageService.isValidUsageDay("2026-13-01")); + }); +}); + +describe("shortSessionLabel", () => { + it("never exposes a file-derived path", () => { + assert.strictEqual( + UsageService.shortSessionLabel("claude:file:session-dir:updates"), + "Untitled session", + ); + }); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0ce3adf8a3f4..756b3c1fb178 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -16,7 +16,6 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, - type ThreadId, type UsageProviderKind, type UsageSource, type UsageSummary, @@ -43,7 +42,7 @@ import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../persistence/Services/ProjectionThreads.ts"; -import { ProviderSessionRuntimeRepository } from "../persistence/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -82,8 +81,8 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; const CACHE_RETENTION_DAYS = 90; /** - * Named thread rows sent per breakdown request. A window can hold thousands - * of sessions; lower-cost rows fold into provider/project remainders. + * Maximum rows sent per breakdown request, including grouped remainders. A + * window can hold thousands of sessions, so lower-cost rows fold together. */ const THREAD_ROW_CAP = 40; @@ -104,6 +103,11 @@ const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema. const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +export function isValidUsageDay(day: string): boolean { + const parsed = DateTime.make(`${day}T00:00:00Z`); + return Option.isSome(parsed) && DateTime.formatIso(parsed.value).slice(0, 10) === day; +} + export class UsageService extends Context.Service< UsageService, { @@ -157,7 +161,7 @@ export const make = Effect.gen(function* () { const hostEnvironment = yield* HostProcessEnvironment; const projectRepository = yield* ProjectionProjectRepository; const threadRepository = yield* ProjectionThreadRepository; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -696,24 +700,55 @@ export const make = Effect.gen(function* () { }); } const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); - if (Option.isNone(windowStart)) { + const windowEnd = DateTime.make(`${input.untilDay}T00:00:00Z`); + if ( + Option.isNone(windowStart) || + Option.isNone(windowEnd) || + !isValidUsageDay(input.sinceDay) || + !isValidUsageDay(input.untilDay) + ) { return yield* new UsageReadError({ reason: "invalidWindow", - detail: `sinceDay '${input.sinceDay}' is not a valid date`, + detail: "Thread usage requires valid sinceDay and untilDay dates", }); } + let exactWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null = null; + if (input.sinceTime !== undefined || input.untilTime !== undefined) { + const sinceTime = + input.sinceTime === undefined ? Option.none() : DateTime.make(input.sinceTime); + const untilTime = + input.untilTime === undefined ? Option.none() : DateTime.make(input.untilTime); + if (Option.isNone(sinceTime) || Option.isNone(untilTime)) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage requires both valid sinceTime and untilTime instants", + }); + } + const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); + const untilTimeMs = DateTime.toEpochMillis(untilTime.value); + if (untilTimeMs <= sinceTimeMs) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Thread usage untilTime must be after sinceTime", + }); + } + exactWindow = { sinceTimeMs, untilTimeMs }; + } + const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureRates(); yield* ensureScanCacheLoaded; const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); - const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS; + const windowStartMs = + (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; const accumulator = new ThreadUsageAccumulator({ timeZone: input.timeZone, sinceDay: input.sinceDay, untilDay: input.untilDay, + ...exactWindow, rates, resolveProject: yield* resolveProjects(), }); @@ -726,6 +761,7 @@ export const make = Effect.gen(function* () { >(); for (const { provider, dir, fileName } of dirs) { + if (input.providers !== undefined && !input.providers.includes(provider)) continue; const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); @@ -744,11 +780,10 @@ export const make = Effect.gen(function* () { const sessionKey = record.sessionId.length > 0 ? `${provider}:${record.sessionId}` - : `${provider}:file:${file.path}`; - if (accumulator.add(record, { sessionKey, agentId }) && !isSubagent) { - if (!titleFiles.has(sessionKey)) { - titleFiles.set(sessionKey, { path: file.path, provider }); - } + : `${provider}:file:${path.basename(path.dirname(file.path))}:${path.basename(file.path, ".jsonl")}`; + accumulator.add(record, { sessionKey, agentId }); + if (!isSubagent && !titleFiles.has(sessionKey)) { + titleFiles.set(sessionKey, { path: file.path, provider }); } } } @@ -757,7 +792,7 @@ export const make = Effect.gen(function* () { const attribution = yield* loadThreadAttribution(); const folded = foldThreadRows(accumulator.finish(), attribution, { cap: THREAD_ROW_CAP, - ...(input.project === undefined ? {} : { projectFilter: input.project }), + ...(input.projectKey === undefined ? {} : { projectFilter: input.projectKey }), }); // Transcript titles only for retained unattributed rows. Grouped remainder @@ -771,7 +806,9 @@ export const make = Effect.gen(function* () { source === undefined ? null : yield* Effect.promise(() => readTranscriptTitle(source.path, source.provider)); - const fallback = row.key.startsWith("session:") ? shortSessionLabel(row.key) : row.key; + const fallback = row.key.startsWith("remainder:") + ? row.key + : shortSessionLabel(titleSessionKey); return { ...row, title: transcriptTitle ?? fallback }; }), { concurrency: 8 }, @@ -793,9 +830,10 @@ export const make = Effect.gen(function* () { return { readSummary, readThreadBreakdown } as const; }); -/** `session:claude:8f14e45f-...` reads as `Session 8f14e45f`. */ -function shortSessionLabel(rowKey: string): string { - const sessionId = rowKey.slice(rowKey.lastIndexOf(":") + 1); +/** `claude:8f14e45f-...` reads as `Session 8f14e45f`. */ +export function shortSessionLabel(sessionKey: string): string { + if (sessionKey.includes(":file:")) return "Untitled session"; + const sessionId = sessionKey.slice(sessionKey.lastIndexOf(":") + 1); return sessionId.length > 8 ? `Session ${sessionId.slice(0, 8)}` : `Session ${sessionId}`; } diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index f299083c3ec4..ff17b062c81c 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -1,4 +1,4 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { UsageAggregator } from "./usageAggregation.ts"; @@ -18,6 +18,9 @@ const rates: RateTable = new Map([ ], ]); +const PROJECT_ONE = { projectId: ProjectId.make("project-one"), title: "Project one" }; +const PROJECT_TWO = { projectId: ProjectId.make("project-two"), title: "Project two" }; + function record(overrides: Partial = {}): UsageRecord { return { provider: "claude", @@ -98,6 +101,43 @@ describe("ThreadUsageAccumulator", () => { expect(groups).toHaveLength(0); }); + + it("applies exact time bounds inside a shared calendar day", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-07", + untilDay: "2026-08-07", + sinceTimeMs: Date.parse("2026-08-07T04:00:00Z"), + untilTimeMs: Date.parse("2026-08-07T05:00:00Z"), + rates, + }); + const context = { sessionKey: "claude:session-a", agentId: null }; + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T03:59:59Z") }), context); + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T04:30:00Z") }), context); + accumulator.add(record({ timestampMs: Date.parse("2026-08-07T05:00:00Z") }), context); + + expect(accumulator.finish()[0]?.totals.outputTokens).toBe(50); + }); + + it("keeps separate cwd slices when one session crosses projects", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO), + }); + const context = { sessionKey: "claude:session-a", agentId: null }; + accumulator.add(record({ cwd: "/work/one" }), context); + accumulator.add(record({ cwd: "/work/two" }), context); + + expect( + accumulator + .finish() + .map((group) => group.projectKey) + .toSorted(), + ).toEqual(["id:project-one", "id:project-two"]); + }); }); describe("foldThreadRows", () => { @@ -129,7 +169,7 @@ describe("foldThreadRows", () => { const standalone = rows.find((row) => row.threadId === null); // Standalone rows leave the title to the caller's transcript read. expect(standalone?.title).toBeNull(); - expect(standalone?.key).toBe("session:claude:session-c"); + expect(standalone?.key).toContain("claude:session-c"); }); it("scopes one T3 thread by provider and project", () => { @@ -138,7 +178,7 @@ describe("foldThreadRows", () => { sinceDay: "2026-08-01", untilDay: "2026-08-31", rates, - resolveProject: (cwd) => (cwd.endsWith("one") ? "Project one" : "Project two"), + resolveProject: (cwd) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO), }); const entries = [ [record({ sessionId: "claude-one", cwd: "/work/one" }), "claude:claude-one"], @@ -178,7 +218,7 @@ describe("foldThreadRows", () => { const projectOne = foldThreadRows(accumulator.finish(), attribution, { cap: 40, - projectFilter: "Project one", + projectFilter: "id:project-one", }); expect(projectOne.rows.map((row) => [row.provider, row.project]).toSorted()).toEqual([ ["claude", "Project one"], @@ -196,15 +236,56 @@ describe("foldThreadRows", () => { const { rows, truncatedRows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 3 }); - expect(rows).toHaveLength(4); - expect(truncatedRows).toBe(2); - expect(rows.find((row) => row.key.startsWith("remainder:"))?.title).toBe("Other threads (2)"); - expect(rows.find((row) => row.key.startsWith("remainder:"))?.groupedRows).toBe(2); + expect(rows).toHaveLength(3); + expect(truncatedRows).toBe(3); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.title).toBe("Other threads (3)"); + expect(rows.find((row) => row.key.startsWith("remainder:"))?.groupedRows).toBe(3); expect(rows.reduce((sum, row) => sum + row.totals.outputTokens, 0)).toBe(250); }); + it("keeps subagent slices when lower-cost rows fold into a remainder", () => { + const groups = accumulate([ + [ + record({ sessionId: "expensive", totals: { ...record().totals, outputTokens: 100 } }), + { sessionKey: "claude:expensive", agentId: null }, + ], + [ + record({ sessionId: "cheaper" }), + { sessionKey: "claude:cheaper", agentId: "agent-cheaper" }, + ], + ]); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 1 }); + const remainder = rows.find((row) => row.key.startsWith("remainder:")); + expect(remainder?.agents.map((agent) => agent.agentId)).toEqual(["agent-cheaper"]); + }); + + it("collapses overflow project scopes without exceeding the response cap", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: (cwd) => ({ + projectId: ProjectId.make(`project-${cwd.slice(-1)}`), + title: `Project ${cwd.slice(-1)}`, + }), + }); + for (let index = 0; index < 6; index += 1) { + accumulator.add(record({ sessionId: `session-${index}`, cwd: `/work/${index}` }), { + sessionKey: `claude:session-${index}`, + agentId: null, + }); + } + + const { rows } = foldThreadRows(accumulator.finish(), NO_ATTRIBUTION, { cap: 3 }); + + expect(rows.length).toBeLessThanOrEqual(3); + expect(rows.reduce((sum, row) => sum + row.totals.outputTokens, 0)).toBe(300); + }); + it("reconciles every provider and project after lower-cost rows are grouped", () => { - const resolveProject = (cwd: string) => (cwd.endsWith("one") ? "Project one" : "Project two"); + const resolveProject = (cwd: string) => (cwd.endsWith("one") ? PROJECT_ONE : PROJECT_TWO); const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", sinceDay: "2026-08-01", @@ -238,7 +319,8 @@ describe("foldThreadRows", () => { } const groups = accumulator.finish(); - const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 1 }); + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 4 }); + expect(rows.length).toBeLessThanOrEqual(4); const expected = new Map(); for (const bucket of summary.finish().buckets) { const key = `${bucket.provider}:${bucket.project ?? ""}`; @@ -259,7 +341,7 @@ describe("foldThreadRows", () => { sinceDay: "2026-08-01", untilDay: "2026-08-31", rates, - resolveProject: (cwd) => (cwd === "/work/app" ? "App" : ""), + resolveProject: (cwd) => (cwd === "/work/app" ? PROJECT_ONE : null), }); accumulator.add(record(), { sessionKey: "claude:session-a", agentId: null }); accumulator.add(record({ sessionId: "session-b", cwd: "/elsewhere" }), { @@ -268,10 +350,15 @@ describe("foldThreadRows", () => { }); const groups = accumulator.finish(); - const app = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40, projectFilter: "App" }); - expect(app.rows.map((row) => row.key)).toEqual(["session:claude:session-a"]); + const app = foldThreadRows(groups, NO_ATTRIBUTION, { + cap: 40, + projectFilter: "id:project-one", + }); + expect(app.rows.map((row) => row.key)).toHaveLength(1); + expect(app.rows[0]?.key).toContain("claude:session-a"); const outside = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40, projectFilter: null }); - expect(outside.rows.map((row) => row.key)).toEqual(["session:claude:session-b"]); + expect(outside.rows.map((row) => row.key)).toHaveLength(1); + expect(outside.rows[0]?.key).toContain("claude:session-b"); }); }); diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 9905d1f55acb..05f7f149f3a3 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -10,6 +10,7 @@ * @module usageThreads */ import type { + ProjectId, ThreadId, UsageAgentRow, UsageProviderKind, @@ -19,7 +20,7 @@ import type { } from "@t3tools/contracts"; import { UsageDay } from "@t3tools/contracts"; -import { makeDayFormatter } from "./usageAggregation.ts"; +import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts"; import { priceUsage, type RateTable } from "./usagePricing.ts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; @@ -41,6 +42,8 @@ export interface SessionUsageGroup { readonly provider: UsageProviderKind; readonly sessionId: string; readonly cwd: string; + readonly projectId: ProjectId | null; + readonly projectKey: string | null; readonly project: string; readonly totals: UsageTokenTotals; readonly costUsd: number; @@ -49,9 +52,13 @@ export interface SessionUsageGroup { } interface MutableSessionGroup { + sessionKey: string; provider: UsageProviderKind; sessionId: string; cwd: string; + projectId: ProjectId | null; + projectKey: string | null; + project: string; totals: UsageTokenTotals; costUsd: number; daily: Map; @@ -62,9 +69,11 @@ export interface ThreadUsageOptions { readonly timeZone: string; readonly sinceDay: string; readonly untilDay: string; + readonly sinceTimeMs?: number; + readonly untilTimeMs?: number; readonly rates: RateTable; - /** Same resolver the summary uses; `""` means outside every project. */ - readonly resolveProject?: (cwd: string) => string; + /** Same stable project resolver the summary uses. */ + readonly resolveProject?: (cwd: string) => ProjectAttribution | null; } /** @@ -92,24 +101,38 @@ export class ThreadUsageAccumulator { } const day = this.#toDay(record.timestampMs); + if ( + this.#options.sinceTimeMs !== undefined && + this.#options.untilTimeMs !== undefined && + (record.timestampMs < this.#options.sinceTimeMs || + record.timestampMs >= this.#options.untilTimeMs) + ) { + return false; + } if (day < this.#options.sinceDay || day > this.#options.untilDay) return false; - let group = this.#groups.get(context.sessionKey); + const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectKey = + resolvedProject === null ? null : `id:${resolvedProject.projectId.replaceAll("\u0000", "")}`; + const groupKey = JSON.stringify([context.sessionKey, record.cwd]); + let group = this.#groups.get(groupKey); if (group === undefined) { group = { + sessionKey: context.sessionKey, provider: record.provider, sessionId: record.sessionId, - cwd: "", + cwd: record.cwd, + projectId: resolvedProject?.projectId ?? null, + projectKey, + project: resolvedProject?.title ?? "", totals: EMPTY_TOTALS, costUsd: 0, daily: new Map(), agents: new Map(), }; - this.#groups.set(context.sessionKey, group); + this.#groups.set(groupKey, group); } - if (group.cwd.length === 0 && record.cwd.length > 0) group.cwd = record.cwd; - const priced = priceUsage( this.#options.rates, record.model, @@ -133,13 +156,14 @@ export class ThreadUsageAccumulator { } finish(): readonly SessionUsageGroup[] { - const resolve = this.#options.resolveProject; - return [...this.#groups.entries()].map(([sessionKey, group]) => ({ - sessionKey, + return [...this.#groups.values()].map((group) => ({ + sessionKey: group.sessionKey, provider: group.provider, sessionId: group.sessionId, cwd: group.cwd, - project: resolve === undefined ? "" : resolve(group.cwd), + projectId: group.projectId, + projectKey: group.projectKey, + project: group.project, totals: group.totals, costUsd: group.costUsd, daily: group.daily, @@ -168,7 +192,7 @@ export interface ThreadAttribution { export interface FoldThreadRowsOptions { /** A title, `null` for outside-projects sessions, `undefined` for no filter. */ readonly projectFilter?: string | null | undefined; - /** Rows kept after sorting by cost; the rest are counted, not sent. */ + /** Maximum returned rows, including grouped remainders. */ readonly cap: number; } @@ -177,10 +201,12 @@ interface MutableThreadRow { title: string | null; provider: UsageProviderKind; project: string; + projectId: ProjectId | null; + projectKey: string | null; cwd: string; totals: UsageTokenTotals; costUsd: number; - sessions: number; + sessionKeys: Set; groupedRows: number; daily: Map; agents: Map; @@ -217,18 +243,15 @@ export function foldThreadRows( const byKey = new Map(); for (const group of groups) { - if (options.projectFilter !== undefined) { - const project = group.project.length === 0 ? null : group.project; - if (project !== options.projectFilter) continue; - } + if (options.projectFilter !== undefined && group.projectKey !== options.projectFilter) continue; const ref = attribution.sessionToThread.get(group.sessionKey) ?? (group.cwd.length > 0 ? attribution.worktreeToThread.get(group.cwd) : undefined); const rowKey = ref === undefined - ? `session:${group.sessionKey}` - : JSON.stringify(["thread", group.provider, group.project, ref.threadId]); + ? JSON.stringify(["session", group.provider, group.projectKey, group.sessionKey]) + : JSON.stringify(["thread", group.provider, group.projectKey, ref.threadId]); let row = byKey.get(rowKey); if (row === undefined) { @@ -237,10 +260,12 @@ export function foldThreadRows( title: ref?.title ?? null, provider: group.provider, project: group.project, + projectId: group.projectId, + projectKey: group.projectKey, cwd: group.cwd, totals: EMPTY_TOTALS, costUsd: 0, - sessions: 0, + sessionKeys: new Set(), groupedRows: 0, daily: new Map(), agents: new Map(), @@ -251,7 +276,7 @@ export function foldThreadRows( row.totals = addTotals(row.totals, group.totals); row.costUsd += group.costUsd; - row.sessions += 1; + row.sessionKeys.add(group.sessionKey); addDailyCosts(row.daily, group.daily); for (const [agentId, slice] of group.agents) { let agent = row.agents.get(agentId); @@ -270,11 +295,34 @@ export function foldThreadRows( totalOf(b[1].totals) - totalOf(a[1].totals) || a[0].localeCompare(b[0]), ); - const kept = sorted.slice(0, options.cap); - const omitted = sorted.slice(options.cap); + let keptCount = Math.min(sorted.length, options.cap); + const projectScopeCount = (rows: typeof sorted): number => + new Set(rows.map(([, row]) => JSON.stringify([row.provider, row.projectKey]))).size; + while (keptCount > 0 && keptCount + projectScopeCount(sorted.slice(keptCount)) > options.cap) { + keptCount -= 1; + } + + let kept = sorted.slice(0, keptCount); + let omitted = sorted.slice(keptCount); + let remainderScope: "project" | "provider" = "project"; + if (projectScopeCount(omitted) > options.cap) { + // More project scopes than the response can represent. Collapse all named + // rows and preserve provider totals in provider-wide overflow rows. + kept = []; + omitted = sorted; + remainderScope = "provider"; + const providerCount = new Set(omitted.map(([, row]) => row.provider)).size; + if (providerCount > options.cap) { + throw new RangeError("Thread row cap must fit one remainder per provider"); + } + } + const remainders = new Map(); for (const [, omittedRow] of omitted) { - const scopeKey = JSON.stringify([omittedRow.provider, omittedRow.project]); + const scopeKey = JSON.stringify([ + omittedRow.provider, + remainderScope === "project" ? omittedRow.projectKey : null, + ]); let remainder = remainders.get(scopeKey); if (remainder === undefined) { const key = `remainder:${scopeKey}`; @@ -282,11 +330,13 @@ export function foldThreadRows( threadId: null, title: null, provider: omittedRow.provider, - project: omittedRow.project, + project: remainderScope === "project" ? omittedRow.project : "", + projectId: remainderScope === "project" ? omittedRow.projectId : null, + projectKey: remainderScope === "project" ? omittedRow.projectKey : null, cwd: "", totals: EMPTY_TOTALS, costUsd: 0, - sessions: 0, + sessionKeys: new Set(), groupedRows: 0, daily: new Map(), agents: new Map(), @@ -297,8 +347,17 @@ export function foldThreadRows( remainder.groupedRows += 1; remainder.totals = addTotals(remainder.totals, omittedRow.totals); remainder.costUsd += omittedRow.costUsd; - remainder.sessions += omittedRow.sessions; + for (const sessionKey of omittedRow.sessionKeys) remainder.sessionKeys.add(sessionKey); addDailyCosts(remainder.daily, omittedRow.daily); + for (const [agentId, slice] of omittedRow.agents) { + let agent = remainder.agents.get(agentId); + if (agent === undefined) { + agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + remainder.agents.set(agentId, agent); + } + agent.totals = addTotals(agent.totals, slice.totals); + agent.costUsd += slice.costUsd; + } } const displayed = [ @@ -321,10 +380,11 @@ export function foldThreadRows( title: row.title, titleSessionKey: row.titleSessionKey, provider: row.provider, + ...(row.projectId === null ? {} : { projectId: row.projectId }), ...(row.project === "" ? {} : { project: row.project }), totals: row.totals, costUsd: row.costUsd, - sessions: row.sessions, + sessions: row.sessionKeys.size, ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), agents: [...row.agents.entries()] .map(([agentId, slice]) => ({ diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 5feb68b2ff58..e6ba151a60c3 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -7,7 +7,7 @@ import * as NodePath from "node:path"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; -import { readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { readTranscriptRecords, readTranscriptTitle } from "./usageTranscriptReader.ts"; let dir: string; @@ -208,3 +208,33 @@ describe("readTranscriptRecords resume", () => { assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); }); }); + +describe("readTranscriptTitle", () => { + it("keeps a real prompt that begins with an angle bracket", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + JSON.stringify({ type: "user", message: { content: "<3 ship this today" } }), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "<3 ship this today"); + }); + + it("skips a known injected preamble and reads the next user prompt", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + [ + { + type: "user", + message: { content: "generated context" }, + }, + { type: "user", message: { content: "Fix the real bug" } }, + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "Fix the real bug"); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 522f02aa8554..474d4c89ca79 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -313,7 +313,16 @@ export async function readTranscriptRecords( } /** Prefixes that mark an injected preamble, not something the user typed. */ -const NOT_TITLE_PREFIXES = ["<", "# AGENTS.md instructions", "Caveat: the messages below"]; +const NOT_TITLE_PREFIXES = [ + "", + "", + "", + "", + "", + "", + "# AGENTS.md instructions", + "Caveat: the messages below", +]; const TITLE_MAX_LENGTH = 80; const TITLE_MAX_LINES = 400; @@ -389,21 +398,24 @@ export async function readTranscriptTitle( ): Promise { if (provider === "grok") return null; try { + const stream = NodeFS.createReadStream(filePath, { encoding: "utf8" }); const lines = NodeReadline.createInterface({ - input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), + input: stream, crlfDelay: Infinity, }); - let seen = 0; - for await (const line of lines) { - seen += 1; - if (seen > TITLE_MAX_LINES) break; - const gate = provider === "claude" ? '"user"' : '"message"'; - if (!line.includes(gate)) continue; - const title = provider === "claude" ? claudeTitleFromLine(line) : codexTitleFromLine(line); - if (title !== null) { - lines.close(); - return title; + try { + let seen = 0; + for await (const line of lines) { + seen += 1; + if (seen > TITLE_MAX_LINES) break; + const gate = provider === "claude" ? '"user"' : '"message"'; + if (!line.includes(gate)) continue; + const title = provider === "claude" ? claudeTitleFromLine(line) : codexTitleFromLine(line); + if (title !== null) return title; } + } finally { + lines.close(); + stream.destroy(); } } catch { return null; diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index f8c4b553d435..cd71a875e5c3 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -270,7 +270,9 @@ describe("UsagePage thread breakdown", () => { sinceDay: "2026-08-10", untilDay: "2026-08-11", timeZone: "UTC", - project: "id:project-expensive", + sinceTime: "2026-08-10T12:37:00.000Z", + untilTime: "2026-08-11T12:37:00.000Z", + projectKey: "id:project-expensive", }, providerContributions: [], }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index fcbadb740aa4..6442277ba7b7 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -466,7 +466,9 @@ export function UsagePage() { sinceDay: window.sinceDay, untilDay: window.untilDay, timeZone: window.timeZone, - ...(projectFilter === undefined ? {} : { project: projectFilter }), + ...(window.sinceTime === undefined ? {} : { sinceTime: window.sinceTime }), + ...(window.untilTime === undefined ? {} : { untilTime: window.untilTime }), + ...(projectFilter === undefined ? {} : { projectKey: projectFilter }), }} providerContributions={merged.providerContributions} /> diff --git a/apps/web/src/components/usage/UsageThreadTable.test.tsx b/apps/web/src/components/usage/UsageThreadTable.test.tsx new file mode 100644 index 000000000000..e4284b87a6bc --- /dev/null +++ b/apps/web/src/components/usage/UsageThreadTable.test.tsx @@ -0,0 +1,93 @@ +import { EnvironmentId, ThreadId, UsageDay } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ useUsageThreads: vi.fn() })); + +vi.mock("../../state/usage", () => ({ useUsageThreads: testState.useUsageThreads })); +vi.mock("../ui/tooltip", async () => { + const React = await import("react"); + return { + Tooltip: "span", + TooltipPopup: "span", + TooltipTrigger: ({ + render, + children, + }: { + render: React.ReactElement; + children: React.ReactNode; + }) => React.cloneElement(render, {}, children), + }; +}); +vi.mock("./usageProviders", () => ({ + PROVIDER_PRESENTATION: { + claude: { mark: "span" }, + codex: { mark: "span" }, + grok: { mark: "span" }, + }, +})); + +import { UsageThreadTable } from "./UsageThreadTable"; + +const input = { + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-31"), + timeZone: "UTC", +}; + +beforeEach(() => { + testState.useUsageThreads.mockReset(); +}); + +describe("UsageThreadTable", () => { + it("reports an unavailable breakdown when every query failed", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [], + truncatedRows: 0, + isPending: false, + failedEnvironments: 2, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Thread activity could not be loaded"); + expect(markup).not.toContain("No activity in this window"); + }); + + it("uses a keyboard-accessible disclosure button without a native title", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [ + { + environmentId: EnvironmentId.make("environment-one"), + key: "row-one", + threadId: ThreadId.make("thread-one"), + title: "Fix the flaky test", + provider: "claude", + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 2, + cacheCreationTokens: 3, + outputTokens: 4, + reasoningTokens: 0, + }, + costUsd: 1, + sessions: 1, + agents: [], + daily: [], + }, + ], + truncatedRows: 0, + isPending: false, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(' ) : ( @@ -109,7 +113,7 @@ export function UsageThreadTable({ ) : null} - {failedEnvironments > 0 ? ( + {failedEnvironments > 0 && rows.length > 0 ? ( +
{truncatedRows === 1 - ? "1 more thread not shown." - : `${truncatedRows} more threads not shown.`} + ? "1 lower-cost thread row is grouped above." + : `${truncatedRows} lower-cost thread rows are grouped above.`}
- No activity in this window. + {failedEnvironments > 0 + ? "Thread activity could not be loaded for this window." + : "No activity in this window."}
{failedEnvironments === 1 @@ -143,28 +147,34 @@ function ThreadRowGroup({ const Chevron = open ? ChevronDownIcon : ChevronRightIcon; return ( <> -
- - - - - {row.title} - - {row.agents.length > 0 ? ( - - {row.agents.length === 1 ? "1 subagent" : `${row.agents.length} subagents`} - - ) : null} - + + + } + > + + + {row.title} + {row.agents.length > 0 ? ( + + {row.agents.length === 1 ? "1 subagent" : `${row.agents.length} subagents`} + + ) : null} + + {row.title} + {formatUsd(row.costUsd)} - {`${(share * 100).toFixed(1)}%`} + {formatPercent(share)} {formatTokens(tokens)} @@ -231,7 +241,7 @@ export function UsageThreadDailyChart({ } const bandWidth = CHART_WIDTH / days.length; - const barWidth = Math.max(1, bandWidth - (days.length > 120 ? 0.5 : 2)); + const barWidth = bandWidth * 0.8; return (
@@ -250,16 +260,17 @@ export function UsageThreadDailyChart({ {days.map((day, index) => { const entry = byDay.get(day); if (entry === undefined) return null; - const x = index * bandWidth; + const x = index * bandWidth + (bandWidth - barWidth) / 2; const height = (entry.costUsd / peak) * (CHART_HEIGHT - 4); + const renderedHeight = Math.max(height, 0.75); return ( {`${formatDayShort(day)}: ${formatUsd(entry.costUsd)}`} diff --git a/apps/web/src/state/usage.test.ts b/apps/web/src/state/usage.test.ts index 973b737cac3c..8f84cf36f3ae 100644 --- a/apps/web/src/state/usage.test.ts +++ b/apps/web/src/state/usage.test.ts @@ -8,7 +8,7 @@ import type { import type { EnvironmentProviderContribution } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { mergeUsageThreadBreakdowns } from "./usage"; +import { makeThreadBreakdownInput, mergeUsageThreadBreakdowns } from "./usage"; function row(provider: UsageProviderKind, overrides: Partial = {}): UsageThreadRow { return { @@ -80,3 +80,30 @@ describe("mergeUsageThreadBreakdowns", () => { expect(merged.truncatedRows).toBe(5); }); }); + +describe("makeThreadBreakdownInput", () => { + it("preserves exact bounds and limits the environment to its owned providers", () => { + expect( + makeThreadBreakdownInput( + { + sinceDay: "2026-08-07" as UsageDay, + untilDay: "2026-08-08" as UsageDay, + timeZone: "UTC", + resolution: "hour", + sinceTime: "2026-08-07T12:00:00.000Z", + untilTime: "2026-08-08T12:00:00.000Z", + }, + "id:project-one", + ["claude"], + ), + ).toEqual({ + sinceDay: "2026-08-07", + untilDay: "2026-08-08", + timeZone: "UTC", + sinceTime: "2026-08-07T12:00:00.000Z", + untilTime: "2026-08-08T12:00:00.000Z", + projectKey: "id:project-one", + providers: ["claude"], + }); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 88c9868e7161..c9845fe400aa 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -12,6 +12,7 @@ import { type EnvironmentId, type UsageSummary, type UsageSummaryInput, + type UsageProviderKind, type UsageThreadBreakdown, type UsageThreadBreakdownInput, type UsageThreadRow, @@ -106,18 +107,6 @@ export function useUsage( const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so the button always rescans. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of environments) { - appAtomRegistry.refresh( - serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), - ); - } - }, [environments, windowKey]); - const merged = useMemo(() => { const answered: EnvironmentUsage[] = environments.flatMap((environment) => environment.summary === null @@ -137,6 +126,26 @@ export function useUsage( ); }, [environments, projectFilter]); + // Refresh the source queries, not just their derived atoms. When the thread + // table is mounted, refresh its provider-owned query at the same time so the + // two views cannot show different scans. + const refresh = useCallback(() => { + const input = JSON.parse(windowKey) as UsageSummaryInput; + for (const environment of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + ); + } + for (const contribution of merged.providerContributions) { + appAtomRegistry.refresh( + serverEnvironment.usageThreadBreakdown({ + environmentId: contribution.environmentId, + input: makeThreadBreakdownInput(input, projectFilter, contribution.providers), + }), + ); + } + }, [environments, merged.providerContributions, projectFilter, windowKey]); + const answeredCount = environments.filter((environment) => environment.summary !== null).length; const stillReporting = environments.filter( (environment) => environment.summary === null && environment.error === null, @@ -168,6 +177,29 @@ export interface EnvironmentUsageThreadBreakdown { readonly breakdown: UsageThreadBreakdown; } +export function makeThreadBreakdownInput( + input: UsageSummaryInput, + projectFilter: string | null | undefined, + providers: readonly UsageProviderKind[], +): UsageThreadBreakdownInput { + return { + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + ...(input.sinceTime === undefined ? {} : { sinceTime: input.sinceTime }), + ...(input.untilTime === undefined ? {} : { untilTime: input.untilTime }), + ...(projectFilter === undefined ? {} : { projectKey: projectFilter }), + providers: [...providers], + }; +} + +function withOwnedProviders( + input: UsageThreadBreakdownInput, + providers: readonly UsageProviderKind[], +): UsageThreadBreakdownInput { + return { ...input, providers: [...providers] }; +} + /** Applies the summary's physical-source ownership to thread rows. */ export function mergeUsageThreadBreakdowns( environments: readonly EnvironmentUsageThreadBreakdown[], @@ -202,8 +234,14 @@ const usageThreadsAtom = Atom.family((requestKey: string) => const breakdowns: EnvironmentUsageThreadBreakdown[] = []; let pending = 0; let failed = 0; - for (const { environmentId } of providerContributions) { - const result = get(serverEnvironment.usageThreadBreakdown({ environmentId, input })); + for (const contribution of providerContributions) { + const { environmentId } = contribution; + const result = get( + serverEnvironment.usageThreadBreakdown({ + environmentId, + input: withOwnedProviders(input, contribution.providers), + }), + ); if (result.waiting) pending += 1; if (result._tag === "Failure") failed += 1; const breakdown = Option.getOrNull(AsyncResult.value(result)); diff --git a/docs/user/usage.md b/docs/user/usage.md index 8e2707feca9b..23fef95dccf8 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -13,15 +13,15 @@ Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. headline and chart, and refreshing rescans every connected environment. Any daily chart zooms: drag across it to make the selection the new date window, and double-click -to return to the preset. The date fields beside the presets accept any custom range directly. +to return to the preset. The date fields beside the presets accept custom ranges up to 90 days. The breakdown's **Thread** view drills into where the spend went: sessions group into the T3 Code thread they belong to, with sessions that never ran through T3 Code listed under the first thing -you asked in them. Expanding a row shows its daily estimated cost, along with any Claude subagents -the thread spawned and their share. -The view names the 40 highest-cost rows and groups lower-cost rows under **Other threads** by -provider and project. Those grouped rows stay in the totals, so the thread view still adds up to -the selected project or full summary. +you asked in them. Grok Build has no trusted prompt title, so its rows use a short session label. +Expanding a row shows its daily estimated cost, along with any Claude subagents the thread spawned +and their share. The view returns at most 40 rows, reserving room to group lower-cost rows under +**Other threads** by provider and project. Those grouped rows stay in the totals, so the thread +view still adds up to the selected project or full summary. Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index dfd0922605f4..5adae4daf764 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -228,11 +228,18 @@ export const UsageThreadBreakdownInput = Schema.Struct({ /** Inclusive last day of the window, in `timeZone`. */ untilDay: UsageDay, timeZone: TrimmedNonEmptyString, + /** Inclusive UTC instant for a rolling window such as Past 24h. */ + sinceTime: Schema.optional(TrimmedNonEmptyString), + /** Exclusive UTC instant for a rolling window such as Past 24h. */ + untilTime: Schema.optional(TrimmedNonEmptyString), /** - * Restrict to one project's sessions: a title selects that project, `null` - * selects sessions outside every project, absent applies no filter. + * Restrict to one project's records: a namespaced stable key selects that + * project, `null` selects records outside every project, absent applies no + * filter. */ - project: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + projectKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** Providers this environment owns after physical-source de-duplication. */ + providers: Schema.optional(Schema.Array(UsageProviderKind)), }); export type UsageThreadBreakdownInput = typeof UsageThreadBreakdownInput.Type; @@ -268,6 +275,7 @@ export const UsageThreadRow = Schema.Struct({ threadId: Schema.NullOr(ThreadId), title: TrimmedNonEmptyString, provider: UsageProviderKind, + projectId: Schema.optional(ProjectId), project: Schema.optional(TrimmedNonEmptyString), totals: UsageTokenTotals, costUsd: Schema.Number, From e38a45a9abde0a0e27118b1d21d94020f5074fe1 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 21:24:01 +1000 Subject: [PATCH 13/78] fix(usage): harden thread scans across environments --- apps/server/src/usage/UsageService.test.ts | 4 +++ apps/server/src/usage/UsageService.ts | 4 +++ .../src/usage/usageTranscriptReader.test.ts | 4 +++ .../server/src/usage/usageTranscriptReader.ts | 36 ++++++++++++------- apps/web/src/state/usage.test.ts | 28 ++++++++++----- apps/web/src/state/usage.ts | 29 ++++++++++++--- packages/contracts/src/usage.ts | 2 ++ packages/shared/src/usageMerge.test.ts | 10 +++--- packages/shared/src/usageMerge.ts | 2 ++ 9 files changed, 88 insertions(+), 31 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 6991cb1867d2..e6291e29608f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -17,7 +17,9 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; @@ -90,6 +92,8 @@ const serviceLayers = (input: { Layer.provideMerge( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProviderSessionRuntime.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), ), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 756b3c1fb178..23ab925ba82f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -789,6 +789,10 @@ export const make = Effect.gen(function* () { } } + // A thread-only client must warm the same durable cache as the summary + // RPC, otherwise every server restart repeats the full transcript parse. + yield* persistScanCache(); + const attribution = yield* loadThreadAttribution(); const folded = foldThreadRows(accumulator.finish(), attribution, { cap: THREAD_ROW_CAP, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index e6ba151a60c3..7c742494e9aa 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -237,4 +237,8 @@ describe("readTranscriptTitle", () => { assert.strictEqual(await readTranscriptTitle(file, "claude"), "Fix the real bug"); }); + + it("returns null when the title stream cannot be read", async () => { + assert.isNull(await readTranscriptTitle(NodePath.join(dir, "missing.jsonl"), "claude")); + }); }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 474d4c89ca79..f2f713d8e225 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -15,6 +15,7 @@ * * @module usageTranscriptReader */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -326,6 +327,7 @@ const NOT_TITLE_PREFIXES = [ const TITLE_MAX_LENGTH = 80; const TITLE_MAX_LINES = 400; +const TITLE_MAX_BYTES = 1024 * 1024; function cleanTitle(text: unknown): string | null { if (typeof text !== "string") return null; @@ -397,28 +399,36 @@ export async function readTranscriptTitle( provider: UsageProviderKind, ): Promise { if (provider === "grok") return null; + const titleFromLine = provider === "claude" ? claudeTitleFromLine : codexTitleFromLine; + let stream: NodeFS.ReadStream | null = null; try { - const stream = NodeFS.createReadStream(filePath, { encoding: "utf8" }); - const lines = NodeReadline.createInterface({ - input: stream, - crlfDelay: Infinity, - }); - try { - let seen = 0; - for await (const line of lines) { + stream = NodeFS.createReadStream(filePath, { encoding: "utf8" }); + let pending = ""; + let seen = 0; + let bytesRead = 0; + for await (const chunk of stream) { + const text = String(chunk); + bytesRead += Buffer.byteLength(text); + pending += text; + for (;;) { + const newline = pending.indexOf("\n"); + if (newline === -1) break; + const line = pending.slice(0, newline).replace(/\r$/, ""); + pending = pending.slice(newline + 1); seen += 1; - if (seen > TITLE_MAX_LINES) break; + if (seen > TITLE_MAX_LINES) return null; const gate = provider === "claude" ? '"user"' : '"message"'; if (!line.includes(gate)) continue; - const title = provider === "claude" ? claudeTitleFromLine(line) : codexTitleFromLine(line); + const title = titleFromLine(line); if (title !== null) return title; } - } finally { - lines.close(); - stream.destroy(); + if (bytesRead >= TITLE_MAX_BYTES) return null; } + if (pending.length > 0 && seen < TITLE_MAX_LINES) return titleFromLine(pending); } catch { return null; + } finally { + stream?.destroy(); } return null; } diff --git a/apps/web/src/state/usage.test.ts b/apps/web/src/state/usage.test.ts index 8f84cf36f3ae..71b853f3db04 100644 --- a/apps/web/src/state/usage.test.ts +++ b/apps/web/src/state/usage.test.ts @@ -1,9 +1,10 @@ -import type { - EnvironmentId, - UsageDay, - UsageProviderKind, - UsageThreadBreakdown, - UsageThreadRow, +import { + USAGE_CONTRACT_VERSION, + type EnvironmentId, + type UsageDay, + type UsageProviderKind, + type UsageThreadBreakdown, + type UsageThreadRow, } from "@t3tools/contracts"; import type { EnvironmentProviderContribution } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; @@ -49,8 +50,16 @@ describe("mergeUsageThreadBreakdowns", () => { const environmentA = "env-a" as EnvironmentId; const environmentB = "env-b" as EnvironmentId; const contributions: readonly EnvironmentProviderContribution[] = [ - { environmentId: environmentA, providers: ["claude"] }, - { environmentId: environmentB, providers: ["codex"] }, + { + environmentId: environmentA, + contractVersion: USAGE_CONTRACT_VERSION, + providers: ["claude"], + }, + { + environmentId: environmentB, + contractVersion: USAGE_CONTRACT_VERSION, + providers: ["codex"], + }, ]; const merged = mergeUsageThreadBreakdowns( [ @@ -93,8 +102,9 @@ describe("makeThreadBreakdownInput", () => { sinceTime: "2026-08-07T12:00:00.000Z", untilTime: "2026-08-08T12:00:00.000Z", }, - "id:project-one", + JSON.stringify(["env-a", "id:project-one"]), ["claude"], + "env-a" as EnvironmentId, ), ).toEqual({ sinceDay: "2026-08-07", diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index c9845fe400aa..af30ec5f877c 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -9,6 +9,7 @@ import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, + USAGE_THREAD_BREAKDOWN_SINCE, type EnvironmentId, type UsageSummary, type UsageSummaryInput, @@ -23,6 +24,7 @@ import { useCallback, useMemo } from "react"; import { mergeUsage, + projectFilterForEnvironment, type EnvironmentProviderContribution, type EnvironmentUsage, type MergedUsage, @@ -137,10 +139,16 @@ export function useUsage( ); } for (const contribution of merged.providerContributions) { + if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; appAtomRegistry.refresh( serverEnvironment.usageThreadBreakdown({ environmentId: contribution.environmentId, - input: makeThreadBreakdownInput(input, projectFilter, contribution.providers), + input: makeThreadBreakdownInput( + input, + projectFilter, + contribution.providers, + contribution.environmentId, + ), }), ); } @@ -181,6 +189,7 @@ export function makeThreadBreakdownInput( input: UsageSummaryInput, projectFilter: string | null | undefined, providers: readonly UsageProviderKind[], + environmentId: EnvironmentId, ): UsageThreadBreakdownInput { return { sinceDay: input.sinceDay, @@ -188,7 +197,9 @@ export function makeThreadBreakdownInput( timeZone: input.timeZone, ...(input.sinceTime === undefined ? {} : { sinceTime: input.sinceTime }), ...(input.untilTime === undefined ? {} : { untilTime: input.untilTime }), - ...(projectFilter === undefined ? {} : { projectKey: projectFilter }), + ...(projectFilter === undefined + ? {} + : { projectKey: projectFilterForEnvironment(projectFilter, environmentId) }), providers: [...providers], }; } @@ -233,13 +244,23 @@ const usageThreadsAtom = Atom.family((requestKey: string) => const breakdowns: EnvironmentUsageThreadBreakdown[] = []; let pending = 0; - let failed = 0; + let failed = providerContributions.filter( + (contribution) => contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE, + ).length; for (const contribution of providerContributions) { + if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; const { environmentId } = contribution; + const environmentInput = + input.projectKey === undefined + ? input + : { + ...input, + projectKey: projectFilterForEnvironment(input.projectKey, environmentId), + }; const result = get( serverEnvironment.usageThreadBreakdown({ environmentId, - input: withOwnedProviders(input, contribution.providers), + input: withOwnedProviders(environmentInput, contribution.providers), }), ); if (result.waiting) pending += 1; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 5adae4daf764..3a84fa47cc6c 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -35,6 +35,8 @@ export const USAGE_CONTRACT_VERSION = 9 as const; export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; /** First contract version that explicitly distinguishes outside from unknown attribution. */ export const USAGE_PROJECT_ATTRIBUTION_SINCE = 8 as const; +/** First contract version that exposes the thread-breakdown RPC. */ +export const USAGE_THREAD_BREAKDOWN_SINCE = 9 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index f855a78bbbbf..0b374bbd36bb 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -97,8 +97,8 @@ describe("mergeUsage", () => { expect(merged.records).toBe(10); expect(merged.duplicateSources).toHaveLength(0); expect(merged.providerContributions).toEqual([ - { environmentId: "env-a", providers: ["claude"] }, - { environmentId: "env-b", providers: ["claude"] }, + { environmentId: "env-a", contractVersion: USAGE_CONTRACT_VERSION, providers: ["claude"] }, + { environmentId: "env-b", contractVersion: USAGE_CONTRACT_VERSION, providers: ["claude"] }, ]); }); @@ -119,7 +119,7 @@ describe("mergeUsage", () => { expect(merged.duplicateSources).toHaveLength(1); expect(merged.contributingEnvironments).toEqual(["env-a"]); expect(merged.providerContributions).toEqual([ - { environmentId: "env-a", providers: ["claude"] }, + { environmentId: "env-a", contractVersion: USAGE_CONTRACT_VERSION, providers: ["claude"] }, ]); }); @@ -156,8 +156,8 @@ describe("mergeUsage", () => { ), ).toEqual({ claude: 1, codex: 1 }); expect(merged.providerContributions).toEqual([ - { environmentId: "env-a", providers: ["claude"] }, - { environmentId: "env-b", providers: ["codex"] }, + { environmentId: "env-a", contractVersion: USAGE_CONTRACT_VERSION, providers: ["claude"] }, + { environmentId: "env-b", contractVersion: USAGE_CONTRACT_VERSION, providers: ["codex"] }, ]); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 483996ea6f30..5ccc5022a9d8 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -77,6 +77,7 @@ export interface CostQuality { export interface EnvironmentProviderContribution { readonly environmentId: EnvironmentId; + readonly contractVersion: number; readonly providers: readonly UsageProviderKind[]; } @@ -363,6 +364,7 @@ export function mergeUsage( contributingEnvironments.push(environment.environmentId); providerContributions.push({ environmentId: environment.environmentId, + contractVersion: environment.summary.contractVersion, providers: [...new Set(buckets.map((bucket) => bucket.provider))].sort(), }); } From 2849d396fbcc511d225daefe32e8952770227db5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 21:43:41 +1000 Subject: [PATCH 14/78] fix(usage): scope and harden thread drill-down --- apps/server/src/usage/UsageService.test.ts | 27 ++++++ apps/server/src/usage/UsageService.ts | 49 +++++++--- apps/server/src/usage/usageThreads.test.ts | 70 +++++++++++++ apps/server/src/usage/usageThreads.ts | 97 +++++++++++++++++-- .../src/usage/usageTranscriptReader.test.ts | 54 +++++++++++ .../server/src/usage/usageTranscriptReader.ts | 47 +++++++-- .../src/components/usage/UsagePage.test.tsx | 6 ++ apps/web/src/components/usage/UsagePage.tsx | 24 ++++- .../usage/UsageThreadTable.test.tsx | 37 ++++++- .../src/components/usage/UsageThreadTable.tsx | 31 +++--- apps/web/src/state/usage.test.ts | 48 ++++++++- apps/web/src/state/usage.ts | 75 ++++++++++---- docs/user/usage.md | 8 +- packages/contracts/src/usage.ts | 2 +- 14 files changed, 501 insertions(+), 74 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index e6291e29608f..749e3950076d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -235,6 +235,33 @@ describe("UsageService", () => { ); }).pipe(Effect.scoped), ); + + it.live("rejects exact thread windows longer than 24 hours", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-thread-window-test", home, settings }), + ), + ); + const reason = yield* service + .readThreadBreakdown({ + timeZone: "UTC", + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-02"), + sinceTime: "2026-08-01T00:00:00.000Z", + untilTime: "2026-08-02T01:00:00.000Z", + }) + .pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + ); + + assert.strictEqual(reason, "invalidWindow"); + }).pipe(Effect.scoped), + ); }); describe("isValidUsageDay", () => { diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 23ab925ba82f..a6d4d10e2fd8 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -287,15 +287,28 @@ export const make = Effect.gen(function* () { const projects = yield* projectRepository .listAll() .pipe(Effect.catchCause(() => Effect.succeed([]))); - return makeProjectResolver( - projects.map((project) => ({ - projectId: project.projectId, - workspaceRoot: project.workspaceRoot, - title: project.title, - deleted: project.deletedAt !== null, - })), - path.sep, + const projectRoots = yield* Effect.forEach( + projects, + Effect.fnUntraced(function* (project) { + const threads = yield* threadRepository + .listByProjectId({ projectId: project.projectId }) + .pipe(Effect.catchCause(() => Effect.succeed([]))); + const root = { + projectId: project.projectId, + workspaceRoot: project.workspaceRoot, + title: project.title, + deleted: project.deletedAt !== null, + }; + return [ + root, + ...threads.flatMap((thread) => + thread.worktreePath === null ? [] : [{ ...root, workspaceRoot: thread.worktreePath }], + ), + ]; + }), + { concurrency: 8 }, ); + return makeProjectResolver(projectRoots.flat(), path.sep); }); /** @@ -727,10 +740,11 @@ export const make = Effect.gen(function* () { } const sinceTimeMs = DateTime.toEpochMillis(sinceTime.value); const untilTimeMs = DateTime.toEpochMillis(untilTime.value); - if (untilTimeMs <= sinceTimeMs) { + const durationMs = untilTimeMs - sinceTimeMs; + if (durationMs <= 0 || durationMs > MAX_HOURLY_WINDOW_MS) { return yield* new UsageReadError({ reason: "invalidWindow", - detail: "Thread usage untilTime must be after sinceTime", + detail: "Thread usage exact window must be greater than zero and at most 24 hours", }); } exactWindow = { sinceTimeMs, untilTimeMs }; @@ -759,6 +773,8 @@ export const make = Effect.gen(function* () { string, { readonly path: string; readonly provider: UsageProviderKind } >(); + const livePaths = new Set(); + const walkedRoots: string[] = []; for (const { provider, dir, fileName } of dirs) { if (input.providers !== undefined && !input.providers.includes(provider)) continue; @@ -766,11 +782,13 @@ export const make = Effect.gen(function* () { .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); if (!exists) continue; + walkedRoots.push(dir); const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), ); for (const file of files) { + livePaths.add(file.path); const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); if (records.length === 0) continue; const isSubagent = @@ -789,8 +807,15 @@ export const make = Effect.gen(function* () { } } - // A thread-only client must warm the same durable cache as the summary - // RPC, otherwise every server restart repeats the full transcript parse. + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + // A thread-only client must warm and bound the same durable cache as the + // summary RPC, otherwise restarts repeat parsing and stale entries grow. yield* persistScanCache(); const attribution = yield* loadThreadAttribution(); diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index ff17b062c81c..f48cbe99cd9e 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -102,6 +102,12 @@ describe("ThreadUsageAccumulator", () => { expect(groups).toHaveLength(0); }); + it("drops timestamps outside the JavaScript date range", () => { + const context = { sessionKey: "grok:session-a", agentId: null }; + expect(() => accumulate([[record({ timestampMs: 1e20 }), context]])).not.toThrow(); + expect(accumulate([[record({ timestampMs: 1e20 }), context]])).toEqual([]); + }); + it("applies exact time bounds inside a shared calendar day", () => { const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", @@ -119,6 +125,24 @@ describe("ThreadUsageAccumulator", () => { expect(accumulator.finish()[0]?.totals.outputTokens).toBe(50); }); + it("uses exact bounds without applying a second calendar-day filter", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-07", + untilDay: "2026-08-07", + sinceTimeMs: Date.parse("2026-08-08T04:00:00Z"), + untilTimeMs: Date.parse("2026-08-08T05:00:00Z"), + rates, + }); + + accumulator.add(record({ timestampMs: Date.parse("2026-08-08T04:30:00Z") }), { + sessionKey: "claude:exact-window", + agentId: null, + }); + + expect(accumulator.finish()[0]?.totals.outputTokens).toBe(50); + }); + it("keeps separate cwd slices when one session crosses projects", () => { const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", @@ -172,6 +196,28 @@ describe("foldThreadRows", () => { expect(standalone?.key).toContain("claude:session-c"); }); + it("uses the deepest worktree ancestor for sessions run in subdirectories", () => { + const nestedThreadId = ThreadId.make("22222222-2222-4222-8222-222222222222"); + const groups = accumulate([ + [ + record({ sessionId: "nested", cwd: "/work/app/.wt/thread-1/packages/web" }), + { sessionKey: "claude:nested", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["/work/app", { threadId, title: "Shared root" }], + ["/work/app/.wt/thread-1", { threadId: nestedThreadId, title: "Nested worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(rows[0]?.threadId).toBe(nestedThreadId); + expect(rows[0]?.title).toBe("Nested worktree"); + }); + it("scopes one T3 thread by provider and project", () => { const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", @@ -260,6 +306,30 @@ describe("foldThreadRows", () => { expect(remainder?.agents.map((agent) => agent.agentId)).toEqual(["agent-cheaper"]); }); + it("bounds and reconciles subagents folded into a remainder", () => { + const groups = accumulate([ + [ + record({ sessionId: "expensive", totals: { ...record().totals, outputTokens: 100 } }), + { sessionKey: "claude:expensive", agentId: null }, + ], + ...Array.from( + { length: 5 }, + (_, index) => + [ + record({ sessionId: `cheaper-${index}` }), + { sessionKey: `claude:cheaper-${index}`, agentId: `agent-${index}` }, + ] as const, + ), + ]); + + const { rows } = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 2 }); + const remainder = rows.find((row) => row.key.startsWith("remainder:")); + + expect(remainder?.agents).toHaveLength(2); + expect(remainder?.agents.some((agent) => agent.agentId === "Other subagents (4)")).toBe(true); + expect(remainder?.agents.reduce((sum, agent) => sum + agent.totals.outputTokens, 0)).toBe(250); + }); + it("collapses overflow project scopes without exceeding the response cap", () => { const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 05f7f149f3a3..13ac7c314a00 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -24,6 +24,8 @@ import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts import { priceUsage, type RateTable } from "./usagePricing.ts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; + /** How the caller identifies the transcript a record came from. */ export interface ThreadRecordContext { /** `provider:sessionId`, or a file-derived fallback when the id is empty. */ @@ -100,7 +102,12 @@ export class ThreadUsageAccumulator { this.#seen.add(record.dedupeKey); } - const day = this.#toDay(record.timestampMs); + if ( + !Number.isFinite(record.timestampMs) || + Math.abs(record.timestampMs) > MAX_DATE_TIMESTAMP_MS + ) { + return false; + } if ( this.#options.sinceTimeMs !== undefined && this.#options.untilTimeMs !== undefined && @@ -109,7 +116,12 @@ export class ThreadUsageAccumulator { ) { return false; } - if (day < this.#options.sinceDay || day > this.#options.untilDay) return false; + const day = this.#toDay(record.timestampMs); + if ( + (this.#options.sinceTimeMs === undefined || this.#options.untilTimeMs === undefined) && + (day < this.#options.sinceDay || day > this.#options.untilDay) + ) + return false; const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; const projectKey = @@ -228,6 +240,75 @@ function addDailyCosts(target: Map, source: ReadonlyMap, +): ThreadRef | undefined { + const normalizedCwd = normalizePath(cwd); + let deepest: { readonly pathLength: number; readonly ref: ThreadRef } | undefined; + for (const [worktree, ref] of worktreeToThread) { + const normalizedWorktree = normalizePath(worktree); + const prefix = normalizedWorktree.endsWith("/") ? normalizedWorktree : `${normalizedWorktree}/`; + if (normalizedCwd !== normalizedWorktree && !normalizedCwd.startsWith(prefix)) continue; + if (deepest === undefined || normalizedWorktree.length > deepest.pathLength) { + deepest = { pathLength: normalizedWorktree.length, ref }; + } + } + return deepest?.ref; +} + +function normalizePath(value: string): string { + const slashPath = value.replaceAll("\\", "/"); + const rooted = slashPath.startsWith("/"); + const segments: string[] = []; + for (const segment of slashPath.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (segments.length > 0 && segments.at(-1) !== "..") segments.pop(); + else if (!rooted) segments.push(segment); + continue; + } + segments.push(segment); + } + const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; + return normalized === "" ? (rooted ? "/" : ".") : normalized; +} + +function toAgentRow([agentId, slice]: readonly [string, MutableAgentSlice]): UsageAgentRow { + return { + agentId, + totals: slice.totals, + costUsd: slice.costUsd, + }; +} + +function boundedAgentRows( + agents: ReadonlyMap, + cap: number, +): readonly UsageAgentRow[] { + const sorted = [...agents.entries()].sort( + (a, b) => + b[1].costUsd - a[1].costUsd || + totalOf(b[1].totals) - totalOf(a[1].totals) || + a[0].localeCompare(b[0]), + ); + if (sorted.length <= cap) return sorted.map(toAgentRow); + + const kept = sorted.slice(0, Math.max(0, cap - 1)); + const omitted = sorted.slice(kept.length); + const overflow = omitted.reduce( + (combined, [, slice]) => ({ + totals: addTotals(combined.totals, slice.totals), + costUsd: combined.costUsd + slice.costUsd, + }), + { + totals: EMPTY_TOTALS, + costUsd: 0, + }, + ); + return [...kept.map(toAgentRow), toAgentRow([`Other subagents (${omitted.length})`, overflow])]; +} + /** * Groups sessions into thread rows: resume-cursor matches first, then unique * worktrees, else one row per session. Rows sort by cost. Rows beyond the cap @@ -247,7 +328,9 @@ export function foldThreadRows( const ref = attribution.sessionToThread.get(group.sessionKey) ?? - (group.cwd.length > 0 ? attribution.worktreeToThread.get(group.cwd) : undefined); + (group.cwd.length > 0 + ? worktreeThreadForCwd(group.cwd, attribution.worktreeToThread) + : undefined); const rowKey = ref === undefined ? JSON.stringify(["session", group.provider, group.projectKey, group.sessionKey]) @@ -386,13 +469,7 @@ export function foldThreadRows( costUsd: row.costUsd, sessions: row.sessionKeys.size, ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), - agents: [...row.agents.entries()] - .map(([agentId, slice]) => ({ - agentId, - totals: slice.totals, - costUsd: slice.costUsd, - })) - .sort((a, b) => b.costUsd - a.costUsd) satisfies UsageAgentRow[], + agents: boundedAgentRows(row.agents, options.cap), daily: [...row.daily.entries()] .map(([day, costUsd]) => ({ day: day as UsageDay, diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 7c742494e9aa..354cc404f8fa 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -238,6 +238,60 @@ describe("readTranscriptTitle", () => { assert.strictEqual(await readTranscriptTitle(file, "claude"), "Fix the real bug"); }); + it("skips a user shell command wrapper", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + [ + { + type: "user", + message: { content: "git status" }, + }, + { type: "user", message: { content: "Explain the failing check" } }, + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), "Explain the failing check"); + }); + + it("uses the child prompt instead of copied parent history for a forked Codex rollout", async () => { + const file = NodePath.join(dir, "session.jsonl"); + const message = (timestamp: string, text: string) => ({ + type: "event_msg", + timestamp, + payload: { type: "message", role: "user", content: [{ type: "input_text", text }] }, + }); + await NodeFSP.writeFile( + file, + [ + { + type: "session_meta", + timestamp: "2026-08-01T05:00:00.000Z", + payload: { type: "session_meta", id: "child", forked_from_id: "parent" }, + }, + message("2026-08-01T05:00:00.600Z", "First copied parent prompt"), + message("2026-08-01T05:00:01.100Z", "Second copied parent prompt"), + message("2026-08-01T05:00:02.500Z", "Investigate the child task"), + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + + assert.strictEqual(await readTranscriptTitle(file, "codex"), "Investigate the child task"); + }); + + it("truncates titles without splitting a Unicode code point", async () => { + const file = NodePath.join(dir, "session.jsonl"); + await NodeFSP.writeFile( + file, + JSON.stringify({ type: "user", message: { content: `${"a".repeat(78)}๐Ÿ™‚more` } }), + ); + + assert.strictEqual(await readTranscriptTitle(file, "claude"), `${"a".repeat(78)}๐Ÿ™‚โ€ฆ`); + }); + it("returns null when the title stream cannot be read", async () => { assert.isNull(await readTranscriptTitle(NodePath.join(dir, "missing.jsonl"), "claude")); }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index f2f713d8e225..9bbd95e8ad2b 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -319,6 +319,7 @@ const NOT_TITLE_PREFIXES = [ "", "", "", + "", "", "", "# AGENTS.md instructions", @@ -334,8 +335,9 @@ function cleanTitle(text: unknown): string | null { const collapsed = text.split(/\s+/).join(" ").trim(); if (collapsed.length === 0) return null; if (NOT_TITLE_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) return null; - return collapsed.length > TITLE_MAX_LENGTH - ? `${collapsed.slice(0, TITLE_MAX_LENGTH - 1)}\u2026` + const characters = Array.from(collapsed); + return characters.length > TITLE_MAX_LENGTH + ? `${characters.slice(0, TITLE_MAX_LENGTH - 1).join("")}\u2026` : collapsed; } @@ -364,7 +366,9 @@ function claudeTitleFromLine(line: string): string | null { return null; } -function codexTitleFromLine(line: string): string | null { +function codexTitleFromLine( + line: string, +): { readonly title: string; readonly timestampMs: number | null } | null { let parsed: unknown; try { parsed = JSON.parse(line); @@ -378,10 +382,13 @@ function codexTitleFromLine(line: string): string | null { if (record["type"] !== "message" || record["role"] !== "user") return null; const content = record["content"]; if (!Array.isArray(content)) return null; + const timestamp = (parsed as Record)["timestamp"]; + const parsedTimestamp = typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN; + const timestampMs = Number.isNaN(parsedTimestamp) ? null : parsedTimestamp; for (const block of content) { if (typeof block !== "object" || block === null) continue; const title = cleanTitle((block as Record)["text"]); - if (title !== null) return title; + if (title !== null) return { title, timestampMs }; } return null; } @@ -399,7 +406,7 @@ export async function readTranscriptTitle( provider: UsageProviderKind, ): Promise { if (provider === "grok") return null; - const titleFromLine = provider === "claude" ? claudeTitleFromLine : codexTitleFromLine; + const codexState = provider === "codex" ? initialCodexScanState() : null; let stream: NodeFS.ReadStream | null = null; try { stream = NodeFS.createReadStream(filePath, { encoding: "utf8" }); @@ -417,14 +424,34 @@ export async function readTranscriptTitle( pending = pending.slice(newline + 1); seen += 1; if (seen > TITLE_MAX_LINES) return null; - const gate = provider === "claude" ? '"user"' : '"message"'; - if (!line.includes(gate)) continue; - const title = titleFromLine(line); - if (title !== null) return title; + if (provider === "claude") { + if (!line.includes('"user"')) continue; + const title = claudeTitleFromLine(line); + if (title !== null) return title; + continue; + } + const title = codexTitleFromLine(line); + parseCodexLine(line, codexState!); + if (title === null) continue; + if (!codexState!.suppressingForkCopies) return title.title; + if (title.timestampMs !== null) { + if (title.timestampMs - codexState!.forkCopyAnchorMs >= 1000) return title.title; + codexState!.forkCopyAnchorMs = title.timestampMs; + } } if (bytesRead >= TITLE_MAX_BYTES) return null; } - if (pending.length > 0 && seen < TITLE_MAX_LINES) return titleFromLine(pending); + if (pending.length > 0 && seen < TITLE_MAX_LINES) { + if (provider === "claude") return claudeTitleFromLine(pending); + const title = codexTitleFromLine(pending); + parseCodexLine(pending, codexState!); + if (title === null) return null; + if (!codexState!.suppressingForkCopies) return title.title; + if (title.timestampMs === null) return null; + if (title.timestampMs - codexState!.forkCopyAnchorMs >= 1000) return title.title; + codexState!.forkCopyAnchorMs = title.timestampMs; + return null; + } } catch { return null; } finally { diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index cd71a875e5c3..39cd284eca0b 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -170,6 +170,7 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); + expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), undefined, false); }); it("keeps recent activity visible first without empty hourly rows", () => { @@ -276,6 +277,11 @@ describe("UsagePage thread breakdown", () => { }, providerContributions: [], }); + expect(testState.useUsage).toHaveBeenLastCalledWith( + expect.anything(), + "id:project-expensive", + true, + ); }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 6442277ba7b7..d8977ddaaee4 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -2,7 +2,12 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useMemo, useRef, useState } from "react"; -import type { DailyTotals, HourlyTotals, ProjectTotals } from "@t3tools/shared/usageMerge"; +import { + projectFilterForEnvironment, + type DailyTotals, + type HourlyTotals, + type ProjectTotals, +} from "@t3tools/shared/usageMerge"; import { isElectron } from "../../env"; import { useCommitOnBlur } from "../../hooks/useCommitOnBlur"; @@ -60,7 +65,11 @@ export function UsagePage() { const [projectFilter, setProjectFilter] = useState(undefined); const { days: windowDays, custom: isCustomWindow, window } = windowSelection; const isPast24Hours = !isCustomWindow && windowDays === 1; - const { merged, environments, isPending, isPartial, refresh } = useUsage(window, projectFilter); + const { merged, environments, isPending, isPartial, refresh } = useUsage( + window, + projectFilter, + breakdown === "thread", + ); // Hold the content until every environment is terminal. Rendering merged // totals while devices are still answering makes every number on the page @@ -471,6 +480,17 @@ export function UsagePage() { ...(projectFilter === undefined ? {} : { projectKey: projectFilter }), }} providerContributions={merged.providerContributions} + summaryFailedEnvironments={ + environments.filter( + (environment) => + (environment.error !== null || + merged.staleEnvironments.includes(environment.environmentId)) && + projectFilterForEnvironment( + projectFilter, + environment.environmentId, + ) !== "environment-mismatch:", + ).length + } /> ) : breakdown === "project" ? ( diff --git a/apps/web/src/components/usage/UsageThreadTable.test.tsx b/apps/web/src/components/usage/UsageThreadTable.test.tsx index e4284b87a6bc..211cba886a59 100644 --- a/apps/web/src/components/usage/UsageThreadTable.test.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.test.tsx @@ -40,16 +40,31 @@ beforeEach(() => { }); describe("UsageThreadTable", () => { + it("uses the shared skeleton treatment while thread data is pending", () => { + testState.useUsageThreads.mockReturnValue({ + rows: [], + truncatedRows: 0, + isPending: true, + failedEnvironments: 0, + }); + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("after:animate-skeleton"); + }); + it("reports an unavailable breakdown when every query failed", () => { testState.useUsageThreads.mockReturnValue({ rows: [], truncatedRows: 0, isPending: false, - failedEnvironments: 2, + failedEnvironments: 0, }); const markup = renderToStaticMarkup( - , + , ); expect(markup).toContain("Thread activity could not be loaded"); @@ -74,7 +89,19 @@ describe("UsageThreadTable", () => { }, costUsd: 1, sessions: 1, - agents: [], + agents: [ + { + agentId: "agent-one", + totals: { + uncachedInputTokens: 1, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 1, + reasoningTokens: 0, + }, + costUsd: 0.1, + }, + ], daily: [], }, ], @@ -84,10 +111,12 @@ describe("UsageThreadTable", () => { }); const markup = renderToStaticMarkup( - , + , ); expect(markup).toContain(' @@ -113,12 +118,12 @@ export function UsageThreadTable({ ) : null} - {failedEnvironments > 0 && rows.length > 0 ? ( + {unavailableEnvironments > 0 && rows.length > 0 ? ( ) : null} @@ -164,9 +169,13 @@ function ThreadRowGroup({ {row.title} {row.agents.length > 0 ? ( - + {row.agents.length === 1 ? "1 subagent" : `${row.agents.length} subagents`} - + ) : null} {row.title} @@ -196,9 +205,9 @@ function ThreadRowGroup({ className="flex items-baseline justify-between gap-4 py-1 text-xs text-muted-foreground" > - + agent - + {agent.agentId} @@ -262,7 +271,7 @@ export function UsageThreadDailyChart({ if (entry === undefined) return null; const x = index * bandWidth + (bandWidth - barWidth) / 2; const height = (entry.costUsd / peak) * (CHART_HEIGHT - 4); - const renderedHeight = Math.max(height, 0.75); + const renderedHeight = height === 0 ? 0 : Math.max(height, 0.75); return ( {`${formatDayShort(day)}: ${formatUsd(entry.costUsd)}`} @@ -272,7 +281,7 @@ export function UsageThreadDailyChart({ width={barWidth} height={renderedHeight} fill="currentColor" - className="text-emerald-500" + className="text-success" /> ); diff --git a/apps/web/src/state/usage.test.ts b/apps/web/src/state/usage.test.ts index 71b853f3db04..725baccf5c27 100644 --- a/apps/web/src/state/usage.test.ts +++ b/apps/web/src/state/usage.test.ts @@ -9,7 +9,30 @@ import { import type { EnvironmentProviderContribution } from "@t3tools/shared/usageMerge"; import { describe, expect, it } from "vite-plus/test"; -import { makeThreadBreakdownInput, mergeUsageThreadBreakdowns } from "./usage"; +import { + filterUsageEnvironmentsForProject, + filterProviderContributionsForProject, + makeThreadBreakdownInput, + mergeUsageThreadBreakdowns, +} from "./usage"; + +describe("filterUsageEnvironmentsForProject", () => { + const environments = [ + { environmentId: "env-a" as EnvironmentId }, + { environmentId: "env-b" as EnvironmentId }, + ]; + + it("keeps only the environment that owns a namespaced project", () => { + expect( + filterUsageEnvironmentsForProject(environments, JSON.stringify(["env-a", "id:project-one"])), + ).toEqual([environments[0]]); + }); + + it("keeps every environment for all and outside-project views", () => { + expect(filterUsageEnvironmentsForProject(environments, undefined)).toEqual(environments); + expect(filterUsageEnvironmentsForProject(environments, null)).toEqual(environments); + }); +}); function row(provider: UsageProviderKind, overrides: Partial = {}): UsageThreadRow { return { @@ -116,4 +139,27 @@ describe("makeThreadBreakdownInput", () => { providers: ["claude"], }); }); + + it("keeps only the environment that owns a namespaced project", () => { + const contributions: readonly EnvironmentProviderContribution[] = [ + { + environmentId: "env-a" as EnvironmentId, + contractVersion: USAGE_CONTRACT_VERSION, + providers: ["claude"], + }, + { + environmentId: "env-b" as EnvironmentId, + contractVersion: USAGE_CONTRACT_VERSION, + providers: ["codex"], + }, + ]; + + expect( + filterProviderContributionsForProject( + JSON.stringify(["env-a", "id:project-one"]), + contributions, + ).map((contribution) => contribution.environmentId), + ).toEqual(["env-a"]); + expect(filterProviderContributionsForProject(null, contributions)).toEqual(contributions); + }); }); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index af30ec5f877c..47e04424ba35 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -82,10 +82,22 @@ export interface UsageView { readonly refresh: () => void; } +export function filterUsageEnvironmentsForProject< + T extends { readonly environmentId: EnvironmentId }, +>(environments: readonly T[], projectFilter: string | null | undefined): readonly T[] { + return environments.filter( + (environment) => + projectFilterForEnvironment(projectFilter, environment.environmentId) !== + "environment-mismatch:", + ); +} + export function useUsage( input: UsageSummaryInput, /** A namespaced project key, `null` for outside-projects buckets, `undefined` for no filter. */ projectFilter?: string | null, + /** Refresh the deferred thread query only while its table is mounted. */ + refreshThreads = false, ): UsageView { const windowKey = useMemo( () => @@ -138,24 +150,32 @@ export function useUsage( serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), ); } - for (const contribution of merged.providerContributions) { - if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; - appAtomRegistry.refresh( - serverEnvironment.usageThreadBreakdown({ - environmentId: contribution.environmentId, - input: makeThreadBreakdownInput( - input, - projectFilter, - contribution.providers, - contribution.environmentId, - ), - }), - ); + if (refreshThreads) { + for (const contribution of filterProviderContributionsForProject( + projectFilter, + merged.providerContributions, + )) { + if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; + appAtomRegistry.refresh( + serverEnvironment.usageThreadBreakdown({ + environmentId: contribution.environmentId, + input: makeThreadBreakdownInput( + input, + projectFilter, + contribution.providers, + contribution.environmentId, + ), + }), + ); + } } - }, [environments, merged.providerContributions, projectFilter, windowKey]); + }, [environments, merged.providerContributions, projectFilter, refreshThreads, windowKey]); - const answeredCount = environments.filter((environment) => environment.summary !== null).length; - const stillReporting = environments.filter( + const relevantEnvironments = filterUsageEnvironmentsForProject(environments, projectFilter); + const answeredCount = relevantEnvironments.filter( + (environment) => environment.summary !== null, + ).length; + const stillReporting = relevantEnvironments.filter( (environment) => environment.summary === null && environment.error === null, ).length; @@ -235,6 +255,19 @@ export function mergeUsageThreadBreakdowns( return { rows, truncatedRows }; } +/** Excludes environments that cannot own a namespaced project selection. */ +export function filterProviderContributionsForProject( + projectKey: string | null | undefined, + providerContributions: readonly EnvironmentProviderContribution[], +): readonly EnvironmentProviderContribution[] { + if (projectKey === undefined || projectKey === null) return providerContributions; + return providerContributions.filter( + (contribution) => + projectFilterForEnvironment(projectKey, contribution.environmentId) !== + "environment-mismatch:", + ); +} + const usageThreadsAtom = Atom.family((requestKey: string) => Atom.make((get): UsageThreadsView => { const { input, providerContributions } = JSON.parse(requestKey) as { @@ -242,12 +275,16 @@ const usageThreadsAtom = Atom.family((requestKey: string) => providerContributions: readonly EnvironmentProviderContribution[]; }; + const relevantContributions = filterProviderContributionsForProject( + input.projectKey, + providerContributions, + ); const breakdowns: EnvironmentUsageThreadBreakdown[] = []; let pending = 0; - let failed = providerContributions.filter( + let failed = relevantContributions.filter( (contribution) => contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE, ).length; - for (const contribution of providerContributions) { + for (const contribution of relevantContributions) { if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; const { environmentId } = contribution; const environmentInput = @@ -269,7 +306,7 @@ const usageThreadsAtom = Atom.family((requestKey: string) => if (breakdown === null) continue; breakdowns.push({ environmentId, breakdown }); } - const merged = mergeUsageThreadBreakdowns(breakdowns, providerContributions); + const merged = mergeUsageThreadBreakdowns(breakdowns, relevantContributions); return { ...merged, isPending: pending > 0, failedEnvironments: failed }; }).pipe(Atom.withLabel(`web-usage:threads:${requestKey}`)), diff --git a/docs/user/usage.md b/docs/user/usage.md index 23fef95dccf8..a8526900e1cb 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,10 +18,10 @@ to return to the preset. The date fields beside the presets accept custom ranges The breakdown's **Thread** view drills into where the spend went: sessions group into the T3 Code thread they belong to, with sessions that never ran through T3 Code listed under the first thing you asked in them. Grok Build has no trusted prompt title, so its rows use a short session label. -Expanding a row shows its daily estimated cost, along with any Claude subagents the thread spawned -and their share. The view returns at most 40 rows, reserving room to group lower-cost rows under -**Other threads** by provider and project. Those grouped rows stay in the totals, so the thread -view still adds up to the selected project or full summary. +Expanding a row shows its daily estimated cost, along with any Claude subagents the thread spawned. +Each connected environment contributes at most 40 rows, reserving room to group +lower-cost rows under **Other threads** by provider and project. Those grouped rows stay in the +totals, so the thread view still adds up to the selected project or full summary. Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 3a84fa47cc6c..5792ef9458a5 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -35,7 +35,7 @@ export const USAGE_CONTRACT_VERSION = 9 as const; export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; /** First contract version that explicitly distinguishes outside from unknown attribution. */ export const USAGE_PROJECT_ATTRIBUTION_SINCE = 8 as const; -/** First contract version that exposes the thread-breakdown RPC. */ +/** First contract version that exposes the current thread-breakdown RPC. */ export const USAGE_THREAD_BREAKDOWN_SINCE = 9 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); From 5b339aab6836da8990153e2373e574f63ebcb54f Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 28 Aug 2026 13:51:07 +1000 Subject: [PATCH 15/78] feat(web): usage thread rows link to the thread A thread row in the usage drill-down named the work but offered no way to get to it. Rows that map to a T3 thread now carry a link that opens the thread in the app; unattributed session rows stay link-free since there is nothing to open. Co-Authored-By: Claude Fable 5 --- .../usage/UsageThreadTable.test.tsx | 2 + .../src/components/usage/UsageThreadTable.tsx | 81 +++++++++++++------ apps/web/src/state/usage.ts | 1 + docs/user/usage.md | 1 + 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/usage/UsageThreadTable.test.tsx b/apps/web/src/components/usage/UsageThreadTable.test.tsx index 211cba886a59..9fc05e7ddf7a 100644 --- a/apps/web/src/components/usage/UsageThreadTable.test.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsageThreads: vi.fn() })); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); vi.mock("../../state/usage", () => ({ useUsageThreads: testState.useUsageThreads })); vi.mock("../ui/tooltip", async () => { const React = await import("react"); @@ -117,6 +118,7 @@ describe("UsageThreadTable", () => { expect(markup).toContain('; +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f99010078dea..1207230cb16b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -41,6 +41,7 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { UsageCacheWriteCell } from "./UsageCacheWriteCell"; import { UsageThreadTable } from "./UsageThreadTable"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -334,7 +335,9 @@ export function UsagePage() { const scope = sessionsKnown ? `${formatCount(merged.sessions)} sessions` : (selectedProjectLabel ?? "Outside projects"); - return metric === "cost" ? `${scope} ยท API estimate` : scope; + return metric === "cost" + ? `${scope} ยท local public-list estimate` + : scope; })()} @@ -429,7 +432,7 @@ export function UsagePage() { /> 0 && merged.costQuality.cacheWriteUsd !== null ? { - detail: `${formatPercent(merged.costQuality.cacheWriteUsd / merged.costUsd, 0)} of cost`, + detail: `${formatPercent(merged.costQuality.cacheWriteUsd / merged.costUsd, 0)} of estimate ยท not an expiry measure`, } : {})} /> @@ -550,7 +553,7 @@ export function UsagePage() { - @@ -611,7 +614,7 @@ export function UsagePage() { - @@ -859,29 +862,6 @@ function Metric({ ); } -/** - * Cache-write cost cell. Providers that bill no cache writes (Codex) show a - * dash rather than a misleading $0.00. - */ -function CacheWriteCell({ - cacheWriteTokens, - cacheWriteUsd, -}: { - readonly cacheWriteTokens: number; - readonly cacheWriteUsd: number | null; -}) { - return ( - - ); -} - -function formatCacheWriteCost(cacheWriteTokens: number, cacheWriteUsd: number | null): string { - if (cacheWriteTokens === 0) return "-"; - return cacheWriteUsd === null ? "Unavailable" : formatUsd(cacheWriteUsd); -} - /** * Says plainly when the totals are incomplete: an environment that failed, or * one whose transcripts another environment already reported. Environments @@ -1026,7 +1006,7 @@ function UsageSkeleton() { "Cached input", "Uncached input", "Output", - "Estimated cache writes", + "Cache writes, estimated", "Cache savings", ].map((label) => (
diff --git a/apps/web/src/components/usage/UsageThreadTable.tsx b/apps/web/src/components/usage/UsageThreadTable.tsx index 246c8ce178c0..f3b1c134cf65 100644 --- a/apps/web/src/components/usage/UsageThreadTable.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.tsx @@ -22,6 +22,7 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { UsageCacheWriteCell } from "./UsageCacheWriteCell"; import { PROVIDER_PRESENTATION } from "./usageProviders"; /** @@ -214,9 +215,10 @@ function ThreadRowGroup({
- + @@ -258,17 +260,13 @@ function ThreadRowGroup({ ); } -function formatCacheWriteCost(cacheWriteTokens: number, cacheWriteUsd: number | null): string { - if (cacheWriteTokens === 0) return "-"; - return cacheWriteUsd === null ? "Unavailable" : formatUsd(cacheWriteUsd); -} - const CHART_WIDTH = 760; const CHART_HEIGHT = 96; /** - * One thread's daily model-priced cost split into cache writes, cache reads, - * and fresh input plus output. Static SVG, no animation. + * One thread's daily model-priced cost stacked by component: cache writes, + * cache reads, and fresh input plus output. Cache writes are a billing + * category, not an inferred cause. Static SVG, no animation. */ export function UsageThreadDailyChart({ daily, diff --git a/docs/user/usage.md b/docs/user/usage.md index 7991b2fb81b5..8cb240f46dff 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,9 +1,14 @@ # Review usage The Usage page combines Codex, Claude Code, and Grok Build activity from your connected -environments. It reads the providers' local session history and shows API-equivalent token cost, +environments. It reads the providers' local session history and shows a public-list-rate estimate, processed tokens, cache savings, provider shares, and model breakdowns. Subscription billing is -separate from the raw token cost shown here. +separate from this local estimate. + +Claude Code accounting keeps the final progressive snapshot for each response and prices every +attempt in a model-fallback sequence. Five-minute and one-hour cache writes use their distinct +public rates when the transcript provides the TTL. Thinking tokens remain part of output rather +than being charged twice. Grok Build totals come from persisted session updates. Interactive turns that never wrote a completed-turn record will not appear. @@ -26,9 +31,10 @@ under **Other threads** by provider and project. Those grouped rows stay in the thread view still adds up to the selected project or full summary. Rows that map to a thread carry a link that opens it. -The **Estimated cache writes** total prices cache-creation tokens at each model's cache-write rate. +The **Cache writes, estimated** total prices cache-creation tokens at each model's cache-write rate. It only applies to model-priced records that report cache-creation tokens. Rows without cache writes show a dash; incomplete or unavailable pricing is labeled **Unavailable** instead of zero. +Cache creation is a billing category, not evidence that a cache entry expired. Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 62a4f53a900a..e5f72ecff24a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -21,7 +21,7 @@ import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./ba * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 10 as const; +export const USAGE_CONTRACT_VERSION = 11 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. @@ -29,9 +29,9 @@ export const USAGE_CONTRACT_VERSION = 10 as const; * v5 only adds `grok` to {@link UsageProviderKind}; v6 adds the optional bucket * `project`; v7 adds its optional stable `projectId`; v8 distinguishes outside * projects from unknown attribution; v9 adds the separate thread-breakdown RPC; - * v10 adds optional cache-write costs. v4 Claude/Codex buckets remain valid, so - * mixed-version environments keep those totals instead of treating every older - * server as stale. + * v10 adds optional cache-write costs; v11 adds optional cache-write TTL counters. + * v4 Claude/Codex buckets remain valid, so mixed-version environments keep those + * totals instead of treating every older server as stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; /** First contract version that explicitly distinguishes outside from unknown attribution. */ @@ -81,6 +81,10 @@ export const UsageTokenTotals = Schema.Struct({ uncachedInputTokens: NonNegativeInt, cachedInputTokens: NonNegativeInt, cacheCreationTokens: NonNegativeInt, + /** Anthropic five-minute cache writes, when the transcript reports the TTL. */ + cacheCreation5mTokens: Schema.optional(NonNegativeInt), + /** Anthropic one-hour cache writes, when the transcript reports the TTL. */ + cacheCreation1hTokens: Schema.optional(NonNegativeInt), outputTokens: NonNegativeInt, reasoningTokens: NonNegativeInt, }); @@ -125,9 +129,9 @@ export const UsageBucket = Schema.Struct({ */ cacheSavingsUsd: Schema.Number, /** - * Estimated cost of the cache-creation tokens in this bucket at the model's - * cache-write rate. A subset of `costUsd` when the bucket is model-priced. - * Absent from summaries written before this field existed. + * Estimated cache-write cost at the model and TTL-specific rates. Cache + * creation is a billing category, not proof of expiry. A subset of `costUsd` + * when the bucket is model-priced. Absent from older summaries. */ cacheWriteUsd: Schema.optional(Schema.Number), costSource: UsageCostSource, From bdff4c9ee8ef821f7f4237f3d51abb13b33524d8 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 28 Aug 2026 13:02:57 +1000 Subject: [PATCH 17/78] feat(usage): surface estimated cache-write cost --- .../server/src/usage/usageAggregation.test.ts | 20 ++++ apps/server/src/usage/usageAggregation.ts | 6 +- apps/server/src/usage/usagePricing.ts | 41 ++++++++ apps/server/src/usage/usageThreads.test.ts | 6 +- apps/server/src/usage/usageThreads.ts | 74 +++++++++++--- .../src/components/usage/UsagePage.test.tsx | 21 ++++ apps/web/src/components/usage/UsagePage.tsx | 97 +++++++++++++++---- .../src/components/usage/UsageThreadTable.tsx | 87 ++++++++++++----- apps/web/src/state/usage.test.ts | 3 +- docs/user/usage.md | 12 ++- packages/contracts/src/usage.ts | 29 ++++-- packages/shared/src/usageMerge.test.ts | 75 ++++++++++++++ packages/shared/src/usageMerge.ts | 33 ++++++- 13 files changed, 429 insertions(+), 75 deletions(-) diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 0a4a7e6a2e15..2948382c5981 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -188,6 +188,26 @@ describe("UsageAggregator", () => { // 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"); + // Cache writes priced at the cache-write rate: 10 * 1.25e-5. + expect(result.buckets[0]?.cacheWriteUsd).toBeCloseTo(1.25e-4, 12); + }); + + it("reports zero cache-write cost for unpriced models and write-free usage", () => { + const unpriced = aggregate([record({ model: "kimi-k3" })]); + expect(unpriced.buckets[0]?.cacheWriteUsd).toBe(0); + + const writeFree = aggregate([ + record({ + totals: { + uncachedInputTokens: 100, + cachedInputTokens: 1000, + cacheCreationTokens: 0, + outputTokens: 50, + reasoningTokens: 0, + }, + }), + ]); + expect(writeFree.buckets[0]?.cacheWriteUsd).toBe(0); }); it("counts tokens but not cost for a model with no rate", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index a826242a0336..0b75ad1321c6 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -21,7 +21,7 @@ import type { } from "@t3tools/contracts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; -import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; +import { cacheSavingsUsd, cacheWriteUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** * Formats an instant as a `YYYY-MM-DD` day in `timeZone`. @@ -113,6 +113,7 @@ interface MutableBucket { totals: UsageTokenTotals; costUsd: number; cacheSavingsUsd: number; + cacheWriteUsd: number; records: number; unpricedRecords: number; providerReportedRecords: number; @@ -230,6 +231,7 @@ export class UsageAggregator { totals: EMPTY_TOTALS, costUsd: 0, cacheSavingsUsd: 0, + cacheWriteUsd: 0, records: 0, unpricedRecords: 0, providerReportedRecords: 0, @@ -248,6 +250,7 @@ export class UsageAggregator { bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); + bucket.cacheWriteUsd += cacheWriteUsd(this.#options.rates, record.model, record.totals); bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; @@ -278,6 +281,7 @@ export class UsageAggregator { totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, + cacheWriteUsd: bucket.cacheWriteUsd, costSource: resolveCostSource(bucket), records: bucket.records, unpricedRecords: bucket.unpricedRecords, diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 3d7f5fd29485..6ee5ff0ed408 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -184,3 +184,44 @@ export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTo if (rate === null) return 0; return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); } + +/** + * Estimated cost of this usage's cache-creation tokens at the model's + * cache-write rate. Zero when the model is unpriced or the provider reports + * no cache-creation tokens. + */ +export function cacheWriteUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { + const rate = lookupRate(table, model); + if (rate === null) return 0; + return totals.cacheCreationTokens * rate.cacheCreationCostPerToken; +} + +export interface UsageComponentCosts { + readonly cacheWriteUsd: number; + readonly cacheReadUsd: number; + /** Fresh input plus output. */ + readonly freshUsd: number; +} + +const ZERO_COMPONENTS: UsageComponentCosts = { cacheWriteUsd: 0, cacheReadUsd: 0, freshUsd: 0 }; + +/** + * Splits model-priced usage into cache writes, cache reads, and everything + * else. Unpriced models contribute nothing here; token totals still include + * them. + */ +export function usageComponentCosts( + table: RateTable, + model: string, + totals: UsageTokenTotals, +): UsageComponentCosts { + const rate = lookupRate(table, model); + if (rate === null) return ZERO_COMPONENTS; + return { + cacheWriteUsd: totals.cacheCreationTokens * rate.cacheCreationCostPerToken, + cacheReadUsd: totals.cachedInputTokens * rate.cacheReadCostPerToken, + freshUsd: + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.outputTokens * rate.outputCostPerToken, + }; +} diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index f48cbe99cd9e..bcde424f2ea8 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -85,12 +85,14 @@ describe("ThreadUsageAccumulator", () => { expect(groups[0]?.totals.outputTokens).toBe(50); }); - it("records each day's estimated cost", () => { + it("splits each day's model-priced cost into cache components", () => { const context = { sessionKey: "claude:session-a", agentId: null }; const groups = accumulate([[record(), context]]); const day = groups[0]?.daily.get("2026-08-07"); - expect(day).toBeCloseTo(100 * 1e-5 + 1000 * 1e-6 + 10 * 1.25e-5 + 50 * 5e-5, 12); + expect(day?.cacheWriteUsd).toBeCloseTo(10 * 1.25e-5, 12); + expect(day?.cacheReadUsd).toBeCloseTo(1000 * 1e-6, 12); + expect(day?.freshUsd).toBeCloseTo(100 * 1e-5 + 50 * 5e-5, 12); }); it("drops records outside the window", () => { diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 13ac7c314a00..80500fbbe7c3 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -21,7 +21,7 @@ import type { import { UsageDay } from "@t3tools/contracts"; import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts"; -import { priceUsage, type RateTable } from "./usagePricing.ts"; +import { cacheWriteUsd, priceUsage, usageComponentCosts, type RateTable } from "./usagePricing.ts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; @@ -34,9 +34,16 @@ export interface ThreadRecordContext { readonly agentId: string | null; } +interface MutableComponentCosts { + cacheWriteUsd: number; + cacheReadUsd: number; + freshUsd: number; +} + interface MutableAgentSlice { totals: UsageTokenTotals; costUsd: number; + cacheWriteUsd: number; } export interface SessionUsageGroup { @@ -49,7 +56,8 @@ export interface SessionUsageGroup { readonly project: string; readonly totals: UsageTokenTotals; readonly costUsd: number; - readonly daily: ReadonlyMap; + readonly cacheWriteUsd: number; + readonly daily: ReadonlyMap; readonly agents: ReadonlyMap; } @@ -63,7 +71,8 @@ interface MutableSessionGroup { project: string; totals: UsageTokenTotals; costUsd: number; - daily: Map; + cacheWriteUsd: number; + daily: Map; agents: Map; } @@ -79,7 +88,7 @@ export interface ThreadUsageOptions { } /** - * Folds records into per-session groups with per-day estimated costs. + * Folds records into per-session groups with per-day component costs. * * De-duplication is global across the scan with the same semantics as the * summary aggregator, so a thread's number here always reconciles with its @@ -139,6 +148,7 @@ export class ThreadUsageAccumulator { project: resolvedProject?.title ?? "", totals: EMPTY_TOTALS, costUsd: 0, + cacheWriteUsd: 0, daily: new Map(), agents: new Map(), }; @@ -151,18 +161,30 @@ export class ThreadUsageAccumulator { record.totals, record.reportedCostUsd, ); + const writeUsd = cacheWriteUsd(this.#options.rates, record.model, record.totals); group.totals = addTotals(group.totals, record.totals); group.costUsd += priced.costUsd; - group.daily.set(day, (group.daily.get(day) ?? 0) + priced.costUsd); + group.cacheWriteUsd += writeUsd; + + const components = usageComponentCosts(this.#options.rates, record.model, record.totals); + let dayEntry = group.daily.get(day); + if (dayEntry === undefined) { + dayEntry = { cacheWriteUsd: 0, cacheReadUsd: 0, freshUsd: 0 }; + group.daily.set(day, dayEntry); + } + dayEntry.cacheWriteUsd += components.cacheWriteUsd; + dayEntry.cacheReadUsd += components.cacheReadUsd; + dayEntry.freshUsd += components.freshUsd; if (context.agentId !== null) { let agent = group.agents.get(context.agentId); if (agent === undefined) { - agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + agent = { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0 }; group.agents.set(context.agentId, agent); } agent.totals = addTotals(agent.totals, record.totals); agent.costUsd += priced.costUsd; + agent.cacheWriteUsd += writeUsd; } return true; } @@ -178,6 +200,7 @@ export class ThreadUsageAccumulator { project: group.project, totals: group.totals, costUsd: group.costUsd, + cacheWriteUsd: group.cacheWriteUsd, daily: group.daily, agents: group.agents, })); @@ -219,8 +242,9 @@ interface MutableThreadRow { totals: UsageTokenTotals; costUsd: number; sessionKeys: Set; + cacheWriteUsd: number; groupedRows: number; - daily: Map; + daily: Map; agents: Map; /** Session whose transcript can supply a title when no thread claims the row. */ titleSessionKey: string; @@ -234,9 +258,19 @@ export interface FoldedThreadRows { readonly truncatedRows: number; } -function addDailyCosts(target: Map, source: ReadonlyMap): void { - for (const [day, costUsd] of source) { - target.set(day, (target.get(day) ?? 0) + costUsd); +function addDailyCosts( + target: Map, + source: ReadonlyMap, +): void { + for (const [day, components] of source) { + let dayEntry = target.get(day); + if (dayEntry === undefined) { + dayEntry = { cacheWriteUsd: 0, cacheReadUsd: 0, freshUsd: 0 }; + target.set(day, dayEntry); + } + dayEntry.cacheWriteUsd += components.cacheWriteUsd; + dayEntry.cacheReadUsd += components.cacheReadUsd; + dayEntry.freshUsd += components.freshUsd; } } @@ -279,6 +313,7 @@ function toAgentRow([agentId, slice]: readonly [string, MutableAgentSlice]): Usa agentId, totals: slice.totals, costUsd: slice.costUsd, + cacheWriteUsd: slice.cacheWriteUsd, }; } @@ -300,10 +335,12 @@ function boundedAgentRows( (combined, [, slice]) => ({ totals: addTotals(combined.totals, slice.totals), costUsd: combined.costUsd + slice.costUsd, + cacheWriteUsd: combined.cacheWriteUsd + slice.cacheWriteUsd, }), { totals: EMPTY_TOTALS, costUsd: 0, + cacheWriteUsd: 0, }, ); return [...kept.map(toAgentRow), toAgentRow([`Other subagents (${omitted.length})`, overflow])]; @@ -349,6 +386,7 @@ export function foldThreadRows( totals: EMPTY_TOTALS, costUsd: 0, sessionKeys: new Set(), + cacheWriteUsd: 0, groupedRows: 0, daily: new Map(), agents: new Map(), @@ -360,15 +398,17 @@ export function foldThreadRows( row.totals = addTotals(row.totals, group.totals); row.costUsd += group.costUsd; row.sessionKeys.add(group.sessionKey); + row.cacheWriteUsd += group.cacheWriteUsd; addDailyCosts(row.daily, group.daily); for (const [agentId, slice] of group.agents) { let agent = row.agents.get(agentId); if (agent === undefined) { - agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + agent = { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0 }; row.agents.set(agentId, agent); } agent.totals = addTotals(agent.totals, slice.totals); agent.costUsd += slice.costUsd; + agent.cacheWriteUsd += slice.cacheWriteUsd; } } @@ -420,6 +460,7 @@ export function foldThreadRows( totals: EMPTY_TOTALS, costUsd: 0, sessionKeys: new Set(), + cacheWriteUsd: 0, groupedRows: 0, daily: new Map(), agents: new Map(), @@ -431,15 +472,17 @@ export function foldThreadRows( remainder.totals = addTotals(remainder.totals, omittedRow.totals); remainder.costUsd += omittedRow.costUsd; for (const sessionKey of omittedRow.sessionKeys) remainder.sessionKeys.add(sessionKey); + remainder.cacheWriteUsd += omittedRow.cacheWriteUsd; addDailyCosts(remainder.daily, omittedRow.daily); for (const [agentId, slice] of omittedRow.agents) { let agent = remainder.agents.get(agentId); if (agent === undefined) { - agent = { totals: EMPTY_TOTALS, costUsd: 0 }; + agent = { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0 }; remainder.agents.set(agentId, agent); } agent.totals = addTotals(agent.totals, slice.totals); agent.costUsd += slice.costUsd; + agent.cacheWriteUsd += slice.cacheWriteUsd; } } @@ -467,13 +510,16 @@ export function foldThreadRows( ...(row.project === "" ? {} : { project: row.project }), totals: row.totals, costUsd: row.costUsd, + cacheWriteUsd: row.cacheWriteUsd, sessions: row.sessionKeys.size, ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), agents: boundedAgentRows(row.agents, options.cap), daily: [...row.daily.entries()] - .map(([day, costUsd]) => ({ + .map(([day, components]) => ({ day: day as UsageDay, - costUsd, + cacheWriteUsd: components.cacheWriteUsd, + cacheReadUsd: components.cacheReadUsd, + freshUsd: components.freshUsd, })) .sort((a, b) => a.day.localeCompare(b.day)) satisfies UsageThreadDayCost[], })), diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 39cd284eca0b..2394e5e71edb 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -88,6 +88,8 @@ const modelTotals = Object.freeze([ provider: "claude" as const, costUsd: 10, totalTokens: 100, + cacheWriteTokens: 40, + cacheWriteUsd: 2.5, records: 1, costShare: 10 / 16, }, @@ -96,6 +98,8 @@ const modelTotals = Object.freeze([ provider: "codex" as const, costUsd: 5, totalTokens: 1_000, + cacheWriteTokens: 0, + cacheWriteUsd: 0, records: 1, costShare: 5 / 16, }, @@ -104,6 +108,8 @@ const modelTotals = Object.freeze([ provider: "codex" as const, costUsd: 1, totalTokens: 1_000, + cacheWriteTokens: 0, + cacheWriteUsd: 0, records: 1, costShare: 1 / 16, }, @@ -116,6 +122,8 @@ const projectTotals = Object.freeze([ project: "Expensive Project", costUsd: 9, totalTokens: 200, + cacheWriteTokens: 60, + cacheWriteUsd: 1.75, records: 2, costShare: 9 / 20, }, @@ -125,6 +133,8 @@ const projectTotals = Object.freeze([ project: null, costUsd: 7, totalTokens: 900, + cacheWriteTokens: 0, + cacheWriteUsd: 0, records: 1, costShare: 7 / 20, }, @@ -295,6 +305,17 @@ describe("UsagePage model breakdown", () => { expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); }); + it("shows cache-write cost per row, with a dash for write-free providers", () => { + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + // Claude row carries its cache-write dollars; codex rows never bill writes. + expect(body).toContain("$2.50"); + expect(body).toMatch(/token-heavy-model.*>- { testState.metric = "tokens"; testState.breakdown = "model"; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index d8977ddaaee4..639de369511b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -420,7 +420,7 @@ export function UsagePage() {

Totals

-
+
+ 0 + ? { + detail: `${formatPercent(merged.costQuality.cacheWriteUsd / merged.costUsd, 0)} of cost`, + } + : {})} + />
- - - - + + + + + + @@ -511,7 +522,7 @@ export function UsagePage() { {breakdownProjects.length === 0 ? ( - +
- {failedEnvironments > 0 + {unavailableEnvironments > 0 ? "Thread activity could not be loaded for this window." : "No activity in this window."}
- {failedEnvironments === 1 + {unavailableEnvironments === 1 ? "1 environment could not report threads." - : `${failedEnvironments} environments could not report threads.`} + : `${unavailableEnvironments} environments could not report threads.`}
- - - } - > - - - {row.title} - {row.agents.length > 0 ? ( - + + + } + > + + + {row.title} + {row.agents.length > 0 ? ( + + {row.agents.length === 1 ? "1 subagent" : `${row.agents.length} subagents`} + + ) : null} + + {row.title} + + {threadId === null ? null : ( + + { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: row.environmentId, threadId }, + }); + }} + /> + } > - {row.agents.length === 1 ? "1 subagent" : `${row.agents.length} subagents`} - - ) : null} - - {row.title} - + + + Open thread + + )} + {formatUsd(row.costUsd)} diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 47e04424ba35..72ebb61ece1e 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -189,6 +189,7 @@ export function useUsage( } export interface UsageThreadRowWithEnvironment extends UsageThreadRow { + /** Environment that reported the row; thread deep links are environment-scoped. */ readonly environmentId: EnvironmentId; } diff --git a/docs/user/usage.md b/docs/user/usage.md index a8526900e1cb..b1f65d2d04fe 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -22,6 +22,7 @@ Expanding a row shows its daily estimated cost, along with any Claude subagents Each connected environment contributes at most 40 rows, reserving room to group lower-cost rows under **Other threads** by provider and project. Those grouped rows stay in the totals, so the thread view still adds up to the selected project or full summary. +Rows that map to a thread carry a link that opens it. Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker From 80a8944073555e74263d1c3eba8a0b448a21beae Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 16:27:06 +1000 Subject: [PATCH 16/78] fix(usage): correct Claude cost accounting --- .../src/features/usage/UsageRouteScreen.tsx | 15 ++- apps/server/src/usage/UsageService.ts | 28 ++--- .../server/src/usage/usageAggregation.test.ts | 67 ++++++++++- apps/server/src/usage/usageAggregation.ts | 74 ++++++++---- apps/server/src/usage/usagePricing.test.ts | 46 +++++++- apps/server/src/usage/usagePricing.ts | 36 ++++-- apps/server/src/usage/usageScanCache.test.ts | 27 ++++- apps/server/src/usage/usageScanCache.ts | 87 ++++++++------ apps/server/src/usage/usageThreads.test.ts | 33 ++++++ apps/server/src/usage/usageThreads.ts | 47 ++++++-- .../server/src/usage/usageTranscriptReader.ts | 6 +- .../server/src/usage/usageTranscripts.test.ts | 78 +++++++++++++ apps/server/src/usage/usageTranscripts.ts | 108 +++++++++++++----- .../components/usage/UsageCacheWriteCell.tsx | 19 +++ apps/web/src/components/usage/UsagePage.tsx | 38 ++---- .../src/components/usage/UsageThreadTable.tsx | 18 ++- docs/user/usage.md | 12 +- packages/contracts/src/usage.ts | 18 +-- 18 files changed, 578 insertions(+), 179 deletions(-) create mode 100644 apps/web/src/components/usage/UsageCacheWriteCell.tsx diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f9543..5201b235e55b 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -204,14 +204,14 @@ function ChartCard(props: { - {metric === "cost" ? "Raw token cost" : "Processed tokens"} + {metric === "cost" ? "Local public-list estimate" : "Processed tokens"} {metric === "cost" ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" + ? "* estimated from local transcripts at public list rates" : `Across ${formatCount(merged.sessions)} sessions`} @@ -382,7 +382,16 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H + (); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + const records = dedupeWithinFile([...base, ...parsed.records]); + const tailRecords = dedupeWithinFile(parsed.tailRecords); fileCache.set(filePath, { size, @@ -404,7 +402,7 @@ export const make = Effect.gen(function* () { position: parsed.position, }); cacheDirty = true; - return tailRecords.length === 0 ? records : [...records, ...tailRecords]; + return tailRecords.length === 0 ? records : dedupeWithinFile([...records, ...tailRecords]); }); /** One provider directory's walk and parse, before rates are involved. */ @@ -529,10 +527,6 @@ export const make = Effect.gen(function* () { walkedRoots.push(dir); let scannedFiles = 0; let skippedFiles = 0; - // Distinct per directory. Buckets carry per-cell session counts, but a - // session spans days and models, so clients total this figure instead. - const sessionIds = new Set(); - for (const file of files) { livePaths.add(file.path); if (file.records.length === 0) { @@ -541,11 +535,7 @@ export const make = Effect.gen(function* () { } scannedFiles += 1; for (const record of file.records) { - // Only sessions that contributed in-window count: the mtime slack - // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { - sessionIds.add(record.sessionId); - } + aggregator.add(record); } } @@ -555,7 +545,9 @@ export const make = Effect.gen(function* () { scannedFiles, skippedFiles, malformedRecords: 0, - distinctSessions: sessionIds.size, + // Read from the settled records so a progressive snapshot replacement + // cannot leave the source count attached to the superseded session. + distinctSessions: aggregator.distinctSessions(provider), message: null, }); } diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index c3e445a18a80..20752f3a91f5 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -13,6 +13,7 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, + cacheCreation1hCostPerToken: 2e-5, }, ], ]); @@ -89,6 +90,22 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(50); }); + it("uses the final complete snapshot for a repeated dedupe key", () => { + const result = aggregate([ + record({ + dedupeKey: "msg_partial:", + totals: { ...record().totals, outputTokens: 1 }, + }), + record({ + dedupeKey: "msg_partial:", + totals: { ...record().totals, outputTokens: 310 }, + }), + ]); + + expect(result.buckets[0]?.records).toBe(1); + expect(result.buckets[0]?.totals.outputTokens).toBe(310); + }); + it("still sums records that carry no dedupe key", () => { const result = aggregate([record(), record()]); @@ -192,6 +209,21 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.cacheWriteUsd).toBeCloseTo(1.25e-4, 12); }); + it("prices one-hour cache writes at their separate rate", () => { + const result = aggregate([ + record({ + totals: { + ...record().totals, + cacheCreationTokens: 30, + cacheCreation5mTokens: 10, + cacheCreation1hTokens: 20, + }, + }), + ]); + + expect(result.buckets[0]?.cacheWriteUsd).toBeCloseTo(10 * 1.25e-5 + 20 * 2e-5, 12); + }); + it("distinguishes unavailable cache-write cost from write-free usage", () => { const unpriced = aggregate([record({ model: "kimi-k3" })]); expect(unpriced.buckets[0]?.cacheWriteUsd).toBeUndefined(); @@ -234,7 +266,7 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(0); }); - it("reports whether a record contributed", () => { + it("reports whether a record falls in the window", () => { const aggregator = new UsageAggregator({ timeZone: "UTC", sinceDay: "2026-08-01", @@ -243,10 +275,41 @@ describe("UsageAggregator", () => { }); expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); + it("counts sessions from the final progressive snapshot", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + aggregator.add(record({ dedupeKey: "msg_1:", sessionId: "partial-session" })); + aggregator.add(record({ dedupeKey: "msg_1:", sessionId: "final-session" })); + + expect(aggregator.distinctSessions("claude")).toBe(1); + expect(aggregator.finish().buckets[0]?.sessions).toBe(1); + }); + + it("applies the window to the final progressive snapshot", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + + aggregator.add(record({ dedupeKey: "msg_1:" })); + aggregator.add( + record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-09-01T00:00:00Z") }), + ); + + expect(aggregator.finish()).toMatchObject({ buckets: [], outOfWindow: 1 }); + }); + it("separates providers and models into their own buckets", () => { const result = aggregate([ record(), diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 88645f884d3b..c29a6a978834 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -140,7 +140,7 @@ export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ readonly duplicatesDropped: number; - /** Records whose day fell outside the requested window. */ + /** Retained records whose day fell outside the requested window. */ readonly outOfWindow: number; } @@ -152,13 +152,12 @@ export interface AggregateResult { * the same `dedupeKey` legitimately appears in several transcripts. */ export class UsageAggregator { - readonly #buckets = new Map(); - readonly #seen = new Set(); + readonly #recordsByKey = new Map(); + readonly #unkeyedRecords: UsageRecord[] = []; readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; #duplicatesDropped = 0; - #outOfWindow = 0; constructor(options: AggregateOptions) { this.#options = options; @@ -176,26 +175,30 @@ export class UsageAggregator { } } - /** - * Folds one record in. Returns whether it actually contributed, so callers - * can derive per-window facts (distinct sessions, for one) from the records - * that landed rather than everything the mtime prefilter happened to admit. - */ + /** Retains one record and reports whether it falls in the requested window. */ add(record: UsageRecord): boolean { - if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) { - this.#duplicatesDropped += 1; - return false; - } - this.#seen.add(record.dedupeKey); + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push(record); + return inWindow; + } + if (this.#recordsByKey.has(record.dedupeKey)) { + // Claude writes progressive snapshots for one response. The final copy + // is complete, so replace the earlier one without counting it twice. + this.#recordsByKey.set(record.dedupeKey, record); + this.#duplicatesDropped += 1; + return inWindow; } + this.#recordsByKey.set(record.dedupeKey, record); + return inWindow; + } + #isInWindow(record: UsageRecord): boolean { if ( this.#hourlyWindow !== null && (record.timestampMs < this.#hourlyWindow.sinceTimeMs || record.timestampMs >= this.#hourlyWindow.untilTimeMs) ) { - this.#outOfWindow += 1; return false; } @@ -204,9 +207,26 @@ export class UsageAggregator { this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) ) { - this.#outOfWindow += 1; return false; } + return true; + } + + /** Distinct in-window sessions retained after progressive snapshots settle. */ + distinctSessions(provider: UsageRecord["provider"]): number { + const sessionIds = new Set(); + const addSession = (record: UsageRecord): void => { + if (this.#isInWindow(record) && record.provider === provider && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + }; + for (const record of this.#unkeyedRecords) addSession(record); + for (const record of this.#recordsByKey.values()) addSession(record); + return sessionIds.size; + } + + #foldRecord(record: UsageRecord, buckets: Map): void { + const day = this.#toDay(record.timestampMs); const hourStart = this.#hourlyWindow === null @@ -226,7 +246,7 @@ export class UsageAggregator { const projectId = resolvedProject?.projectId.replaceAll("\u0000", "") ?? ""; const project = resolvedProject?.title.replaceAll("\u0000", "") ?? ""; const key = `${day}\u0000${hourStart}\u0000${projectAttribution}\u0000${projectId}\u0000${project}\u0000${record.provider}\u0000${record.model}`; - let bucket = this.#buckets.get(key); + let bucket = buckets.get(key); if (bucket === undefined) { bucket = { totals: EMPTY_TOTALS, @@ -239,7 +259,7 @@ export class UsageAggregator { providerReportedRecords: 0, sessions: new Set(), }; - this.#buckets.set(key, bucket); + buckets.set(key, bucket); } const priced = priceUsage( @@ -261,12 +281,22 @@ export class UsageAggregator { if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); - return true; } finish(): AggregateResult { + const bucketsByKey = new Map(); + let outOfWindow = 0; + const foldIfInWindow = (record: UsageRecord): void => { + if (this.#isInWindow(record)) { + this.#foldRecord(record, bucketsByKey); + } else { + outOfWindow += 1; + } + }; + for (const record of this.#unkeyedRecords) foldIfInWindow(record); + for (const record of this.#recordsByKey.values()) foldIfInWindow(record); const buckets: UsageBucket[] = []; - for (const [key, bucket] of this.#buckets) { + for (const [key, bucket] of bucketsByKey) { const [ day = "", hourStart = "", @@ -308,7 +338,7 @@ export class UsageAggregator { return { buckets, duplicatesDropped: this.#duplicatesDropped, - outOfWindow: this.#outOfWindow, + outOfWindow, }; } } diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 2ea27375b148..a3f7006a6b99 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; +import { cacheWriteUsd, lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; const rate = (input: number, cacheRead?: number) => ({ input_cost_per_token: input, @@ -52,4 +52,48 @@ describe("usage pricing", () => { expect(lookupRate(table, "provider-b/example-model")?.inputCostPerToken).toBe(3); expect(lookupRate(table, "example-model")).toBeNull(); }); + + it("keeps a bare name ambiguous when only the one-hour cache rate differs", () => { + const common = { + input_cost_per_token: 1, + output_cost_per_token: 5, + cache_read_input_token_cost: 0.1, + cache_creation_input_token_cost: 1.25, + }; + const table = parseRateTable({ + "provider-a/example-model": { + ...common, + cache_creation_input_token_cost_above_1hr: 2, + }, + "provider-b/example-model": { + ...common, + cache_creation_input_token_cost_above_1hr: 3, + }, + }); + + expect(lookupRate(table, "example-model")).toBeNull(); + }); + + it("cannot price more TTL-specific tokens than total cache creation", () => { + const table = parseRateTable({ + "example-model": { + input_cost_per_token: 1, + output_cost_per_token: 5, + cache_creation_input_token_cost: 1.25, + cache_creation_input_token_cost_above_1hr: 2, + }, + }); + + expect( + cacheWriteUsd(table, "example-model", { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 10, + cacheCreation5mTokens: 20, + cacheCreation1hTokens: 20, + outputTokens: 0, + reasoningTokens: 0, + }), + ).toBe(20); + }); }); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 6ee5ff0ed408..1493ab4ae48a 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -22,6 +22,7 @@ export interface ModelRate { readonly outputCostPerToken: number; readonly cacheReadCostPerToken: number; readonly cacheCreationCostPerToken: number; + readonly cacheCreation1hCostPerToken?: number; } export type RateTable = ReadonlyMap; @@ -32,6 +33,7 @@ interface LiteLlmEntry { readonly output_cost_per_token?: unknown; readonly cache_read_input_token_cost?: unknown; readonly cache_creation_input_token_cost?: unknown; + readonly cache_creation_input_token_cost_above_1hr?: unknown; } function finiteNumber(value: unknown): number | null { @@ -69,6 +71,10 @@ export function parseRateTable(document: unknown): RateTable { // input rather than as free. cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + cacheCreation1hCostPerToken: + finiteNumber(entry.cache_creation_input_token_cost_above_1hr) ?? + finiteNumber(entry.cache_creation_input_token_cost) ?? + input, }); } @@ -96,7 +102,8 @@ function sameRate(a: ModelRate, b: ModelRate): boolean { a.inputCostPerToken === b.inputCostPerToken && a.outputCostPerToken === b.outputCostPerToken && a.cacheReadCostPerToken === b.cacheReadCostPerToken && - a.cacheCreationCostPerToken === b.cacheCreationCostPerToken + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken && + a.cacheCreation1hCostPerToken === b.cacheCreation1hCostPerToken ); } @@ -147,6 +154,22 @@ export interface PricedUsage { readonly costSource: UsageCostSource; } +function cacheCreationCost(totals: UsageTokenTotals, rate: ModelRate): number { + const oneHour = Math.min( + totals.cacheCreationTokens, + Math.max(0, totals.cacheCreation1hTokens ?? 0), + ); + const fiveMinute = Math.min( + totals.cacheCreationTokens - oneHour, + Math.max(0, totals.cacheCreation5mTokens ?? 0), + ); + const unclassified = totals.cacheCreationTokens - fiveMinute - oneHour; + return ( + (unclassified + fiveMinute) * rate.cacheCreationCostPerToken + + oneHour * (rate.cacheCreation1hCostPerToken ?? rate.cacheCreationCostPerToken) + ); +} + /** * Prices a bucket's tokens. * @@ -169,7 +192,7 @@ export function priceUsage( const costUsd = totals.uncachedInputTokens * rate.inputCostPerToken + totals.cachedInputTokens * rate.cacheReadCostPerToken + - totals.cacheCreationTokens * rate.cacheCreationCostPerToken + + cacheCreationCost(totals, rate) + totals.outputTokens * rate.outputCostPerToken; return { costUsd, costSource: "modelPriced" }; @@ -186,14 +209,13 @@ export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTo } /** - * Estimated cost of this usage's cache-creation tokens at the model's - * cache-write rate. Zero when the model is unpriced or the provider reports - * no cache-creation tokens. + * Estimates what this usage's cache writes cost at the model and TTL-specific rates. + * Cache creation is a billing category, not proof of an expiry rewrite. */ export function cacheWriteUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { const rate = lookupRate(table, model); if (rate === null) return 0; - return totals.cacheCreationTokens * rate.cacheCreationCostPerToken; + return cacheCreationCost(totals, rate); } export interface UsageComponentCosts { @@ -218,7 +240,7 @@ export function usageComponentCosts( const rate = lookupRate(table, model); if (rate === null) return ZERO_COMPONENTS; return { - cacheWriteUsd: totals.cacheCreationTokens * rate.cacheCreationCostPerToken, + cacheWriteUsd: cacheCreationCost(totals, rate), cacheReadUsd: totals.cachedInputTokens * rate.cacheReadCostPerToken, freshUsd: totals.uncachedInputTokens * rate.inputCostPerToken + diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 45a5bd5496b6..7a89e007b915 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -21,6 +21,7 @@ function record(overrides: Partial = {}): UsageRecord { uncachedInputTokens: 2, cachedInputTokens: 1000, cacheCreationTokens: 10, + cacheCreation5mTokens: 10, outputTokens: 50, reasoningTokens: 0, }, @@ -197,7 +198,27 @@ describe("scan cache round trip", () => { files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, - r: [[...row.slice(0, 10), cwdIndex]], + r: [[...row.slice(0, 10), cwdIndex, ...row.slice(11)]], + }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it.each([ + [20, 0], + [-1, 11], + [5.5, 4.5], + ])("drops an entry with invalid cache TTL counters %s + %s", (fiveMinute, oneHour) => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const row = encoded.files["/a.jsonl"]!.r[0]!; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [[...row.slice(0, 11), fiveMinute, oneHour]], }, }, }; @@ -299,7 +320,7 @@ describe("pruneScanCache with an unwalked root", () => { }); describe("dedupeWithinFile", () => { - it("keeps the first record per dedupe key", () => { + it("keeps the final record per dedupe key", () => { const kept = dedupeWithinFile([ record({ totals: { ...record().totals, outputTokens: 1 } }), record({ totals: { ...record().totals, outputTokens: 999 } }), @@ -307,7 +328,7 @@ describe("dedupeWithinFile", () => { ]); expect(kept).toHaveLength(2); - expect(kept[0]?.totals.outputTokens).toBe(1); + expect(kept[0]?.totals.outputTokens).toBe(999); }); it("keeps every record that has no dedupe key", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 34eaf11f43a9..e47ea120ac5b 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -28,7 +28,8 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // re-parses only its appended bytes instead of starting over. // v4: records carry the session's cwd for project attribution; v3 entries // would pin every cached file to "no project" forever. -export const USAGE_SCAN_CACHE_VERSION = 4 as const; +// v5: Claude records retain cache TTLs and expanded fallback iterations. +export const USAGE_SCAN_CACHE_VERSION = 5 as const; export interface CachedFile { readonly size: number; @@ -64,6 +65,8 @@ type SerializedRecord = readonly [ dedupeKey: string | null, reportedCostUsd: number | null, cwdIndex: number, + cacheCreation5mTokens: number, + cacheCreation1hTokens: number, ]; interface SerializedFile { @@ -107,19 +110,30 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { return next; }; - const serializeRecord = (record: UsageRecord): SerializedRecord => [ - 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, - intern(cwds, cwdIndex, record.cwd), - ]; + const serializeRecord = (record: UsageRecord): SerializedRecord => { + const oneHour = Math.min( + record.totals.cacheCreationTokens, + Math.max(0, record.totals.cacheCreation1hTokens ?? 0), + ); + // Unclassified cache creation uses the five-minute price. Persist it in + // that bucket so the serialized TTL counters retain an exact sum. + const fiveMinute = record.totals.cacheCreationTokens - oneHour; + return [ + 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, + intern(cwds, cwdIndex, record.cwd), + fiveMinute, + oneHour, + ]; + }; const files: Record = {}; for (const [path, entry] of cache) { @@ -143,6 +157,10 @@ function isRecordArray(value: unknown): value is readonly unknown[] { return Array.isArray(value); } +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + /** * Rebuilds the cache from a parsed document. * @@ -179,7 +197,7 @@ export function decodeScanCache(document: unknown): ScanCache { ): UsageRecord[] | null => { const records: UsageRecord[] = []; for (const row of rows) { - if (!isRecordArray(row) || row.length < 11) return null; + if (!isRecordArray(row) || row.length < 13) return null; const [ timestampMs, modelIndex, @@ -192,6 +210,8 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey, reportedCostUsd, cwdIndex, + cacheCreation5m, + cacheCreation1h, ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; @@ -206,11 +226,14 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isInteger(sessionIndex) || cwd === undefined || !Number.isInteger(cwdIndex) || - !Number.isFinite(uncached) || - !Number.isFinite(cached) || - !Number.isFinite(cacheCreation) || - !Number.isFinite(output) || - !Number.isFinite(reasoning) + !isNonNegativeInteger(uncached) || + !isNonNegativeInteger(cached) || + !isNonNegativeInteger(cacheCreation) || + !isNonNegativeInteger(cacheCreation5m) || + !isNonNegativeInteger(cacheCreation1h) || + cacheCreation5m + cacheCreation1h !== cacheCreation || + !isNonNegativeInteger(output) || + !isNonNegativeInteger(reasoning) ) { return null; } @@ -225,6 +248,8 @@ export function decodeScanCache(document: unknown): ScanCache { uncachedInputTokens: uncached, cachedInputTokens: cached, cacheCreationTokens: cacheCreation, + ...(cacheCreation5m === 0 ? {} : { cacheCreation5mTokens: cacheCreation5m }), + ...(cacheCreation1h === 0 ? {} : { cacheCreation1hTokens: cacheCreation1h }), outputTokens: output, reasoningTokens: reasoning, }, @@ -365,22 +390,18 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** - * Within-file de-duplication, applied before an entry is cached. - * - * Callers stitching an incremental parse together pass one `seen` set across - * the line and tail record batches so the whole file stays deduplicated as a - * unit; the set is mutated in place. - */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +/** Within-file de-duplication, retaining the final complete Claude snapshot. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const indexByKey = new Map(); const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const existing = indexByKey.get(record.dedupeKey); + if (existing !== undefined) { + kept[existing] = record; + continue; + } + indexByKey.set(record.dedupeKey, kept.length); } kept.push(record); } diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index 90398d9cc35c..2a42fa5a524c 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -14,6 +14,7 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, + cacheCreation1hCostPerToken: 2e-5, }, ], ]); @@ -85,6 +86,38 @@ describe("ThreadUsageAccumulator", () => { expect(groups[0]?.totals.outputTokens).toBe(50); }); + it("uses the final complete snapshot across files", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 1 } }), + context, + ], + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 310 } }), + context, + ], + ]); + + expect(groups[0]?.totals.outputTokens).toBe(310); + }); + + it("applies the window to the final complete snapshot", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ dedupeKey: "msg_partial:" }), context], + [ + record({ + dedupeKey: "msg_partial:", + timestampMs: Date.parse("2026-09-01T00:00:00Z"), + }), + context, + ], + ]); + + expect(groups).toEqual([]); + }); + it("splits each day's model-priced cost into cache components", () => { const context = { sessionKey: "claude:session-a", agentId: null }; const groups = accumulate([[record(), context]]); diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 062d96f6be11..75ef944d640d 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -98,8 +98,14 @@ export interface ThreadUsageOptions { * share of the summary. */ export class ThreadUsageAccumulator { - readonly #groups = new Map(); - readonly #seen = new Set(); + readonly #recordsByKey = new Map< + string, + { readonly record: UsageRecord; readonly context: ThreadRecordContext } + >(); + readonly #unkeyedRecords: { + readonly record: UsageRecord; + readonly context: ThreadRecordContext; + }[] = []; readonly #toDay: (timestampMs: number) => string; readonly #options: ThreadUsageOptions; @@ -109,11 +115,20 @@ export class ThreadUsageAccumulator { } add(record: UsageRecord, context: ThreadRecordContext): boolean { - if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) return false; - this.#seen.add(record.dedupeKey); + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push({ record, context }); + return inWindow; } + if (this.#recordsByKey.has(record.dedupeKey)) { + this.#recordsByKey.set(record.dedupeKey, { record, context }); + return inWindow; + } + this.#recordsByKey.set(record.dedupeKey, { record, context }); + return inWindow; + } + #isInWindow(record: UsageRecord): boolean { if ( !Number.isFinite(record.timestampMs) || Math.abs(record.timestampMs) > MAX_DATE_TIMESTAMP_MS @@ -134,12 +149,20 @@ export class ThreadUsageAccumulator { (day < this.#options.sinceDay || day > this.#options.untilDay) ) return false; + return true; + } + #foldRecord( + record: UsageRecord, + context: ThreadRecordContext, + groups: Map, + ): void { + const day = this.#toDay(record.timestampMs); const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; const projectKey = resolvedProject === null ? null : `id:${resolvedProject.projectId.replaceAll("\u0000", "")}`; const groupKey = JSON.stringify([context.sessionKey, record.cwd]); - let group = this.#groups.get(groupKey); + let group = groups.get(groupKey); if (group === undefined) { group = { sessionKey: context.sessionKey, @@ -156,7 +179,7 @@ export class ThreadUsageAccumulator { daily: new Map(), agents: new Map(), }; - this.#groups.set(groupKey, group); + groups.set(groupKey, group); } const priced = priceUsage( @@ -204,11 +227,17 @@ export class ThreadUsageAccumulator { agent.cacheWriteUsd += writeUsd; agent.cacheWriteComplete &&= cacheWriteComplete; } - return true; } finish(): readonly SessionUsageGroup[] { - return [...this.#groups.values()].map((group) => ({ + const groups = new Map(); + for (const { record, context } of this.#unkeyedRecords) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + for (const { record, context } of this.#recordsByKey.values()) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + return [...groups.values()].map((group) => ({ sessionKey: group.sessionKey, provider: group.provider, sessionId: group.sessionId, diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9bbd95e8ad2b..dca9b93d3e65 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -18,13 +18,14 @@ 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 "@t3tools/contracts"; import { initialCodexScanState, mightCarryUsage, - parseClaudeLine, + parseClaudeLineRecords, parseCodexLine, parseGrokLine, type CodexScanState, @@ -236,8 +237,7 @@ export async function readTranscriptRecords( for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); return; } - const record = parseClaudeLine(line); - if (record !== null) out.push(record); + for (const record of parseClaudeLineRecords(line)) out.push(record); }; const toLineString = (lineBuffer: Buffer): string => { diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 95794a4683e2..5d01ba25b38c 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -4,6 +4,7 @@ import { GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, parseClaudeLine, + parseClaudeLineRecords, parseCodexLine, parseGrokLine, totalTokens, @@ -15,12 +16,14 @@ function claudeLine(overrides: { contentType: string; model?: string; outputTokens?: number; + requestId?: string; }): string { return JSON.stringify({ type: "assistant", timestamp: "2026-08-07T04:05:13.944Z", sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", cwd: "/home/theo/project", + ...(overrides.requestId === undefined ? {} : { requestId: overrides.requestId }), message: { id: overrides.messageId, role: "assistant", @@ -64,6 +67,81 @@ describe("parseClaudeLine", () => { expect(text?.totals).toEqual(toolUse?.totals); }); + it("keeps a shared message id when the request id differs", () => { + const first = parseClaudeLine( + claudeLine({ messageId: "msg_shared", requestId: "req_1", contentType: "text" }), + ); + const second = parseClaudeLine( + claudeLine({ messageId: "msg_shared", requestId: "req_2", contentType: "text" }), + ); + + expect(first?.dedupeKey).toBe("msg_shared:req_1"); + expect(second?.dedupeKey).toBe("msg_shared:req_2"); + }); + + it("expands fallback iterations under their own models and TTL counters", () => { + const records = parseClaudeLineRecords( + JSON.stringify({ + type: "assistant", + timestamp: "2026-08-18T01:13:44.675Z", + requestId: "req_fallback", + sessionId: "session-fallback", + cwd: "/work/app", + message: { + id: "msg_fallback", + model: "claude-opus-5", + usage: { + output_tokens: 300, + output_tokens_details: { thinking_tokens: 125 }, + iterations: [ + { + type: "message", + model: "claude-fable-5", + input_tokens: 2, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 20, + cache_creation: { + ephemeral_5m_input_tokens: 20, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 100, + }, + { + type: "fallback_message", + model: "claude-opus-5", + input_tokens: 3, + cache_read_input_tokens: 11, + cache_creation_input_tokens: 40, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 40, + }, + output_tokens: 300, + }, + ], + }, + }, + }), + ); + + expect(records).toHaveLength(2); + expect(records.map((record) => record.model)).toEqual(["claude-fable-5", "claude-opus-5"]); + expect(records[0]?.totals).toMatchObject({ + cacheCreationTokens: 20, + cacheCreation5mTokens: 20, + reasoningTokens: 0, + }); + expect(records[1]?.totals).toMatchObject({ + cacheCreationTokens: 40, + cacheCreation1hTokens: 40, + reasoningTokens: 125, + }); + expect(records.map((record) => record.dedupeKey)).toEqual([ + "msg_fallback:req_fallback:0", + "msg_fallback:req_fallback:1", + ]); + }); + it("ignores records that are not assistant messages", () => { expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); expect(parseClaudeLine("not json")).toBeNull(); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index a8327656d157..f47e52ecfb63 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -46,10 +46,14 @@ function parseTimestampMs(value: unknown): number | null { } export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { + const cacheCreation5mTokens = (a.cacheCreation5mTokens ?? 0) + (b.cacheCreation5mTokens ?? 0); + const cacheCreation1hTokens = (a.cacheCreation1hTokens ?? 0) + (b.cacheCreation1hTokens ?? 0); return { uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens, cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens, + ...(cacheCreation5mTokens === 0 ? {} : { cacheCreation5mTokens }), + ...(cacheCreation1hTokens === 0 ? {} : { cacheCreation1hTokens }), outputTokens: a.outputTokens + b.outputTokens, reasoningTokens: a.reasoningTokens + b.reasoningTokens, }; @@ -96,36 +100,36 @@ export function grokCostTicksToUsd(ticks: unknown): number | null { /** * Parses one line of a Claude Code transcript. * - * T3 Code 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 by roughly 2.4x on a real workload, so the - * caller must drop repeats by `dedupeKey` and keep the first. + * Claude Code can write several snapshots for one assistant message. The last + * snapshot is the complete one, so callers replace an earlier record carrying + * the same `dedupeKey`. `usage.iterations` is expanded into one record per + * attempted model; the top-level usage is the serving iteration and must not + * be added again. */ -export function parseClaudeLine(line: string): UsageRecord | null { +export function parseClaudeLineRecords(line: string): readonly UsageRecord[] { let parsed: unknown; try { parsed = JSON.parse(line); } catch { - return null; + return []; } - if (typeof parsed !== "object" || parsed === null) return null; + if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; - if (record["type"] !== "assistant") return null; + if (record["type"] !== "assistant") return []; const message = record["message"]; - if (typeof message !== "object" || message === null) return null; + if (typeof message !== "object" || message === null) return []; const messageRecord = message as Record; const usage = messageRecord["usage"]; - if (typeof usage !== "object" || usage === null) return null; + if (typeof usage !== "object" || usage === null) return []; const usageRecord = usage as Record; const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null) return null; + if (timestampMs === null) return []; 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; @@ -134,25 +138,71 @@ export function parseClaudeLine(line: string): UsageRecord | null { const dedupeKey = messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`; + const iterations = Array.isArray(usageRecord["iterations"]) + ? usageRecord["iterations"].filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const attempts = iterations.length > 0 ? iterations : [usageRecord]; + const topLevelThinking = + typeof usageRecord["output_tokens_details"] === "object" && + usageRecord["output_tokens_details"] !== null + ? int((usageRecord["output_tokens_details"] as Record)["thinking_tokens"]) + : 0; const cost = record["costUSD"]; - return { - provider: "claude", - timestampMs, - model, - sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", - cwd: typeof record["cwd"] === "string" ? record["cwd"] : "", - totals: { - uncachedInputTokens: int(usageRecord["input_tokens"]), - cachedInputTokens: int(usageRecord["cache_read_input_tokens"]), - 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, - }; + return attempts.flatMap((attempt, index) => { + const attemptModel = + typeof attempt["model"] === "string" + ? attempt["model"] + : iterations.length === 0 + ? model + : ""; + if (attemptModel.length === 0) return []; + + const cacheCreation = + typeof attempt["cache_creation"] === "object" && attempt["cache_creation"] !== null + ? (attempt["cache_creation"] as Record) + : null; + const cacheCreation5mTokens = int(cacheCreation?.["ephemeral_5m_input_tokens"]); + const cacheCreation1hTokens = int(cacheCreation?.["ephemeral_1h_input_tokens"]); + const detailedCacheCreation = cacheCreation5mTokens + cacheCreation1hTokens; + const outputTokens = int(attempt["output_tokens"]); + const isServingIteration = iterations.length === 0 || index === attempts.length - 1; + + return [ + { + provider: "claude" as const, + timestampMs, + model: attemptModel, + sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "", + cwd: typeof record["cwd"] === "string" ? record["cwd"] : "", + totals: { + uncachedInputTokens: int(attempt["input_tokens"]), + cachedInputTokens: int(attempt["cache_read_input_tokens"]), + cacheCreationTokens: + detailedCacheCreation > 0 + ? detailedCacheCreation + : int(attempt["cache_creation_input_tokens"]), + ...(cacheCreation5mTokens === 0 ? {} : { cacheCreation5mTokens }), + ...(cacheCreation1hTokens === 0 ? {} : { cacheCreation1hTokens }), + outputTokens, + reasoningTokens: isServingIteration ? Math.min(outputTokens, topLevelThinking) : 0, + }, + reportedCostUsd: + iterations.length === 0 && typeof cost === "number" && Number.isFinite(cost) + ? cost + : null, + dedupeKey: + dedupeKey === null || iterations.length === 0 ? dedupeKey : `${dedupeKey}:${index}`, + }, + ]; + }); +} + +/** Compatibility helper for callers that only need a non-iterated line. */ +export function parseClaudeLine(line: string): UsageRecord | null { + return parseClaudeLineRecords(line)[0] ?? null; } /* -------------------------------------------------------------------------- */ diff --git a/apps/web/src/components/usage/UsageCacheWriteCell.tsx b/apps/web/src/components/usage/UsageCacheWriteCell.tsx new file mode 100644 index 000000000000..4aba2f81a7b8 --- /dev/null +++ b/apps/web/src/components/usage/UsageCacheWriteCell.tsx @@ -0,0 +1,19 @@ +import { formatUsd } from "@t3tools/shared/usageFormat"; + +/** Consistent cache-write treatment across project, model, and thread tables. */ +export function UsageCacheWriteCell({ + cacheWriteTokens, + cacheWriteUsd, +}: { + readonly cacheWriteTokens: number; + readonly cacheWriteUsd: number | null; +}) { + const value = + cacheWriteTokens === 0 + ? "-" + : cacheWriteUsd === null + ? "Unavailable" + : formatUsd(cacheWriteUsd); + + return {value} {formatUsd(project.costUsd)} {formatUsd(model.costUsd)} - {formatCacheWriteCost(cacheWriteTokens, cacheWriteUsd)} - {formatUsd(row.costUsd)} - {formatCacheWriteCost(row.totals.cacheCreationTokens, row.cacheWriteUsd)} - {formatPercent(share)}
Project CostCache writes Share Tokens
+ {merged.records === 0 ? "No activity in this window." : "No project attribution in this window."} @@ -535,6 +546,10 @@ export function UsagePage() { {formatUsd(project.costUsd)} {formatPercent( projectFilter === undefined @@ -555,15 +570,17 @@ export function UsagePage() { ) : breakdown === "model" ? ( - - - - + + + + + + @@ -571,7 +588,7 @@ export function UsagePage() { {breakdownModels.length === 0 ? ( - @@ -590,6 +607,10 @@ export function UsagePage() { + @@ -814,15 +835,44 @@ function ProviderMark({ return ; } -function Metric({ label, value }: { readonly label: string; readonly value: string }) { +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail?: string; +}) { return (
{label} {value} + {detail === undefined ? null : ( + {detail} + )}
); } +/** + * Cache-write cost cell. Providers that bill no cache writes (Codex) show a + * dash rather than a misleading $0.00. + */ +function CacheWriteCell({ + cacheWriteTokens, + cacheWriteUsd, +}: { + readonly cacheWriteTokens: number; + readonly cacheWriteUsd: number; +}) { + return ( +
+ ); +} + /** * Says plainly when the totals are incomplete: an environment that failed, or * one whose transcripts another environment already reported. Environments @@ -961,15 +1011,20 @@ function UsageSkeleton() {

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} -
-
- ), - )} +
+ {[ + "Processed tokens", + "Cached input", + "Uncached input", + "Output", + "Estimated cache writes", + "Cache savings", + ].map((label) => ( +
+ {label} +
+
+ ))}
diff --git a/apps/web/src/components/usage/UsageThreadTable.tsx b/apps/web/src/components/usage/UsageThreadTable.tsx index f2fb1abdabe3..387d42dc7542 100644 --- a/apps/web/src/components/usage/UsageThreadTable.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.tsx @@ -16,6 +16,7 @@ import { } from "@t3tools/shared/usageFormat"; import type { EnvironmentProviderContribution } from "@t3tools/shared/usageMerge"; +import { cn } from "../../lib/utils"; import { useUsageThreads, type UsageThreadRowWithEnvironment } from "../../state/usage"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -66,15 +67,17 @@ export function UsageThreadTable({ return (
Model CostCache writes Share Tokens
+ No activity in this window.
{formatUsd(model.costUsd)} {formatPercent(model.costShare)} + {cacheWriteTokens === 0 ? "-" : formatUsd(cacheWriteUsd)} +
- - - - + + + + + + @@ -82,7 +85,7 @@ export function UsageThreadTable({ {rows.length === 0 ? ( - - - + @@ -220,7 +226,7 @@ function ThreadRowGroup({ {open ? ( - ); } +function formatCacheWriteCost(cacheWriteTokens: number, cacheWriteUsd: number | null): string { + if (cacheWriteTokens === 0) return "-"; + return cacheWriteUsd === null ? "Unavailable" : formatUsd(cacheWriteUsd); +} + /** * Says plainly when the totals are incomplete: an environment that failed, or * one whose transcripts another environment already reported. Environments diff --git a/apps/web/src/components/usage/UsageThreadTable.test.tsx b/apps/web/src/components/usage/UsageThreadTable.test.tsx index 9fc05e7ddf7a..9b8466218226 100644 --- a/apps/web/src/components/usage/UsageThreadTable.test.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.test.tsx @@ -89,6 +89,7 @@ describe("UsageThreadTable", () => { reasoningTokens: 0, }, costUsd: 1, + cacheWriteUsd: 0.25, sessions: 1, agents: [ { diff --git a/apps/web/src/components/usage/UsageThreadTable.tsx b/apps/web/src/components/usage/UsageThreadTable.tsx index 387d42dc7542..246c8ce178c0 100644 --- a/apps/web/src/components/usage/UsageThreadTable.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.tsx @@ -215,7 +215,7 @@ function ThreadRowGroup({
Thread CostCache writes Share Tokens
+ {unavailableEnvironments > 0 ? "Thread activity could not be loaded for this window." : "No activity in this window."} @@ -113,7 +116,7 @@ export function UsageThreadTable({ )} {truncatedRows > 0 ? (
+ {truncatedRows === 1 ? "1 lower-cost thread row is grouped above." : `${truncatedRows} lower-cost thread rows are grouped above.`} @@ -122,7 +125,7 @@ export function UsageThreadTable({ ) : null} {unavailableEnvironments > 0 && rows.length > 0 ? (
+ {unavailableEnvironments === 1 ? "1 environment could not report threads." : `${unavailableEnvironments} environments could not report threads.`} @@ -211,6 +214,9 @@ function ThreadRowGroup({ {formatUsd(row.costUsd)} + {row.totals.cacheCreationTokens === 0 ? "-" : formatUsd(row.cacheWriteUsd)} + {formatPercent(share)}
+ {row.agents.map((agent) => { const agentTokens = @@ -256,7 +262,8 @@ const CHART_WIDTH = 760; const CHART_HEIGHT = 96; /** - * One thread's daily estimated cost. Static SVG, no animation. + * One thread's daily model-priced cost split into cache writes, cache reads, + * and fresh input plus output. Static SVG, no animation. */ export function UsageThreadDailyChart({ daily, @@ -272,7 +279,10 @@ export function UsageThreadDailyChart({ () => new Map(daily.map((entry) => [entry.day, entry])), [daily], ); - const peak = daily.reduce((max, entry) => Math.max(max, entry.costUsd), 0); + const peak = daily.reduce( + (max, entry) => Math.max(max, entry.cacheWriteUsd + entry.cacheReadUsd + entry.freshUsd), + 0, + ); if (peak === 0 || days.length === 0) { return

No priced usage in this window.

; @@ -283,35 +293,51 @@ export function UsageThreadDailyChart({ return (
-
+
Daily cost, {formatDayShort(sinceDay)} to {formatDayShort(untilDay)} + + +
{days.map((day, index) => { const entry = byDay.get(day); if (entry === undefined) return null; const x = index * bandWidth + (bandWidth - barWidth) / 2; - const height = (entry.costUsd / peak) * (CHART_HEIGHT - 4); - const renderedHeight = height === 0 ? 0 : Math.max(height, 0.75); + const segments = [ + { value: entry.freshUsd, className: "text-success" }, + { value: entry.cacheReadUsd, className: "text-muted-foreground" }, + { value: entry.cacheWriteUsd, className: "text-sky-500" }, + ]; + let y = CHART_HEIGHT; + const total = entry.cacheWriteUsd + entry.cacheReadUsd + entry.freshUsd; return ( - {`${formatDayShort(day)}: ${formatUsd(entry.costUsd)}`} - + {`${formatDayShort(day)}: ${formatUsd(total)}. Cache writes ${formatUsd(entry.cacheWriteUsd)}, cache reads ${formatUsd(entry.cacheReadUsd)}, fresh input and output ${formatUsd(entry.freshUsd)}`} + {segments.map((segment) => { + if (segment.value <= 0) return null; + const height = (segment.value / peak) * (CHART_HEIGHT - 4); + y -= height; + return ( + + ); + })} ); })} @@ -320,6 +346,21 @@ export function UsageThreadDailyChart({ ); } +function LegendSwatch({ + className, + label, +}: { + readonly className: string; + readonly label: string; +}) { + return ( + + + {label} + + ); +} + function ProviderMark({ provider }: { readonly provider: UsageProviderKind }) { const Mark = PROVIDER_PRESENTATION[provider].mark; return ; diff --git a/apps/web/src/state/usage.test.ts b/apps/web/src/state/usage.test.ts index 725baccf5c27..c29e53f3a330 100644 --- a/apps/web/src/state/usage.test.ts +++ b/apps/web/src/state/usage.test.ts @@ -49,6 +49,7 @@ function row(provider: UsageProviderKind, overrides: Partial = { reasoningTokens: 5, }, costUsd: 1, + cacheWriteUsd: 0.25, sessions: 1, agents: [], daily: [], @@ -58,7 +59,7 @@ function row(provider: UsageProviderKind, overrides: Partial = { function breakdown(rows: readonly UsageThreadRow[]): UsageThreadBreakdown { return { - contractVersion: 7, + contractVersion: 8, readAt: "2026-08-28T01:15:00.000Z", sinceDay: "2026-08-01" as UsageDay, untilDay: "2026-08-28" as UsageDay, diff --git a/docs/user/usage.md b/docs/user/usage.md index b1f65d2d04fe..dfe585a114ba 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -18,12 +18,16 @@ to return to the preset. The date fields beside the presets accept custom ranges The breakdown's **Thread** view drills into where the spend went: sessions group into the T3 Code thread they belong to, with sessions that never ran through T3 Code listed under the first thing you asked in them. Grok Build has no trusted prompt title, so its rows use a short session label. -Expanding a row shows its daily estimated cost, along with any Claude subagents the thread spawned. -Each connected environment contributes at most 40 rows, reserving room to group -lower-cost rows under **Other threads** by provider and project. Those grouped rows stay in the -totals, so the thread view still adds up to the selected project or full summary. +Expanding a row splits its daily model-priced cost into cache writes, cache +reads, and fresh input plus output, alongside any Claude subagents the thread spawned. +Each connected environment contributes at most 40 rows, reserving room to group lower-cost rows +under **Other threads** by provider and project. Those grouped rows stay in the totals, so the +thread view still adds up to the selected project or full summary. Rows that map to a thread carry a link that opens it. +The **Estimated cache writes** total prices cache-creation tokens at each model's cache-write rate. +It only applies to providers that report cache-creation tokens, so rows without them show a dash. + Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker narrows the whole page to one project; work that ran outside every project is grouped under diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 5792ef9458a5..08b453bb6ef6 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -21,22 +21,23 @@ import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./ba * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 9 as const; +export const USAGE_CONTRACT_VERSION = 10 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * * v5 only adds `grok` to {@link UsageProviderKind}; v6 adds the optional bucket * `project`; v7 adds its optional stable `projectId`; v8 distinguishes outside - * projects from unknown attribution; v9 adds the separate thread-breakdown RPC. - * v4 Claude/Codex buckets remain valid, so mixed-version environments keep - * those totals instead of treating every older server as stale. + * projects from unknown attribution; v9 adds the separate thread-breakdown RPC; + * v10 adds optional cache-write costs. v4 Claude/Codex buckets remain valid, so + * mixed-version environments keep those totals instead of treating every older + * server as stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; /** First contract version that explicitly distinguishes outside from unknown attribution. */ export const USAGE_PROJECT_ATTRIBUTION_SINCE = 8 as const; /** First contract version that exposes the current thread-breakdown RPC. */ -export const USAGE_THREAD_BREAKDOWN_SINCE = 9 as const; +export const USAGE_THREAD_BREAKDOWN_SINCE = 10 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; @@ -123,6 +124,12 @@ export const UsageBucket = Schema.Struct({ * rather than derived on the client. */ cacheSavingsUsd: Schema.Number, + /** + * Estimated cost of the cache-creation tokens in this bucket at the model's + * cache-write rate. A subset of `costUsd` when the bucket is model-priced. + * Absent from summaries written before this field existed. + */ + cacheWriteUsd: Schema.optional(Schema.Number), costSource: UsageCostSource, /** Distinct assistant responses, after de-duplication. */ records: NonNegativeInt, @@ -250,16 +257,21 @@ export const UsageAgentRow = Schema.Struct({ agentId: TrimmedNonEmptyString, totals: UsageTokenTotals, costUsd: Schema.Number, + cacheWriteUsd: Schema.Number, }); export type UsageAgentRow = typeof UsageAgentRow.Type; /** - * One day of a thread's estimated cost. Days the thread was idle are omitted. - * Unpriced records contribute tokens to the row totals but nothing here. + * One day of a thread's model-priced cost split by component. Days the thread + * was idle are omitted. Unpriced records contribute tokens to the row totals + * but nothing here. */ export const UsageThreadDayCost = Schema.Struct({ day: UsageDay, - costUsd: Schema.Number, + cacheWriteUsd: Schema.Number, + cacheReadUsd: Schema.Number, + /** Fresh input plus output. */ + freshUsd: Schema.Number, }); export type UsageThreadDayCost = typeof UsageThreadDayCost.Type; @@ -281,6 +293,7 @@ export const UsageThreadRow = Schema.Struct({ project: Schema.optional(TrimmedNonEmptyString), totals: UsageTokenTotals, costUsd: Schema.Number, + cacheWriteUsd: Schema.Number, /** Distinct transcript sessions folded into this row. */ sessions: NonNegativeInt, /** Lower-cost thread rows represented by this grouped remainder row. */ diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 0b374bbd36bb..898031f34d4b 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -409,6 +409,81 @@ describe("mergeUsage", () => { expect(filtered.costUsd).toBe(2); }); + it("reconciles aggregate, provider, project, and filtered project totals", () => { + const environments = [ + environment( + "env-a", + summary( + [ + bucket({ project: "App", costUsd: 6 }), + bucket({ + project: "App", + provider: "codex", + model: "gpt-5.6-sol", + costUsd: 3, + totals: { + uncachedInputTokens: 20, + cachedInputTokens: 200, + cacheCreationTokens: 0, + outputTokens: 10, + reasoningTokens: 5, + }, + }), + bucket({ costUsd: 2 }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ]; + const merged = mergeUsage(environments, USAGE_CONTRACT_VERSION); + + expect(merged.providers.reduce((sum, provider) => sum + provider.costUsd, 0)).toBe( + merged.costUsd, + ); + expect(merged.providers.reduce((sum, provider) => sum + provider.totalTokens, 0)).toBe( + merged.totalTokens, + ); + expect(merged.projects.reduce((sum, project) => sum + project.costUsd, 0)).toBe(merged.costUsd); + expect(merged.projects.reduce((sum, project) => sum + project.totalTokens, 0)).toBe( + merged.totalTokens, + ); + + for (const project of merged.projects) { + const filtered = mergeUsage(environments, USAGE_CONTRACT_VERSION, { + projectFilter: project.projectKey, + }); + expect(filtered.costUsd).toBe(project.costUsd); + expect(filtered.totalTokens).toBe(project.totalTokens); + } + }); + + it("sums cache-write cost overall and per model, tolerating summaries without it", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ cacheWriteUsd: 3 }), + bucket({ cacheWriteUsd: 1, model: "claude-opus-5" }), + // A summary written before the field existed contributes nothing. + bucket({ model: "claude-opus-5" }), + ], + [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costQuality.cacheWriteUsd).toBe(4); + const opus = merged.models.find((model) => model.model === "claude-opus-5"); + expect(opus?.cacheWriteUsd).toBe(1); + }); + it("filters every figure except the project list when a project is selected", () => { const environments = [ environment( diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 5ccc5022a9d8..8635a95096c7 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -37,6 +37,8 @@ export interface ModelTotals { readonly provider: UsageProviderKind; readonly costUsd: number; readonly totalTokens: number; + readonly cacheWriteTokens: number; + readonly cacheWriteUsd: number; readonly records: number; readonly costShare: number; } @@ -49,6 +51,8 @@ export interface ProjectTotals { readonly project: string | null; readonly costUsd: number; readonly totalTokens: number; + readonly cacheWriteTokens: number; + readonly cacheWriteUsd: number; readonly records: number; readonly costShare: number; } @@ -73,6 +77,8 @@ export interface CostQuality { readonly modelPricedShare: number; readonly unpricedShare: number; readonly cacheSavingsUsd: number; + /** Estimated cost of reported cache-creation tokens at cache-write rates. */ + readonly cacheWriteUsd: number; } export interface EnvironmentProviderContribution { @@ -223,6 +229,7 @@ const EMPTY_MERGED: MergedUsage = { modelPricedShare: 0, unpricedShare: 0, cacheSavingsUsd: 0, + cacheWriteUsd: 0, }, duplicateSources: [], contributingEnvironments: [], @@ -312,6 +319,7 @@ export function mergeUsage( let records = 0; let sessions = 0; let cacheSavingsUsd = 0; + let cacheWriteUsd = 0; let providerReportedRecords = 0; let unpricedRecords = 0; @@ -321,7 +329,14 @@ export function mergeUsage( >(); const modelAccumulator = new Map< string, - { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + { + provider: UsageProviderKind; + costUsd: number; + totalTokens: number; + cacheWriteTokens: number; + cacheWriteUsd: number; + records: number; + } >(); // Keyed by stable project id where available, with a namespaced title // fallback for pre-v7 summaries. Accumulated before the project filter. @@ -333,6 +348,8 @@ export function mergeUsage( project: string | null; costUsd: number; totalTokens: number; + cacheWriteTokens: number; + cacheWriteUsd: number; records: number; } >(); @@ -407,10 +424,14 @@ export function mergeUsage( project: bucket.project ?? null, costUsd: 0, totalTokens: 0, + cacheWriteTokens: 0, + cacheWriteUsd: 0, records: 0, }; project.costUsd += bucket.costUsd; project.totalTokens += tokens; + project.cacheWriteTokens += bucket.totals.cacheCreationTokens; + project.cacheWriteUsd += bucket.cacheWriteUsd ?? 0; project.records += bucket.records; projectAccumulator.set(accumulatorKey, project); @@ -419,6 +440,7 @@ export function mergeUsage( costUsd += bucket.costUsd; cacheSavingsUsd += bucket.cacheSavingsUsd; + cacheWriteUsd += bucket.cacheWriteUsd ?? 0; uncachedInputTokens += bucket.totals.uncachedInputTokens; cachedInputTokens += bucket.totals.cachedInputTokens; cacheCreationTokens += bucket.totals.cacheCreationTokens; @@ -444,10 +466,14 @@ export function mergeUsage( provider: bucket.provider, costUsd: 0, totalTokens: 0, + cacheWriteTokens: 0, + cacheWriteUsd: 0, records: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; + model.cacheWriteTokens += bucket.totals.cacheCreationTokens; + model.cacheWriteUsd += bucket.cacheWriteUsd ?? 0; model.records += bucket.records; modelAccumulator.set(modelKey, model); @@ -506,6 +532,8 @@ export function mergeUsage( provider: totals.provider, costUsd: totals.costUsd, totalTokens: totals.totalTokens, + cacheWriteTokens: totals.cacheWriteTokens, + cacheWriteUsd: totals.cacheWriteUsd, records: totals.records, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) @@ -518,6 +546,8 @@ export function mergeUsage( project: totals.project, costUsd: totals.costUsd, totalTokens: totals.totalTokens, + cacheWriteTokens: totals.cacheWriteTokens, + cacheWriteUsd: totals.cacheWriteUsd, records: totals.records, costShare: unfilteredCostUsd === 0 ? 0 : totals.costUsd / unfilteredCostUsd, })) @@ -557,6 +587,7 @@ export function mergeUsage( modelPricedShare: records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, + cacheWriteUsd, }, duplicateSources: duplicates, contributingEnvironments, From 0a4964ceb3cb2916b49cf88e60e1aeb7a3eb93e0 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 21:26:28 +1000 Subject: [PATCH 18/78] fix(usage): keep transcript title reads bounded --- apps/server/src/usage/usageTranscriptReader.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index dca9b93d3e65..60a4b1358a37 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -18,7 +18,6 @@ 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 "@t3tools/contracts"; From 1d816174ddfd8dda4e6e170c7b0b21473b20aee5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 20:38:39 +1000 Subject: [PATCH 19/78] fix(usage): preserve cache cost availability --- .../server/src/usage/usageAggregation.test.ts | 5 +- apps/server/src/usage/usageAggregation.ts | 10 ++- apps/server/src/usage/usageThreads.test.ts | 11 +++ apps/server/src/usage/usageThreads.ts | 68 +++++++++++++++---- .../src/components/usage/UsagePage.test.tsx | 18 +++++ apps/web/src/components/usage/UsagePage.tsx | 17 +++-- .../usage/UsageThreadTable.test.tsx | 1 + .../src/components/usage/UsageThreadTable.tsx | 7 +- docs/user/usage.md | 4 +- packages/contracts/src/usage.ts | 9 ++- packages/shared/src/usageMerge.test.ts | 23 ++++++- packages/shared/src/usageMerge.ts | 22 ++++-- 12 files changed, 159 insertions(+), 36 deletions(-) diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 2948382c5981..c3e445a18a80 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -192,9 +192,9 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.cacheWriteUsd).toBeCloseTo(1.25e-4, 12); }); - it("reports zero cache-write cost for unpriced models and write-free usage", () => { + it("distinguishes unavailable cache-write cost from write-free usage", () => { const unpriced = aggregate([record({ model: "kimi-k3" })]); - expect(unpriced.buckets[0]?.cacheWriteUsd).toBe(0); + expect(unpriced.buckets[0]?.cacheWriteUsd).toBeUndefined(); const writeFree = aggregate([ record({ @@ -224,6 +224,7 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.costUsd).toBe(1.25); expect(result.buckets[0]?.costSource).toBe("providerReported"); + expect(result.buckets[0]?.cacheWriteUsd).toBeUndefined(); }); it("drops records outside the window", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 0b75ad1321c6..88645f884d3b 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -114,6 +114,7 @@ interface MutableBucket { costUsd: number; cacheSavingsUsd: number; cacheWriteUsd: number; + cacheWriteComplete: boolean; records: number; unpricedRecords: number; providerReportedRecords: number; @@ -232,6 +233,7 @@ export class UsageAggregator { costUsd: 0, cacheSavingsUsd: 0, cacheWriteUsd: 0, + cacheWriteComplete: true, records: 0, unpricedRecords: 0, providerReportedRecords: 0, @@ -250,7 +252,11 @@ export class UsageAggregator { bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); - bucket.cacheWriteUsd += cacheWriteUsd(this.#options.rates, record.model, record.totals); + if (priced.costSource === "modelPriced") { + bucket.cacheWriteUsd += cacheWriteUsd(this.#options.rates, record.model, record.totals); + } else if (record.totals.cacheCreationTokens > 0) { + bucket.cacheWriteComplete = false; + } bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; @@ -281,7 +287,7 @@ export class UsageAggregator { totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, - cacheWriteUsd: bucket.cacheWriteUsd, + ...(bucket.cacheWriteComplete ? { cacheWriteUsd: bucket.cacheWriteUsd } : {}), costSource: resolveCostSource(bucket), records: bucket.records, unpricedRecords: bucket.unpricedRecords, diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index bcde424f2ea8..90398d9cc35c 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -95,6 +95,17 @@ describe("ThreadUsageAccumulator", () => { expect(day?.freshUsd).toBeCloseTo(100 * 1e-5 + 50 * 5e-5, 12); }); + it("does not invent component costs for provider-reported totals", () => { + const context = { sessionKey: "claude:session-a", agentId: "agent-1" }; + const groups = accumulate([[record({ reportedCostUsd: 1.25 }), context]]); + const rows = foldThreadRows(groups, NO_ATTRIBUTION, { cap: 40 }); + + expect(rows.rows[0]?.costUsd).toBe(1.25); + expect(rows.rows[0]?.cacheWriteUsd).toBeNull(); + expect(rows.rows[0]?.agents[0]?.cacheWriteUsd).toBeNull(); + expect(rows.rows[0]?.daily).toEqual([]); + }); + it("drops records outside the window", () => { const context = { sessionKey: "claude:session-a", agentId: null }; const groups = accumulate([ diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 80500fbbe7c3..062d96f6be11 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -44,6 +44,7 @@ interface MutableAgentSlice { totals: UsageTokenTotals; costUsd: number; cacheWriteUsd: number; + cacheWriteComplete: boolean; } export interface SessionUsageGroup { @@ -57,6 +58,7 @@ export interface SessionUsageGroup { readonly totals: UsageTokenTotals; readonly costUsd: number; readonly cacheWriteUsd: number; + readonly cacheWriteComplete: boolean; readonly daily: ReadonlyMap; readonly agents: ReadonlyMap; } @@ -72,6 +74,7 @@ interface MutableSessionGroup { totals: UsageTokenTotals; costUsd: number; cacheWriteUsd: number; + cacheWriteComplete: boolean; daily: Map; agents: Map; } @@ -149,6 +152,7 @@ export class ThreadUsageAccumulator { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0, + cacheWriteComplete: true, daily: new Map(), agents: new Map(), }; @@ -161,30 +165,44 @@ export class ThreadUsageAccumulator { record.totals, record.reportedCostUsd, ); - const writeUsd = cacheWriteUsd(this.#options.rates, record.model, record.totals); + const cacheWriteComplete = + priced.costSource === "modelPriced" || record.totals.cacheCreationTokens === 0; + const writeUsd = + priced.costSource === "modelPriced" + ? cacheWriteUsd(this.#options.rates, record.model, record.totals) + : 0; group.totals = addTotals(group.totals, record.totals); group.costUsd += priced.costUsd; group.cacheWriteUsd += writeUsd; - - const components = usageComponentCosts(this.#options.rates, record.model, record.totals); - let dayEntry = group.daily.get(day); - if (dayEntry === undefined) { - dayEntry = { cacheWriteUsd: 0, cacheReadUsd: 0, freshUsd: 0 }; - group.daily.set(day, dayEntry); + group.cacheWriteComplete &&= cacheWriteComplete; + + if (priced.costSource === "modelPriced") { + const components = usageComponentCosts(this.#options.rates, record.model, record.totals); + let dayEntry = group.daily.get(day); + if (dayEntry === undefined) { + dayEntry = { cacheWriteUsd: 0, cacheReadUsd: 0, freshUsd: 0 }; + group.daily.set(day, dayEntry); + } + dayEntry.cacheWriteUsd += components.cacheWriteUsd; + dayEntry.cacheReadUsd += components.cacheReadUsd; + dayEntry.freshUsd += components.freshUsd; } - dayEntry.cacheWriteUsd += components.cacheWriteUsd; - dayEntry.cacheReadUsd += components.cacheReadUsd; - dayEntry.freshUsd += components.freshUsd; if (context.agentId !== null) { let agent = group.agents.get(context.agentId); if (agent === undefined) { - agent = { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0 }; + agent = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + }; group.agents.set(context.agentId, agent); } agent.totals = addTotals(agent.totals, record.totals); agent.costUsd += priced.costUsd; agent.cacheWriteUsd += writeUsd; + agent.cacheWriteComplete &&= cacheWriteComplete; } return true; } @@ -201,6 +219,7 @@ export class ThreadUsageAccumulator { totals: group.totals, costUsd: group.costUsd, cacheWriteUsd: group.cacheWriteUsd, + cacheWriteComplete: group.cacheWriteComplete, daily: group.daily, agents: group.agents, })); @@ -243,6 +262,7 @@ interface MutableThreadRow { costUsd: number; sessionKeys: Set; cacheWriteUsd: number; + cacheWriteComplete: boolean; groupedRows: number; daily: Map; agents: Map; @@ -313,7 +333,7 @@ function toAgentRow([agentId, slice]: readonly [string, MutableAgentSlice]): Usa agentId, totals: slice.totals, costUsd: slice.costUsd, - cacheWriteUsd: slice.cacheWriteUsd, + cacheWriteUsd: slice.cacheWriteComplete ? slice.cacheWriteUsd : null, }; } @@ -336,11 +356,13 @@ function boundedAgentRows( totals: addTotals(combined.totals, slice.totals), costUsd: combined.costUsd + slice.costUsd, cacheWriteUsd: combined.cacheWriteUsd + slice.cacheWriteUsd, + cacheWriteComplete: combined.cacheWriteComplete && slice.cacheWriteComplete, }), { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0, + cacheWriteComplete: true, }, ); return [...kept.map(toAgentRow), toAgentRow([`Other subagents (${omitted.length})`, overflow])]; @@ -387,6 +409,7 @@ export function foldThreadRows( costUsd: 0, sessionKeys: new Set(), cacheWriteUsd: 0, + cacheWriteComplete: true, groupedRows: 0, daily: new Map(), agents: new Map(), @@ -399,16 +422,23 @@ export function foldThreadRows( row.costUsd += group.costUsd; row.sessionKeys.add(group.sessionKey); row.cacheWriteUsd += group.cacheWriteUsd; + row.cacheWriteComplete &&= group.cacheWriteComplete; addDailyCosts(row.daily, group.daily); for (const [agentId, slice] of group.agents) { let agent = row.agents.get(agentId); if (agent === undefined) { - agent = { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0 }; + agent = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + }; row.agents.set(agentId, agent); } agent.totals = addTotals(agent.totals, slice.totals); agent.costUsd += slice.costUsd; agent.cacheWriteUsd += slice.cacheWriteUsd; + agent.cacheWriteComplete &&= slice.cacheWriteComplete; } } @@ -461,6 +491,7 @@ export function foldThreadRows( costUsd: 0, sessionKeys: new Set(), cacheWriteUsd: 0, + cacheWriteComplete: true, groupedRows: 0, daily: new Map(), agents: new Map(), @@ -473,16 +504,23 @@ export function foldThreadRows( remainder.costUsd += omittedRow.costUsd; for (const sessionKey of omittedRow.sessionKeys) remainder.sessionKeys.add(sessionKey); remainder.cacheWriteUsd += omittedRow.cacheWriteUsd; + remainder.cacheWriteComplete &&= omittedRow.cacheWriteComplete; addDailyCosts(remainder.daily, omittedRow.daily); for (const [agentId, slice] of omittedRow.agents) { let agent = remainder.agents.get(agentId); if (agent === undefined) { - agent = { totals: EMPTY_TOTALS, costUsd: 0, cacheWriteUsd: 0 }; + agent = { + totals: EMPTY_TOTALS, + costUsd: 0, + cacheWriteUsd: 0, + cacheWriteComplete: true, + }; remainder.agents.set(agentId, agent); } agent.totals = addTotals(agent.totals, slice.totals); agent.costUsd += slice.costUsd; agent.cacheWriteUsd += slice.cacheWriteUsd; + agent.cacheWriteComplete &&= slice.cacheWriteComplete; } } @@ -510,7 +548,7 @@ export function foldThreadRows( ...(row.project === "" ? {} : { project: row.project }), totals: row.totals, costUsd: row.costUsd, - cacheWriteUsd: row.cacheWriteUsd, + cacheWriteUsd: row.cacheWriteComplete ? row.cacheWriteUsd : null, sessions: row.sessionKeys.size, ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), agents: boundedAgentRows(row.agents, options.cap), diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 2394e5e71edb..d8498cd6de8e 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -316,6 +316,24 @@ describe("UsagePage model breakdown", () => { expect(body).toMatch(/token-heavy-model.*>- { + testState.breakdown = "model"; + const usage = testState.useUsage(); + testState.useUsage.mockReturnValue({ + ...usage, + merged: { + ...usage.merged, + models: [{ ...modelTotals[0], cacheWriteUsd: null }], + costQuality: { ...usage.merged.costQuality, cacheWriteUsd: null }, + }, + }); + + const markup = renderToStaticMarkup(); + + expect(markup.match(/Unavailable/g)).toHaveLength(2); + expect(markup).not.toContain("NaN%"); + }); + it("sorts models by token usage when the token metric is selected", () => { testState.metric = "tokens"; testState.breakdown = "model"; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 639de369511b..f99010078dea 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -430,8 +430,12 @@ export function UsagePage() { 0 + value={ + merged.costQuality.cacheWriteUsd === null + ? "Unavailable" + : formatUsd(merged.costQuality.cacheWriteUsd) + } + {...(merged.costUsd > 0 && merged.costQuality.cacheWriteUsd !== null ? { detail: `${formatPercent(merged.costQuality.cacheWriteUsd / merged.costUsd, 0)} of cost`, } @@ -864,15 +868,20 @@ function CacheWriteCell({ cacheWriteUsd, }: { readonly cacheWriteTokens: number; - readonly cacheWriteUsd: number; + readonly cacheWriteUsd: number | null; }) { return (
- {cacheWriteTokens === 0 ? "-" : formatUsd(cacheWriteUsd)} + {formatCacheWriteCost(cacheWriteTokens, cacheWriteUsd)} {formatUsd(row.costUsd)} - {row.totals.cacheCreationTokens === 0 ? "-" : formatUsd(row.cacheWriteUsd)} + {formatCacheWriteCost(row.totals.cacheCreationTokens, row.cacheWriteUsd)} {formatPercent(share)} @@ -258,6 +258,11 @@ function ThreadRowGroup({ ); } +function formatCacheWriteCost(cacheWriteTokens: number, cacheWriteUsd: number | null): string { + if (cacheWriteTokens === 0) return "-"; + return cacheWriteUsd === null ? "Unavailable" : formatUsd(cacheWriteUsd); +} + const CHART_WIDTH = 760; const CHART_HEIGHT = 96; diff --git a/docs/user/usage.md b/docs/user/usage.md index dfe585a114ba..7991b2fb81b5 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -20,13 +20,15 @@ thread they belong to, with sessions that never ran through T3 Code listed under you asked in them. Grok Build has no trusted prompt title, so its rows use a short session label. Expanding a row splits its daily model-priced cost into cache writes, cache reads, and fresh input plus output, alongside any Claude subagents the thread spawned. +Provider-reported totals are not split into estimated components. Each connected environment contributes at most 40 rows, reserving room to group lower-cost rows under **Other threads** by provider and project. Those grouped rows stay in the totals, so the thread view still adds up to the selected project or full summary. Rows that map to a thread carry a link that opens it. The **Estimated cache writes** total prices cache-creation tokens at each model's cache-write rate. -It only applies to providers that report cache-creation tokens, so rows without them show a dash. +It only applies to model-priced records that report cache-creation tokens. Rows without cache +writes show a dash; incomplete or unavailable pricing is labeled **Unavailable** instead of zero. Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 08b453bb6ef6..62a4f53a900a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -257,14 +257,16 @@ export const UsageAgentRow = Schema.Struct({ agentId: TrimmedNonEmptyString, totals: UsageTokenTotals, costUsd: Schema.Number, - cacheWriteUsd: Schema.Number, + /** `null` when cache-creation tokens lack a model-priced estimate. */ + cacheWriteUsd: Schema.NullOr(Schema.Number), }); export type UsageAgentRow = typeof UsageAgentRow.Type; /** * One day of a thread's model-priced cost split by component. Days the thread * was idle are omitted. Unpriced records contribute tokens to the row totals - * but nothing here. + * but nothing here. Provider-reported totals also stay out because an + * estimated split could disagree with the provider's authoritative total. */ export const UsageThreadDayCost = Schema.Struct({ day: UsageDay, @@ -293,7 +295,8 @@ export const UsageThreadRow = Schema.Struct({ project: Schema.optional(TrimmedNonEmptyString), totals: UsageTokenTotals, costUsd: Schema.Number, - cacheWriteUsd: Schema.Number, + /** `null` when cache-creation tokens lack a model-priced estimate. */ + cacheWriteUsd: Schema.NullOr(Schema.Number), /** Distinct transcript sessions folded into this row. */ sessions: NonNegativeInt, /** Lower-cost thread rows represented by this grouped remainder row. */ diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 898031f34d4b..aa10523a678a 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -460,7 +460,7 @@ describe("mergeUsage", () => { } }); - it("sums cache-write cost overall and per model, tolerating summaries without it", () => { + it("marks cache-write cost unavailable when a cache-creating bucket omits it", () => { const merged = mergeUsage( [ environment( @@ -469,7 +469,7 @@ describe("mergeUsage", () => { [ bucket({ cacheWriteUsd: 3 }), bucket({ cacheWriteUsd: 1, model: "claude-opus-5" }), - // A summary written before the field existed contributes nothing. + // A summary written before the field existed makes the estimate incomplete. bucket({ model: "claude-opus-5" }), ], [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }], @@ -479,6 +479,25 @@ describe("mergeUsage", () => { USAGE_CONTRACT_VERSION, ); + expect(merged.costQuality.cacheWriteUsd).toBeNull(); + const opus = merged.models.find((model) => model.model === "claude-opus-5"); + expect(opus?.cacheWriteUsd).toBeNull(); + }); + + it("sums cache-write cost when every cache-creating bucket reports it", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ cacheWriteUsd: 3 }), bucket({ cacheWriteUsd: 1, model: "claude-opus-5" })], + [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + expect(merged.costQuality.cacheWriteUsd).toBe(4); const opus = merged.models.find((model) => model.model === "claude-opus-5"); expect(opus?.cacheWriteUsd).toBe(1); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 8635a95096c7..a37f4a0f520f 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -38,7 +38,7 @@ export interface ModelTotals { readonly costUsd: number; readonly totalTokens: number; readonly cacheWriteTokens: number; - readonly cacheWriteUsd: number; + readonly cacheWriteUsd: number | null; readonly records: number; readonly costShare: number; } @@ -52,7 +52,7 @@ export interface ProjectTotals { readonly costUsd: number; readonly totalTokens: number; readonly cacheWriteTokens: number; - readonly cacheWriteUsd: number; + readonly cacheWriteUsd: number | null; readonly records: number; readonly costShare: number; } @@ -78,7 +78,7 @@ export interface CostQuality { readonly unpricedShare: number; readonly cacheSavingsUsd: number; /** Estimated cost of reported cache-creation tokens at cache-write rates. */ - readonly cacheWriteUsd: number; + readonly cacheWriteUsd: number | null; } export interface EnvironmentProviderContribution { @@ -320,6 +320,7 @@ export function mergeUsage( let sessions = 0; let cacheSavingsUsd = 0; let cacheWriteUsd = 0; + let cacheWriteComplete = true; let providerReportedRecords = 0; let unpricedRecords = 0; @@ -335,6 +336,7 @@ export function mergeUsage( totalTokens: number; cacheWriteTokens: number; cacheWriteUsd: number; + cacheWriteComplete: boolean; records: number; } >(); @@ -350,6 +352,7 @@ export function mergeUsage( totalTokens: number; cacheWriteTokens: number; cacheWriteUsd: number; + cacheWriteComplete: boolean; records: number; } >(); @@ -405,6 +408,8 @@ export function mergeUsage( for (const bucket of buckets) { const tokens = bucketTokens(bucket); + const bucketCacheWriteComplete = + bucket.totals.cacheCreationTokens === 0 || bucket.cacheWriteUsd !== undefined; unfilteredCostUsd += bucket.costUsd; const localProjectKey = localBucketProjectKey(bucket); @@ -426,12 +431,14 @@ export function mergeUsage( totalTokens: 0, cacheWriteTokens: 0, cacheWriteUsd: 0, + cacheWriteComplete: true, records: 0, }; project.costUsd += bucket.costUsd; project.totalTokens += tokens; project.cacheWriteTokens += bucket.totals.cacheCreationTokens; project.cacheWriteUsd += bucket.cacheWriteUsd ?? 0; + project.cacheWriteComplete &&= bucketCacheWriteComplete; project.records += bucket.records; projectAccumulator.set(accumulatorKey, project); @@ -441,6 +448,7 @@ export function mergeUsage( costUsd += bucket.costUsd; cacheSavingsUsd += bucket.cacheSavingsUsd; cacheWriteUsd += bucket.cacheWriteUsd ?? 0; + cacheWriteComplete &&= bucketCacheWriteComplete; uncachedInputTokens += bucket.totals.uncachedInputTokens; cachedInputTokens += bucket.totals.cachedInputTokens; cacheCreationTokens += bucket.totals.cacheCreationTokens; @@ -468,12 +476,14 @@ export function mergeUsage( totalTokens: 0, cacheWriteTokens: 0, cacheWriteUsd: 0, + cacheWriteComplete: true, records: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; model.cacheWriteTokens += bucket.totals.cacheCreationTokens; model.cacheWriteUsd += bucket.cacheWriteUsd ?? 0; + model.cacheWriteComplete &&= bucketCacheWriteComplete; model.records += bucket.records; modelAccumulator.set(modelKey, model); @@ -533,7 +543,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, cacheWriteTokens: totals.cacheWriteTokens, - cacheWriteUsd: totals.cacheWriteUsd, + cacheWriteUsd: totals.cacheWriteComplete ? totals.cacheWriteUsd : null, records: totals.records, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) @@ -547,7 +557,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, cacheWriteTokens: totals.cacheWriteTokens, - cacheWriteUsd: totals.cacheWriteUsd, + cacheWriteUsd: totals.cacheWriteComplete ? totals.cacheWriteUsd : null, records: totals.records, costShare: unfilteredCostUsd === 0 ? 0 : totals.costUsd / unfilteredCostUsd, })) @@ -587,7 +597,7 @@ export function mergeUsage( modelPricedShare: records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, - cacheWriteUsd, + cacheWriteUsd: cacheWriteComplete ? cacheWriteUsd : null, }, duplicateSources: duplicates, contributingEnvironments, From e17084b262712c1cd36a0348208a3883255c4075 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 1 Sep 2026 21:45:39 +1000 Subject: [PATCH 20/78] fix(usage): replace progressive Claude snapshots exactly --- apps/server/src/usage/UsageService.ts | 21 ++++++----- .../server/src/usage/usageTranscripts.test.ts | 36 ++++++++++++++++++- apps/server/src/usage/usageTranscripts.ts | 4 ++- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 9867d12e7b3f..15cbdeaa752e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -36,6 +36,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; @@ -164,7 +165,9 @@ export const make = Effect.gen(function* () { const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const fileCache: ScanCache = new Map(); - let cacheDirty = false; + let cacheRevision = 0; + let persistedCacheRevision = 0; + const cachePersistSemaphore = yield* Semaphore.make(1); const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); @@ -329,19 +332,19 @@ export const make = Effect.gen(function* () { }), ); - 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 persistScanCacheUnlocked = Effect.fn("UsageService.persistScanCacheUnlocked")(function* () { + if (cacheRevision === persistedCacheRevision) return; + const revision = cacheRevision; yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), Effect.map(() => { - cacheDirty = false; + persistedCacheRevision = revision; }), // A cache we cannot write is a slower next start, not a failed read. Effect.catchCause(() => Effect.void), ); }); + const persistScanCache = () => cachePersistSemaphore.withPermits(1)(persistScanCacheUnlocked()); /** * Parses one transcript, reusing the cached result when it is unchanged. @@ -401,7 +404,7 @@ export const make = Effect.gen(function* () { tailRecords, position: parsed.position, }); - cacheDirty = true; + cacheRevision += 1; return tailRecords.length === 0 ? records : dedupeWithinFile([...records, ...tailRecords]); }); @@ -558,7 +561,7 @@ export const make = Effect.gen(function* () { windowStartMs, retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, }); - if (pruned > 0) cacheDirty = true; + if (pruned > 0) cacheRevision += 1; yield* persistScanCache(); const aggregated = aggregator.finish(); @@ -805,7 +808,7 @@ export const make = Effect.gen(function* () { windowStartMs, retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, }); - if (pruned > 0) cacheDirty = true; + if (pruned > 0) cacheRevision += 1; // A thread-only client must warm and bound the same durable cache as the // summary RPC, otherwise restarts repeat parsing and stale entries grow. yield* persistScanCache(); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 5d01ba25b38c..146a5c80bd99 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -138,7 +138,41 @@ describe("parseClaudeLine", () => { }); expect(records.map((record) => record.dedupeKey)).toEqual([ "msg_fallback:req_fallback:0", - "msg_fallback:req_fallback:1", + "msg_fallback:req_fallback", + ]); + }); + + it("replaces a progressive Claude snapshot with its final serving iteration", () => { + const partial = parseClaudeLineRecords( + claudeLine({ + messageId: "msg_progressive", + requestId: "req_progressive", + contentType: "text", + }), + ); + const complete = parseClaudeLineRecords( + JSON.stringify({ + type: "assistant", + timestamp: "2026-08-18T01:13:44.675Z", + requestId: "req_progressive", + message: { + id: "msg_progressive", + model: "claude-opus-5", + usage: { + output_tokens: 300, + iterations: [ + { model: "claude-fable-5", output_tokens: 100 }, + { model: "claude-opus-5", output_tokens: 300 }, + ], + }, + }, + }), + ); + + expect(partial[0]?.dedupeKey).toBe("msg_progressive:req_progressive"); + expect(complete.map((record) => record.dedupeKey)).toEqual([ + "msg_progressive:req_progressive:0", + "msg_progressive:req_progressive", ]); }); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index f47e52ecfb63..edb0301e09c9 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -194,7 +194,9 @@ export function parseClaudeLineRecords(line: string): readonly UsageRecord[] { ? cost : null, dedupeKey: - dedupeKey === null || iterations.length === 0 ? dedupeKey : `${dedupeKey}:${index}`, + dedupeKey === null || iterations.length === 0 || isServingIteration + ? dedupeKey + : `${dedupeKey}:${index}`, }, ]; }); From 9cb3cfd244cfe1488524383b31c7d2d1a12069cf Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:44:06 +1000 Subject: [PATCH 21/78] fix(web): style usage dates as a segmented control --- apps/web/src/components/usage/UsagePage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index dbbd3ce863bb..18c4483df4cc 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -546,26 +546,35 @@ function UsageDateRangeInputs({ }); const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; + const inputClassName = + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return ( -
+
- to + to Date: Wed, 2 Sep 2026 13:44:06 +1000 Subject: [PATCH 22/78] fix(web): style usage dates as a segmented control --- apps/web/src/components/usage/UsagePage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 65e1673e8b46..cb19ae8a66b3 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -665,26 +665,35 @@ function UsageDateRangeInputs({ }); const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; + const inputClassName = + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return ( -
+
- to + to Date: Wed, 2 Sep 2026 13:44:06 +1000 Subject: [PATCH 23/78] fix(web): style usage dates as a segmented control --- apps/web/src/components/usage/UsagePage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index d8977ddaaee4..81657d9fcd64 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -704,26 +704,35 @@ function UsageDateRangeInputs({ }); const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; + const inputClassName = + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return ( -
+
- to + to Date: Wed, 2 Sep 2026 13:44:06 +1000 Subject: [PATCH 24/78] fix(web): style usage dates as a segmented control --- apps/web/src/components/usage/UsagePage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index d8977ddaaee4..81657d9fcd64 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -704,26 +704,35 @@ function UsageDateRangeInputs({ }); const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; + const inputClassName = + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return ( -
+
- to + to Date: Wed, 2 Sep 2026 13:44:06 +1000 Subject: [PATCH 25/78] fix(web): style usage dates as a segmented control --- apps/web/src/components/usage/UsagePage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f99010078dea..90aa8814e39e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -729,26 +729,35 @@ function UsageDateRangeInputs({ }); const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; + const inputClassName = + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return ( -
+
- to + to Date: Wed, 2 Sep 2026 13:44:06 +1000 Subject: [PATCH 26/78] fix(web): style usage dates as a segmented control --- apps/web/src/components/usage/UsagePage.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1207230cb16b..5c1a537d0a02 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -732,26 +732,35 @@ function UsageDateRangeInputs({ }); const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; + const inputClassName = + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return ( -
+
- to + to Date: Wed, 2 Sep 2026 13:58:03 +1000 Subject: [PATCH 27/78] fix(web): preserve usage date touch targets --- apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 7139570dc7af..25e60d5242d1 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -141,6 +141,7 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); + expect(markup.match(/pointer-coarse:h-8\.5/g)).toHaveLength(4); }); it("keeps recent activity visible first without empty hourly rows", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 18c4483df4cc..8b2d3d817f69 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -547,7 +547,7 @@ function UsageDateRangeInputs({ const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; const inputClassName = - "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return (
Date: Wed, 2 Sep 2026 13:58:03 +1000 Subject: [PATCH 28/78] fix(web): preserve usage date touch targets --- apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index a8e55c02ab8c..a80dfb9d319d 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -167,6 +167,7 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); + expect(markup.match(/pointer-coarse:h-8\.5/g)).toHaveLength(4); }); it("keeps recent activity visible first without empty hourly rows", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index cb19ae8a66b3..d580a005897b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -666,7 +666,7 @@ function UsageDateRangeInputs({ const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; const inputClassName = - "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return (
Date: Wed, 2 Sep 2026 13:58:03 +1000 Subject: [PATCH 29/78] fix(web): preserve usage date touch targets --- apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 39cd284eca0b..116abcdcbf86 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -171,6 +171,7 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), undefined, false); + expect(markup.match(/pointer-coarse:h-8\.5/g)).toHaveLength(4); }); it("keeps recent activity visible first without empty hourly rows", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 81657d9fcd64..246ff39f89a1 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -705,7 +705,7 @@ function UsageDateRangeInputs({ const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; const inputClassName = - "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return (
Date: Wed, 2 Sep 2026 13:58:03 +1000 Subject: [PATCH 30/78] fix(web): preserve usage date touch targets --- apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 39cd284eca0b..116abcdcbf86 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -171,6 +171,7 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), undefined, false); + expect(markup.match(/pointer-coarse:h-8\.5/g)).toHaveLength(4); }); it("keeps recent activity visible first without empty hourly rows", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 81657d9fcd64..246ff39f89a1 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -705,7 +705,7 @@ function UsageDateRangeInputs({ const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; const inputClassName = - "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return (
Date: Wed, 2 Sep 2026 13:58:03 +1000 Subject: [PATCH 31/78] fix(web): preserve usage date touch targets --- apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index d8498cd6de8e..0a31300c6783 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -181,6 +181,7 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), undefined, false); + expect(markup.match(/pointer-coarse:h-8\.5/g)).toHaveLength(4); }); it("keeps recent activity visible first without empty hourly rows", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 90aa8814e39e..4814c654d851 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -730,7 +730,7 @@ function UsageDateRangeInputs({ const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; const inputClassName = - "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return (
Date: Wed, 2 Sep 2026 13:58:03 +1000 Subject: [PATCH 32/78] fix(web): preserve usage date touch targets --- apps/web/src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index d8498cd6de8e..0a31300c6783 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -181,6 +181,7 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), undefined, false); + expect(markup.match(/pointer-coarse:h-8\.5/g)).toHaveLength(4); }); it("keeps recent activity visible first without empty hourly rows", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 5c1a537d0a02..6c8f46e7dd8a 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -733,7 +733,7 @@ function UsageDateRangeInputs({ const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; const inputClassName = - "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; + "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return (
Date: Wed, 2 Sep 2026 16:23:53 +1000 Subject: [PATCH 33/78] fix(server): avoid rescanning fresh usage ranges Usage source scans were keyed by the exact requested date window, so every range change repeated the directory scan. Cache recent source coverage for one minute, serialize updates, and preserve the widest loaded range so stale refreshes only parse changed transcript bytes. --- apps/server/src/usage/UsageService.test.ts | 41 +++++++++++- apps/server/src/usage/UsageService.ts | 74 +++++++++++++++++++--- docs/user/usage.md | 4 +- 3 files changed, 106 insertions(+), 13 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8fc86ee3d462..084973421c91 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -39,6 +39,12 @@ const WINDOW: UsageSummaryInput = { untilDay: UsageDay.make("2026-08-02"), }; +const NARROW_WINDOW: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-01"), +}; + const setup = Effect.gen(function* () { const home = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), @@ -101,10 +107,12 @@ describe("UsageService", () => { Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), ); - const first = yield* service.readSummary(WINDOW); + const first = yield* service.readSummary(NARROW_WINDOW); assert.strictEqual(totalOutputTokens(first), 5); yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + // Expanding beyond the cached coverage requires a source update. The + // grown transcript resumes at its cached byte position. const second = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(second), 12); }).pipe(Effect.scoped), @@ -136,9 +144,36 @@ describe("UsageService", () => { assert.deepStrictEqual(first, second); assert.strictEqual(ratesFetches, 1); - // A later request is fresh work again, not a stale cached answer. + // A later request within the freshness window reuses the source snapshot. + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 1); + }).pipe(Effect.scoped), + ); + + it.live("reuses a recent scan when only the date range changes", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-window-cache-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + yield* service.readSummary(WINDOW); - assert.strictEqual(ratesFetches, 2); + const narrower = yield* service.readSummary(NARROW_WINDOW); + + assert.strictEqual(totalOutputTokens(narrower), 5); + assert.strictEqual(ratesFetches, 1); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 16a7478d954e..8cef3115fbf1 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -34,6 +34,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; @@ -70,6 +71,9 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000; const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Match the client query TTL so changing a date range does not rescan fresh sources. */ +const SOURCE_SCAN_TTL_MS = 60 * 1000; + /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; @@ -349,6 +353,15 @@ export const make = Effect.gen(function* () { | null; } + interface SourceSnapshot { + readonly completedAtMs: number; + readonly windowStartMs: number; + readonly dirs: readonly ScannedDir[]; + } + + let sourceSnapshot: SourceSnapshot | null = null; + const sourceScanSemaphore = yield* Semaphore.make(1); + const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so the scan stays context-free. @@ -376,6 +389,54 @@ export const make = Effect.gen(function* () { return scanned; }); + const getSourceSnapshot = Effect.fn("UsageService.getSourceSnapshot")(function* ( + windowStartMs: number, + ) { + return yield* sourceScanSemaphore.withPermits(1)( + Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const currentSnapshot = sourceSnapshot; + const snapshotAgeMs = + currentSnapshot === null + ? Number.POSITIVE_INFINITY + : startedAtMs - currentSnapshot.completedAtMs; + const snapshotCoversWindow = + currentSnapshot !== null && currentSnapshot.windowStartMs <= windowStartMs; + + if ( + currentSnapshot !== null && + snapshotCoversWindow && + snapshotAgeMs < SOURCE_SCAN_TTL_MS + ) { + return currentSnapshot; + } + + // Preserve the widest coverage already loaded. A stale narrow request + // should update changed files, not discard older records and force the + // next wider range to read them again. + const scanWindowStartMs = Math.min( + windowStartMs, + currentSnapshot?.windowStartMs ?? windowStartMs, + ); + + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + const [, dirs] = yield* Effect.all([ensureRates(), collectDirs(scanWindowStartMs)], { + concurrency: 2, + }); + const completedAtMs = yield* Clock.currentTimeMillis; + const nextSnapshot = { + completedAtMs, + windowStartMs: scanWindowStartMs, + dirs, + } satisfies SourceSnapshot; + sourceSnapshot = nextSnapshot; + return nextSnapshot; + }), + ); + }); + const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ @@ -421,13 +482,9 @@ export const make = Effect.gen(function* () { } const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; - - // Pricing only matters once records are aggregated, so the rate table - // loads while transcripts stream instead of gating them: a cold rates - // fetch on a slow network no longer delays the scan by its own timeout. - const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { - concurrency: 2, - }); + const currentSnapshot = yield* getSourceSnapshot(windowStartMs); + const scannedDirs = currentSnapshot.dirs; + const sourceReadAtMs = currentSnapshot.completedAtMs; const aggregator = new UsageAggregator({ timeZone: input.timeZone, @@ -500,12 +557,11 @@ export const make = Effect.gen(function* () { yield* persistScanCache(); const aggregated = aggregator.finish(); - const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; return { contractVersion: USAGE_CONTRACT_VERSION, - readAt: DateTime.formatIso(readAt), + readAt: DateTime.formatIso(DateTime.makeUnsafe(sourceReadAtMs)), timeZone: input.timeZone, sinceDay: input.sinceDay, untilDay: input.untilDay, diff --git a/docs/user/usage.md b/docs/user/usage.md index e5be1e784ea3..30b8c19de1be 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -10,7 +10,9 @@ completed-turn record will not appear. Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the -headline and chart, and refreshing rescans every connected environment. +headline and chart. Changing dates reuses a source snapshot from the last minute when it already +covers the requested range. An older snapshot, or a range that reaches farther back, updates the +source data first. Updates parse only new or changed transcript content. Any daily chart zooms: drag across it to make the selection the new date window, and double-click to return to the preset. The date fields beside the presets accept any custom range directly. From 983d2978d198206e21dfb765e6dc8ea487b8c6ed Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:31:12 +1000 Subject: [PATCH 34/78] docs(usage): state custom range limit --- docs/user/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index 30b8c19de1be..fcaed72528fa 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -15,4 +15,4 @@ covers the requested range. An older snapshot, or a range that reaches farther b source data first. Updates parse only new or changed transcript content. Any daily chart zooms: drag across it to make the selection the new date window, and double-click -to return to the preset. The date fields beside the presets accept any custom range directly. +to return to the preset. The date fields beside the presets accept custom ranges up to 90 days. From 2ce048e2c3feae2bff4cf12a51358c307be53c0e Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:03:54 +1000 Subject: [PATCH 35/78] fix(usage): keep manual refresh authoritative The source freshness cache could hide transcript writes from an explicit Refresh click. Carry an opaque snapshot token from web and mobile so each newly observed token forces one serialized delta scan, while date changes and repeated reads still reuse fresh source data. --- apps/mobile/src/state/usage.ts | 61 ++++++++++++++-------- apps/server/src/usage/UsageService.test.ts | 34 ++++++++++++ apps/server/src/usage/UsageService.ts | 11 +++- apps/web/src/state/usage.ts | 61 ++++++++++++++-------- docs/user/usage.md | 3 +- packages/contracts/src/usage.ts | 6 +++ packages/shared/src/usageMerge.test.ts | 22 +++++++- packages/shared/src/usageMerge.ts | 16 ++++++ 8 files changed, 166 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index cce91b65a6d0..c4d77c1c3b15 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,10 +16,15 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import { + makeUsageRefreshToken, + mergeUsage, + type EnvironmentUsage, + type MergedUsage, +} from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; @@ -75,6 +80,7 @@ export interface UsageView { } export function useUsage(input: UsageSummaryInput): UsageView { + const [refreshToken, setRefreshToken] = useState(); const windowKey = useMemo( () => JSON.stringify({ @@ -84,6 +90,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { resolution: input.resolution, sinceTime: input.sinceTime, untilTime: input.untilTime, + refreshToken, }), [ input.sinceDay, @@ -92,37 +99,47 @@ export function useUsage(input: UsageSummaryInput): UsageView { input.resolution, input.sinceTime, input.untilTime, + refreshToken, ], ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so pull-to-refresh always rescans. + const answered = useMemo( + () => + environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ), + [environments], + ); + const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; + const nextToken = makeUsageRefreshToken(answered); + if (nextToken !== undefined) { + setRefreshToken(nextToken); + return; + } + + const currentInput = JSON.parse(windowKey) as UsageSummaryInput; for (const environment of environments) { appAtomRegistry.refresh( - serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + serverEnvironment.usageSummary({ + environmentId: environment.environmentId, + input: currentInput, + }), ); } - }, [environments, windowKey]); + }, [answered, environments, windowKey]); - const merged = useMemo(() => { - const answered: EnvironmentUsage[] = environments.flatMap((environment) => - environment.summary === null - ? [] - : [ - { - environmentId: environment.environmentId, - label: environment.label, - summary: environment.summary, - }, - ], - ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [environments]); + const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); const answeredCount = environments.filter((environment) => environment.summary !== null).length; const stillReporting = environments.filter( diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 084973421c91..40d43a766a24 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -177,6 +177,40 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("updates fresh source data for a new manual refresh token", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-manual-refresh-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const refreshedInput = { ...WINDOW, refreshToken: "manual-refresh-1" }; + const refreshed = yield* service.readSummary(refreshedInput); + + assert.strictEqual(totalOutputTokens(refreshed), 12); + assert.strictEqual(ratesFetches, 2); + + yield* service.readSummary(refreshedInput); + assert.strictEqual(ratesFetches, 2); + }).pipe(Effect.scoped), + ); + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 8cef3115fbf1..0bc925573a50 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -360,6 +360,7 @@ export const make = Effect.gen(function* () { } let sourceSnapshot: SourceSnapshot | null = null; + let lastRefreshToken: string | null = null; const sourceScanSemaphore = yield* Semaphore.make(1); const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { @@ -391,6 +392,7 @@ export const make = Effect.gen(function* () { const getSourceSnapshot = Effect.fn("UsageService.getSourceSnapshot")(function* ( windowStartMs: number, + refreshToken: string | undefined, ) { return yield* sourceScanSemaphore.withPermits(1)( Effect.gen(function* () { @@ -402,8 +404,10 @@ export const make = Effect.gen(function* () { : startedAtMs - currentSnapshot.completedAtMs; const snapshotCoversWindow = currentSnapshot !== null && currentSnapshot.windowStartMs <= windowStartMs; + const manualRefresh = refreshToken !== undefined && refreshToken !== lastRefreshToken; if ( + !manualRefresh && currentSnapshot !== null && snapshotCoversWindow && snapshotAgeMs < SOURCE_SCAN_TTL_MS @@ -425,13 +429,15 @@ export const make = Effect.gen(function* () { const [, dirs] = yield* Effect.all([ensureRates(), collectDirs(scanWindowStartMs)], { concurrency: 2, }); - const completedAtMs = yield* Clock.currentTimeMillis; + const now = yield* Clock.currentTimeMillis; + const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); const nextSnapshot = { completedAtMs, windowStartMs: scanWindowStartMs, dirs, } satisfies SourceSnapshot; sourceSnapshot = nextSnapshot; + if (refreshToken !== undefined) lastRefreshToken = refreshToken; return nextSnapshot; }), ); @@ -482,7 +488,7 @@ export const make = Effect.gen(function* () { } const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; - const currentSnapshot = yield* getSourceSnapshot(windowStartMs); + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken); const scannedDirs = currentSnapshot.dirs; const sourceReadAtMs = currentSnapshot.completedAtMs; @@ -595,6 +601,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, + input.refreshToken ?? null, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index ba78a61d8a88..8ec9642bd5eb 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -15,9 +15,14 @@ import { } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; -import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; +import { + makeUsageRefreshToken, + mergeUsage, + type EnvironmentUsage, + type MergedUsage, +} from "@t3tools/shared/usageMerge"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; @@ -72,6 +77,7 @@ export interface UsageView { } export function useUsage(input: UsageSummaryInput): UsageView { + const [refreshToken, setRefreshToken] = useState(); const windowKey = useMemo( () => JSON.stringify({ @@ -81,6 +87,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { resolution: input.resolution, sinceTime: input.sinceTime, untilTime: input.untilTime, + refreshToken, }), [ input.sinceDay, @@ -89,37 +96,47 @@ export function useUsage(input: UsageSummaryInput): UsageView { input.resolution, input.sinceTime, input.untilTime, + refreshToken, ], ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so the button always rescans. + const answered = useMemo( + () => + environments.flatMap((environment) => + environment.summary === null + ? [] + : [ + { + environmentId: environment.environmentId, + label: environment.label, + summary: environment.summary, + }, + ], + ), + [environments], + ); + const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; + const nextToken = makeUsageRefreshToken(answered); + if (nextToken !== undefined) { + setRefreshToken(nextToken); + return; + } + + const currentInput = JSON.parse(windowKey) as UsageSummaryInput; for (const environment of environments) { appAtomRegistry.refresh( - serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }), + serverEnvironment.usageSummary({ + environmentId: environment.environmentId, + input: currentInput, + }), ); } - }, [environments, windowKey]); + }, [answered, environments, windowKey]); - const merged = useMemo(() => { - const answered: EnvironmentUsage[] = environments.flatMap((environment) => - environment.summary === null - ? [] - : [ - { - environmentId: environment.environmentId, - label: environment.label, - summary: environment.summary, - }, - ], - ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [environments]); + const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); const answeredCount = environments.filter((environment) => environment.summary !== null).length; const stillReporting = environments.filter( diff --git a/docs/user/usage.md b/docs/user/usage.md index fcaed72528fa..d0fe8e385525 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -12,7 +12,8 @@ Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the headline and chart. Changing dates reuses a source snapshot from the last minute when it already covers the requested range. An older snapshot, or a range that reaches farther back, updates the -source data first. Updates parse only new or changed transcript content. +source data first. The Refresh action always requests an update. Updates parse only new or changed +transcript content. Any daily chart zooms: drag across it to make the selection the new date window, and double-click to return to the preset. The date fields beside the presets accept custom ranges up to 90 days. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 8c099ddb33aa..29c15b61f0e0 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -185,6 +185,12 @@ export const UsageSummaryInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), /** Exclusive UTC instant for an hourly rolling window. */ untilTime: Schema.optional(TrimmedNonEmptyString), + /** + * Opaque identity of the source snapshots visible when the user explicitly + * requested a refresh. A new value bypasses the short-lived source cache + * once; repeated reads with the same value may reuse the updated snapshot. + */ + refreshToken: Schema.optional(TrimmedNonEmptyString), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ff..750bfbf7f502 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +import { makeUsageRefreshToken, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; function bucket(overrides: Partial = {}): UsageBucket { return { @@ -339,3 +339,23 @@ describe("mergeUsage", () => { expect(merged.daily[0]?.costUsd).toBe(10); }); }); + +describe("makeUsageRefreshToken", () => { + it("changes when an environment returns a newer source snapshot", () => { + const first = environment("env-a", summary([], [])); + const second = { + ...first, + summary: { ...first.summary, readAt: "2026-08-07T00:01:00.000Z" }, + }; + + expect(makeUsageRefreshToken([second])).not.toBe(makeUsageRefreshToken([first])); + }); + + it("is stable when environment order changes", () => { + const first = environment("env-a", summary([], [])); + const second = environment("env-b", summary([], [])); + + expect(makeUsageRefreshToken([first, second])).toBe(makeUsageRefreshToken([second, first])); + expect(makeUsageRefreshToken([])).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 428599d51c74..69f7c228446d 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -21,6 +21,22 @@ export interface EnvironmentUsage { readonly summary: UsageSummary; } +/** + * Identifies the exact per-environment snapshots currently visible to a + * client. Passing a changed value back to the server requests one source + * update without relying on clocks shared across environments. + */ +export function makeUsageRefreshToken( + environments: readonly EnvironmentUsage[], +): string | undefined { + if (environments.length === 0) return undefined; + return JSON.stringify( + environments + .map(({ environmentId, summary }) => [environmentId, summary.readAt] as const) + .sort(([left], [right]) => left.localeCompare(right)), + ); +} + export interface ProviderTotals { readonly provider: UsageProviderKind; readonly costUsd: number; From 47e1afcc48e583518dd819e8e33033a76ce0272c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 2 Sep 2026 17:10:28 +1000 Subject: [PATCH 36/78] feat(usage): stack thread cost components --- apps/server/src/usage/usagePricing.ts | 30 ++++ apps/server/src/usage/usageThreads.test.ts | 14 +- apps/server/src/usage/usageThreads.ts | 43 +++-- .../usage/UsageThreadTable.test.tsx | 36 +++- .../src/components/usage/UsageThreadTable.tsx | 167 +++++++++++++++--- packages/contracts/src/usage.ts | 10 +- 6 files changed, 261 insertions(+), 39 deletions(-) diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 3d7f5fd29485..8055de569a67 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -184,3 +184,33 @@ export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTo if (rate === null) return 0; return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); } + +export interface UsageComponentCosts { + readonly cacheWriteUsd: number; + readonly cacheReadUsd: number; + /** Fresh input plus output. */ + readonly freshUsd: number; +} + +const ZERO_COMPONENT_COSTS: UsageComponentCosts = { + cacheWriteUsd: 0, + cacheReadUsd: 0, + freshUsd: 0, +}; + +/** Splits model-priced usage into the three components shown in usage charts. */ +export function usageComponentCosts( + table: RateTable, + model: string, + totals: UsageTokenTotals, +): UsageComponentCosts { + const rate = lookupRate(table, model); + if (rate === null) return ZERO_COMPONENT_COSTS; + return { + cacheWriteUsd: totals.cacheCreationTokens * rate.cacheCreationCostPerToken, + cacheReadUsd: totals.cachedInputTokens * rate.cacheReadCostPerToken, + freshUsd: + totals.uncachedInputTokens * rate.inputCostPerToken + + totals.outputTokens * rate.outputCostPerToken, + }; +} diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index f48cbe99cd9e..0c98006cc143 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -85,12 +85,22 @@ describe("ThreadUsageAccumulator", () => { expect(groups[0]?.totals.outputTokens).toBe(50); }); - it("records each day's estimated cost", () => { + it("splits each day's model-priced cost into cache components", () => { const context = { sessionKey: "claude:session-a", agentId: null }; const groups = accumulate([[record(), context]]); const day = groups[0]?.daily.get("2026-08-07"); - expect(day).toBeCloseTo(100 * 1e-5 + 1000 * 1e-6 + 10 * 1.25e-5 + 50 * 5e-5, 12); + expect(day?.cacheWriteUsd).toBeCloseTo(10 * 1.25e-5, 12); + expect(day?.cacheReadUsd).toBeCloseTo(1000 * 1e-6, 12); + expect(day?.freshUsd).toBeCloseTo(100 * 1e-5 + 50 * 5e-5, 12); + }); + + it("does not invent a component split for provider-reported costs", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([[record({ reportedCostUsd: 1.25 }), context]]); + + expect(groups[0]?.costUsd).toBe(1.25); + expect(groups[0]?.daily.size).toBe(0); }); it("drops records outside the window", () => { diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 13ac7c314a00..cfa720e506a3 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -21,7 +21,12 @@ import type { import { UsageDay } from "@t3tools/contracts"; import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts"; -import { priceUsage, type RateTable } from "./usagePricing.ts"; +import { + priceUsage, + usageComponentCosts, + type RateTable, + type UsageComponentCosts, +} from "./usagePricing.ts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; @@ -49,7 +54,7 @@ export interface SessionUsageGroup { readonly project: string; readonly totals: UsageTokenTotals; readonly costUsd: number; - readonly daily: ReadonlyMap; + readonly daily: ReadonlyMap; readonly agents: ReadonlyMap; } @@ -63,7 +68,7 @@ interface MutableSessionGroup { project: string; totals: UsageTokenTotals; costUsd: number; - daily: Map; + daily: Map; agents: Map; } @@ -79,7 +84,7 @@ export interface ThreadUsageOptions { } /** - * Folds records into per-session groups with per-day estimated costs. + * Folds records into per-session groups with per-day component costs. * * De-duplication is global across the scan with the same semantics as the * summary aggregator, so a thread's number here always reconciles with its @@ -153,7 +158,15 @@ export class ThreadUsageAccumulator { ); group.totals = addTotals(group.totals, record.totals); group.costUsd += priced.costUsd; - group.daily.set(day, (group.daily.get(day) ?? 0) + priced.costUsd); + if (priced.costSource === "modelPriced") { + const components = usageComponentCosts(this.#options.rates, record.model, record.totals); + const current = group.daily.get(day); + group.daily.set(day, { + cacheWriteUsd: (current?.cacheWriteUsd ?? 0) + components.cacheWriteUsd, + cacheReadUsd: (current?.cacheReadUsd ?? 0) + components.cacheReadUsd, + freshUsd: (current?.freshUsd ?? 0) + components.freshUsd, + }); + } if (context.agentId !== null) { let agent = group.agents.get(context.agentId); @@ -220,7 +233,7 @@ interface MutableThreadRow { costUsd: number; sessionKeys: Set; groupedRows: number; - daily: Map; + daily: Map; agents: Map; /** Session whose transcript can supply a title when no thread claims the row. */ titleSessionKey: string; @@ -234,9 +247,17 @@ export interface FoldedThreadRows { readonly truncatedRows: number; } -function addDailyCosts(target: Map, source: ReadonlyMap): void { - for (const [day, costUsd] of source) { - target.set(day, (target.get(day) ?? 0) + costUsd); +function addDailyCosts( + target: Map, + source: ReadonlyMap, +): void { + for (const [day, components] of source) { + const current = target.get(day); + target.set(day, { + cacheWriteUsd: (current?.cacheWriteUsd ?? 0) + components.cacheWriteUsd, + cacheReadUsd: (current?.cacheReadUsd ?? 0) + components.cacheReadUsd, + freshUsd: (current?.freshUsd ?? 0) + components.freshUsd, + }); } } @@ -471,9 +492,9 @@ export function foldThreadRows( ...(row.groupedRows === 0 ? {} : { groupedRows: row.groupedRows }), agents: boundedAgentRows(row.agents, options.cap), daily: [...row.daily.entries()] - .map(([day, costUsd]) => ({ + .map(([day, components]) => ({ day: day as UsageDay, - costUsd, + ...components, })) .sort((a, b) => a.day.localeCompare(b.day)) satisfies UsageThreadDayCost[], })), diff --git a/apps/web/src/components/usage/UsageThreadTable.test.tsx b/apps/web/src/components/usage/UsageThreadTable.test.tsx index 211cba886a59..dd3a75f74a6e 100644 --- a/apps/web/src/components/usage/UsageThreadTable.test.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.test.tsx @@ -27,7 +27,7 @@ vi.mock("./usageProviders", () => ({ }, })); -import { UsageThreadTable } from "./UsageThreadTable"; +import { UsageThreadDailyChart, UsageThreadTable } from "./UsageThreadTable"; const input = { sinceDay: UsageDay.make("2026-08-01"), @@ -120,3 +120,37 @@ describe("UsageThreadTable", () => { expect(markup).not.toContain('title="Fix the flaky test"'); }); }); + +describe("UsageThreadDailyChart", () => { + it("renders continuous stacked component bands with a peak and date labels", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Peak $12.00"); + expect(markup).toContain("cache writes"); + expect(markup).toContain("cache reads"); + expect(markup).toContain("fresh input + output"); + expect(markup.match(/ = { + cacheWriteUsd: 0, + cacheReadUsd: 0, + freshUsd: 0, +}; + +const CHART_BANDS = [ + { + key: "freshUsd", + label: "fresh input + output", + className: "text-emerald-500", + lowerKeys: [], + }, + { + key: "cacheReadUsd", + label: "cache reads", + className: "text-muted-foreground", + lowerKeys: ["freshUsd"], + }, + { + key: "cacheWriteUsd", + label: "cache writes", + className: "text-sky-500", + lowerKeys: ["freshUsd", "cacheReadUsd"], + }, +] as const; + +type ChartCostKey = (typeof CHART_BANDS)[number]["key"]; + +function dayCost(entry: Omit): number { + return entry.cacheWriteUsd + entry.cacheReadUsd + entry.freshUsd; +} + +/** Rounds the ceiling up without leaving a compact thread chart mostly empty. */ +function chartCeiling(peak: number): number { + if (peak <= 0) return 0; + const magnitude = 10 ** Math.floor(Math.log10(peak)); + const normalized = peak / magnitude; + const step = [1, 2, 2.5, 5, 10].find((candidate) => candidate >= normalized) ?? 10; + return step * magnitude; +} + +function chartNumber(value: number): string { + return value.toFixed(2).replace(/\.00$/, ""); +} + +/** One filled band between two cumulative step boundaries. */ +function steppedAreaPath( + columns: readonly Omit[], + key: ChartCostKey, + lowerKeys: readonly ChartCostKey[], + ceiling: number, +): string { + if (columns.length === 0 || ceiling <= 0) return ""; + if (columns.every((column) => column[key] === 0)) return ""; + + const width = CHART_WIDTH / columns.length; + const y = (value: number) => + chartNumber(CHART_HEIGHT - (value / ceiling) * (CHART_HEIGHT - CHART_TOP)); + const lower = columns.map((column) => + lowerKeys.reduce((sum, lowerKey) => sum + column[lowerKey], 0), + ); + const upper = columns.map((column, index) => (lower[index] ?? 0) + column[key]); + let path = `M0,${y(upper[0] ?? 0)}`; + + for (let index = 0; index < columns.length; index += 1) { + const right = chartNumber((index + 1) * width); + path += ` H${right}`; + const next = upper[index + 1]; + if (next !== undefined) path += ` V${y(next)}`; + } + + path += ` L${CHART_WIDTH},${y(lower.at(-1) ?? 0)}`; + for (let index = columns.length - 1; index >= 0; index -= 1) { + const left = chartNumber(index * width); + path += ` H${left}`; + const previous = lower[index - 1]; + if (previous !== undefined) path += ` V${y(previous)}`; + } + return `${path} Z`; +} /** - * One thread's daily estimated cost. Static SVG, no animation. + * One thread's daily model-priced cost split into continuous stacked bands. + * Static SVG, no animation. */ export function UsageThreadDailyChart({ daily, @@ -243,50 +327,89 @@ export function UsageThreadDailyChart({ () => new Map(daily.map((entry) => [entry.day, entry])), [daily], ); - const peak = daily.reduce((max, entry) => Math.max(max, entry.costUsd), 0); + const columns = days.map((day) => byDay.get(day) ?? EMPTY_DAY); + const peakEntry = daily.reduce( + (largest, entry) => + largest === undefined || dayCost(entry) > dayCost(largest) ? entry : largest, + undefined, + ); - if (peak === 0 || days.length === 0) { + if (peakEntry === undefined || dayCost(peakEntry) === 0 || days.length === 0) { return

No priced usage in this window.

; } + const peak = dayCost(peakEntry); + const ceiling = chartCeiling(peak); const bandWidth = CHART_WIDTH / days.length; - const barWidth = bandWidth * 0.8; + const labelDays = [days[0], days[Math.floor((days.length - 1) / 2)], days.at(-1)].filter( + (day, index, labels): day is string => day !== undefined && labels.indexOf(day) === index, + ); return (
-
+
Daily cost, {formatDayShort(sinceDay)} to {formatDayShort(untilDay)} + + Peak {formatUsd(peak)} ยท {formatDayShort(peakEntry.day)} + +
+
+ {CHART_BANDS.toReversed().map((band) => ( + + + {band.label} + + ))}
+ + {CHART_BANDS.map((band) => ( + + ))} {days.map((day, index) => { - const entry = byDay.get(day); - if (entry === undefined) return null; - const x = index * bandWidth + (bandWidth - barWidth) / 2; - const height = (entry.costUsd / peak) * (CHART_HEIGHT - 4); - const renderedHeight = height === 0 ? 0 : Math.max(height, 0.75); + const entry = byDay.get(day) ?? EMPTY_DAY; + const total = dayCost(entry); return ( - - {`${formatDayShort(day)}: ${formatUsd(entry.costUsd)}`} - - + + {`${formatDayShort(day)}: ${formatUsd(total)}. Cache writes ${formatUsd(entry.cacheWriteUsd)}, cache reads ${formatUsd(entry.cacheReadUsd)}, fresh input and output ${formatUsd(entry.freshUsd)}`} + ); })} +
+ {labelDays.map((day) => ( + {formatDayShort(day)} + ))} +
); } diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 5792ef9458a5..f9f1c379f34a 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -254,12 +254,16 @@ export const UsageAgentRow = Schema.Struct({ export type UsageAgentRow = typeof UsageAgentRow.Type; /** - * One day of a thread's estimated cost. Days the thread was idle are omitted. - * Unpriced records contribute tokens to the row totals but nothing here. + * One day of a thread's model-priced cost split by component. Days the thread + * was idle are omitted. Unpriced and provider-reported records contribute to + * the row totals but not this split. */ export const UsageThreadDayCost = Schema.Struct({ day: UsageDay, - costUsd: Schema.Number, + cacheWriteUsd: Schema.Number, + cacheReadUsd: Schema.Number, + /** Fresh input plus output. */ + freshUsd: Schema.Number, }); export type UsageThreadDayCost = typeof UsageThreadDayCost.Type; From 5198c178042f2f2f27dc4f80432cb805ca8631fd Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:12:47 +1000 Subject: [PATCH 37/78] fix(usage): refresh rebased rolling windows Rolling refreshes that moved their bounds only updated selection and skipped the source refresh token. Always refresh after rebasing web and mobile windows. --- .../src/features/usage/UsageRouteScreen.tsx | 11 ++++---- .../src/components/usage/UsagePage.test.tsx | 27 ++++++++++++++++--- apps/web/src/components/usage/UsagePage.tsx | 11 ++++---- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 817e6d7f9543..e1d4d8ae0ed3 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -82,15 +82,14 @@ export function UsageRouteScreen() { const refreshWindow = () => { const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refresh(); }; return ( diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 25e60d5242d1..fb9542277930 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -1,5 +1,6 @@ import { USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { mergeUsage } from "@t3tools/shared/usageMerge"; +import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -7,6 +8,9 @@ const testState = vi.hoisted(() => ({ useUsage: vi.fn(), metric: "cost" as "cost" | "tokens", breakdown: "time" as "model" | "time", + refresh: vi.fn(), + setWindowSelection: vi.fn(), + refreshWindow: undefined as (() => void) | undefined, })); vi.mock("react", async (importOriginal) => { @@ -31,14 +35,19 @@ vi.mock("react", async (importOriginal) => { : initial === "model" ? testState.breakdown : initial, - vi.fn(), + typeof initial === "function" ? testState.setWindowSelection : vi.fn(), ]), }; }); vi.mock("../../env", () => ({ isElectron: false })); vi.mock("../../state/usage", () => ({ useUsage: testState.useUsage })); -vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/button", () => ({ + Button: (props: { "aria-label"?: string; children?: ReactNode; onClick?: () => void }) => { + if (props["aria-label"] === "Refresh usage") testState.refreshWindow = props.onClick; + return ; + }, +})); vi.mock("../ui/input", () => ({ Input: "input" })); vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); vi.mock("../ui/select", () => ({ @@ -107,6 +116,9 @@ const modelTotals = Object.freeze([ beforeEach(() => { testState.metric = "cost"; testState.breakdown = "time"; + testState.refresh.mockReset(); + testState.setWindowSelection.mockReset(); + testState.refreshWindow = undefined; testState.useUsage.mockReturnValue({ merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), @@ -131,11 +143,20 @@ beforeEach(() => { environments: [], isPending: false, isPartial: false, - refresh: vi.fn(), + refresh: testState.refresh, }); }); describe("UsagePage hourly breakdown", () => { + it("refreshes after rebasing a rolling window", () => { + renderToStaticMarkup(); + + testState.refreshWindow?.(); + + expect(testState.setWindowSelection).toHaveBeenCalledOnce(); + expect(testState.refresh).toHaveBeenCalledOnce(); + }); + it("keeps custom date fields available in both desktop and compact layouts", () => { const markup = renderToStaticMarkup(); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 8b2d3d817f69..f79a713a95f8 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -116,15 +116,14 @@ export function UsagePage() { } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, custom: false, window: nextWindow }); } + refresh(); }; const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined From 30b8a75e500c3e4c76a0afe87ed09cdebbd40e5a Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:26:22 +1000 Subject: [PATCH 38/78] fix(usage): retain totals during refresh Keep each environment's last settled summary visible while a token-bearing refresh query starts. Replace values independently as environments answer, without retaining them across ranges or failures. --- apps/mobile/src/state/usage.ts | 29 ++++++++++++- apps/web/src/state/usage.ts | 29 ++++++++++++- packages/shared/src/usageMerge.test.ts | 59 +++++++++++++++++++++++++- packages/shared/src/usageMerge.ts | 44 +++++++++++++++++++ 4 files changed, 156 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index c4d77c1c3b15..cfa32b14b82a 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -19,12 +19,14 @@ import { import { makeUsageRefreshToken, mergeUsage, + retainUsageStatuses, type EnvironmentUsage, type MergedUsage, + type SettledUsageStatuses, } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; @@ -81,6 +83,25 @@ export interface UsageView { export function useUsage(input: UsageSummaryInput): UsageView { const [refreshToken, setRefreshToken] = useState(); + const rangeKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + resolution: input.resolution, + sinceTime: input.sinceTime, + untilTime: input.untilTime, + }), + [ + input.sinceDay, + input.untilDay, + input.timeZone, + input.resolution, + input.sinceTime, + input.untilTime, + ], + ); const windowKey = useMemo( () => JSON.stringify({ @@ -103,7 +124,11 @@ export function useUsage(input: UsageSummaryInput): UsageView { ], ); const atom = usageByWindowAtom(windowKey); - const environments = useAtomValue(atom); + const currentEnvironments = useAtomValue(atom); + const settledStatuses = useRef | null>(null); + const retained = retainUsageStatuses(rangeKey, currentEnvironments, settledStatuses.current); + settledStatuses.current = retained.settled; + const environments = retained.visible; const answered = useMemo( () => diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 8ec9642bd5eb..62f08d05922c 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -15,13 +15,15 @@ import { } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { makeUsageRefreshToken, mergeUsage, + retainUsageStatuses, type EnvironmentUsage, type MergedUsage, + type SettledUsageStatuses, } from "@t3tools/shared/usageMerge"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; @@ -78,6 +80,25 @@ export interface UsageView { export function useUsage(input: UsageSummaryInput): UsageView { const [refreshToken, setRefreshToken] = useState(); + const rangeKey = useMemo( + () => + JSON.stringify({ + sinceDay: input.sinceDay, + untilDay: input.untilDay, + timeZone: input.timeZone, + resolution: input.resolution, + sinceTime: input.sinceTime, + untilTime: input.untilTime, + }), + [ + input.sinceDay, + input.untilDay, + input.timeZone, + input.resolution, + input.sinceTime, + input.untilTime, + ], + ); const windowKey = useMemo( () => JSON.stringify({ @@ -100,7 +121,11 @@ export function useUsage(input: UsageSummaryInput): UsageView { ], ); const atom = usageByWindowAtom(windowKey); - const environments = useAtomValue(atom); + const currentEnvironments = useAtomValue(atom); + const settledStatuses = useRef | null>(null); + const retained = retainUsageStatuses(rangeKey, currentEnvironments, settledStatuses.current); + settledStatuses.current = retained.settled; + const environments = retained.visible; const answered = useMemo( () => diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 750bfbf7f502..a24c8f2db320 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -8,7 +8,12 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { makeUsageRefreshToken, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +import { + makeUsageRefreshToken, + mergeUsage, + retainUsageStatuses, + type EnvironmentUsage, +} from "./usageMerge.ts"; function bucket(overrides: Partial = {}): UsageBucket { return { @@ -359,3 +364,55 @@ describe("makeUsageRefreshToken", () => { expect(makeUsageRefreshToken([])).toBeUndefined(); }); }); + +describe("retainUsageStatuses", () => { + const status = (id: string, usageSummary: UsageSummary | null, isPending = false) => ({ + environmentId: id as EnvironmentId, + label: id, + isPending, + error: null, + summary: usageSummary, + }); + + it("keeps each environment's settled value while the same range refreshes", () => { + const oldA = summary([bucket({ costUsd: 2 })], []); + const oldB = summary([bucket({ costUsd: 3 })], []); + const newA = { ...oldA, readAt: "2026-08-07T00:01:00.000Z" }; + const previous = { + rangeKey: "range-a", + statuses: [status("env-a", oldA), status("env-b", oldB)], + }; + + const refreshing = retainUsageStatuses( + "range-a", + [status("env-a", null, true), status("env-b", null, true)], + previous, + ); + const partlyAnswered = retainUsageStatuses( + "range-a", + [status("env-a", newA), status("env-b", null, true)], + refreshing.settled, + ); + + expect(refreshing.visible.map(({ isPending, summary: value }) => [isPending, value])).toEqual([ + [true, oldA], + [true, oldB], + ]); + expect( + partlyAnswered.visible.map(({ isPending, summary: value }) => [isPending, value]), + ).toEqual([ + [false, newA], + [true, oldB], + ]); + }); + + it("does not retain values across date ranges", () => { + const old = summary([], []); + const result = retainUsageStatuses("range-b", [status("env-a", null, true)], { + rangeKey: "range-a", + statuses: [status("env-a", old)], + }); + + expect(result.visible[0]?.summary).toBeNull(); + }); +}); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 69f7c228446d..f24e70559ccb 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -21,6 +21,50 @@ export interface EnvironmentUsage { readonly summary: UsageSummary; } +export interface RetainableUsageStatus { + readonly environmentId: EnvironmentId; + readonly error: string | null; + readonly summary: UsageSummary | null; +} + +export interface SettledUsageStatuses { + readonly rangeKey: string; + readonly statuses: readonly T[]; +} + +/** + * Keeps each environment's last value visible while a token-bearing query for + * the same date range starts cold. New answers replace retained values one at + * a time; failures and date-range changes never inherit old data. + */ +export function retainUsageStatuses( + rangeKey: string, + current: readonly T[], + previous: SettledUsageStatuses | null, +): { + readonly visible: readonly T[]; + readonly settled: SettledUsageStatuses | null; +} { + const previousByEnvironment = + previous?.rangeKey === rangeKey + ? new Map(previous.statuses.map((status) => [status.environmentId, status] as const)) + : null; + let retainedAny = false; + const withRetained = current.map((status) => { + if (status.summary !== null || status.error !== null) return status; + const settledStatus = previousByEnvironment?.get(status.environmentId); + if (settledStatus?.summary === null || settledStatus === undefined) return status; + retainedAny = true; + return Object.assign({}, status, { summary: settledStatus.summary }); + }); + const visible = retainedAny ? withRetained : current; + const settled = visible.some((status) => status.summary !== null) + ? { rangeKey, statuses: visible } + : previous; + + return { visible, settled }; +} + /** * Identifies the exact per-environment snapshots currently visible to a * client. Passing a changed value back to the server requests one source From e1b98f56304d6a9b1ceb5fd3822ddc4c32ef8c14 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 2 Sep 2026 17:26:53 +1000 Subject: [PATCH 39/78] fix(web): reuse segmented control tokens --- apps/web/src/components/ui/input.tsx | 19 ++++++++++++++++++- .../components/ui/segmented-control-styles.ts | 8 ++++++++ apps/web/src/components/ui/toggle-group.tsx | 3 ++- apps/web/src/components/ui/toggle.tsx | 10 ++++++---- .../src/components/usage/UsagePage.test.tsx | 4 +++- apps/web/src/components/usage/UsagePage.tsx | 18 ++++++++++-------- .../src/components/usage/UsageThreadTable.tsx | 4 ++-- 7 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/ui/segmented-control-styles.ts diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index cae3dfe62852..bb2958693c24 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -4,9 +4,14 @@ import { Input as InputPrimitive } from "@base-ui/react/input"; import type * as React from "react"; import { cn } from "~/lib/utils"; +import { + segmentedControlItemSizeClassName, + segmentedControlItemVariantClassName, +} from "~/components/ui/segmented-control-styles"; type InputProps = Omit, "size"> & { - size?: "sm" | "compact" | "default" | "lg" | number; + size?: "sm" | "compact" | "default" | "lg" | "segmented" | number; + variant?: "default" | "segmented"; unstyled?: boolean; nativeInput?: boolean; }; @@ -14,6 +19,7 @@ type InputProps = Omit {inputElement} diff --git a/apps/web/src/components/ui/segmented-control-styles.ts b/apps/web/src/components/ui/segmented-control-styles.ts new file mode 100644 index 000000000000..bde9af0fb71e --- /dev/null +++ b/apps/web/src/components/ui/segmented-control-styles.ts @@ -0,0 +1,8 @@ +/** Shared visual contract for segmented controls and segmented inputs. */ +export const segmentedControlGroupClassName = "gap-0.5 rounded-lg bg-input/40 p-0.5"; + +export const segmentedControlItemSizeClassName = + "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]"; + +export const segmentedControlItemVariantClassName = + "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72"; diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx index ae10fe81611f..a297b9df6cc5 100644 --- a/apps/web/src/components/ui/toggle-group.tsx +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -6,6 +6,7 @@ import type { VariantProps } from "class-variance-authority"; import * as React from "react"; import { cn } from "~/lib/utils"; +import { segmentedControlGroupClassName } from "~/components/ui/segmented-control-styles"; import { Separator } from "~/components/ui/separator"; import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle"; @@ -31,7 +32,7 @@ function ToggleGroup({ ? "*:pointer-coarse:after:min-w-auto" : "*:pointer-coarse:after:min-h-auto", variant === "segmented" - ? "gap-0.5 rounded-lg bg-input/40 p-0.5" + ? segmentedControlGroupClassName : variant === "default" ? "gap-0.5" : orientation === "horizontal" diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 9f74d4546cc7..14b80a4440fa 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -4,6 +4,10 @@ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "~/lib/utils"; +import { + segmentedControlItemSizeClassName, + segmentedControlItemVariantClassName, +} from "~/components/ui/segmented-control-styles"; const toggleVariants = cva( "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base text-foreground outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-pressed:bg-input/64 data-pressed:text-accent-foreground sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", @@ -18,8 +22,7 @@ const toggleVariants = cva( "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", - segmented: - "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", + segmented: segmentedControlItemSizeClassName, sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -29,8 +32,7 @@ const toggleVariants = cva( "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", - segmented: - "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-background/55 hover:text-foreground data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-xs/10 dark:hover:bg-input/32 dark:data-pressed:bg-input/72", + segmented: segmentedControlItemVariantClassName, }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 116abcdcbf86..91cb427f95cf 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -171,7 +171,9 @@ describe("UsagePage hourly breakdown", () => { expect(markup.match(/aria-label="From day"/g)).toHaveLength(2); expect(markup.match(/aria-label="To day"/g)).toHaveLength(2); expect(testState.useUsage).toHaveBeenLastCalledWith(expect.anything(), undefined, false); - expect(markup.match(/pointer-coarse:h-8\.5/g)).toHaveLength(4); + expect( + markup.match(/]*variant="segmented"[^>]*aria-label="(?:From|To) day"/g), + ).toHaveLength(4); }); it("keeps recent activity visible first without empty hourly rows", () => { diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 246ff39f89a1..17abbd43a35c 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -30,6 +30,7 @@ import { import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { ScrollArea } from "../ui/scroll-area"; +import { segmentedControlGroupClassName } from "../ui/segmented-control-styles"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; @@ -705,22 +706,23 @@ function UsageDateRangeInputs({ const comparison = compareUsageDays(sinceInput.value, untilInput.value); const invalid = comparison === null || comparison > 0; const inputClassName = - "w-auto rounded-md transition-colors hover:bg-background/55 hover:text-foreground focus-within:bg-background focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1 focus-within:ring-offset-background has-aria-invalid:text-destructive focus-within:has-aria-invalid:ring-destructive/50 dark:hover:bg-input/32 dark:focus-within:bg-input/72 [&_[data-slot=input]]:h-6 [&_[data-slot=input]]:px-2.5 [&_[data-slot=input]]:leading-6 [&_[data-slot=input]]:pointer-coarse:h-8.5 [&_[data-slot=input]]:pointer-coarse:leading-8.5 [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; + "[color-scheme:inherit] [&_[data-slot=input]::-webkit-calendar-picker-indicator]:opacity-50"; return (
to Date: Wed, 2 Sep 2026 17:39:54 +1000 Subject: [PATCH 40/78] fix(usage): allow retrying stalled refreshes Treat retained summaries as settled so a disconnected environment cannot pin the mobile refresh indicator. Repeated refreshes retry the current token query. --- apps/mobile/src/state/usage.ts | 4 ++-- apps/web/src/state/usage.ts | 4 ++-- packages/shared/src/usageMerge.test.ts | 6 +++--- packages/shared/src/usageMerge.ts | 3 ++- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index cfa32b14b82a..0bc9751a2d0e 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -148,7 +148,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { const refresh = useCallback(() => { const nextToken = makeUsageRefreshToken(answered); - if (nextToken !== undefined) { + if (nextToken !== undefined && nextToken !== refreshToken) { setRefreshToken(nextToken); return; } @@ -162,7 +162,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { }), ); } - }, [answered, environments, windowKey]); + }, [answered, environments, refreshToken, windowKey]); const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 62f08d05922c..e7fca71a6917 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -145,7 +145,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { const refresh = useCallback(() => { const nextToken = makeUsageRefreshToken(answered); - if (nextToken !== undefined) { + if (nextToken !== undefined && nextToken !== refreshToken) { setRefreshToken(nextToken); return; } @@ -159,7 +159,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { }), ); } - }, [answered, environments, windowKey]); + }, [answered, environments, refreshToken, windowKey]); const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index a24c8f2db320..1e4d053f27d5 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -395,14 +395,14 @@ describe("retainUsageStatuses", () => { ); expect(refreshing.visible.map(({ isPending, summary: value }) => [isPending, value])).toEqual([ - [true, oldA], - [true, oldB], + [false, oldA], + [false, oldB], ]); expect( partlyAnswered.visible.map(({ isPending, summary: value }) => [isPending, value]), ).toEqual([ [false, newA], - [true, oldB], + [false, oldB], ]); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index f24e70559ccb..42b189dd8497 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -23,6 +23,7 @@ export interface EnvironmentUsage { export interface RetainableUsageStatus { readonly environmentId: EnvironmentId; + readonly isPending: boolean; readonly error: string | null; readonly summary: UsageSummary | null; } @@ -55,7 +56,7 @@ export function retainUsageStatuses( const settledStatus = previousByEnvironment?.get(status.environmentId); if (settledStatus?.summary === null || settledStatus === undefined) return status; retainedAny = true; - return Object.assign({}, status, { summary: settledStatus.summary }); + return Object.assign({}, status, { isPending: false, summary: settledStatus.summary }); }); const visible = retainedAny ? withRetained : current; const settled = visible.some((status) => status.summary !== null) From 12a8837c1089d14c7412f292f46e2b6fc64d13e7 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:52:16 +1000 Subject: [PATCH 41/78] fix(mobile): bound usage refresh indicator Keep retained summaries pending so normal pull refreshes show progress, while capping the mobile indicator when an environment never answers. --- .../src/features/usage/UsageRouteScreen.tsx | 44 ++++++++++++++++++- packages/shared/src/usageMerge.test.ts | 6 +-- packages/shared/src/usageMerge.ts | 3 +- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index e1d4d8ae0ed3..10b32fb931c1 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -11,7 +11,7 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -32,6 +32,7 @@ const WINDOW_OPTIONS = [ ] as const; const CHART_HEIGHT = 180; +const REFRESH_INDICATOR_TIMEOUT_MS = 30_000; export function UsageRouteScreen() { const navigation = useNavigation(); @@ -41,6 +42,9 @@ export function UsageRouteScreen() { window: makeWindow(30), })); const [metric, setMetric] = useState("cost"); + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const refreshWasPending = useRef(false); + const refreshIndicatorTimeout = useRef | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); @@ -72,7 +76,34 @@ export function UsageRouteScreen() { // The pull spinner tracks re-scans of environments that have answered // before. The initial scan renders its own placeholder, and an unreachable // environment stays pending forever โ€” neither may pin the spinner on. - const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); + const refreshPending = environments.some((entry) => entry.isPending && entry.summary !== null); + const refreshing = isPullRefreshing && refreshPending; + useEffect(() => { + if (!isPullRefreshing) { + refreshWasPending.current = false; + return; + } + if (refreshPending) { + refreshWasPending.current = true; + return; + } + if (!refreshWasPending.current) return; + + setIsPullRefreshing(false); + refreshWasPending.current = false; + if (refreshIndicatorTimeout.current !== null) { + clearTimeout(refreshIndicatorTimeout.current); + refreshIndicatorTimeout.current = null; + } + }, [isPullRefreshing, refreshPending]); + useEffect( + () => () => { + if (refreshIndicatorTimeout.current !== null) { + clearTimeout(refreshIndicatorTimeout.current); + } + }, + [], + ); const selectWindow = (days: number) => { setWindowSelection({ days, @@ -80,6 +111,15 @@ export function UsageRouteScreen() { }); }; const refreshWindow = () => { + setIsPullRefreshing(true); + if (refreshIndicatorTimeout.current !== null) { + clearTimeout(refreshIndicatorTimeout.current); + } + refreshIndicatorTimeout.current = setTimeout(() => { + setIsPullRefreshing(false); + refreshWasPending.current = false; + refreshIndicatorTimeout.current = null; + }, REFRESH_INDICATOR_TIMEOUT_MS); const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 1e4d053f27d5..a24c8f2db320 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -395,14 +395,14 @@ describe("retainUsageStatuses", () => { ); expect(refreshing.visible.map(({ isPending, summary: value }) => [isPending, value])).toEqual([ - [false, oldA], - [false, oldB], + [true, oldA], + [true, oldB], ]); expect( partlyAnswered.visible.map(({ isPending, summary: value }) => [isPending, value]), ).toEqual([ [false, newA], - [false, oldB], + [true, oldB], ]); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 42b189dd8497..f24e70559ccb 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -23,7 +23,6 @@ export interface EnvironmentUsage { export interface RetainableUsageStatus { readonly environmentId: EnvironmentId; - readonly isPending: boolean; readonly error: string | null; readonly summary: UsageSummary | null; } @@ -56,7 +55,7 @@ export function retainUsageStatuses( const settledStatus = previousByEnvironment?.get(status.environmentId); if (settledStatus?.summary === null || settledStatus === undefined) return status; retainedAny = true; - return Object.assign({}, status, { isPending: false, summary: settledStatus.summary }); + return Object.assign({}, status, { summary: settledStatus.summary }); }); const visible = retainedAny ? withRetained : current; const settled = visible.some((status) => status.summary !== null) From 3dd0596193da56a85742d113be9d1fe6654a9f29 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 3 Sep 2026 10:15:15 +1000 Subject: [PATCH 42/78] fix(usage): exclude unknown work from outside filter --- apps/server/src/usage/usageThreads.test.ts | 26 ++++++++++++++++++++++ apps/server/src/usage/usageThreads.ts | 18 ++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index 0c98006cc143..fb413a92d59b 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -441,4 +441,30 @@ describe("foldThreadRows", () => { expect(outside.rows.map((row) => row.key)).toHaveLength(1); expect(outside.rows[0]?.key).toContain("claude:session-b"); }); + + it("excludes unknown project attribution from the outside-project filter", () => { + const accumulator = new ThreadUsageAccumulator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + resolveProject: () => null, + }); + accumulator.add(record({ sessionId: "outside", cwd: "/elsewhere" }), { + sessionKey: "claude:outside", + agentId: null, + }); + accumulator.add(record({ provider: "grok", sessionId: "unknown", cwd: "" }), { + sessionKey: "grok:unknown", + agentId: null, + }); + + const outside = foldThreadRows(accumulator.finish(), NO_ATTRIBUTION, { + cap: 40, + projectFilter: null, + }); + + expect(outside.rows).toHaveLength(1); + expect(outside.rows[0]?.key).toContain("claude:outside"); + }); }); diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index cfa720e506a3..7a813507952c 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -51,6 +51,7 @@ export interface SessionUsageGroup { readonly cwd: string; readonly projectId: ProjectId | null; readonly projectKey: string | null; + readonly projectAttribution: "project" | "outside" | "unknown"; readonly project: string; readonly totals: UsageTokenTotals; readonly costUsd: number; @@ -65,6 +66,7 @@ interface MutableSessionGroup { cwd: string; projectId: ProjectId | null; projectKey: string | null; + projectAttribution: "project" | "outside" | "unknown"; project: string; totals: UsageTokenTotals; costUsd: number; @@ -129,6 +131,12 @@ export class ThreadUsageAccumulator { return false; const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; + const projectAttribution = + resolvedProject !== null + ? "project" + : this.#options.resolveProject === undefined || record.cwd.length === 0 + ? "unknown" + : "outside"; const projectKey = resolvedProject === null ? null : `id:${resolvedProject.projectId.replaceAll("\u0000", "")}`; const groupKey = JSON.stringify([context.sessionKey, record.cwd]); @@ -141,6 +149,7 @@ export class ThreadUsageAccumulator { cwd: record.cwd, projectId: resolvedProject?.projectId ?? null, projectKey, + projectAttribution, project: resolvedProject?.title ?? "", totals: EMPTY_TOTALS, costUsd: 0, @@ -188,6 +197,7 @@ export class ThreadUsageAccumulator { cwd: group.cwd, projectId: group.projectId, projectKey: group.projectKey, + projectAttribution: group.projectAttribution, project: group.project, totals: group.totals, costUsd: group.costUsd, @@ -345,7 +355,13 @@ export function foldThreadRows( const byKey = new Map(); for (const group of groups) { - if (options.projectFilter !== undefined && group.projectKey !== options.projectFilter) continue; + if ( + options.projectFilter !== undefined && + (options.projectFilter === null + ? group.projectAttribution !== "outside" + : group.projectKey !== options.projectFilter) + ) + continue; const ref = attribution.sessionToThread.get(group.sessionKey) ?? From 3eb3719b184606c170cf501c249e38fb0d1fe92f Mon Sep 17 00:00:00 2001 From: Nightly replay proof Date: Thu, 3 Sep 2026 13:43:39 +1000 Subject: [PATCH 43/78] fix(usage): retain model-less Claude iterations --- apps/server/src/usage/usageScanCache.test.ts | 3 +- apps/server/src/usage/usageScanCache.ts | 3 +- .../server/src/usage/usageTranscripts.test.ts | 37 +++++++++++++++++++ apps/server/src/usage/usageTranscripts.ts | 6 +-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 7a89e007b915..2d0c5805c638 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -5,6 +5,7 @@ import { dedupeWithinFile, encodeScanCache, pruneScanCache, + USAGE_SCAN_CACHE_VERSION, type CachedFile, type ScanCache, } from "./usageScanCache.ts"; @@ -128,7 +129,7 @@ describe("scan cache round trip", () => { it("rejects a document from the previous cache version", () => { const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const previous = { ...encoded, version: 2 }; + const previous = { ...encoded, version: USAGE_SCAN_CACHE_VERSION - 1 }; expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index e47ea120ac5b..da98a29f35e5 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -29,7 +29,8 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // v4: records carry the session's cwd for project attribution; v3 entries // would pin every cached file to "no project" forever. // v5: Claude records retain cache TTLs and expanded fallback iterations. -export const USAGE_SCAN_CACHE_VERSION = 5 as const; +// v6: Claude iterations without their own model inherit the serving model. +export const USAGE_SCAN_CACHE_VERSION = 6 as const; export interface CachedFile { readonly size: number; diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 146a5c80bd99..d2e14bab8c79 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -142,6 +142,43 @@ describe("parseClaudeLine", () => { ]); }); + it("uses the serving model when an iteration omits its model", () => { + const records = parseClaudeLineRecords( + JSON.stringify({ + type: "assistant", + timestamp: "2026-09-03T01:13:44.675Z", + requestId: "req_model_omitted", + sessionId: "session-model-omitted", + cwd: "/work/app", + message: { + id: "msg_model_omitted", + model: "claude-fable-5-1", + usage: { + output_tokens: 12, + iterations: [ + { + type: "message", + input_tokens: 2, + cache_read_input_tokens: 100, + cache_creation_input_tokens: 20, + output_tokens: 12, + }, + ], + }, + }, + }), + ); + + expect(records).toHaveLength(1); + expect(records[0]?.model).toBe("claude-fable-5-1"); + expect(records[0]?.totals).toMatchObject({ + uncachedInputTokens: 2, + cachedInputTokens: 100, + cacheCreationTokens: 20, + outputTokens: 12, + }); + }); + it("replaces a progressive Claude snapshot with its final serving iteration", () => { const partial = parseClaudeLineRecords( claudeLine({ diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index edb0301e09c9..11af0edec230 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -153,11 +153,9 @@ export function parseClaudeLineRecords(line: string): readonly UsageRecord[] { return attempts.flatMap((attempt, index) => { const attemptModel = - typeof attempt["model"] === "string" + typeof attempt["model"] === "string" && attempt["model"].length > 0 ? attempt["model"] - : iterations.length === 0 - ? model - : ""; + : model; if (attemptModel.length === 0) return []; const cacheCreation = From 08db6d34535d2ede4a9f630c232e322604f7dc45 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:59:28 +1000 Subject: [PATCH 44/78] fix(usage): normalize attribution paths --- apps/server/src/usage/UsageService.ts | 7 +- .../server/src/usage/usageAggregation.test.ts | 69 ++++++++++--------- apps/server/src/usage/usageAggregation.ts | 16 ++--- apps/server/src/usage/usagePaths.test.ts | 22 ++++++ apps/server/src/usage/usagePaths.ts | 33 +++++++++ apps/server/src/usage/usageThreads.test.ts | 19 +++++ apps/server/src/usage/usageThreads.ts | 22 +----- 7 files changed, 123 insertions(+), 65 deletions(-) create mode 100644 apps/server/src/usage/usagePaths.test.ts create mode 100644 apps/server/src/usage/usagePaths.ts diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 15cbdeaa752e..166fb200c491 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -48,6 +48,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; +import { dedicatedUsageWorktreePath } from "./usagePaths.ts"; import { parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -311,7 +312,7 @@ export const make = Effect.gen(function* () { }), { concurrency: 8 }, ); - return makeProjectResolver(projectRoots.flat(), path.sep); + return makeProjectResolver(projectRoots.flat()); }); /** @@ -657,10 +658,10 @@ export const make = Effect.gen(function* () { for (const thread of threads) { const title = thread.title.trim(); if (title.length > 0) titles.set(thread.threadId, title); - const worktree = thread.worktreePath?.trim() ?? ""; + const worktree = dedicatedUsageWorktreePath(project.workspaceRoot, thread.worktreePath); // The project root is not a dedicated worktree: interactive sessions // run there too, and several threads usually share it. - if (worktree.length === 0 || worktree === project.workspaceRoot) continue; + if (worktree === null) continue; const ref: ThreadRef = { threadId: thread.threadId, title: title || thread.threadId }; const claim = worktreeClaims.get(worktree); if (claim === undefined) worktreeClaims.set(worktree, { ref, shared: false }); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 20752f3a91f5..38245255c002 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -327,36 +327,33 @@ describe("makeProjectResolver", () => { const legacyDeletedId = ProjectId.make("project-legacy-deleted"); const legacyId = ProjectId.make("project-legacy"); const untitledId = ProjectId.make("project-untitled"); - const resolver = makeProjectResolver( - [ - { projectId: appId, workspaceRoot: "/work/app", title: "App", deleted: false }, - { - projectId: vendoredId, - workspaceRoot: "/work/app/vendored", - title: "Vendored", - deleted: false, - }, - { - projectId: legacyDeletedId, - workspaceRoot: "/work/legacy", - title: "Legacy Was Deleted", - deleted: true, - }, - { - projectId: legacyId, - workspaceRoot: "/work/legacy", - title: "Legacy", - deleted: false, - }, - { - projectId: untitledId, - workspaceRoot: "/work/untitled", - title: " ", - deleted: false, - }, - ], - "/", - ); + const resolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "/work/app", title: "App", deleted: false }, + { + projectId: vendoredId, + workspaceRoot: "/work/app/vendored", + title: "Vendored", + deleted: false, + }, + { + projectId: legacyDeletedId, + workspaceRoot: "/work/legacy", + title: "Legacy Was Deleted", + deleted: true, + }, + { + projectId: legacyId, + workspaceRoot: "/work/legacy", + title: "Legacy", + deleted: false, + }, + { + projectId: untitledId, + workspaceRoot: "/work/untitled", + title: " ", + deleted: false, + }, + ]); it("matches the root itself and any path under it", () => { expect(resolver("/work/app")).toEqual({ projectId: appId, title: "App" }); @@ -385,11 +382,15 @@ describe("makeProjectResolver", () => { it("matches descendants when the project root is the filesystem root", () => { const rootId = ProjectId.make("project-root"); - const rootResolver = makeProjectResolver( - [{ projectId: rootId, workspaceRoot: "/", title: "Root", deleted: false }], - "/", - ); + const rootResolver = makeProjectResolver([ + { projectId: rootId, workspaceRoot: "/", title: "Root", deleted: false }, + ]); expect(rootResolver("/work/app")).toEqual({ projectId: rootId, title: "Root" }); }); + + it("matches mixed slash styles and normalized segments", () => { + expect(resolver("\\work\\app\\src")).toEqual({ projectId: appId, title: "App" }); + expect(resolver("/work/app/other/../src")).toEqual({ projectId: appId, title: "App" }); + }); }); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index c29a6a978834..eded20500baa 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -20,6 +20,7 @@ import type { UsageTokenTotals, } from "@t3tools/contracts"; +import { normalizeUsagePath } from "./usagePaths.ts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; import { cacheSavingsUsd, cacheWriteUsd, priceUsage, type RateTable } from "./usagePricing.ts"; @@ -75,15 +76,11 @@ export interface ProjectAttribution { */ export function makeProjectResolver( projects: readonly ProjectRoot[], - separator: string, ): (cwd: string) => ProjectAttribution | null { const roots = projects .map((project) => ({ projectId: project.projectId, - root: - project.workspaceRoot.length > 1 && project.workspaceRoot.endsWith(separator) - ? project.workspaceRoot.slice(0, -1) - : project.workspaceRoot, + root: project.workspaceRoot.length === 0 ? "" : normalizeUsagePath(project.workspaceRoot), title: project.title.trim(), deleted: project.deleted, })) @@ -93,18 +90,19 @@ export function makeProjectResolver( const byCwd = new Map(); return (cwd) => { if (cwd.length === 0) return null; - if (byCwd.has(cwd)) return byCwd.get(cwd) ?? null; + const normalizedCwd = normalizeUsagePath(cwd); + if (byCwd.has(normalizedCwd)) return byCwd.get(normalizedCwd) ?? null; let resolved: ProjectAttribution | null = null; for (const { projectId, root, title } of roots) { if ( - cwd === root || - (root === separator ? cwd.startsWith(separator) : cwd.startsWith(`${root}${separator}`)) + normalizedCwd === root || + (root === "/" ? normalizedCwd.startsWith("/") : normalizedCwd.startsWith(`${root}/`)) ) { resolved = { projectId, title }; break; } } - byCwd.set(cwd, resolved); + byCwd.set(normalizedCwd, resolved); return resolved; }; } diff --git a/apps/server/src/usage/usagePaths.test.ts b/apps/server/src/usage/usagePaths.test.ts new file mode 100644 index 000000000000..86d7157d25b6 --- /dev/null +++ b/apps/server/src/usage/usagePaths.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { dedicatedUsageWorktreePath, normalizeUsagePath } from "./usagePaths.ts"; + +describe("usage path normalization", () => { + it("folds slash styles, trailing separators, and dot segments", () => { + expect(normalizeUsagePath("C:\\work\\app\\other\\..\\src\\")).toBe("C:/work/app/src"); + }); + + it("does not treat another spelling of the project root as a dedicated worktree", () => { + expect(dedicatedUsageWorktreePath("C:\\work\\app", "C:/work/app/")).toBeNull(); + }); + + it("returns one stable key for equivalent dedicated worktree paths", () => { + expect(dedicatedUsageWorktreePath("C:/work/app", "C:\\work\\app\\.wt\\thread-1\\")).toBe( + "C:/work/app/.wt/thread-1", + ); + expect(dedicatedUsageWorktreePath("C:/work/app", "C:/work/app/other/../.wt/thread-1")).toBe( + "C:/work/app/.wt/thread-1", + ); + }); +}); diff --git a/apps/server/src/usage/usagePaths.ts b/apps/server/src/usage/usagePaths.ts new file mode 100644 index 000000000000..ea9dede3cb60 --- /dev/null +++ b/apps/server/src/usage/usagePaths.ts @@ -0,0 +1,33 @@ +/** + * Normalizes persisted provider and worktree paths for usage attribution. + * + * Provider transcripts can retain paths written on another platform or with a + * different slash style, so attribution cannot rely on the host separator. + */ +export function normalizeUsagePath(value: string): string { + const slashPath = value.replaceAll("\\", "/"); + const rooted = slashPath.startsWith("/"); + const segments: string[] = []; + for (const segment of slashPath.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (segments.length > 0 && segments.at(-1) !== "..") segments.pop(); + else if (!rooted) segments.push(segment); + continue; + } + segments.push(segment); + } + const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; + return normalized === "" ? (rooted ? "/" : ".") : normalized; +} + +/** Returns a normalized dedicated worktree, excluding the shared project root. */ +export function dedicatedUsageWorktreePath( + projectRoot: string, + worktree: string | null, +): string | null { + const candidate = worktree?.trim() ?? ""; + if (candidate.length === 0) return null; + const normalized = normalizeUsagePath(candidate); + return normalized === normalizeUsagePath(projectRoot) ? null : normalized; +} diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index 2a42fa5a524c..fcd4dc0b147a 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -264,6 +264,25 @@ describe("foldThreadRows", () => { expect(rows[0]?.title).toBe("Nested worktree"); }); + it("matches worktrees across slash styles and normalized segments", () => { + const groups = accumulate([ + [ + record({ sessionId: "mixed", cwd: "\\work\\app\\.wt\\thread-1\\packages\\web" }), + { sessionKey: "claude:mixed", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["/work/app/other/../.wt/thread-1/", { threadId, title: "Normalized worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + expect(rows[0]?.threadId).toBe(threadId); + expect(rows[0]?.title).toBe("Normalized worktree"); + }); it("scopes one T3 thread by provider and project", () => { const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index 75ef944d640d..460e7bd84e1a 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -21,6 +21,7 @@ import type { import { UsageDay } from "@t3tools/contracts"; import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts"; +import { normalizeUsagePath } from "./usagePaths.ts"; import { cacheWriteUsd, priceUsage, usageComponentCosts, type RateTable } from "./usagePricing.ts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; @@ -327,10 +328,10 @@ function worktreeThreadForCwd( cwd: string, worktreeToThread: ReadonlyMap, ): ThreadRef | undefined { - const normalizedCwd = normalizePath(cwd); + const normalizedCwd = normalizeUsagePath(cwd); let deepest: { readonly pathLength: number; readonly ref: ThreadRef } | undefined; for (const [worktree, ref] of worktreeToThread) { - const normalizedWorktree = normalizePath(worktree); + const normalizedWorktree = normalizeUsagePath(worktree); const prefix = normalizedWorktree.endsWith("/") ? normalizedWorktree : `${normalizedWorktree}/`; if (normalizedCwd !== normalizedWorktree && !normalizedCwd.startsWith(prefix)) continue; if (deepest === undefined || normalizedWorktree.length > deepest.pathLength) { @@ -340,23 +341,6 @@ function worktreeThreadForCwd( return deepest?.ref; } -function normalizePath(value: string): string { - const slashPath = value.replaceAll("\\", "/"); - const rooted = slashPath.startsWith("/"); - const segments: string[] = []; - for (const segment of slashPath.split("/")) { - if (segment === "" || segment === ".") continue; - if (segment === "..") { - if (segments.length > 0 && segments.at(-1) !== "..") segments.pop(); - else if (!rooted) segments.push(segment); - continue; - } - segments.push(segment); - } - const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; - return normalized === "" ? (rooted ? "/" : ".") : normalized; -} - function toAgentRow([agentId, slice]: readonly [string, MutableAgentSlice]): UsageAgentRow { return { agentId, From 48ac934c83926ad5dd14762b7ab8a4eea7f47646 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:04:01 +1000 Subject: [PATCH 45/78] docs: explain absent Codex cache writes --- docs/user/usage.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user/usage.md b/docs/user/usage.md index 8cb240f46dff..3219b5c02ea2 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -35,6 +35,8 @@ The **Cache writes, estimated** total prices cache-creation tokens at each model It only applies to model-priced records that report cache-creation tokens. Rows without cache writes show a dash; incomplete or unavailable pricing is labeled **Unavailable** instead of zero. Cache creation is a billing category, not evidence that a cache entry expired. +When a Codex rollout reports `cache_write_input_tokens` as zero, T3 Code cannot reconstruct a +separate write charge; those prompt tokens remain in **Fresh input + output**. Usage is attributed to the project whose folder a session ran in, including sessions driven outside T3 Code. The breakdown's **Project** view ranks projects by spend, and the project picker From 6c4fd5d62bb7a4fc700eee4c0243ecc4996e032b Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:07:31 +1000 Subject: [PATCH 46/78] fix(usage): preserve attribution failures --- apps/server/src/usage/UsageService.ts | 22 +++++++++++----- .../server/src/usage/usageAggregation.test.ts | 5 ++++ apps/server/src/usage/usagePaths.test.ts | 15 +++++++---- apps/server/src/usage/usagePaths.ts | 4 ++- apps/server/src/usage/usageThreads.test.ts | 25 +++++++++++++++++++ 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 166fb200c491..431524e950d7 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -290,7 +290,8 @@ export const make = Effect.gen(function* () { const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* () { const projects = yield* projectRepository .listAll() - .pipe(Effect.catchCause(() => Effect.succeed([]))); + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (projects === null) return undefined; const projectRoots = yield* Effect.forEach( projects, Effect.fnUntraced(function* (project) { @@ -500,6 +501,7 @@ export const make = Effect.gen(function* () { concurrency: 2, }); + const resolveProject = yield* resolveProjects(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -507,7 +509,7 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, - resolveProject: yield* resolveProjects(), + ...(resolveProject === undefined ? {} : { resolveProject }), }); const sources: UsageSource[] = []; @@ -672,9 +674,16 @@ export const make = Effect.gen(function* () { if (!claim.shared) worktreeToThread.set(worktree, claim.ref); } - const runtimes = yield* runtimeRepository - .list() - .pipe(Effect.catchCause(() => Effect.succeed([]))); + const runtimes = yield* runtimeRepository.list().pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Provider runtime state could not be read", + cause: Cause.squash(cause), + }), + ), + ); for (const runtime of runtimes) { const cursor = runtime.resumeCursor; if (typeof cursor !== "object" || cursor === null) continue; @@ -754,13 +763,14 @@ export const make = Effect.gen(function* () { const windowStartMs = (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const resolveProject = yield* resolveProjects(); const accumulator = new ThreadUsageAccumulator({ timeZone: input.timeZone, sinceDay: input.sinceDay, untilDay: input.untilDay, ...exactWindow, rates, - resolveProject: yield* resolveProjects(), + ...(resolveProject === undefined ? {} : { resolveProject }), }); // Preferred transcript per session for title extraction: the main file, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 38245255c002..52f4b9b1e810 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -392,5 +392,10 @@ describe("makeProjectResolver", () => { it("matches mixed slash styles and normalized segments", () => { expect(resolver("\\work\\app\\src")).toEqual({ projectId: appId, title: "App" }); expect(resolver("/work/app/other/../src")).toEqual({ projectId: appId, title: "App" }); + + const windowsResolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "C:\\Work\\App", title: "App", deleted: false }, + ]); + expect(windowsResolver("c:/work/app/src")).toEqual({ projectId: appId, title: "App" }); }); }); diff --git a/apps/server/src/usage/usagePaths.test.ts b/apps/server/src/usage/usagePaths.test.ts index 86d7157d25b6..cb8d5c6c468a 100644 --- a/apps/server/src/usage/usagePaths.test.ts +++ b/apps/server/src/usage/usagePaths.test.ts @@ -4,19 +4,24 @@ import { dedicatedUsageWorktreePath, normalizeUsagePath } from "./usagePaths.ts" describe("usage path normalization", () => { it("folds slash styles, trailing separators, and dot segments", () => { - expect(normalizeUsagePath("C:\\work\\app\\other\\..\\src\\")).toBe("C:/work/app/src"); + expect(normalizeUsagePath("C:\\Work\\App\\other\\..\\src\\")).toBe("c:/work/app/src"); }); it("does not treat another spelling of the project root as a dedicated worktree", () => { - expect(dedicatedUsageWorktreePath("C:\\work\\app", "C:/work/app/")).toBeNull(); + expect(dedicatedUsageWorktreePath("C:\\Work\\App", "c:/work/app/")).toBeNull(); }); it("returns one stable key for equivalent dedicated worktree paths", () => { - expect(dedicatedUsageWorktreePath("C:/work/app", "C:\\work\\app\\.wt\\thread-1\\")).toBe( - "C:/work/app/.wt/thread-1", + expect(dedicatedUsageWorktreePath("C:/work/app", "C:\\WORK\\APP\\.wt\\thread-1\\")).toBe( + "c:/work/app/.wt/thread-1", ); expect(dedicatedUsageWorktreePath("C:/work/app", "C:/work/app/other/../.wt/thread-1")).toBe( - "C:/work/app/.wt/thread-1", + "c:/work/app/.wt/thread-1", ); }); + + it("preserves case-sensitive POSIX comparisons", () => { + expect(normalizeUsagePath("/Work/App")).toBe("/Work/App"); + expect(normalizeUsagePath("/work/app")).toBe("/work/app"); + }); }); diff --git a/apps/server/src/usage/usagePaths.ts b/apps/server/src/usage/usagePaths.ts index ea9dede3cb60..abf13b6c4cc7 100644 --- a/apps/server/src/usage/usagePaths.ts +++ b/apps/server/src/usage/usagePaths.ts @@ -5,6 +5,7 @@ * different slash style, so attribution cannot rely on the host separator. */ export function normalizeUsagePath(value: string): string { + const isWindowsPath = /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); const slashPath = value.replaceAll("\\", "/"); const rooted = slashPath.startsWith("/"); const segments: string[] = []; @@ -18,7 +19,8 @@ export function normalizeUsagePath(value: string): string { segments.push(segment); } const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; - return normalized === "" ? (rooted ? "/" : ".") : normalized; + const result = normalized === "" ? (rooted ? "/" : ".") : normalized; + return isWindowsPath ? result.toLowerCase() : result; } /** Returns a normalized dedicated worktree, excluding the shared project root. */ diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index fcd4dc0b147a..9a008e58eba5 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -283,6 +283,31 @@ describe("foldThreadRows", () => { expect(rows[0]?.threadId).toBe(threadId); expect(rows[0]?.title).toBe("Normalized worktree"); }); + it("matches Windows worktrees without changing POSIX case sensitivity", () => { + const groups = accumulate([ + [ + record({ sessionId: "windows", cwd: "c:\\work\\app\\.wt\\thread-1\\src" }), + { sessionKey: "claude:windows", agentId: null }, + ], + [ + record({ sessionId: "posix", cwd: "/work/app/.wt/thread-1/src" }), + { sessionKey: "claude:posix", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["C:\\Work\\App\\.wt\\thread-1", { threadId, title: "Windows worktree" }], + ["/Work/App/.wt/thread-1", { threadId, title: "Different POSIX worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + const threadRow = rows.find((row) => row.threadId === threadId); + expect(threadRow?.sessions).toBe(1); + expect(rows.some((row) => row.threadId === null && row.sessions === 1)).toBe(true); + }); it("scopes one T3 thread by provider and project", () => { const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", From 55b59c956126c4103a398e5c14afbc3defe792d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:33:42 +1000 Subject: [PATCH 47/78] fix(usage): preserve partial cache TTL counters --- apps/server/src/usage/usageScanCache.test.ts | 19 +++++++++++++++++++ apps/server/src/usage/usageScanCache.ts | 9 +++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 2d0c5805c638..13ca208cf639 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -101,6 +101,25 @@ describe("scan cache round trip", () => { expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); }); + it("preserves partial TTL classification and its unclassified remainder", () => { + const partial = record({ + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 10, + cacheCreationTokens: 60, + cacheCreation5mTokens: 20, + cacheCreation1hTokens: 10, + outputTokens: 12, + reasoningTokens: 0, + }, + }); + const original = cacheWith([["/partial.jsonl", 100, [partial]]]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.get("/partial.jsonl")?.records[0]?.totals).toEqual(partial.totals); + }); + it("drops an entry whose persisted parse state is corrupt", () => { // Resuming with a bad reducer state would attach appended usage to the // wrong model or replay fork-copied history; that entry must cold parse. diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index da98a29f35e5..1e4737f728dd 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -116,9 +116,10 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.totals.cacheCreationTokens, Math.max(0, record.totals.cacheCreation1hTokens ?? 0), ); - // Unclassified cache creation uses the five-minute price. Persist it in - // that bucket so the serialized TTL counters retain an exact sum. - const fiveMinute = record.totals.cacheCreationTokens - oneHour; + const fiveMinute = Math.min( + record.totals.cacheCreationTokens - oneHour, + Math.max(0, record.totals.cacheCreation5mTokens ?? 0), + ); return [ record.timestampMs, intern(models, modelIndex, record.model), @@ -232,7 +233,7 @@ export function decodeScanCache(document: unknown): ScanCache { !isNonNegativeInteger(cacheCreation) || !isNonNegativeInteger(cacheCreation5m) || !isNonNegativeInteger(cacheCreation1h) || - cacheCreation5m + cacheCreation1h !== cacheCreation || + cacheCreation5m + cacheCreation1h > cacheCreation || !isNonNegativeInteger(output) || !isNonNegativeInteger(reasoning) ) { From 4578354ce3624d6161800ad7c8cc1391ee634163 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:07:00 +1000 Subject: [PATCH 48/78] fix(usage): preserve final snapshots and failures --- apps/server/src/usage/UsageService.test.ts | 97 +++++++++++++++++- apps/server/src/usage/UsageService.ts | 55 ++++++----- .../server/src/usage/usageAggregation.test.ts | 98 +++++++++++-------- apps/server/src/usage/usageAggregation.ts | 84 ++++++++++------ apps/server/src/usage/usagePaths.test.ts | 27 +++++ apps/server/src/usage/usagePaths.ts | 35 +++++++ apps/server/src/usage/usageScanCache.test.ts | 4 +- apps/server/src/usage/usageScanCache.ts | 22 ++--- apps/server/src/usage/usageThreads.test.ts | 58 +++++++++++ apps/server/src/usage/usageThreads.ts | 65 ++++++------ 10 files changed, 401 insertions(+), 144 deletions(-) create mode 100644 apps/server/src/usage/usagePaths.test.ts create mode 100644 apps/server/src/usage/usagePaths.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index e26dedf55a6b..fd33ad930fc9 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -21,17 +21,24 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; -function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { +function claudeLine( + id: number, + outputTokens: number, + model = "claude-fable-5", + cwd?: string, +): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", requestId: `req_${id}`, sessionId: "session-1", + ...(cwd === undefined ? {} : { cwd }), message: { id: `msg_${id}`, model, @@ -80,6 +87,8 @@ const serviceLayers = (input: { readonly onRatesFetch?: () => void; /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; + readonly projectRepository?: ProjectionProjectRepository["Service"]; + readonly runtimeRepository?: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"]; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -102,9 +111,16 @@ const serviceLayers = (input: { ), Layer.provideMerge( Layer.mergeAll( - ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + input.projectRepository === undefined + ? ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)) + : Layer.succeed(ProjectionProjectRepository, input.projectRepository), ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), - ProviderSessionRuntime.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + input.runtimeRepository === undefined + ? ProviderSessionRuntime.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)) + : Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + input.runtimeRepository, + ), SqlitePersistenceMemory, ), ), @@ -178,6 +194,81 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("replaces a cached progressive snapshot when a transcript grows", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-progressive-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(1, 12))); + const second = yield* service.readSummary({ ...WINDOW, refreshToken: "progressive-final" }); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("keeps project attribution unknown when the project repository cannot be read", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile(transcript, claudeLine(1, 5, "claude-fable-5", "/work/app")), + ); + const repositoryFailure = Effect.die(new Error("project repository unavailable")); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => repositoryFailure, + getById: () => repositoryFailure, + listAll: () => repositoryFailure, + deleteById: () => repositoryFailure, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-project-failure-test", + home, + settings, + projectRepository, + }), + ), + ); + + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.projectAttribution, "unknown"); + }).pipe(Effect.scoped), + ); + + it.live("returns a usage read error when provider runtime state cannot be read", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const repositoryFailure = Effect.die(new Error("runtime repository unavailable")); + const runtimeRepository: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"] = + { + upsert: () => repositoryFailure, + getByThreadId: () => repositoryFailure, + list: () => repositoryFailure, + deleteByThreadId: () => repositoryFailure, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-runtime-failure-test", + home, + settings, + runtimeRepository, + }), + ), + ); + + const error = yield* service.readThreadBreakdown(WINDOW).pipe(Effect.flip); + assert.strictEqual(error.reason, "scanFailed"); + assert.strictEqual(error.detail, "Provider runtime state could not be read"); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 634ff89f11f1..b9b693fd38ea 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -50,6 +50,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { makeProjectResolver, UsageAggregator } from "./usageAggregation.ts"; +import { dedicatedUsageWorktreePath } from "./usagePaths.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -319,7 +320,8 @@ export const make = Effect.gen(function* () { const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* () { const projects = yield* projectRepository .listAll() - .pipe(Effect.catchCause(() => Effect.succeed([]))); + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (projects === null) return undefined; const projectRoots = yield* Effect.forEach( projects, Effect.fnUntraced(function* (project) { @@ -341,7 +343,7 @@ export const make = Effect.gen(function* () { }), { concurrency: 8 }, ); - return makeProjectResolver(projectRoots.flat(), path.sep); + return makeProjectResolver(projectRoots.flat()); }); /** @@ -402,7 +404,7 @@ export const make = Effect.gen(function* () { ) { return cached.tailRecords.length === 0 ? cached.records - : [...cached.records, ...cached.tailRecords]; + : dedupeWithinFile([...cached.records, ...cached.tailRecords]); } // Only a strictly grown file may resume. Same size with a new mtime, or @@ -420,13 +422,11 @@ export const make = Effect.gen(function* () { if (parsed === null) return []; // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. One - // seen set spans the cached base, the new lines, and the tail so a - // resumed parse dedupes exactly like a full one. + // duplicates. The final snapshot wins so a resumed Claude parse can + // replace an earlier progressive snapshot from the cached base. const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + const records = dedupeWithinFile([...base, ...parsed.records]); + const tailRecords = dedupeWithinFile(parsed.tailRecords); fileCache.set(filePath, { size, @@ -437,7 +437,7 @@ export const make = Effect.gen(function* () { position: parsed.position, }); cacheDirty = true; - return tailRecords.length === 0 ? records : [...records, ...tailRecords]; + return tailRecords.length === 0 ? records : dedupeWithinFile([...records, ...tailRecords]); }); /** One provider directory's walk and parse, before rates are involved. */ @@ -608,6 +608,7 @@ export const make = Effect.gen(function* () { const scannedDirs = currentSnapshot.dirs; const sourceReadAtMs = currentSnapshot.completedAtMs; + const resolveProject = yield* resolveProjects(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -615,7 +616,7 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, - resolveProject: yield* resolveProjects(), + ...(resolveProject === undefined ? {} : { resolveProject }), priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), }); @@ -640,10 +641,6 @@ export const make = Effect.gen(function* () { walkedRoots.push(dir); let scannedFiles = 0; let skippedFiles = 0; - // Distinct per directory. Buckets carry per-cell session counts, but a - // session spans days and models, so clients total this figure instead. - const sessionIds = new Set(); - for (const file of files) { livePaths.add(file.path); if (file.records.length === 0) { @@ -652,11 +649,7 @@ export const make = Effect.gen(function* () { } scannedFiles += 1; for (const record of file.records) { - // Only sessions that contributed in-window count: the mtime slack - // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { - sessionIds.add(record.sessionId); - } + aggregator.add(record); } } @@ -666,7 +659,7 @@ export const make = Effect.gen(function* () { scannedFiles, skippedFiles, malformedRecords: 0, - distinctSessions: sessionIds.size, + distinctSessions: aggregator.distinctSessions(provider), message: null, }); } @@ -770,10 +763,10 @@ export const make = Effect.gen(function* () { for (const thread of threads) { const title = thread.title.trim(); if (title.length > 0) titles.set(thread.threadId, title); - const worktree = thread.worktreePath?.trim() ?? ""; + const worktree = dedicatedUsageWorktreePath(project.workspaceRoot, thread.worktreePath); // The project root is not a dedicated worktree: interactive sessions // run there too, and several threads usually share it. - if (worktree.length === 0 || worktree === project.workspaceRoot) continue; + if (worktree === null) continue; const ref: ThreadRef = { threadId: thread.threadId, title: title || thread.threadId }; const claim = worktreeClaims.get(worktree); if (claim === undefined) worktreeClaims.set(worktree, { ref, shared: false }); @@ -784,9 +777,16 @@ export const make = Effect.gen(function* () { if (!claim.shared) worktreeToThread.set(worktree, claim.ref); } - const runtimes = yield* runtimeRepository - .list() - .pipe(Effect.catchCause(() => Effect.succeed([]))); + const runtimes = yield* runtimeRepository.list().pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Provider runtime state could not be read", + cause: Cause.squash(cause), + }), + ), + ); for (const runtime of runtimes) { const cursor = runtime.resumeCursor; if (typeof cursor !== "object" || cursor === null) continue; @@ -869,6 +869,7 @@ export const make = Effect.gen(function* () { const windowStartMs = (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const resolveProject = yield* resolveProjects(); const accumulator = new ThreadUsageAccumulator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -876,7 +877,7 @@ export const make = Effect.gen(function* () { ...exactWindow, rates, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), - resolveProject: yield* resolveProjects(), + ...(resolveProject === undefined ? {} : { resolveProject }), }); // Preferred transcript per session for title extraction: the main file, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 0a4a7e6a2e15..c4e4151746ff 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -76,17 +76,25 @@ describe("UsageAggregator", () => { ).toThrow("requires exact time bounds"); }); - it("keeps only the first record for a repeated dedupe key", () => { + it("uses the final complete snapshot for a repeated dedupe key", () => { const result = aggregate([ - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:", totals: { ...record().totals, outputTokens: 1 } }), + record({ dedupeKey: "msg_1:", totals: { ...record().totals, outputTokens: 310 } }), ]); - expect(result.duplicatesDropped).toBe(2); + expect(result.duplicatesDropped).toBe(1); expect(result.buckets).toHaveLength(1); expect(result.buckets[0]?.records).toBe(1); - expect(result.buckets[0]?.totals.outputTokens).toBe(50); + expect(result.buckets[0]?.totals.outputTokens).toBe(310); + }); + + it("applies the window to the final complete snapshot", () => { + const result = aggregate([ + record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-09-01T00:00:00Z") }), + ]); + + expect(result).toMatchObject({ buckets: [], duplicatesDropped: 1, outOfWindow: 1 }); }); it("still sums records that carry no dedupe key", () => { @@ -213,7 +221,7 @@ describe("UsageAggregator", () => { expect(result.buckets).toHaveLength(0); }); - it("reports whether a record contributed", () => { + it("reports whether a record falls in the window", () => { const aggregator = new UsageAggregator({ timeZone: "UTC", sinceDay: "2026-08-01", @@ -222,7 +230,7 @@ describe("UsageAggregator", () => { }); expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); + expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); }); @@ -243,36 +251,33 @@ describe("makeProjectResolver", () => { const legacyDeletedId = ProjectId.make("project-legacy-deleted"); const legacyId = ProjectId.make("project-legacy"); const untitledId = ProjectId.make("project-untitled"); - const resolver = makeProjectResolver( - [ - { projectId: appId, workspaceRoot: "/work/app", title: "App", deleted: false }, - { - projectId: vendoredId, - workspaceRoot: "/work/app/vendored", - title: "Vendored", - deleted: false, - }, - { - projectId: legacyDeletedId, - workspaceRoot: "/work/legacy", - title: "Legacy Was Deleted", - deleted: true, - }, - { - projectId: legacyId, - workspaceRoot: "/work/legacy", - title: "Legacy", - deleted: false, - }, - { - projectId: untitledId, - workspaceRoot: "/work/untitled", - title: " ", - deleted: false, - }, - ], - "/", - ); + const resolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "/work/app", title: "App", deleted: false }, + { + projectId: vendoredId, + workspaceRoot: "/work/app/vendored", + title: "Vendored", + deleted: false, + }, + { + projectId: legacyDeletedId, + workspaceRoot: "/work/legacy", + title: "Legacy Was Deleted", + deleted: true, + }, + { + projectId: legacyId, + workspaceRoot: "/work/legacy", + title: "Legacy", + deleted: false, + }, + { + projectId: untitledId, + workspaceRoot: "/work/untitled", + title: " ", + deleted: false, + }, + ]); it("matches the root itself and any path under it", () => { expect(resolver("/work/app")).toEqual({ projectId: appId, title: "App" }); @@ -301,11 +306,20 @@ describe("makeProjectResolver", () => { it("matches descendants when the project root is the filesystem root", () => { const rootId = ProjectId.make("project-root"); - const rootResolver = makeProjectResolver( - [{ projectId: rootId, workspaceRoot: "/", title: "Root", deleted: false }], - "/", - ); + const rootResolver = makeProjectResolver([ + { projectId: rootId, workspaceRoot: "/", title: "Root", deleted: false }, + ]); expect(rootResolver("/work/app")).toEqual({ projectId: rootId, title: "Root" }); }); + + it("matches mixed slash styles and normalized segments", () => { + expect(resolver("\\work\\app\\src")).toEqual({ projectId: appId, title: "App" }); + expect(resolver("/work/app/other/../src")).toEqual({ projectId: appId, title: "App" }); + + const windowsResolver = makeProjectResolver([ + { projectId: appId, workspaceRoot: "C:\\Work\\App", title: "App", deleted: false }, + ]); + expect(windowsResolver("c:/work/app/src")).toEqual({ projectId: appId, title: "App" }); + }); }); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 5b3605b9e7ad..3c0007ace172 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -21,6 +21,7 @@ import type { } from "@t3tools/contracts"; import { addTotals, EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts"; +import { normalizeUsagePath } from "./usagePaths.ts"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; /** @@ -75,15 +76,11 @@ export interface ProjectAttribution { */ export function makeProjectResolver( projects: readonly ProjectRoot[], - separator: string, ): (cwd: string) => ProjectAttribution | null { const roots = projects .map((project) => ({ projectId: project.projectId, - root: - project.workspaceRoot.length > 1 && project.workspaceRoot.endsWith(separator) - ? project.workspaceRoot.slice(0, -1) - : project.workspaceRoot, + root: normalizeUsagePath(project.workspaceRoot), title: project.title.trim(), deleted: project.deleted, })) @@ -94,11 +91,12 @@ export function makeProjectResolver( return (cwd) => { if (cwd.length === 0) return null; if (byCwd.has(cwd)) return byCwd.get(cwd) ?? null; + const normalizedCwd = normalizeUsagePath(cwd); let resolved: ProjectAttribution | null = null; for (const { projectId, root, title } of roots) { if ( - cwd === root || - (root === separator ? cwd.startsWith(separator) : cwd.startsWith(`${root}${separator}`)) + normalizedCwd === root || + (root === "/" ? normalizedCwd.startsWith("/") : normalizedCwd.startsWith(`${root}/`)) ) { resolved = { projectId, title }; break; @@ -139,7 +137,7 @@ export interface AggregateResult { readonly buckets: readonly UsageBucket[]; /** Records dropped because an earlier record carried the same dedupe key. */ readonly duplicatesDropped: number; - /** Records whose day fell outside the requested window. */ + /** Retained records whose day fell outside the requested window. */ readonly outOfWindow: number; } @@ -151,13 +149,12 @@ export interface AggregateResult { * the same `dedupeKey` legitimately appears in several transcripts. */ export class UsageAggregator { - readonly #buckets = new Map(); - readonly #seen = new Set(); + readonly #recordsByKey = new Map(); + readonly #unkeyedRecords: UsageRecord[] = []; readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; #duplicatesDropped = 0; - #outOfWindow = 0; constructor(options: AggregateOptions) { this.#options = options; @@ -175,26 +172,28 @@ export class UsageAggregator { } } - /** - * Folds one record in. Returns whether it actually contributed, so callers - * can derive per-window facts (distinct sessions, for one) from the records - * that landed rather than everything the mtime prefilter happened to admit. - */ + /** Retains one record and reports whether it falls in the requested window. */ add(record: UsageRecord): boolean { - if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) { - this.#duplicatesDropped += 1; - return false; - } - this.#seen.add(record.dedupeKey); + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push(record); + return inWindow; + } + if (this.#recordsByKey.has(record.dedupeKey)) { + this.#recordsByKey.set(record.dedupeKey, record); + this.#duplicatesDropped += 1; + return inWindow; } + this.#recordsByKey.set(record.dedupeKey, record); + return inWindow; + } + #isInWindow(record: UsageRecord): boolean { if ( this.#hourlyWindow !== null && (record.timestampMs < this.#hourlyWindow.sinceTimeMs || record.timestampMs >= this.#hourlyWindow.untilTimeMs) ) { - this.#outOfWindow += 1; return false; } @@ -203,9 +202,26 @@ export class UsageAggregator { this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) ) { - this.#outOfWindow += 1; return false; } + return true; + } + + /** Distinct in-window sessions retained after progressive snapshots settle. */ + distinctSessions(provider: UsageRecord["provider"]): number { + const sessionIds = new Set(); + const addSession = (record: UsageRecord): void => { + if (this.#isInWindow(record) && record.provider === provider && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + }; + for (const record of this.#unkeyedRecords) addSession(record); + for (const record of this.#recordsByKey.values()) addSession(record); + return sessionIds.size; + } + + #foldRecord(record: UsageRecord, buckets: Map): void { + const day = this.#toDay(record.timestampMs); const hourStart = this.#hourlyWindow === null @@ -225,7 +241,7 @@ export class UsageAggregator { const projectId = resolvedProject?.projectId.replaceAll("\u0000", "") ?? ""; const project = resolvedProject?.title.replaceAll("\u0000", "") ?? ""; const key = `${day}\u0000${hourStart}\u0000${projectAttribution}\u0000${projectId}\u0000${project}\u0000${record.provider}\u0000${record.model}`; - let bucket = this.#buckets.get(key); + let bucket = buckets.get(key); if (bucket === undefined) { bucket = { totals: EMPTY_TOTALS, @@ -236,7 +252,7 @@ export class UsageAggregator { providerReportedRecords: 0, sessions: new Set(), }; - this.#buckets.set(key, bucket); + buckets.set(key, bucket); } const priced = priceUsage( @@ -259,12 +275,22 @@ export class UsageAggregator { if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); - return true; } finish(): AggregateResult { + const bucketsByKey = new Map(); + let outOfWindow = 0; + const foldIfInWindow = (record: UsageRecord): void => { + if (this.#isInWindow(record)) { + this.#foldRecord(record, bucketsByKey); + } else { + outOfWindow += 1; + } + }; + for (const record of this.#unkeyedRecords) foldIfInWindow(record); + for (const record of this.#recordsByKey.values()) foldIfInWindow(record); const buckets: UsageBucket[] = []; - for (const [key, bucket] of this.#buckets) { + for (const [key, bucket] of bucketsByKey) { const [ day = "", hourStart = "", @@ -305,7 +331,7 @@ export class UsageAggregator { return { buckets, duplicatesDropped: this.#duplicatesDropped, - outOfWindow: this.#outOfWindow, + outOfWindow, }; } } diff --git a/apps/server/src/usage/usagePaths.test.ts b/apps/server/src/usage/usagePaths.test.ts new file mode 100644 index 000000000000..e1d43c5192bf --- /dev/null +++ b/apps/server/src/usage/usagePaths.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { dedicatedUsageWorktreePath, normalizeUsagePath } from "./usagePaths.ts"; + +describe("usage path normalization", () => { + it("folds slash styles, trailing separators, and dot segments", () => { + expect(normalizeUsagePath("C:\\Work\\App\\other\\..\\src\\")).toBe("c:/work/app/src"); + }); + + it("does not treat another spelling of the project root as a dedicated worktree", () => { + expect(dedicatedUsageWorktreePath("C:\\Work\\App", "c:/work/app/")).toBeNull(); + }); + + it("returns one stable key for equivalent dedicated worktree paths", () => { + expect(dedicatedUsageWorktreePath("C:/work/app", "C:\\WORK\\APP\\.wt\\thread-1\\")).toBe( + "c:/work/app/.wt/thread-1", + ); + expect(dedicatedUsageWorktreePath("C:/work/app", "C:/work/app/other/../.wt/thread-1")).toBe( + "c:/work/app/.wt/thread-1", + ); + }); + + it("preserves case-sensitive POSIX comparisons", () => { + expect(normalizeUsagePath("/Work/App")).toBe("/Work/App"); + expect(normalizeUsagePath("/work/app")).toBe("/work/app"); + }); +}); diff --git a/apps/server/src/usage/usagePaths.ts b/apps/server/src/usage/usagePaths.ts new file mode 100644 index 000000000000..abf13b6c4cc7 --- /dev/null +++ b/apps/server/src/usage/usagePaths.ts @@ -0,0 +1,35 @@ +/** + * Normalizes persisted provider and worktree paths for usage attribution. + * + * Provider transcripts can retain paths written on another platform or with a + * different slash style, so attribution cannot rely on the host separator. + */ +export function normalizeUsagePath(value: string): string { + const isWindowsPath = /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); + const slashPath = value.replaceAll("\\", "/"); + const rooted = slashPath.startsWith("/"); + const segments: string[] = []; + for (const segment of slashPath.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (segments.length > 0 && segments.at(-1) !== "..") segments.pop(); + else if (!rooted) segments.push(segment); + continue; + } + segments.push(segment); + } + const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; + const result = normalized === "" ? (rooted ? "/" : ".") : normalized; + return isWindowsPath ? result.toLowerCase() : result; +} + +/** Returns a normalized dedicated worktree, excluding the shared project root. */ +export function dedicatedUsageWorktreePath( + projectRoot: string, + worktree: string | null, +): string | null { + const candidate = worktree?.trim() ?? ""; + if (candidate.length === 0) return null; + const normalized = normalizeUsagePath(candidate); + return normalized === normalizeUsagePath(projectRoot) ? null : normalized; +} diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 45a5bd5496b6..8a4857eb8e2f 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -299,7 +299,7 @@ describe("pruneScanCache with an unwalked root", () => { }); describe("dedupeWithinFile", () => { - it("keeps the first record per dedupe key", () => { + it("keeps the final record per dedupe key", () => { const kept = dedupeWithinFile([ record({ totals: { ...record().totals, outputTokens: 1 } }), record({ totals: { ...record().totals, outputTokens: 999 } }), @@ -307,7 +307,7 @@ describe("dedupeWithinFile", () => { ]); expect(kept).toHaveLength(2); - expect(kept[0]?.totals.outputTokens).toBe(1); + expect(kept[0]?.totals.outputTokens).toBe(999); }); it("keeps every record that has no dedupe key", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 34eaf11f43a9..ff03c080bb1a 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -365,22 +365,18 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** - * Within-file de-duplication, applied before an entry is cached. - * - * Callers stitching an incremental parse together pass one `seen` set across - * the line and tail record batches so the whole file stays deduplicated as a - * unit; the set is mutated in place. - */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +/** Within-file de-duplication, retaining the final complete Claude snapshot. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { + const indexByKey = new Map(); const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const existing = indexByKey.get(record.dedupeKey); + if (existing !== undefined) { + kept[existing] = record; + continue; + } + indexByKey.set(record.dedupeKey, kept.length); } kept.push(record); } diff --git a/apps/server/src/usage/usageThreads.test.ts b/apps/server/src/usage/usageThreads.test.ts index efc20906fe80..86b1ec1d7804 100644 --- a/apps/server/src/usage/usageThreads.test.ts +++ b/apps/server/src/usage/usageThreads.test.ts @@ -85,6 +85,38 @@ describe("ThreadUsageAccumulator", () => { expect(groups[0]?.totals.outputTokens).toBe(50); }); + it("uses the final complete snapshot across files", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 1 } }), + context, + ], + [ + record({ dedupeKey: "msg_partial:", totals: { ...record().totals, outputTokens: 310 } }), + context, + ], + ]); + + expect(groups[0]?.totals.outputTokens).toBe(310); + }); + + it("applies the window to the final complete snapshot", () => { + const context = { sessionKey: "claude:session-a", agentId: null }; + const groups = accumulate([ + [record({ dedupeKey: "msg_partial:" }), context], + [ + record({ + dedupeKey: "msg_partial:", + timestampMs: Date.parse("2026-09-01T00:00:00Z"), + }), + context, + ], + ]); + + expect(groups).toEqual([]); + }); + it("splits each day's model-priced cost into cache components", () => { const context = { sessionKey: "claude:session-a", agentId: null }; const groups = accumulate([[record(), context]]); @@ -260,6 +292,32 @@ describe("foldThreadRows", () => { expect(rows[0]?.title).toBe("Nested worktree"); }); + it("matches Windows worktrees without changing POSIX case sensitivity", () => { + const groups = accumulate([ + [ + record({ sessionId: "windows", cwd: "c:\\work\\app\\.wt\\thread-1\\src" }), + { sessionKey: "claude:windows", agentId: null }, + ], + [ + record({ sessionId: "posix", cwd: "/work/app/.wt/thread-1/src" }), + { sessionKey: "claude:posix", agentId: null }, + ], + ]); + const attribution: ThreadAttribution = { + sessionToThread: new Map(), + worktreeToThread: new Map([ + ["C:\\Work\\App\\.wt\\thread-1", { threadId, title: "Windows worktree" }], + ["/Work/App/.wt/thread-1", { threadId, title: "Different POSIX worktree" }], + ]), + }; + + const { rows } = foldThreadRows(groups, attribution, { cap: 40 }); + + const threadRow = rows.find((row) => row.threadId === threadId); + expect(threadRow?.sessions).toBe(1); + expect(rows.some((row) => row.threadId === null && row.sessions === 1)).toBe(true); + }); + it("scopes one T3 thread by provider and project", () => { const accumulator = new ThreadUsageAccumulator({ timeZone: "UTC", diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index f867299c753b..49c5a096c412 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -21,6 +21,7 @@ import type { import { UsageDay } from "@t3tools/contracts"; import { makeDayFormatter, type ProjectAttribution } from "./usageAggregation.ts"; +import { normalizeUsagePath } from "./usagePaths.ts"; import { priceUsage, usageComponentCosts, @@ -94,8 +95,14 @@ export interface ThreadUsageOptions { * share of the summary. */ export class ThreadUsageAccumulator { - readonly #groups = new Map(); - readonly #seen = new Set(); + readonly #recordsByKey = new Map< + string, + { readonly record: UsageRecord; readonly context: ThreadRecordContext } + >(); + readonly #unkeyedRecords: { + readonly record: UsageRecord; + readonly context: ThreadRecordContext; + }[] = []; readonly #toDay: (timestampMs: number) => string; readonly #options: ThreadUsageOptions; @@ -105,11 +112,16 @@ export class ThreadUsageAccumulator { } add(record: UsageRecord, context: ThreadRecordContext): boolean { - if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) return false; - this.#seen.add(record.dedupeKey); + const inWindow = this.#isInWindow(record); + if (record.dedupeKey === null) { + this.#unkeyedRecords.push({ record, context }); + return inWindow; } + this.#recordsByKey.set(record.dedupeKey, { record, context }); + return inWindow; + } + #isInWindow(record: UsageRecord): boolean { if ( !Number.isFinite(record.timestampMs) || Math.abs(record.timestampMs) > MAX_DATE_TIMESTAMP_MS @@ -130,7 +142,15 @@ export class ThreadUsageAccumulator { (day < this.#options.sinceDay || day > this.#options.untilDay) ) return false; + return true; + } + #foldRecord( + record: UsageRecord, + context: ThreadRecordContext, + groups: Map, + ): void { + const day = this.#toDay(record.timestampMs); const resolvedProject = this.#options.resolveProject?.(record.cwd) ?? null; const projectAttribution = resolvedProject !== null @@ -141,7 +161,7 @@ export class ThreadUsageAccumulator { const projectKey = resolvedProject === null ? null : `id:${resolvedProject.projectId.replaceAll("\u0000", "")}`; const groupKey = JSON.stringify([context.sessionKey, record.cwd]); - let group = this.#groups.get(groupKey); + let group = groups.get(groupKey); if (group === undefined) { group = { sessionKey: context.sessionKey, @@ -157,7 +177,7 @@ export class ThreadUsageAccumulator { daily: new Map(), agents: new Map(), }; - this.#groups.set(groupKey, group); + groups.set(groupKey, group); } const priced = priceUsage( @@ -193,11 +213,17 @@ export class ThreadUsageAccumulator { agent.totals = addTotals(agent.totals, record.totals); agent.costUsd += priced.costUsd; } - return true; } finish(): readonly SessionUsageGroup[] { - return [...this.#groups.values()].map((group) => ({ + const groups = new Map(); + for (const { record, context } of this.#unkeyedRecords) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + for (const { record, context } of this.#recordsByKey.values()) { + if (this.#isInWindow(record)) this.#foldRecord(record, context, groups); + } + return [...groups.values()].map((group) => ({ sessionKey: group.sessionKey, provider: group.provider, sessionId: group.sessionId, @@ -282,10 +308,10 @@ function worktreeThreadForCwd( cwd: string, worktreeToThread: ReadonlyMap, ): ThreadRef | undefined { - const normalizedCwd = normalizePath(cwd); + const normalizedCwd = normalizeUsagePath(cwd); let deepest: { readonly pathLength: number; readonly ref: ThreadRef } | undefined; for (const [worktree, ref] of worktreeToThread) { - const normalizedWorktree = normalizePath(worktree); + const normalizedWorktree = normalizeUsagePath(worktree); const prefix = normalizedWorktree.endsWith("/") ? normalizedWorktree : `${normalizedWorktree}/`; if (normalizedCwd !== normalizedWorktree && !normalizedCwd.startsWith(prefix)) continue; if (deepest === undefined || normalizedWorktree.length > deepest.pathLength) { @@ -295,23 +321,6 @@ function worktreeThreadForCwd( return deepest?.ref; } -function normalizePath(value: string): string { - const slashPath = value.replaceAll("\\", "/"); - const rooted = slashPath.startsWith("/"); - const segments: string[] = []; - for (const segment of slashPath.split("/")) { - if (segment === "" || segment === ".") continue; - if (segment === "..") { - if (segments.length > 0 && segments.at(-1) !== "..") segments.pop(); - else if (!rooted) segments.push(segment); - continue; - } - segments.push(segment); - } - const normalized = `${rooted ? "/" : ""}${segments.join("/")}`; - return normalized === "" ? (rooted ? "/" : ".") : normalized; -} - function toAgentRow([agentId, slice]: readonly [string, MutableAgentSlice]): UsageAgentRow { return { agentId, From 4dfc9af194a6a36437df708c2d2984cc87f663fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:29:59 +1000 Subject: [PATCH 49/78] fix(usage): clear failed retained snapshots --- .../src/features/usage/UsageRouteScreen.tsx | 8 ++- .../features/usage/usagePullRefresh.test.ts | 32 +++++++++++ .../src/features/usage/usagePullRefresh.ts | 28 ++++++++++ apps/mobile/src/state/usage.ts | 54 ++++++++++--------- packages/shared/src/usageMerge.test.ts | 34 +++++++++++- packages/shared/src/usageMerge.ts | 2 +- 6 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/src/features/usage/usagePullRefresh.test.ts create mode 100644 apps/mobile/src/features/usage/usagePullRefresh.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index b02f14657f33..7c2481fcf544 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -25,6 +25,7 @@ import { UsageDailyChart } from "./UsageDailyChart"; import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; +import { isUsagePullRefreshPending, usagePullRefreshTargets } from "./usagePullRefresh"; type UsageTab = "usage" | "limits"; const TAB_OPTIONS = [ @@ -65,6 +66,7 @@ export function UsageRouteScreen() { const [metric, setMetric] = useState("cost"); const [isPullRefreshing, setIsPullRefreshing] = useState(false); const refreshWasPending = useRef(false); + const refreshTargets = useRef>(new Set()); const refreshIndicatorTimeout = useRef | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; @@ -98,7 +100,7 @@ export function UsageRouteScreen() { // The pull spinner tracks re-scans of environments that have answered // before. The initial scan renders its own placeholder, and an unreachable // environment stays pending forever โ€” neither may pin the spinner on. - const refreshPending = environments.some((entry) => entry.isPending && entry.summary !== null); + const refreshPending = isUsagePullRefreshPending(environments, refreshTargets.current); const refreshingUsage = isPullRefreshing && refreshPending; const showingLimits = tab === "limits"; // One ScrollView serves both tabs, so the offset would otherwise carry over @@ -112,6 +114,7 @@ export function UsageRouteScreen() { useEffect(() => { if (!isPullRefreshing) { refreshWasPending.current = false; + refreshTargets.current = new Set(); return; } if (refreshPending) { @@ -142,6 +145,7 @@ export function UsageRouteScreen() { }); }; const refreshWindow = () => { + refreshTargets.current = usagePullRefreshTargets(environments); setIsPullRefreshing(true); if (refreshIndicatorTimeout.current !== null) { clearTimeout(refreshIndicatorTimeout.current); @@ -159,6 +163,8 @@ export function UsageRouteScreen() { nextWindow.untilTime !== window.untilTime ) { setWindowSelection({ days: windowDays, window: nextWindow }); + refresh(nextWindow); + return; } refresh(); }; diff --git a/apps/mobile/src/features/usage/usagePullRefresh.test.ts b/apps/mobile/src/features/usage/usagePullRefresh.test.ts new file mode 100644 index 000000000000..e4e472255962 --- /dev/null +++ b/apps/mobile/src/features/usage/usagePullRefresh.test.ts @@ -0,0 +1,32 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { isUsagePullRefreshPending, usagePullRefreshTargets } from "./usagePullRefresh"; + +const status = (environmentId: string, summary: unknown | null, isPending: boolean) => ({ + environmentId: environmentId as EnvironmentId, + summary, + isPending, +}); + +describe("usage pull refresh", () => { + it("follows previously answered environments across a rebased 24-hour window", () => { + const targets = usagePullRefreshTargets([ + status("answered", { readAt: "before" }, false), + status("unreachable", null, true), + ]); + + expect( + isUsagePullRefreshPending( + [status("answered", null, true), status("unreachable", null, true)], + targets, + ), + ).toBe(true); + expect( + isUsagePullRefreshPending( + [status("answered", { readAt: "after" }, false), status("unreachable", null, true)], + targets, + ), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/usage/usagePullRefresh.ts b/apps/mobile/src/features/usage/usagePullRefresh.ts new file mode 100644 index 000000000000..50d57aefda75 --- /dev/null +++ b/apps/mobile/src/features/usage/usagePullRefresh.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +interface UsageRefreshStatus { + readonly environmentId: EnvironmentId; + readonly isPending: boolean; + readonly summary: unknown | null; +} + +/** Tracks only environments that had a value when pull-to-refresh began. */ +export function usagePullRefreshTargets( + environments: readonly UsageRefreshStatus[], +): ReadonlySet { + return new Set( + environments.flatMap((environment) => + environment.summary === null ? [] : [environment.environmentId], + ), + ); +} + +/** Reports whether one of the environments selected at refresh start is still answering. */ +export function isUsagePullRefreshPending( + environments: readonly UsageRefreshStatus[], + targets: ReadonlySet, +): boolean { + return environments.some( + (environment) => environment.isPending && targets.has(environment.environmentId), + ); +} diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index e7105293ae52..389921a19367 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -79,7 +79,7 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (requestedInput?: UsageSummaryInput) => void; } export function useUsage(input: UsageSummaryInput): UsageView { @@ -154,29 +154,35 @@ export function useUsage(input: UsageSummaryInput): UsageView { // Each environment refetches model pricing first, so a model released since // its last daily fetch gets priced by the rescan. The rescan runs whether or // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const nextToken = makeUsageRefreshToken(answered); - const currentInput = JSON.parse(windowKey) as UsageSummaryInput; - const rateRefreshes = environments.map(({ environmentId }) => - runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ), - ); - void Promise.allSettled(rateRefreshes).then(() => { - if (nextToken !== undefined && nextToken !== refreshToken) { - setRefreshToken(nextToken); - return; - } - for (const { environmentId } of environments) { - appAtomRegistry.refresh( - serverEnvironment.usageSummary({ environmentId, input: currentInput }), - ); - } - }); - }, [answered, environments, refreshToken, windowKey]); + const refresh = useCallback( + (requestedInput?: UsageSummaryInput) => { + const nextToken = makeUsageRefreshToken(answered); + const currentInput = + requestedInput === undefined + ? (JSON.parse(windowKey) as UsageSummaryInput) + : { ...requestedInput, refreshToken }; + const rateRefreshes = environments.map(({ environmentId }) => + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ); + void Promise.allSettled(rateRefreshes).then(() => { + if (nextToken !== undefined && nextToken !== refreshToken) { + setRefreshToken(nextToken); + return; + } + for (const { environmentId } of environments) { + appAtomRegistry.refresh( + serverEnvironment.usageSummary({ environmentId, input: currentInput }), + ); + } + }); + }, + [answered, environments, refreshToken, windowKey], + ); const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index a24c8f2db320..e775e6ad07b3 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -366,7 +366,17 @@ describe("makeUsageRefreshToken", () => { }); describe("retainUsageStatuses", () => { - const status = (id: string, usageSummary: UsageSummary | null, isPending = false) => ({ + const status = ( + id: string, + usageSummary: UsageSummary | null, + isPending = false, + ): { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPending: boolean; + readonly error: string | null; + readonly summary: UsageSummary | null; + } => ({ environmentId: id as EnvironmentId, label: id, isPending, @@ -415,4 +425,26 @@ describe("retainUsageStatuses", () => { expect(result.visible[0]?.summary).toBeNull(); }); + + it("does not revive a settled summary after every environment fails", () => { + const old = summary([bucket({ costUsd: 2 })], []); + const previous = { + rangeKey: "range-a", + statuses: [status("env-a", old)], + }; + const failed = retainUsageStatuses( + "range-a", + [{ ...status("env-a", null), error: "could not report usage" }], + previous, + ); + const retrying = retainUsageStatuses("range-a", [status("env-a", null, true)], failed.settled); + + expect(failed.visible[0]).toMatchObject({ + error: "could not report usage", + summary: null, + }); + expect(failed.settled).toBeNull(); + expect(retrying.visible[0]).toMatchObject({ isPending: true, error: null, summary: null }); + expect(retrying.settled).toBeNull(); + }); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index f24e70559ccb..8255721699ad 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -60,7 +60,7 @@ export function retainUsageStatuses( const visible = retainedAny ? withRetained : current; const settled = visible.some((status) => status.summary !== null) ? { rangeKey, statuses: visible } - : previous; + : null; return { visible, settled }; } From 45d994e059e9cc2603e476e31f91c657a74c412e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:36:26 +1000 Subject: [PATCH 50/78] fix(mobile): sequence rebased usage refreshes --- .../src/features/usage/UsageRouteScreen.tsx | 21 ++++++++----- .../features/usage/usagePullRefresh.test.ts | 31 ++++++++++++++++++- .../src/features/usage/usagePullRefresh.ts | 11 +++++++ apps/mobile/src/state/usage.ts | 4 +-- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 7c2481fcf544..13678a271ea0 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -25,7 +25,11 @@ import { UsageDailyChart } from "./UsageDailyChart"; import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; -import { isUsagePullRefreshPending, usagePullRefreshTargets } from "./usagePullRefresh"; +import { + isUsagePullRefreshPending, + refreshRebasedUsageWindow, + usagePullRefreshTargets, +} from "./usagePullRefresh"; type UsageTab = "usage" | "limits"; const TAB_OPTIONS = [ @@ -97,11 +101,11 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // The pull spinner tracks re-scans of environments that have answered - // before. The initial scan renders its own placeholder, and an unreachable - // environment stays pending forever โ€” neither may pin the spinner on. + // Completion tracks only environments that had answered when the pull + // began. The spinner also covers the preceding rate refresh, while an + // environment that was already unreachable cannot pin it on. const refreshPending = isUsagePullRefreshPending(environments, refreshTargets.current); - const refreshingUsage = isPullRefreshing && refreshPending; + const refreshingUsage = isPullRefreshing; const showingLimits = tab === "limits"; // One ScrollView serves both tabs, so the offset would otherwise carry over // and a short Limits list could open scrolled past its own content. @@ -162,11 +166,12 @@ export function UsageRouteScreen() { nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { - setWindowSelection({ days: windowDays, window: nextWindow }); - refresh(nextWindow); + void refreshRebasedUsageWindow(nextWindow, refresh, (refreshedWindow) => { + setWindowSelection({ days: windowDays, window: refreshedWindow }); + }); return; } - refresh(); + void refresh(); }; return ( diff --git a/apps/mobile/src/features/usage/usagePullRefresh.test.ts b/apps/mobile/src/features/usage/usagePullRefresh.test.ts index e4e472255962..4f73315547f8 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.test.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.test.ts @@ -1,7 +1,13 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { isUsagePullRefreshPending, usagePullRefreshTargets } from "./usagePullRefresh"; +import type { UsageSummaryInput } from "@t3tools/contracts"; + +import { + isUsagePullRefreshPending, + refreshRebasedUsageWindow, + usagePullRefreshTargets, +} from "./usagePullRefresh"; const status = (environmentId: string, summary: unknown | null, isPending: boolean) => ({ environmentId: environmentId as EnvironmentId, @@ -29,4 +35,27 @@ describe("usage pull refresh", () => { ), ).toBe(false); }); + + it("commits a rebased window only after its explicit refresh starts", async () => { + const events: string[] = []; + let releaseRates!: () => void; + const rates = new Promise((resolve) => { + releaseRates = resolve; + }); + const input = { sinceDay: "2026-09-04" } as UsageSummaryInput; + const operation = refreshRebasedUsageWindow( + input, + async () => { + events.push("rates-started"); + await rates; + events.push("rescan-started"); + }, + () => events.push("window-committed"), + ); + + expect(events).toEqual(["rates-started"]); + releaseRates(); + await operation; + expect(events).toEqual(["rates-started", "rescan-started", "window-committed"]); + }); }); diff --git a/apps/mobile/src/features/usage/usagePullRefresh.ts b/apps/mobile/src/features/usage/usagePullRefresh.ts index 50d57aefda75..51b3f290146b 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.ts @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import type { UsageSummaryInput } from "@t3tools/contracts"; interface UsageRefreshStatus { readonly environmentId: EnvironmentId; @@ -26,3 +27,13 @@ export function isUsagePullRefreshPending( (environment) => environment.isPending && targets.has(environment.environmentId), ); } + +/** Starts the explicit rescan before committing a rebased window to the screen. */ +export async function refreshRebasedUsageWindow( + input: UsageSummaryInput, + refresh: (input: UsageSummaryInput) => Promise, + commit: (input: UsageSummaryInput) => void, +): Promise { + await refresh(input); + commit(input); +} diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 389921a19367..3e60f55061d7 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -79,7 +79,7 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: (requestedInput?: UsageSummaryInput) => void; + readonly refresh: (requestedInput?: UsageSummaryInput) => Promise; } export function useUsage(input: UsageSummaryInput): UsageView { @@ -169,7 +169,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { { reportFailure: false }, ), ); - void Promise.allSettled(rateRefreshes).then(() => { + return Promise.allSettled(rateRefreshes).then(() => { if (nextToken !== undefined && nextToken !== refreshToken) { setRefreshToken(nextToken); return; From ab052c99251d6defc3a1c96dd2a047f9dcc72552 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:41:04 +1000 Subject: [PATCH 51/78] fix(mobile): preserve newer usage selections --- .../src/features/usage/UsageRouteScreen.tsx | 24 +++++++++++++-- .../features/usage/usagePullRefresh.test.ts | 30 +++++++++++++++++++ .../src/features/usage/usagePullRefresh.ts | 3 +- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 13678a271ea0..a884d566e57d 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -72,6 +72,7 @@ export function UsageRouteScreen() { const refreshWasPending = useRef(false); const refreshTargets = useRef>(new Set()); const refreshIndicatorTimeout = useRef | null>(null); + const refreshRequest = useRef(0); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); @@ -128,6 +129,7 @@ export function UsageRouteScreen() { if (!refreshWasPending.current) return; setIsPullRefreshing(false); + refreshRequest.current += 1; refreshWasPending.current = false; if (refreshIndicatorTimeout.current !== null) { clearTimeout(refreshIndicatorTimeout.current); @@ -136,6 +138,7 @@ export function UsageRouteScreen() { }, [isPullRefreshing, refreshPending]); useEffect( () => () => { + refreshRequest.current += 1; if (refreshIndicatorTimeout.current !== null) { clearTimeout(refreshIndicatorTimeout.current); } @@ -143,18 +146,28 @@ export function UsageRouteScreen() { [], ); const selectWindow = (days: number) => { + refreshRequest.current += 1; + setIsPullRefreshing(false); + refreshWasPending.current = false; + refreshTargets.current = new Set(); + if (refreshIndicatorTimeout.current !== null) { + clearTimeout(refreshIndicatorTimeout.current); + refreshIndicatorTimeout.current = null; + } setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; const refreshWindow = () => { + const request = ++refreshRequest.current; refreshTargets.current = usagePullRefreshTargets(environments); setIsPullRefreshing(true); if (refreshIndicatorTimeout.current !== null) { clearTimeout(refreshIndicatorTimeout.current); } refreshIndicatorTimeout.current = setTimeout(() => { + refreshRequest.current += 1; setIsPullRefreshing(false); refreshWasPending.current = false; refreshIndicatorTimeout.current = null; @@ -166,9 +179,14 @@ export function UsageRouteScreen() { nextWindow.sinceTime !== window.sinceTime || nextWindow.untilTime !== window.untilTime ) { - void refreshRebasedUsageWindow(nextWindow, refresh, (refreshedWindow) => { - setWindowSelection({ days: windowDays, window: refreshedWindow }); - }); + void refreshRebasedUsageWindow( + nextWindow, + refresh, + (refreshedWindow) => { + setWindowSelection({ days: windowDays, window: refreshedWindow }); + }, + () => request === refreshRequest.current, + ); return; } void refresh(); diff --git a/apps/mobile/src/features/usage/usagePullRefresh.test.ts b/apps/mobile/src/features/usage/usagePullRefresh.test.ts index 4f73315547f8..ba991245c47d 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.test.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.test.ts @@ -51,6 +51,7 @@ describe("usage pull refresh", () => { events.push("rescan-started"); }, () => events.push("window-committed"), + () => true, ); expect(events).toEqual(["rates-started"]); @@ -58,4 +59,33 @@ describe("usage pull refresh", () => { await operation; expect(events).toEqual(["rates-started", "rescan-started", "window-committed"]); }); + + it("does not restore a rebased window after a newer selection", async () => { + let releaseRates!: () => void; + const rates = new Promise((resolve) => { + releaseRates = resolve; + }); + const rebased = { sinceDay: "2026-09-04" } as UsageSummaryInput; + const newer = { sinceDay: "2026-08-07" } as UsageSummaryInput; + let selected = rebased; + let activeRequest = 1; + const request = activeRequest; + const operation = refreshRebasedUsageWindow( + rebased, + async () => { + await rates; + }, + (input) => { + selected = input; + }, + () => request === activeRequest, + ); + + selected = newer; + activeRequest += 1; + releaseRates(); + await operation; + + expect(selected).toBe(newer); + }); }); diff --git a/apps/mobile/src/features/usage/usagePullRefresh.ts b/apps/mobile/src/features/usage/usagePullRefresh.ts index 51b3f290146b..6e13c9080998 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.ts @@ -33,7 +33,8 @@ export async function refreshRebasedUsageWindow( input: UsageSummaryInput, refresh: (input: UsageSummaryInput) => Promise, commit: (input: UsageSummaryInput) => void, + isCurrent: () => boolean, ): Promise { await refresh(input); - commit(input); + if (isCurrent()) commit(input); } From 0088405695ef1c5442e24e9dd7deb9d0017f1a1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:50:47 +1000 Subject: [PATCH 52/78] fix(mobile): finish empty usage refreshes --- .../src/features/usage/UsageRouteScreen.tsx | 20 +++++++++++-------- .../features/usage/usagePullRefresh.test.ts | 10 ++++++++++ .../src/features/usage/usagePullRefresh.ts | 4 ++-- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index a884d566e57d..bcc0ad292972 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -161,17 +161,21 @@ export function UsageRouteScreen() { }; const refreshWindow = () => { const request = ++refreshRequest.current; - refreshTargets.current = usagePullRefreshTargets(environments); - setIsPullRefreshing(true); + const targets = usagePullRefreshTargets(environments); + refreshTargets.current = targets; + setIsPullRefreshing(targets.size > 0); if (refreshIndicatorTimeout.current !== null) { clearTimeout(refreshIndicatorTimeout.current); } - refreshIndicatorTimeout.current = setTimeout(() => { - refreshRequest.current += 1; - setIsPullRefreshing(false); - refreshWasPending.current = false; - refreshIndicatorTimeout.current = null; - }, REFRESH_INDICATOR_TIMEOUT_MS); + refreshIndicatorTimeout.current = + targets.size > 0 + ? setTimeout(() => { + refreshRequest.current += 1; + setIsPullRefreshing(false); + refreshWasPending.current = false; + refreshIndicatorTimeout.current = null; + }, REFRESH_INDICATOR_TIMEOUT_MS) + : null; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || diff --git a/apps/mobile/src/features/usage/usagePullRefresh.test.ts b/apps/mobile/src/features/usage/usagePullRefresh.test.ts index ba991245c47d..cadf4e3fc81c 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.test.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.test.ts @@ -36,6 +36,16 @@ describe("usage pull refresh", () => { ).toBe(false); }); + it("tracks failed retries without waiting on already-pending environments", () => { + const targets = usagePullRefreshTargets([ + status("failed", null, false), + status("already-pending", null, true), + ]); + + expect([...targets]).toEqual(["failed"]); + expect(usagePullRefreshTargets([status("already-pending", null, true)]).size).toBe(0); + }); + it("commits a rebased window only after its explicit refresh starts", async () => { const events: string[] = []; let releaseRates!: () => void; diff --git a/apps/mobile/src/features/usage/usagePullRefresh.ts b/apps/mobile/src/features/usage/usagePullRefresh.ts index 6e13c9080998..0c7899a08931 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.ts @@ -7,13 +7,13 @@ interface UsageRefreshStatus { readonly summary: unknown | null; } -/** Tracks only environments that had a value when pull-to-refresh began. */ +/** Tracks environments whose next request can produce a completion transition. */ export function usagePullRefreshTargets( environments: readonly UsageRefreshStatus[], ): ReadonlySet { return new Set( environments.flatMap((environment) => - environment.summary === null ? [] : [environment.environmentId], + environment.summary === null && environment.isPending ? [] : [environment.environmentId], ), ); } From 60242ad16eff8a3eb1cce44984bd18e3ae2445e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:11:12 +1000 Subject: [PATCH 53/78] fix(mobile): await refreshed usage for the requested window --- .../src/features/usage/UsageRouteScreen.tsx | 59 ++-- .../features/usage/usagePullRefresh.test.ts | 57 ++-- .../src/features/usage/usagePullRefresh.ts | 12 +- apps/mobile/src/state/usage.test.tsx | 263 ++++++++++++++++++ apps/mobile/src/state/usage.ts | 88 +++--- 5 files changed, 369 insertions(+), 110 deletions(-) create mode 100644 apps/mobile/src/state/usage.test.tsx diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index bcc0ad292972..ba4bc33941fc 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -12,7 +12,7 @@ import { makeWindow, } from "@t3tools/shared/usageFormat"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -25,11 +25,7 @@ import { UsageDailyChart } from "./UsageDailyChart"; import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; -import { - isUsagePullRefreshPending, - refreshRebasedUsageWindow, - usagePullRefreshTargets, -} from "./usagePullRefresh"; +import { refreshRebasedUsageWindow, usagePullRefreshTargets } from "./usagePullRefresh"; type UsageTab = "usage" | "limits"; const TAB_OPTIONS = [ @@ -69,8 +65,6 @@ export function UsageRouteScreen() { })); const [metric, setMetric] = useState("cost"); const [isPullRefreshing, setIsPullRefreshing] = useState(false); - const refreshWasPending = useRef(false); - const refreshTargets = useRef>(new Set()); const refreshIndicatorTimeout = useRef | null>(null); const refreshRequest = useRef(0); const { days: windowDays, window } = windowSelection; @@ -102,10 +96,6 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // Completion tracks only environments that had answered when the pull - // began. The spinner also covers the preceding rate refresh, while an - // environment that was already unreachable cannot pin it on. - const refreshPending = isUsagePullRefreshPending(environments, refreshTargets.current); const refreshingUsage = isPullRefreshing; const showingLimits = tab === "limits"; // One ScrollView serves both tabs, so the offset would otherwise carry over @@ -116,26 +106,6 @@ export function UsageRouteScreen() { setTab(next); scrollRef.current?.scrollTo({ y: 0, animated: false }); }; - useEffect(() => { - if (!isPullRefreshing) { - refreshWasPending.current = false; - refreshTargets.current = new Set(); - return; - } - if (refreshPending) { - refreshWasPending.current = true; - return; - } - if (!refreshWasPending.current) return; - - setIsPullRefreshing(false); - refreshRequest.current += 1; - refreshWasPending.current = false; - if (refreshIndicatorTimeout.current !== null) { - clearTimeout(refreshIndicatorTimeout.current); - refreshIndicatorTimeout.current = null; - } - }, [isPullRefreshing, refreshPending]); useEffect( () => () => { refreshRequest.current += 1; @@ -148,8 +118,6 @@ export function UsageRouteScreen() { const selectWindow = (days: number) => { refreshRequest.current += 1; setIsPullRefreshing(false); - refreshWasPending.current = false; - refreshTargets.current = new Set(); if (refreshIndicatorTimeout.current !== null) { clearTimeout(refreshIndicatorTimeout.current); refreshIndicatorTimeout.current = null; @@ -162,7 +130,6 @@ export function UsageRouteScreen() { const refreshWindow = () => { const request = ++refreshRequest.current; const targets = usagePullRefreshTargets(environments); - refreshTargets.current = targets; setIsPullRefreshing(targets.size > 0); if (refreshIndicatorTimeout.current !== null) { clearTimeout(refreshIndicatorTimeout.current); @@ -172,10 +139,26 @@ export function UsageRouteScreen() { ? setTimeout(() => { refreshRequest.current += 1; setIsPullRefreshing(false); - refreshWasPending.current = false; refreshIndicatorTimeout.current = null; }, REFRESH_INDICATOR_TIMEOUT_MS) : null; + const completeRefresh = () => { + if (request !== refreshRequest.current) return false; + refreshRequest.current += 1; + setIsPullRefreshing(false); + if (refreshIndicatorTimeout.current !== null) { + clearTimeout(refreshIndicatorTimeout.current); + refreshIndicatorTimeout.current = null; + } + return true; + }; + const failRefresh = (error: unknown) => { + if (!completeRefresh()) return; + Alert.alert( + "Could not refresh usage", + error instanceof Error ? error.message : "Usage could not be refreshed. Try again.", + ); + }; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || @@ -190,10 +173,10 @@ export function UsageRouteScreen() { setWindowSelection({ days: windowDays, window: refreshedWindow }); }, () => request === refreshRequest.current, - ); + ).then(completeRefresh, failRefresh); return; } - void refresh(); + void refresh().then(completeRefresh, failRefresh); }; return ( diff --git a/apps/mobile/src/features/usage/usagePullRefresh.test.ts b/apps/mobile/src/features/usage/usagePullRefresh.test.ts index cadf4e3fc81c..1eb68825f2f2 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.test.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.test.ts @@ -3,11 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { UsageSummaryInput } from "@t3tools/contracts"; -import { - isUsagePullRefreshPending, - refreshRebasedUsageWindow, - usagePullRefreshTargets, -} from "./usagePullRefresh"; +import { refreshRebasedUsageWindow, usagePullRefreshTargets } from "./usagePullRefresh"; const status = (environmentId: string, summary: unknown | null, isPending: boolean) => ({ environmentId: environmentId as EnvironmentId, @@ -16,24 +12,13 @@ const status = (environmentId: string, summary: unknown | null, isPending: boole }); describe("usage pull refresh", () => { - it("follows previously answered environments across a rebased 24-hour window", () => { + it("shows pull state for answered environments without waiting on initial reads", () => { const targets = usagePullRefreshTargets([ status("answered", { readAt: "before" }, false), status("unreachable", null, true), ]); - expect( - isUsagePullRefreshPending( - [status("answered", null, true), status("unreachable", null, true)], - targets, - ), - ).toBe(true); - expect( - isUsagePullRefreshPending( - [status("answered", { readAt: "after" }, false), status("unreachable", null, true)], - targets, - ), - ).toBe(false); + expect([...targets]).toEqual(["answered"]); }); it("tracks failed retries without waiting on already-pending environments", () => { @@ -46,12 +31,16 @@ describe("usage pull refresh", () => { expect(usagePullRefreshTargets([status("already-pending", null, true)]).size).toBe(0); }); - it("commits a rebased window only after its explicit refresh starts", async () => { + it("commits a rebased window only after its refreshed snapshot publishes", async () => { const events: string[] = []; let releaseRates!: () => void; const rates = new Promise((resolve) => { releaseRates = resolve; }); + let releasePublication!: () => void; + const publication = new Promise((resolve) => { + releasePublication = resolve; + }); const input = { sinceDay: "2026-09-04" } as UsageSummaryInput; const operation = refreshRebasedUsageWindow( input, @@ -59,6 +48,8 @@ describe("usage pull refresh", () => { events.push("rates-started"); await rates; events.push("rescan-started"); + await publication; + events.push("rescan-published"); }, () => events.push("window-committed"), () => true, @@ -66,8 +57,17 @@ describe("usage pull refresh", () => { expect(events).toEqual(["rates-started"]); releaseRates(); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["rates-started", "rescan-started"]); + releasePublication(); await operation; - expect(events).toEqual(["rates-started", "rescan-started", "window-committed"]); + expect(events).toEqual([ + "rates-started", + "rescan-started", + "rescan-published", + "window-committed", + ]); }); it("does not restore a rebased window after a newer selection", async () => { @@ -98,4 +98,21 @@ describe("usage pull refresh", () => { expect(selected).toBe(newer); }); + + it("does not commit a rebased window when refresh fails", async () => { + const failure = new Error("transcript scan failed"); + let committed = false; + + await expect( + refreshRebasedUsageWindow( + { sinceDay: "2026-09-04" } as UsageSummaryInput, + async () => Promise.reject(failure), + () => { + committed = true; + }, + () => true, + ), + ).rejects.toBe(failure); + expect(committed).toBe(false); + }); }); diff --git a/apps/mobile/src/features/usage/usagePullRefresh.ts b/apps/mobile/src/features/usage/usagePullRefresh.ts index 0c7899a08931..848bf842e685 100644 --- a/apps/mobile/src/features/usage/usagePullRefresh.ts +++ b/apps/mobile/src/features/usage/usagePullRefresh.ts @@ -7,7 +7,7 @@ interface UsageRefreshStatus { readonly summary: unknown | null; } -/** Tracks environments whose next request can produce a completion transition. */ +/** Selects environments for which pull-to-refresh should show its indicator. */ export function usagePullRefreshTargets( environments: readonly UsageRefreshStatus[], ): ReadonlySet { @@ -18,16 +18,6 @@ export function usagePullRefreshTargets( ); } -/** Reports whether one of the environments selected at refresh start is still answering. */ -export function isUsagePullRefreshPending( - environments: readonly UsageRefreshStatus[], - targets: ReadonlySet, -): boolean { - return environments.some( - (environment) => environment.isPending && targets.has(environment.environmentId), - ); -} - /** Starts the explicit rescan before committing a rebased window to the screen. */ export async function refreshRebasedUsageWindow( input: UsageSummaryInput, diff --git a/apps/mobile/src/state/usage.test.tsx b/apps/mobile/src/state/usage.test.tsx new file mode 100644 index 000000000000..d9555b445a13 --- /dev/null +++ b/apps/mobile/src/state/usage.test.tsx @@ -0,0 +1,263 @@ +import { + USAGE_CONTRACT_VERSION, + UsageDay, + type UsageSummary, + type UsageSummaryInput, +} from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + statuses: [] as readonly { + environmentId: string; + label: string; + isPending: boolean; + error: string | null; + summary: UsageSummary | null; + }[], + runAtomCommand: vi.fn(), + getAtom: vi.fn(() => ({ waiting: false })), + usageSummary: vi.fn((_request: { environmentId: string; input: UsageSummaryInput }) => ({})), + executeAtomQuery: vi.fn(), +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => mocks.statuses })); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: mocks.executeAtomQuery, + runAtomCommand: mocks.runAtomCommand, + squashAtomCommandFailure: (result: { cause: unknown }) => result.cause, +})); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "refresh-attempt" })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: { get: mocks.getAtom } })); +vi.mock("./presentation", () => ({ presentationsAtom: {} })); +vi.mock("./server", () => ({ + serverEnvironment: { + usageSummary: mocks.usageSummary, + refreshUsageRates: {}, + }, +})); +import { useUsage, type UsageView } from "./usage"; + +const WINDOW_A: UsageSummaryInput = { + sinceDay: UsageDay.make("2026-08-01"), + untilDay: UsageDay.make("2026-08-31"), + timeZone: "UTC", + resolution: "day", +}; +const WINDOW_B: UsageSummaryInput = { + ...WINDOW_A, + sinceDay: UsageDay.make("2026-08-02"), + untilDay: UsageDay.make("2026-09-01"), +}; +const SUMMARY: UsageSummary = { + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-08-31T12:00:00.000Z", + timeZone: "UTC", + sinceDay: WINDOW_A.sinceDay, + untilDay: WINDOW_A.untilDay, + buckets: [], + sources: [], + pricing: { status: "unavailable", source: "test", fetchedAt: null, knownModels: 0 }, + scanDurationMs: 0, +}; + +class TestNode { + parentNode: TestNode | null = null; + childNodes: TestNode[] = []; + readonly nodeName: string; + readonly tagName: string; + readonly namespaceURI = "http://www.w3.org/1999/xhtml"; + readonly style = {}; + + constructor( + name: string, + readonly ownerDocument: TestNode | null = null, + readonly nodeType = 1, + ) { + this.nodeName = name.toUpperCase(); + this.tagName = this.nodeName; + } + + set textContent(_value: string) { + this.childNodes = []; + } + + appendChild(child: TestNode) { + child.parentNode = this; + this.childNodes.push(child); + return child; + } + + removeChild(child: TestNode) { + this.childNodes.splice(this.childNodes.indexOf(child), 1); + child.parentNode = null; + return child; + } + + createElement(name: string) { + return new TestNode(name, this); + } + + addEventListener() {} + removeEventListener() {} + setAttribute() {} +} + +function installTestDom() { + const document = new TestNode("#document", null, 9); + const window = { + document, + HTMLIFrameElement: TestNode, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", window); + vi.stubGlobal("HTMLIFrameElement", window.HTMLIFrameElement); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return document; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function Harness({ + input, + onView, +}: { + input: UsageSummaryInput; + onView: (view: UsageView) => void; +}) { + onView(useUsage(input)); + return null; +} + +async function renderHarness(input: UsageSummaryInput, onView: (view: UsageView) => void) { + const document = installTestDom(); + // The mobile app does not ship react-dom types, but the lightweight host + // renderer keeps this hook test independent from a native runtime. + // @ts-expect-error react-dom is only used by this test harness. + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.createElement("div") as unknown as Element); + await act(() => root.render(createElement(Harness, { input, onView }))); + return root; +} + +describe("mobile useUsage requested-window refresh", () => { + beforeEach(() => { + mocks.statuses = [ + { environmentId: "env-1", label: "Local", isPending: false, error: null, summary: SUMMARY }, + ]; + mocks.runAtomCommand.mockReset(); + mocks.getAtom.mockReset(); + mocks.getAtom.mockReturnValue({ waiting: false }); + mocks.usageSummary.mockClear(); + mocks.executeAtomQuery.mockReset(); + }); + + it("awaits the token scan and target-window publication before completing", async () => { + const rates = deferred<{ _tag: "Success" | "Failure" }>(); + const published = deferred<{ _tag: "Success" | "Failure" }>(); + mocks.runAtomCommand.mockReturnValue(rates.promise); + mocks.executeAtomQuery + .mockResolvedValueOnce({ _tag: "Success" }) + .mockReturnValueOnce(published.promise); + let view!: UsageView; + const root = await renderHarness(WINDOW_A, (nextView) => { + view = nextView; + }); + + try { + let completed = false; + const refresh = view.refresh(WINDOW_B).then(() => { + completed = true; + }); + await Promise.resolve(); + expect(mocks.executeAtomQuery).not.toHaveBeenCalled(); + expect(completed).toBe(false); + + rates.resolve({ _tag: "Success" }); + await rates.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(mocks.executeAtomQuery).toHaveBeenCalledTimes(2); + const tokenInput = mocks.usageSummary.mock.calls[0]?.[0]?.input; + expect(tokenInput).toEqual({ + ...WINDOW_B, + refreshToken: expect.any(String), + }); + if (tokenInput?.refreshToken === undefined) throw new Error("missing refresh token"); + expect(JSON.parse(tokenInput.refreshToken)).toEqual([expect.any(String), "refresh-attempt"]); + expect(mocks.usageSummary).toHaveBeenNthCalledWith(2, { + environmentId: "env-1", + input: WINDOW_B, + }); + expect(completed).toBe(false); + + published.resolve({ _tag: "Success" }); + await refresh; + expect(completed).toBe(true); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }); + + it("rejects a failed retry without waiting on an environment still doing its initial read", async () => { + const failure = new Error("transcript scan failed"); + mocks.statuses = [ + { + environmentId: "failed", + label: "Failed", + isPending: false, + error: "This environment could not report usage.", + summary: null, + }, + { + environmentId: "initial", + label: "Initial", + isPending: true, + error: null, + summary: null, + }, + ]; + mocks.runAtomCommand.mockResolvedValue({ _tag: "Success" }); + mocks.executeAtomQuery.mockResolvedValue({ _tag: "Failure", cause: failure }); + let view!: UsageView; + const root = await renderHarness(WINDOW_A, (nextView) => { + view = nextView; + }); + + try { + await expect(view.refresh(WINDOW_B)).rejects.toBe(failure); + expect(mocks.runAtomCommand).toHaveBeenCalledOnce(); + expect(mocks.runAtomCommand.mock.calls[0]?.[2]).toEqual({ + environmentId: "failed", + input: {}, + }); + expect(mocks.usageSummary).toHaveBeenCalledOnce(); + expect(mocks.usageSummary.mock.calls[0]?.[0]).toEqual({ + environmentId: "failed", + input: { + ...WINDOW_B, + refreshToken: JSON.stringify(["unknown", "refresh-attempt"]), + }, + }); + } finally { + await act(() => root.unmount()); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 3e60f55061d7..dadbb832c346 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,11 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + executeAtomQuery, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { makeUsageRefreshToken, mergeUsage, @@ -27,8 +31,9 @@ import { } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef } from "react"; +import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; @@ -83,7 +88,6 @@ export interface UsageView { } export function useUsage(input: UsageSummaryInput): UsageView { - const [refreshToken, setRefreshToken] = useState(); const rangeKey = useMemo( () => JSON.stringify({ @@ -103,27 +107,7 @@ export function useUsage(input: UsageSummaryInput): UsageView { input.untilTime, ], ); - const windowKey = useMemo( - () => - JSON.stringify({ - sinceDay: input.sinceDay, - untilDay: input.untilDay, - timeZone: input.timeZone, - resolution: input.resolution, - sinceTime: input.sinceTime, - untilTime: input.untilTime, - refreshToken, - }), - [ - input.sinceDay, - input.untilDay, - input.timeZone, - input.resolution, - input.sinceTime, - input.untilTime, - refreshToken, - ], - ); + const windowKey = rangeKey; const atom = usageByWindowAtom(windowKey); const currentEnvironments = useAtomValue(atom); const settledStatuses = useRef | null>(null); @@ -156,12 +140,12 @@ export function useUsage(input: UsageSummaryInput): UsageView { // not the refetch succeeds: an offline environment still recounts tokens. const refresh = useCallback( (requestedInput?: UsageSummaryInput) => { - const nextToken = makeUsageRefreshToken(answered); - const currentInput = - requestedInput === undefined - ? (JSON.parse(windowKey) as UsageSummaryInput) - : { ...requestedInput, refreshToken }; - const rateRefreshes = environments.map(({ environmentId }) => + const currentInput = requestedInput ?? (JSON.parse(windowKey) as UsageSummaryInput); + const refreshEnvironments = environments.filter( + (environment) => environment.summary !== null || !environment.isPending, + ); + const refreshToken = JSON.stringify([makeUsageRefreshToken(answered) ?? "unknown", uuidv4()]); + const rateRefreshes = refreshEnvironments.map(({ environmentId }) => runAtomCommand( appAtomRegistry, serverEnvironment.refreshUsageRates, @@ -169,19 +153,41 @@ export function useUsage(input: UsageSummaryInput): UsageView { { reportFailure: false }, ), ); - return Promise.allSettled(rateRefreshes).then(() => { - if (nextToken !== undefined && nextToken !== refreshToken) { - setRefreshToken(nextToken); - return; - } - for (const { environmentId } of environments) { - appAtomRegistry.refresh( - serverEnvironment.usageSummary({ environmentId, input: currentInput }), - ); - } + return Promise.allSettled(rateRefreshes).then(async () => { + const refreshes = await Promise.allSettled( + refreshEnvironments.map(async ({ environmentId }) => { + const refreshed = await executeAtomQuery( + appAtomRegistry, + serverEnvironment.usageSummary({ + environmentId, + input: { ...currentInput, refreshToken }, + }), + { reportFailure: false, refresh: true }, + ); + if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed); + const baseAtom = serverEnvironment.usageSummary({ + environmentId, + input: currentInput, + }); + if (appAtomRegistry.get(baseAtom).waiting) { + await executeAtomQuery(appAtomRegistry, baseAtom, { + reportFailure: false, + }); + } + const published = await executeAtomQuery(appAtomRegistry, baseAtom, { + reportFailure: false, + refresh: true, + }); + if (published._tag === "Failure") throw squashAtomCommandFailure(published); + }), + ); + const failed = refreshes.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed !== undefined) throw failed.reason; }); }, - [answered, environments, refreshToken, windowKey], + [answered, environments, windowKey], ); const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); From 8d7c43114f87ee0d9dd56d7ec0f3d9590d0dbf7a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:24:39 +1000 Subject: [PATCH 54/78] fix(web): make usage retries rescan failed environments --- apps/web/src/components/usage/UsagePage.tsx | 22 ++++++--- apps/web/src/state/usage.test.tsx | 22 ++++++++- apps/web/src/state/usage.ts | 50 ++++++++++----------- 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 16cb26d5e3bf..eab9fd5aaf35 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -411,10 +411,17 @@ export function UsagePage() {
-

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

+
+

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

+ {isPast24Hours ? null : ( + + drag to zoom ยท double-click resets + + )} +
selectWindow(windowDays), + })} />
diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index e01478e6a71a..04dd380cdf11 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -3,7 +3,12 @@ import { act, useLayoutEffect } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { useUsage, type EnvironmentUsageStatus, type UsageView } from "./usage"; +import { + useUsage, + withUsageRefreshAttempt, + type EnvironmentUsageStatus, + type UsageView, +} from "./usage"; const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); vi.mock("@effect/atom-react", async (importOriginal) => ({ @@ -164,3 +169,18 @@ describe("usage environment selection", () => { expect(latest.isPartial).toBe(false); }); }); + +describe("usage refresh attempts", () => { + it("gives failed selected environments a fresh token without invalidating unselected ones", () => { + const selected = [EnvironmentId.make("a"), EnvironmentId.make("b")]; + const initial = { a: "old-a", b: "old-b", untouched: "keep" }; + + const first = withUsageRefreshAttempt(initial, selected, [], "attempt-1"); + const second = withUsageRefreshAttempt(first, selected, [], "attempt-2"); + + expect(first.a).toBe(first.b); + expect(first.a).not.toBe("old-a"); + expect(second.a).not.toBe(first.a); + expect(second.untouched).toBe("keep"); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 5197674daf1d..3e2e11f62e3d 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -26,6 +26,7 @@ import { type MergedUsage, type SettledUsageStatuses, } from "@t3tools/shared/usageMerge"; +import { randomUUID } from "../lib/utils"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; @@ -73,6 +74,20 @@ const usageByWindowAtom = Atom.family((windowKey: string) => }).pipe(Atom.withLabel(`web-usage:window:${windowKey}`)), ); +export function withUsageRefreshAttempt( + current: Readonly>, + selectedEnvironmentIds: readonly EnvironmentId[], + answered: readonly EnvironmentUsage[], + nonce: string, +): Readonly> { + if (selectedEnvironmentIds.length === 0) return current; + const token = JSON.stringify([makeUsageRefreshToken(answered) ?? null, nonce]); + return { + ...current, + ...Object.fromEntries(selectedEnvironmentIds.map((environmentId) => [environmentId, token])), + }; +} + export interface UsageView { readonly merged: MergedUsage; readonly environments: readonly EnvironmentUsageStatus[]; @@ -165,15 +180,14 @@ export function useUsage( ); // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so the button always rescans. + // queries within their stale window and change nothing. Give every selected + // environment a fresh token so each manual attempt rescans, including when + // every previous request failed before producing a summary. // // Each environment refetches model pricing first, so a model released since // its last daily fetch gets priced by the rescan. The rescan runs whether or // not the refetch succeeds: an offline environment still recounts tokens. const refresh = useCallback(() => { - const nextToken = makeUsageRefreshToken(answered); - const input = JSON.parse(rangeKey) as UsageSummaryInput; const rateRefreshes = selectedEnvironments.map(({ environmentId }) => runAtomCommand( appAtomRegistry, @@ -183,30 +197,12 @@ export function useUsage( ), ); void Promise.allSettled(rateRefreshes).then(() => { - const selectedIds = new Set(selectedEnvironments.map(({ environmentId }) => environmentId)); - const tokenChanged = - nextToken !== undefined && - [...selectedIds].some((environmentId) => refreshTokens[environmentId] !== nextToken); - if (tokenChanged) { - setRefreshTokens((current) => ({ - ...current, - ...Object.fromEntries( - [...selectedIds].map((environmentId) => [environmentId, nextToken]), - ), - })); - return; - } - for (const { environmentId } of selectedEnvironments) { - const refreshToken = refreshTokens[environmentId]; - appAtomRegistry.refresh( - serverEnvironment.usageSummary({ - environmentId, - input: refreshToken === undefined ? input : { ...input, refreshToken }, - }), - ); - } + const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); + setRefreshTokens((current) => + withUsageRefreshAttempt(current, selectedIds, answered, randomUUID()), + ); }); - }, [answered, rangeKey, refreshTokens, selectedEnvironments]); + }, [answered, selectedEnvironments]); const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); From b37334f99bdbab109ded14c0a38d75e5605bb7a5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:31:00 +1000 Subject: [PATCH 55/78] fix(web): keep usage refresh updater pure --- apps/web/src/state/usage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 3e2e11f62e3d..62c3c6d113e0 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -198,8 +198,9 @@ export function useUsage( ); void Promise.allSettled(rateRefreshes).then(() => { const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); + const attemptId = randomUUID(); setRefreshTokens((current) => - withUsageRefreshAttempt(current, selectedIds, answered, randomUUID()), + withUsageRefreshAttempt(current, selectedIds, answered, attemptId), ); }); }, [answered, selectedEnvironments]); From 3a67fde734f0f7db5d3e19e1cba43986bca68073 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:50:40 +1000 Subject: [PATCH 56/78] fix(server): preserve iterated Claude cost --- apps/server/src/usage/usageTranscripts.test.ts | 2 ++ apps/server/src/usage/usageTranscripts.ts | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 37d3407aac9b..428e27118b97 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -87,6 +87,7 @@ describe("parseClaudeLine", () => { requestId: "req_fallback", sessionId: "session-fallback", cwd: "/work/app", + costUSD: 0.123, message: { id: "msg_fallback", model: "claude-opus-5", @@ -140,6 +141,7 @@ describe("parseClaudeLine", () => { "msg_fallback:req_fallback:0", "msg_fallback:req_fallback", ]); + expect(records.map((record) => record.reportedCostUsd)).toEqual([null, 0.123]); }); it("preserves an aggregate cache creation count when TTL details are partial", () => { diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index cf00547e9490..775c70e2ec48 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -189,9 +189,7 @@ export function parseClaudeLineRecords(line: string): readonly UsageRecord[] { reasoningTokens: isServingIteration ? Math.min(outputTokens, topLevelThinking) : 0, }, reportedCostUsd: - iterations.length === 0 && typeof cost === "number" && Number.isFinite(cost) - ? cost - : null, + isServingIteration && typeof cost === "number" && Number.isFinite(cost) ? cost : null, dedupeKey: dedupeKey === null || iterations.length === 0 || isServingIteration ? dedupeKey From b7a94b4cb689dbee652e83a07f9d21ebb1d1f7f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:50:44 +1000 Subject: [PATCH 57/78] fix(server): share usage source snapshots --- apps/server/src/usage/UsageService.test.ts | 25 +++++++++++++++++++++ apps/server/src/usage/UsageService.ts | 26 ++++++++-------------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index fd33ad930fc9..3ff036ec6e7d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -370,6 +370,31 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("folds thread rows from the same source snapshot as the summary", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const summary = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const breakdown = yield* service.readThreadBreakdown(WINDOW); + + assert.strictEqual(totalOutputTokens(summary), 5); + assert.strictEqual( + breakdown.rows.reduce((total, row) => total + row.totals.outputTokens, 0), + 5, + ); + assert.strictEqual(breakdown.readAt, summary.readAt); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-thread-source-cache-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("updates fresh source data for a new manual refresh token", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index b9b693fd38ea..36d27aba132f 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -863,11 +863,12 @@ export const make = Effect.gen(function* () { yield* ensureRates(false); yield* ensureScanCacheLoaded; - const dirs = yield* resolveTranscriptDirs(settings).pipe( - Effect.provideService(Path.Path, path), - ); const windowStartMs = (exactWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Thread rows and the summary must fold the same transcript snapshot. In + // particular, a file that grows during the source-cache TTL belongs to the + // next refresh on both RPCs instead of appearing in the drill-down alone. + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, undefined, settings); const resolveProject = yield* resolveProjects(); const accumulator = new ThreadUsageAccumulator({ @@ -889,25 +890,17 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir, fileName } of dirs) { + for (const { provider, dir, files } of currentSnapshot.dirs) { if (input.providers !== undefined && !input.providers.includes(provider)) continue; - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - if (!exists) continue; + if (files === null) continue; walkedRoots.push(dir); - - const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); for (const file of files) { livePaths.add(file.path); - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - if (records.length === 0) continue; + if (file.records.length === 0) continue; const isSubagent = provider === "claude" && path.basename(path.dirname(file.path)) === "subagents"; const agentId = isSubagent ? path.basename(file.path, ".jsonl") : null; - for (const record of records) { + for (const record of file.records) { const sessionKey = record.sessionId.length > 0 ? `${provider}:${record.sessionId}` @@ -956,11 +949,10 @@ export const make = Effect.gen(function* () { { concurrency: 8 }, ); - const readAt = yield* DateTime.now; const finishedAtMs = yield* Clock.currentTimeMillis; return { contractVersion: USAGE_CONTRACT_VERSION, - readAt: DateTime.formatIso(readAt), + readAt: DateTime.formatIso(DateTime.makeUnsafe(currentSnapshot.completedAtMs)), sinceDay: input.sinceDay, untilDay: input.untilDay, rows, From 4b9875e5a7cc700cd1f01acc19e8596410649e4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:51:16 +1000 Subject: [PATCH 58/78] fix(mobile): wait for usage refresh completion --- .../src/features/usage/UsageRouteScreen.tsx | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 18287f367055..ef2935f51040 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -48,7 +48,6 @@ const METRIC_OPTIONS = [ ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; const CHART_HEIGHT = 180; -const REFRESH_INDICATOR_TIMEOUT_MS = 30_000; /** * Two tabs over one screen. Usage is the transcript-derived spend for a @@ -65,7 +64,6 @@ export function UsageRouteScreen() { })); const [metric, setMetric] = useState("cost"); const [isPullRefreshing, setIsPullRefreshing] = useState(false); - const refreshIndicatorTimeout = useRef | null>(null); const refreshRequest = useRef(0); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; @@ -101,19 +99,12 @@ export function UsageRouteScreen() { useEffect( () => () => { refreshRequest.current += 1; - if (refreshIndicatorTimeout.current !== null) { - clearTimeout(refreshIndicatorTimeout.current); - } }, [], ); const selectWindow = (days: number) => { refreshRequest.current += 1; setIsPullRefreshing(false); - if (refreshIndicatorTimeout.current !== null) { - clearTimeout(refreshIndicatorTimeout.current); - refreshIndicatorTimeout.current = null; - } setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), @@ -123,25 +114,10 @@ export function UsageRouteScreen() { const request = ++refreshRequest.current; const targets = usagePullRefreshTargets(environments); setIsPullRefreshing(targets.size > 0); - if (refreshIndicatorTimeout.current !== null) { - clearTimeout(refreshIndicatorTimeout.current); - } - refreshIndicatorTimeout.current = - targets.size > 0 - ? setTimeout(() => { - refreshRequest.current += 1; - setIsPullRefreshing(false); - refreshIndicatorTimeout.current = null; - }, REFRESH_INDICATOR_TIMEOUT_MS) - : null; const completeRefresh = () => { if (request !== refreshRequest.current) return false; refreshRequest.current += 1; setIsPullRefreshing(false); - if (refreshIndicatorTimeout.current !== null) { - clearTimeout(refreshIndicatorTimeout.current); - refreshIndicatorTimeout.current = null; - } return true; }; const failRefresh = (error: unknown) => { From ed6f570c1b9b5d593fef734f97aa27b4633853bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:00:20 +1000 Subject: [PATCH 59/78] fix(server): preserve project repository defects --- apps/server/src/usage/UsageService.test.ts | 62 +++++++++++++++++++++- apps/server/src/usage/UsageService.ts | 2 +- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 6ba64d728695..dd6ab0eecc6e 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -8,6 +8,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -20,6 +21,8 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; @@ -78,6 +81,7 @@ const serviceLayers = (input: { readonly onRatesFetch?: () => void; /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; + readonly projectRepository?: ProjectionProjectRepository["Service"]; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -100,7 +104,9 @@ const serviceLayers = (input: { ), Layer.provideMerge( Layer.mergeAll( - ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + input.projectRepository === undefined + ? ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)) + : Layer.succeed(ProjectionProjectRepository, input.projectRepository), SqlitePersistenceMemory, ), ), @@ -111,6 +117,60 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("degrades a typed project repository failure without hiding defects", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const typedFailure = Effect.fail( + new PersistenceSqlError({ operation: "ProjectionProjectRepository.listAll:test" }), + ); + const typedRepository: ProjectionProjectRepository["Service"] = { + upsert: () => typedFailure, + getById: () => typedFailure, + listAll: () => typedFailure, + deleteById: () => typedFailure, + }; + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-project-typed-failure-test", + home, + settings, + projectRepository: typedRepository, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 5); + + const defect = new Error("project repository defect"); + const repositoryDefect = Effect.die(defect); + const defectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => repositoryDefect, + getById: () => repositoryDefect, + listAll: () => repositoryDefect, + deleteById: () => repositoryDefect, + }; + const exit = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* Effect.exit(service.readSummary(WINDOW)); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-project-defect-test", + home, + settings, + projectRepository: defectRepository, + }), + ), + ); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) assert.strictEqual(Cause.squash(exit.cause), defect); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index b17d5ed0c6a1..1f3c26f64012 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -287,7 +287,7 @@ export const make = Effect.gen(function* () { const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* () { const projects = yield* projectRepository .listAll() - .pipe(Effect.catchCause(() => Effect.succeed([]))); + .pipe(Effect.catch(() => Effect.succeed([]))); return makeProjectResolver( projects.map((project) => ({ projectId: project.projectId, From c0b7f947c2e3180467c21ebd6ed67b8fe7c41870 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:43:29 +1000 Subject: [PATCH 60/78] fix(usage): keep refreshes fresh and isolate concurrent scans --- apps/server/src/usage/UsageService.ts | 28 ++++--- .../UsageProviderChart.interaction.test.tsx | 81 +++++++++++++++++++ .../components/usage/UsageProviderChart.tsx | 22 ++++- apps/web/src/state/usage.test.tsx | 52 +++++++++++- apps/web/src/state/usage.ts | 30 +++---- 5 files changed, 185 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index e67b025d741b..e68bc5a3a364 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -384,12 +384,14 @@ export const make = Effect.gen(function* () { interface SourceSnapshot { readonly completedAtMs: number; + readonly scanRevision: number; readonly windowStartMs: number; readonly sourceKey: string; readonly dirs: readonly ScannedDir[]; } let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; let lastRefreshToken: string | null = null; const sourceScanSemaphore = yield* Semaphore.make(1); @@ -468,6 +470,8 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; const [, dirs] = yield* Effect.all( [ensureRates(false), collectDirs(scanWindowStartMs, settings)], { concurrency: 2 }, @@ -476,6 +480,7 @@ export const make = Effect.gen(function* () { const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); const nextSnapshot = { completedAtMs, + scanRevision, windowStartMs: scanWindowStartMs, sourceKey, dirs, @@ -601,14 +606,19 @@ export const make = Effect.gen(function* () { }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); const finishedAtMs = yield* Clock.currentTimeMillis; @@ -644,7 +654,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - input.refreshToken ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 3978fc046efa..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -247,6 +247,7 @@ export function UsageProviderChart({ const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); const brushRef = useRef<{ readonly pointerId: number; + readonly days: readonly string[]; readonly start: number; readonly end: number; } | null>(null); @@ -255,6 +256,23 @@ export function UsageProviderChart({ const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -402,11 +420,11 @@ export function UsageProviderChart({ event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - const nextBrush = { pointerId: event.pointerId, start: index, end: index }; + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, - [indexAt, zoomable], + [days, indexAt, zoomable], ); const finishBrush = useCallback( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index 04dd380cdf11..7705a8bb1904 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -10,10 +10,31 @@ import { type UsageView, } from "./usage"; -const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); +function deferred() { + let resolve = () => {}; + let reject = (_reason?: unknown) => {}; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const testState = vi.hoisted(() => ({ + environments: [] as EnvironmentUsageStatus[], + windowLabel: "", + runAtomCommand: vi.fn(), +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: testState.runAtomCommand, +})); vi.mock("@effect/atom-react", async (importOriginal) => ({ ...(await importOriginal()), - useAtomValue: () => testState.environments, + useAtomValue: (atom: { readonly label?: readonly [string, string] }) => { + testState.windowLabel = atom.label?.[0] ?? ""; + return testState.environments; + }, })); const input = { @@ -95,6 +116,7 @@ async function select(...ids: string[]) { } beforeEach(async () => { + testState.runAtomCommand.mockReset(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; await act(() => { @@ -184,3 +206,29 @@ describe("usage refresh attempts", () => { expect(second.untouched).toBe("keep"); }); }); + +describe("independent environment refresh", () => { + it("rescans a healthy environment without waiting for another rate request", async () => { + const fast = deferred(); + const slow = deferred(); + testState.runAtomCommand.mockImplementation( + (_registry, _command, { environmentId }: { environmentId: EnvironmentId }) => + environmentId === "a" ? fast.promise : slow.promise, + ); + await select("a", "b"); + await act(() => latest.refresh()); + expect(testState.runAtomCommand).toHaveBeenCalledTimes(2); + await act(() => fast.resolve()); + const fastTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(fastTokens.a).toEqual(expect.any(String)); + expect(fastTokens.b).toBeUndefined(); + await act(() => slow.reject(new Error("Rates unavailable"))); + const finalTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(finalTokens.a).toBe(fastTokens.a); + expect(finalTokens.b).toBe(fastTokens.a); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 62c3c6d113e0..215649ca7cc6 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -188,21 +188,21 @@ export function useUsage( // its last daily fetch gets priced by the rescan. The rescan runs whether or // not the refetch succeeds: an offline environment still recounts tokens. const refresh = useCallback(() => { - const rateRefreshes = selectedEnvironments.map(({ environmentId }) => - runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ), - ); - void Promise.allSettled(rateRefreshes).then(() => { - const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); - const attemptId = randomUUID(); - setRefreshTokens((current) => - withUsageRefreshAttempt(current, selectedIds, answered, attemptId), - ); - }); + const attemptId = randomUUID(); + for (const { environmentId } of selectedEnvironments) { + void Promise.allSettled([ + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ]).then(() => { + setRefreshTokens((current) => + withUsageRefreshAttempt(current, [environmentId], answered, attemptId), + ); + }); + } }, [answered, selectedEnvironments]); const merged = useMemo(() => mergeUsage(answered, USAGE_CONTRACT_VERSION), [answered]); From e44f88f174f0872250f0f105fe570cf3fd20d34e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:43:29 +1000 Subject: [PATCH 61/78] fix(usage): keep refreshes fresh and isolate concurrent scans --- apps/server/src/usage/UsageService.test.ts | 47 +++++++++++ apps/server/src/usage/UsageService.ts | 28 ++++--- .../UsageProviderChart.interaction.test.tsx | 81 +++++++++++++++++++ .../components/usage/UsageProviderChart.tsx | 22 ++++- apps/web/src/state/usage.test.tsx | 52 +++++++++++- apps/web/src/state/usage.ts | 30 +++---- 6 files changed, 232 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index dd6ab0eecc6e..50f76e5949f7 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -308,6 +308,53 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("coalesces concurrent caller refresh tokens for the same summary", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const scanStarted = yield* Deferred.make(); + const releaseScan = yield* Deferred.make(); + let projectReads = 0; + const unused = Effect.die(new Error("unused project repository operation")); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => unused, + getById: () => unused, + listAll: () => + Effect.sync(() => { + projectReads += 1; + }).pipe( + Effect.andThen(Deferred.succeed(scanStarted, undefined)), + Effect.andThen(Deferred.await(releaseScan)), + Effect.as([]), + ), + deleteById: () => unused, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-refresh-coalescing-test", + home, + settings, + projectRepository, + }), + ), + ); + + const reads = yield* Effect.forEach( + Array.from({ length: 16 }, (_, index) => `caller-${index}`), + (refreshToken) => service.readSummary({ ...WINDOW, refreshToken }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(scanStarted); + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseScan, undefined); + const summaries = yield* Fiber.join(reads); + + assert.strictEqual(projectReads, 1); + assert.strictEqual(new Set(summaries.map(({ readAt }) => readAt)).size, 1); + }).pipe(Effect.scoped), + ); + it.live("reuses a recent scan when only the date range changes", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 1f3c26f64012..0698c6f3039b 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -408,12 +408,14 @@ export const make = Effect.gen(function* () { interface SourceSnapshot { readonly completedAtMs: number; + readonly scanRevision: number; readonly windowStartMs: number; readonly sourceKey: string; readonly dirs: readonly ScannedDir[]; } let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; let lastRefreshToken: string | null = null; const sourceScanSemaphore = yield* Semaphore.make(1); @@ -492,6 +494,8 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; const [, dirs] = yield* Effect.all( [ensureRates(false), collectDirs(scanWindowStartMs, settings)], { concurrency: 2 }, @@ -500,6 +504,7 @@ export const make = Effect.gen(function* () { const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); const nextSnapshot = { completedAtMs, + scanRevision, windowStartMs: scanWindowStartMs, sourceKey, dirs, @@ -626,14 +631,19 @@ export const make = Effect.gen(function* () { }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); const finishedAtMs = yield* Clock.currentTimeMillis; @@ -669,7 +679,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - input.refreshToken ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 3978fc046efa..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -247,6 +247,7 @@ export function UsageProviderChart({ const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); const brushRef = useRef<{ readonly pointerId: number; + readonly days: readonly string[]; readonly start: number; readonly end: number; } | null>(null); @@ -255,6 +256,23 @@ export function UsageProviderChart({ const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -402,11 +420,11 @@ export function UsageProviderChart({ event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - const nextBrush = { pointerId: event.pointerId, start: index, end: index }; + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, - [indexAt, zoomable], + [days, indexAt, zoomable], ); const finishBrush = useCallback( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index 04dd380cdf11..7705a8bb1904 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -10,10 +10,31 @@ import { type UsageView, } from "./usage"; -const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); +function deferred() { + let resolve = () => {}; + let reject = (_reason?: unknown) => {}; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const testState = vi.hoisted(() => ({ + environments: [] as EnvironmentUsageStatus[], + windowLabel: "", + runAtomCommand: vi.fn(), +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: testState.runAtomCommand, +})); vi.mock("@effect/atom-react", async (importOriginal) => ({ ...(await importOriginal()), - useAtomValue: () => testState.environments, + useAtomValue: (atom: { readonly label?: readonly [string, string] }) => { + testState.windowLabel = atom.label?.[0] ?? ""; + return testState.environments; + }, })); const input = { @@ -95,6 +116,7 @@ async function select(...ids: string[]) { } beforeEach(async () => { + testState.runAtomCommand.mockReset(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; await act(() => { @@ -184,3 +206,29 @@ describe("usage refresh attempts", () => { expect(second.untouched).toBe("keep"); }); }); + +describe("independent environment refresh", () => { + it("rescans a healthy environment without waiting for another rate request", async () => { + const fast = deferred(); + const slow = deferred(); + testState.runAtomCommand.mockImplementation( + (_registry, _command, { environmentId }: { environmentId: EnvironmentId }) => + environmentId === "a" ? fast.promise : slow.promise, + ); + await select("a", "b"); + await act(() => latest.refresh()); + expect(testState.runAtomCommand).toHaveBeenCalledTimes(2); + await act(() => fast.resolve()); + const fastTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(fastTokens.a).toEqual(expect.any(String)); + expect(fastTokens.b).toBeUndefined(); + await act(() => slow.reject(new Error("Rates unavailable"))); + const finalTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(finalTokens.a).toBe(fastTokens.a); + expect(finalTokens.b).toBe(fastTokens.a); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 2830ebf9b4d3..32e3df104af3 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -190,21 +190,21 @@ export function useUsage( // its last daily fetch gets priced by the rescan. The rescan runs whether or // not the refetch succeeds: an offline environment still recounts tokens. const refresh = useCallback(() => { - const rateRefreshes = selectedEnvironments.map(({ environmentId }) => - runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ), - ); - void Promise.allSettled(rateRefreshes).then(() => { - const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); - const attemptId = randomUUID(); - setRefreshTokens((current) => - withUsageRefreshAttempt(current, selectedIds, answered, attemptId), - ); - }); + const attemptId = randomUUID(); + for (const { environmentId } of selectedEnvironments) { + void Promise.allSettled([ + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ]).then(() => { + setRefreshTokens((current) => + withUsageRefreshAttempt(current, [environmentId], answered, attemptId), + ); + }); + } }, [answered, selectedEnvironments]); const merged = useMemo( From c3dab9986a533784af56cfca5ebd3f381b21cd25 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:43:29 +1000 Subject: [PATCH 62/78] fix(usage): keep refreshes fresh and isolate concurrent scans --- apps/server/src/usage/UsageService.ts | 55 +++++++----- .../UsageProviderChart.interaction.test.tsx | 81 +++++++++++++++++ .../components/usage/UsageProviderChart.tsx | 22 ++++- apps/web/src/state/usage.test.tsx | 90 ++++++++++++++++++- apps/web/src/state/usage.ts | 83 ++++++++++------- packages/contracts/src/usage.test.ts | 22 +++++ packages/contracts/src/usage.ts | 2 + 7 files changed, 295 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 59bb9b369984..37b21951fdcc 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -453,12 +453,14 @@ export const make = Effect.gen(function* () { interface SourceSnapshot { readonly completedAtMs: number; + readonly scanRevision: number; readonly windowStartMs: number; readonly sourceKey: string; readonly dirs: readonly ScannedDir[]; } let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; let lastRefreshToken: string | null = null; const sourceScanSemaphore = yield* Semaphore.make(1); @@ -537,6 +539,8 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; const [, dirs] = yield* Effect.all( [ensureRates(false), collectDirs(scanWindowStartMs, settings)], { concurrency: 2 }, @@ -545,6 +549,7 @@ export const make = Effect.gen(function* () { const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); const nextSnapshot = { completedAtMs, + scanRevision, windowStartMs: scanWindowStartMs, sourceKey, dirs, @@ -664,14 +669,19 @@ export const make = Effect.gen(function* () { }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); const finishedAtMs = yield* Clock.currentTimeMillis; @@ -707,7 +717,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - input.refreshToken ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); @@ -868,7 +878,7 @@ export const make = Effect.gen(function* () { // Thread rows and the summary must fold the same transcript snapshot. In // particular, a file that grows during the source-cache TTL belongs to the // next refresh on both RPCs instead of appearing in the drill-down alone. - const currentSnapshot = yield* getSourceSnapshot(windowStartMs, undefined, settings); + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); const resolveProject = yield* resolveProjects(); const accumulator = new ThreadUsageAccumulator({ @@ -913,16 +923,21 @@ export const make = Effect.gen(function* () { } } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - // A thread-only client must warm and bound the same durable cache as the - // summary RPC, otherwise restarts repeat parsing and stale entries grow. - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + // A thread-only client must warm and bound the same durable cache as the + // summary RPC, otherwise restarts repeat parsing and stale entries grow. + yield* persistScanCache(); + } const attribution = yield* loadThreadAttribution(); const folded = foldThreadRows(accumulator.finish(), attribution, { diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 3978fc046efa..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -247,6 +247,7 @@ export function UsageProviderChart({ const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); const brushRef = useRef<{ readonly pointerId: number; + readonly days: readonly string[]; readonly start: number; readonly end: number; } | null>(null); @@ -255,6 +256,23 @@ export function UsageProviderChart({ const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -402,11 +420,11 @@ export function UsageProviderChart({ event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - const nextBrush = { pointerId: event.pointerId, start: index, end: index }; + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, - [indexAt, zoomable], + [days, indexAt, zoomable], ); const finishBrush = useCallback( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index 04dd380cdf11..9b58c7455859 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -10,10 +10,37 @@ import { type UsageView, } from "./usage"; -const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); +function deferred() { + let resolve = () => {}; + let reject = (_reason?: unknown) => {}; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const testState = vi.hoisted(() => ({ + environments: [] as EnvironmentUsageStatus[], + windowLabel: "", + runAtomCommand: vi.fn(), + executeAtomQuery: vi.fn(), + refreshAtom: vi.fn(), +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: testState.runAtomCommand, + executeAtomQuery: testState.executeAtomQuery, +})); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: testState.refreshAtom }, +})); vi.mock("@effect/atom-react", async (importOriginal) => ({ ...(await importOriginal()), - useAtomValue: () => testState.environments, + useAtomValue: (atom: { readonly label?: readonly [string, string] }) => { + testState.windowLabel = atom.label?.[0] ?? ""; + return testState.environments; + }, })); const input = { @@ -80,8 +107,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU let renderer: ReactTestRenderer | undefined; let latest: UsageView; -function Probe({ selected }: { selected: ReadonlySet | null }) { - const usage = useUsage(input, selected); +function Probe({ + selected, + refreshThreads = false, +}: { + selected: ReadonlySet | null; + refreshThreads?: boolean; +}) { + const usage = useUsage(input, selected, undefined, refreshThreads); useLayoutEffect(() => { latest = usage; }, [usage]); @@ -95,6 +128,9 @@ async function select(...ids: string[]) { } beforeEach(async () => { + testState.runAtomCommand.mockReset(); + testState.executeAtomQuery.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + testState.refreshAtom.mockReset(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; await act(() => { @@ -184,3 +220,49 @@ describe("usage refresh attempts", () => { expect(second.untouched).toBe("keep"); }); }); + +describe("independent environment refresh", () => { + it("rescans a healthy environment without waiting for another rate request", async () => { + const fast = deferred(); + const slow = deferred(); + testState.runAtomCommand.mockImplementation( + (_registry, _command, { environmentId }: { environmentId: EnvironmentId }) => + environmentId === "a" ? fast.promise : slow.promise, + ); + await select("a", "b"); + await act(() => latest.refresh()); + expect(testState.runAtomCommand).toHaveBeenCalledTimes(2); + await act(() => fast.resolve()); + const fastTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(fastTokens.a).toEqual(expect.any(String)); + expect(fastTokens.b).toBeUndefined(); + await act(() => slow.reject(new Error("Rates unavailable"))); + const finalTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(finalTokens.a).toBe(fastTokens.a); + expect(finalTokens.b).toBe(fastTokens.a); + }); + + it("waits for an environment summary before refreshing its thread rows", async () => { + const rates = deferred(); + const summary = deferred(); + testState.runAtomCommand.mockReturnValue(rates.promise); + testState.executeAtomQuery.mockReturnValue(summary.promise); + await act(() => { + renderer?.update( + , + ); + }); + await act(() => latest.refresh()); + + await act(() => rates.resolve()); + expect(testState.executeAtomQuery).toHaveBeenCalledOnce(); + expect(testState.refreshAtom).not.toHaveBeenCalled(); + + await act(() => summary.resolve()); + expect(testState.refreshAtom).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9190da84e003..0e56d60e67d1 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -18,7 +18,7 @@ import { type UsageThreadBreakdownInput, type UsageThreadRow, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { executeAtomQuery, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -211,43 +211,58 @@ export function useUsage( ); // Give every selected environment a fresh token after refreshing model - // prices. When the thread table is mounted, refresh its provider-owned query - // at the same time so both views recount the same transcript state. + // prices. When the thread table is mounted, wait for that environment's + // summary before refreshing its thread rows so both views use one snapshot. const refresh = useCallback(() => { const currentInput = JSON.parse(rangeKey) as UsageSummaryInput; - const rateRefreshes = selectedEnvironments.map(({ environmentId }) => - runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ), - ); - void Promise.allSettled(rateRefreshes).then(() => { - const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); - const attemptId = randomUUID(); - setRefreshTokens((current) => - withUsageRefreshAttempt(current, selectedIds, answered, attemptId), - ); - if (!refreshThreads) return; - for (const contribution of filterProviderContributionsForProject( - projectFilter, - merged.providerContributions, - )) { - if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; - appAtomRegistry.refresh( - serverEnvironment.usageThreadBreakdown({ - environmentId: contribution.environmentId, - input: makeThreadBreakdownInput( - currentInput, - projectFilter, - contribution.providers, - contribution.environmentId, - ), + const attemptId = randomUUID(); + for (const { environmentId } of selectedEnvironments) { + void Promise.allSettled([ + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ]).then(async () => { + const refreshToken = withUsageRefreshAttempt({}, [environmentId], answered, attemptId)[ + environmentId + ]; + setRefreshTokens((current) => + withUsageRefreshAttempt(current, [environmentId], answered, attemptId), + ); + if (!refreshThreads) return; + await executeAtomQuery( + appAtomRegistry, + serverEnvironment.usageSummary({ + environmentId, + input: { ...currentInput, refreshToken }, }), + { reportFailure: false, refresh: true }, ); - } - }); + for (const contribution of filterProviderContributionsForProject( + projectFilter, + merged.providerContributions, + )) { + if ( + contribution.environmentId !== environmentId || + contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE + ) + continue; + appAtomRegistry.refresh( + serverEnvironment.usageThreadBreakdown({ + environmentId, + input: makeThreadBreakdownInput( + currentInput, + projectFilter, + contribution.providers, + environmentId, + ), + }), + ); + } + }); + } }, [ answered, merged.providerContributions, diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts new file mode 100644 index 000000000000..2f742e778a84 --- /dev/null +++ b/packages/contracts/src/usage.test.ts @@ -0,0 +1,22 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { UsageThreadBreakdownInput } from "./usage.ts"; + +const decodeThreadInput = Schema.decodeUnknownSync(UsageThreadBreakdownInput); + +describe("UsageThreadBreakdownInput", () => { + const input = { + sinceDay: "2026-08-01", + untilDay: "2026-08-02", + timeZone: "UTC", + }; + + it("accepts and trims a refresh token", () => { + expect(decodeThreadInput({ ...input, refreshToken: " turn-2 " }).refreshToken).toBe("turn-2"); + }); + + it("rejects a blank refresh token", () => { + expect(() => decodeThreadInput({ ...input, refreshToken: " " })).toThrow(); + }); +}); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 2a12cb82d2a6..021e3b5eec16 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -240,6 +240,8 @@ export const UsageThreadBreakdownInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), /** Exclusive UTC instant for a rolling window such as Past 24h. */ untilTime: Schema.optional(TrimmedNonEmptyString), + /** Changed by callers that need to bypass the short-lived source snapshot. */ + refreshToken: Schema.optional(TrimmedNonEmptyString), /** * Restrict to one project's records: a namespaced stable key selects that * project, `null` selects records outside every project, absent applies no From 01afb7b59a6a0f117376e66f85ad0e0606e5f7cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:43:30 +1000 Subject: [PATCH 63/78] fix(usage): keep refreshes fresh and isolate concurrent scans --- apps/server/src/usage/UsageService.ts | 55 +++++++----- .../UsageProviderChart.interaction.test.tsx | 81 +++++++++++++++++ .../components/usage/UsageProviderChart.tsx | 22 ++++- apps/web/src/state/usage.test.tsx | 90 ++++++++++++++++++- apps/web/src/state/usage.ts | 83 ++++++++++------- packages/contracts/src/usage.test.ts | 22 +++++ packages/contracts/src/usage.ts | 2 + 7 files changed, 295 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 59bb9b369984..37b21951fdcc 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -453,12 +453,14 @@ export const make = Effect.gen(function* () { interface SourceSnapshot { readonly completedAtMs: number; + readonly scanRevision: number; readonly windowStartMs: number; readonly sourceKey: string; readonly dirs: readonly ScannedDir[]; } let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; let lastRefreshToken: string | null = null; const sourceScanSemaphore = yield* Semaphore.make(1); @@ -537,6 +539,8 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; const [, dirs] = yield* Effect.all( [ensureRates(false), collectDirs(scanWindowStartMs, settings)], { concurrency: 2 }, @@ -545,6 +549,7 @@ export const make = Effect.gen(function* () { const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); const nextSnapshot = { completedAtMs, + scanRevision, windowStartMs: scanWindowStartMs, sourceKey, dirs, @@ -664,14 +669,19 @@ export const make = Effect.gen(function* () { }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); const finishedAtMs = yield* Clock.currentTimeMillis; @@ -707,7 +717,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - input.refreshToken ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); @@ -868,7 +878,7 @@ export const make = Effect.gen(function* () { // Thread rows and the summary must fold the same transcript snapshot. In // particular, a file that grows during the source-cache TTL belongs to the // next refresh on both RPCs instead of appearing in the drill-down alone. - const currentSnapshot = yield* getSourceSnapshot(windowStartMs, undefined, settings); + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); const resolveProject = yield* resolveProjects(); const accumulator = new ThreadUsageAccumulator({ @@ -913,16 +923,21 @@ export const make = Effect.gen(function* () { } } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - // A thread-only client must warm and bound the same durable cache as the - // summary RPC, otherwise restarts repeat parsing and stale entries grow. - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + // A thread-only client must warm and bound the same durable cache as the + // summary RPC, otherwise restarts repeat parsing and stale entries grow. + yield* persistScanCache(); + } const attribution = yield* loadThreadAttribution(); const folded = foldThreadRows(accumulator.finish(), attribution, { diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 3978fc046efa..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -247,6 +247,7 @@ export function UsageProviderChart({ const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); const brushRef = useRef<{ readonly pointerId: number; + readonly days: readonly string[]; readonly start: number; readonly end: number; } | null>(null); @@ -255,6 +256,23 @@ export function UsageProviderChart({ const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -402,11 +420,11 @@ export function UsageProviderChart({ event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - const nextBrush = { pointerId: event.pointerId, start: index, end: index }; + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, - [indexAt, zoomable], + [days, indexAt, zoomable], ); const finishBrush = useCallback( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index 04dd380cdf11..9b58c7455859 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -10,10 +10,37 @@ import { type UsageView, } from "./usage"; -const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); +function deferred() { + let resolve = () => {}; + let reject = (_reason?: unknown) => {}; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const testState = vi.hoisted(() => ({ + environments: [] as EnvironmentUsageStatus[], + windowLabel: "", + runAtomCommand: vi.fn(), + executeAtomQuery: vi.fn(), + refreshAtom: vi.fn(), +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: testState.runAtomCommand, + executeAtomQuery: testState.executeAtomQuery, +})); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: testState.refreshAtom }, +})); vi.mock("@effect/atom-react", async (importOriginal) => ({ ...(await importOriginal()), - useAtomValue: () => testState.environments, + useAtomValue: (atom: { readonly label?: readonly [string, string] }) => { + testState.windowLabel = atom.label?.[0] ?? ""; + return testState.environments; + }, })); const input = { @@ -80,8 +107,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU let renderer: ReactTestRenderer | undefined; let latest: UsageView; -function Probe({ selected }: { selected: ReadonlySet | null }) { - const usage = useUsage(input, selected); +function Probe({ + selected, + refreshThreads = false, +}: { + selected: ReadonlySet | null; + refreshThreads?: boolean; +}) { + const usage = useUsage(input, selected, undefined, refreshThreads); useLayoutEffect(() => { latest = usage; }, [usage]); @@ -95,6 +128,9 @@ async function select(...ids: string[]) { } beforeEach(async () => { + testState.runAtomCommand.mockReset(); + testState.executeAtomQuery.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + testState.refreshAtom.mockReset(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; await act(() => { @@ -184,3 +220,49 @@ describe("usage refresh attempts", () => { expect(second.untouched).toBe("keep"); }); }); + +describe("independent environment refresh", () => { + it("rescans a healthy environment without waiting for another rate request", async () => { + const fast = deferred(); + const slow = deferred(); + testState.runAtomCommand.mockImplementation( + (_registry, _command, { environmentId }: { environmentId: EnvironmentId }) => + environmentId === "a" ? fast.promise : slow.promise, + ); + await select("a", "b"); + await act(() => latest.refresh()); + expect(testState.runAtomCommand).toHaveBeenCalledTimes(2); + await act(() => fast.resolve()); + const fastTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(fastTokens.a).toEqual(expect.any(String)); + expect(fastTokens.b).toBeUndefined(); + await act(() => slow.reject(new Error("Rates unavailable"))); + const finalTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(finalTokens.a).toBe(fastTokens.a); + expect(finalTokens.b).toBe(fastTokens.a); + }); + + it("waits for an environment summary before refreshing its thread rows", async () => { + const rates = deferred(); + const summary = deferred(); + testState.runAtomCommand.mockReturnValue(rates.promise); + testState.executeAtomQuery.mockReturnValue(summary.promise); + await act(() => { + renderer?.update( + , + ); + }); + await act(() => latest.refresh()); + + await act(() => rates.resolve()); + expect(testState.executeAtomQuery).toHaveBeenCalledOnce(); + expect(testState.refreshAtom).not.toHaveBeenCalled(); + + await act(() => summary.resolve()); + expect(testState.refreshAtom).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 5e58c5d2e5bd..8d7561a7ee82 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -18,7 +18,7 @@ import { type UsageThreadBreakdownInput, type UsageThreadRow, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { executeAtomQuery, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -211,43 +211,58 @@ export function useUsage( ); // Give every selected environment a fresh token after refreshing model - // prices. When the thread table is mounted, refresh its provider-owned query - // at the same time so both views recount the same transcript state. + // prices. When the thread table is mounted, wait for that environment's + // summary before refreshing its thread rows so both views use one snapshot. const refresh = useCallback(() => { const currentInput = JSON.parse(rangeKey) as UsageSummaryInput; - const rateRefreshes = selectedEnvironments.map(({ environmentId }) => - runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ), - ); - void Promise.allSettled(rateRefreshes).then(() => { - const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); - const attemptId = randomUUID(); - setRefreshTokens((current) => - withUsageRefreshAttempt(current, selectedIds, answered, attemptId), - ); - if (!refreshThreads) return; - for (const contribution of filterProviderContributionsForProject( - projectFilter, - merged.providerContributions, - )) { - if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; - appAtomRegistry.refresh( - serverEnvironment.usageThreadBreakdown({ - environmentId: contribution.environmentId, - input: makeThreadBreakdownInput( - currentInput, - projectFilter, - contribution.providers, - contribution.environmentId, - ), + const attemptId = randomUUID(); + for (const { environmentId } of selectedEnvironments) { + void Promise.allSettled([ + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ]).then(async () => { + const refreshToken = withUsageRefreshAttempt({}, [environmentId], answered, attemptId)[ + environmentId + ]; + setRefreshTokens((current) => + withUsageRefreshAttempt(current, [environmentId], answered, attemptId), + ); + if (!refreshThreads) return; + await executeAtomQuery( + appAtomRegistry, + serverEnvironment.usageSummary({ + environmentId, + input: { ...currentInput, refreshToken }, }), + { reportFailure: false, refresh: true }, ); - } - }); + for (const contribution of filterProviderContributionsForProject( + projectFilter, + merged.providerContributions, + )) { + if ( + contribution.environmentId !== environmentId || + contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE + ) + continue; + appAtomRegistry.refresh( + serverEnvironment.usageThreadBreakdown({ + environmentId, + input: makeThreadBreakdownInput( + currentInput, + projectFilter, + contribution.providers, + environmentId, + ), + }), + ); + } + }); + } }, [ answered, merged.providerContributions, diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts new file mode 100644 index 000000000000..2f742e778a84 --- /dev/null +++ b/packages/contracts/src/usage.test.ts @@ -0,0 +1,22 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { UsageThreadBreakdownInput } from "./usage.ts"; + +const decodeThreadInput = Schema.decodeUnknownSync(UsageThreadBreakdownInput); + +describe("UsageThreadBreakdownInput", () => { + const input = { + sinceDay: "2026-08-01", + untilDay: "2026-08-02", + timeZone: "UTC", + }; + + it("accepts and trims a refresh token", () => { + expect(decodeThreadInput({ ...input, refreshToken: " turn-2 " }).refreshToken).toBe("turn-2"); + }); + + it("rejects a blank refresh token", () => { + expect(() => decodeThreadInput({ ...input, refreshToken: " " })).toThrow(); + }); +}); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 2a12cb82d2a6..021e3b5eec16 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -240,6 +240,8 @@ export const UsageThreadBreakdownInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), /** Exclusive UTC instant for a rolling window such as Past 24h. */ untilTime: Schema.optional(TrimmedNonEmptyString), + /** Changed by callers that need to bypass the short-lived source snapshot. */ + refreshToken: Schema.optional(TrimmedNonEmptyString), /** * Restrict to one project's records: a namespaced stable key selects that * project, `null` selects records outside every project, absent applies no From 4426b0d5403a5ba1f37b73b8bd388a68459bdfaf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:43:30 +1000 Subject: [PATCH 64/78] fix(usage): keep refreshes fresh and isolate concurrent scans --- apps/server/src/usage/UsageService.ts | 55 +++++++----- .../UsageProviderChart.interaction.test.tsx | 81 +++++++++++++++++ .../components/usage/UsageProviderChart.tsx | 22 ++++- apps/web/src/state/usage.test.tsx | 90 ++++++++++++++++++- apps/web/src/state/usage.ts | 83 ++++++++++------- packages/contracts/src/usage.test.ts | 22 +++++ packages/contracts/src/usage.ts | 2 + 7 files changed, 295 insertions(+), 60 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 59bb9b369984..37b21951fdcc 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -453,12 +453,14 @@ export const make = Effect.gen(function* () { interface SourceSnapshot { readonly completedAtMs: number; + readonly scanRevision: number; readonly windowStartMs: number; readonly sourceKey: string; readonly dirs: readonly ScannedDir[]; } let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; let lastRefreshToken: string | null = null; const sourceScanSemaphore = yield* Semaphore.make(1); @@ -537,6 +539,8 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; const [, dirs] = yield* Effect.all( [ensureRates(false), collectDirs(scanWindowStartMs, settings)], { concurrency: 2 }, @@ -545,6 +549,7 @@ export const make = Effect.gen(function* () { const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); const nextSnapshot = { completedAtMs, + scanRevision, windowStartMs: scanWindowStartMs, sourceKey, dirs, @@ -664,14 +669,19 @@ export const make = Effect.gen(function* () { }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); const finishedAtMs = yield* Clock.currentTimeMillis; @@ -707,7 +717,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - input.refreshToken ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); @@ -868,7 +878,7 @@ export const make = Effect.gen(function* () { // Thread rows and the summary must fold the same transcript snapshot. In // particular, a file that grows during the source-cache TTL belongs to the // next refresh on both RPCs instead of appearing in the drill-down alone. - const currentSnapshot = yield* getSourceSnapshot(windowStartMs, undefined, settings); + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); const resolveProject = yield* resolveProjects(); const accumulator = new ThreadUsageAccumulator({ @@ -913,16 +923,21 @@ export const make = Effect.gen(function* () { } } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheDirty = true; - // A thread-only client must warm and bound the same durable cache as the - // summary RPC, otherwise restarts repeat parsing and stale entries grow. - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheDirty = true; + // A thread-only client must warm and bound the same durable cache as the + // summary RPC, otherwise restarts repeat parsing and stale entries grow. + yield* persistScanCache(); + } const attribution = yield* loadThreadAttribution(); const folded = foldThreadRows(accumulator.finish(), attribution, { diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 3978fc046efa..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -247,6 +247,7 @@ export function UsageProviderChart({ const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); const brushRef = useRef<{ readonly pointerId: number; + readonly days: readonly string[]; readonly start: number; readonly end: number; } | null>(null); @@ -255,6 +256,23 @@ export function UsageProviderChart({ const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -402,11 +420,11 @@ export function UsageProviderChart({ event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - const nextBrush = { pointerId: event.pointerId, start: index, end: index }; + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, - [indexAt, zoomable], + [days, indexAt, zoomable], ); const finishBrush = useCallback( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index 04dd380cdf11..9b58c7455859 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -10,10 +10,37 @@ import { type UsageView, } from "./usage"; -const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); +function deferred() { + let resolve = () => {}; + let reject = (_reason?: unknown) => {}; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const testState = vi.hoisted(() => ({ + environments: [] as EnvironmentUsageStatus[], + windowLabel: "", + runAtomCommand: vi.fn(), + executeAtomQuery: vi.fn(), + refreshAtom: vi.fn(), +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: testState.runAtomCommand, + executeAtomQuery: testState.executeAtomQuery, +})); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: testState.refreshAtom }, +})); vi.mock("@effect/atom-react", async (importOriginal) => ({ ...(await importOriginal()), - useAtomValue: () => testState.environments, + useAtomValue: (atom: { readonly label?: readonly [string, string] }) => { + testState.windowLabel = atom.label?.[0] ?? ""; + return testState.environments; + }, })); const input = { @@ -80,8 +107,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU let renderer: ReactTestRenderer | undefined; let latest: UsageView; -function Probe({ selected }: { selected: ReadonlySet | null }) { - const usage = useUsage(input, selected); +function Probe({ + selected, + refreshThreads = false, +}: { + selected: ReadonlySet | null; + refreshThreads?: boolean; +}) { + const usage = useUsage(input, selected, undefined, refreshThreads); useLayoutEffect(() => { latest = usage; }, [usage]); @@ -95,6 +128,9 @@ async function select(...ids: string[]) { } beforeEach(async () => { + testState.runAtomCommand.mockReset(); + testState.executeAtomQuery.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + testState.refreshAtom.mockReset(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; await act(() => { @@ -184,3 +220,49 @@ describe("usage refresh attempts", () => { expect(second.untouched).toBe("keep"); }); }); + +describe("independent environment refresh", () => { + it("rescans a healthy environment without waiting for another rate request", async () => { + const fast = deferred(); + const slow = deferred(); + testState.runAtomCommand.mockImplementation( + (_registry, _command, { environmentId }: { environmentId: EnvironmentId }) => + environmentId === "a" ? fast.promise : slow.promise, + ); + await select("a", "b"); + await act(() => latest.refresh()); + expect(testState.runAtomCommand).toHaveBeenCalledTimes(2); + await act(() => fast.resolve()); + const fastTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(fastTokens.a).toEqual(expect.any(String)); + expect(fastTokens.b).toBeUndefined(); + await act(() => slow.reject(new Error("Rates unavailable"))); + const finalTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(finalTokens.a).toBe(fastTokens.a); + expect(finalTokens.b).toBe(fastTokens.a); + }); + + it("waits for an environment summary before refreshing its thread rows", async () => { + const rates = deferred(); + const summary = deferred(); + testState.runAtomCommand.mockReturnValue(rates.promise); + testState.executeAtomQuery.mockReturnValue(summary.promise); + await act(() => { + renderer?.update( + , + ); + }); + await act(() => latest.refresh()); + + await act(() => rates.resolve()); + expect(testState.executeAtomQuery).toHaveBeenCalledOnce(); + expect(testState.refreshAtom).not.toHaveBeenCalled(); + + await act(() => summary.resolve()); + expect(testState.refreshAtom).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 5e58c5d2e5bd..8d7561a7ee82 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -18,7 +18,7 @@ import { type UsageThreadBreakdownInput, type UsageThreadRow, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { executeAtomQuery, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -211,43 +211,58 @@ export function useUsage( ); // Give every selected environment a fresh token after refreshing model - // prices. When the thread table is mounted, refresh its provider-owned query - // at the same time so both views recount the same transcript state. + // prices. When the thread table is mounted, wait for that environment's + // summary before refreshing its thread rows so both views use one snapshot. const refresh = useCallback(() => { const currentInput = JSON.parse(rangeKey) as UsageSummaryInput; - const rateRefreshes = selectedEnvironments.map(({ environmentId }) => - runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ), - ); - void Promise.allSettled(rateRefreshes).then(() => { - const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); - const attemptId = randomUUID(); - setRefreshTokens((current) => - withUsageRefreshAttempt(current, selectedIds, answered, attemptId), - ); - if (!refreshThreads) return; - for (const contribution of filterProviderContributionsForProject( - projectFilter, - merged.providerContributions, - )) { - if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; - appAtomRegistry.refresh( - serverEnvironment.usageThreadBreakdown({ - environmentId: contribution.environmentId, - input: makeThreadBreakdownInput( - currentInput, - projectFilter, - contribution.providers, - contribution.environmentId, - ), + const attemptId = randomUUID(); + for (const { environmentId } of selectedEnvironments) { + void Promise.allSettled([ + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ]).then(async () => { + const refreshToken = withUsageRefreshAttempt({}, [environmentId], answered, attemptId)[ + environmentId + ]; + setRefreshTokens((current) => + withUsageRefreshAttempt(current, [environmentId], answered, attemptId), + ); + if (!refreshThreads) return; + await executeAtomQuery( + appAtomRegistry, + serverEnvironment.usageSummary({ + environmentId, + input: { ...currentInput, refreshToken }, }), + { reportFailure: false, refresh: true }, ); - } - }); + for (const contribution of filterProviderContributionsForProject( + projectFilter, + merged.providerContributions, + )) { + if ( + contribution.environmentId !== environmentId || + contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE + ) + continue; + appAtomRegistry.refresh( + serverEnvironment.usageThreadBreakdown({ + environmentId, + input: makeThreadBreakdownInput( + currentInput, + projectFilter, + contribution.providers, + environmentId, + ), + }), + ); + } + }); + } }, [ answered, merged.providerContributions, diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts new file mode 100644 index 000000000000..2f742e778a84 --- /dev/null +++ b/packages/contracts/src/usage.test.ts @@ -0,0 +1,22 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { UsageThreadBreakdownInput } from "./usage.ts"; + +const decodeThreadInput = Schema.decodeUnknownSync(UsageThreadBreakdownInput); + +describe("UsageThreadBreakdownInput", () => { + const input = { + sinceDay: "2026-08-01", + untilDay: "2026-08-02", + timeZone: "UTC", + }; + + it("accepts and trims a refresh token", () => { + expect(decodeThreadInput({ ...input, refreshToken: " turn-2 " }).refreshToken).toBe("turn-2"); + }); + + it("rejects a blank refresh token", () => { + expect(() => decodeThreadInput({ ...input, refreshToken: " " })).toThrow(); + }); +}); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index a2eb17545b26..f85bf2728d22 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -247,6 +247,8 @@ export const UsageThreadBreakdownInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), /** Exclusive UTC instant for a rolling window such as Past 24h. */ untilTime: Schema.optional(TrimmedNonEmptyString), + /** Changed by callers that need to bypass the short-lived source snapshot. */ + refreshToken: Schema.optional(TrimmedNonEmptyString), /** * Restrict to one project's records: a namespaced stable key selects that * project, `null` selects records outside every project, absent applies no From fe497f655c8aa8b3a0dcfb9a025a3d99b564d1e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:43:30 +1000 Subject: [PATCH 65/78] fix(usage): keep refreshes fresh and isolate concurrent scans --- apps/server/src/usage/UsageService.test.ts | 145 +++++++++++++++++- apps/server/src/usage/UsageService.ts | 55 ++++--- .../UsageProviderChart.interaction.test.tsx | 81 ++++++++++ .../components/usage/UsageProviderChart.tsx | 22 ++- apps/web/src/state/usage.test.tsx | 90 ++++++++++- apps/web/src/state/usage.ts | 83 ++++++---- packages/contracts/src/usage.test.ts | 22 +++ packages/contracts/src/usage.ts | 2 + 8 files changed, 439 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx create mode 100644 packages/contracts/src/usage.test.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 37fb4da2298a..c5e3adf444b8 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -16,6 +16,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -84,6 +85,7 @@ const setup = Effect.gen(function* () { const serviceLayers = (input: { readonly prefix: string; + readonly baseDir?: string; readonly home: string; readonly settings: Parameters[0]; readonly onRatesFetch?: () => void; @@ -92,7 +94,7 @@ const serviceLayers = (input: { readonly projectRepository?: ProjectionProjectRepository["Service"]; readonly runtimeRepository?: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"]; }) => - ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + ServerConfig.layerTest(process.cwd(), input.baseDir ?? { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( @@ -377,6 +379,71 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("does not prune files added by a newer source scan", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const baseDir = NodePath.join(home, "server-state"); + const newerTranscript = NodePath.join(NodePath.dirname(transcript), "newer.jsonl"); + const firstAggregationStarted = yield* Deferred.make(); + const releaseFirstAggregation = yield* Deferred.make(); + let projectReads = 0; + const unused = Effect.die(new Error("unused project repository operation")); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => unused, + getById: () => unused, + listAll: () => + Effect.gen(function* () { + projectReads += 1; + if (projectReads === 1) { + yield* Deferred.succeed(firstAggregationStarted, undefined); + yield* Deferred.await(releaseFirstAggregation); + } + return []; + }), + deleteById: () => unused, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-prune-race-test", + baseDir, + home, + settings, + projectRepository, + }), + ), + ); + + const older = yield* service + .readSummary({ ...WINDOW, refreshToken: "older" }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstAggregationStarted); + yield* Effect.promise(() => NodeFSP.writeFile(newerTranscript, claudeLine(2, 7))); + yield* service.readSummary({ + ...WINDOW, + timeZone: "America/Los_Angeles", + refreshToken: "newer", + }); + yield* Deferred.succeed(releaseFirstAggregation, undefined); + yield* Fiber.join(older); + + const persisted = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(baseDir, "userdata", "usage-scan-cache.json"), "utf8"), + ), + ); + assert.isTrue( + typeof persisted === "object" && + persisted !== null && + "files" in persisted && + typeof persisted.files === "object" && + persisted.files !== null && + Object.hasOwn(persisted.files, newerTranscript), + ); + }).pipe(Effect.scoped), + ); + it.live("reuses a recent scan when only the date range changes", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -429,6 +496,82 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("does not let an older thread breakdown prune a newer source scan", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const baseDir = NodePath.join(home, "server-state"); + const newerTranscript = NodePath.join(NodePath.dirname(transcript), "newer-thread.jsonl"); + const threadAggregationStarted = yield* Deferred.make(); + const releaseThreadAggregation = yield* Deferred.make(); + let projectReads = 0; + const unused = Effect.die(new Error("unused project repository operation")); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => unused, + getById: () => unused, + listAll: () => + Effect.gen(function* () { + projectReads += 1; + if (projectReads === 1) { + yield* Deferred.succeed(threadAggregationStarted, undefined); + yield* Deferred.await(releaseThreadAggregation); + } + return []; + }), + deleteById: () => unused, + }; + const runtimeRepository: ProviderSessionRuntime.ProviderSessionRuntimeRepository["Service"] = + { + upsert: () => unused, + getByThreadId: () => unused, + list: () => Effect.succeed([]), + deleteByThreadId: () => unused, + }; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-thread-prune-race-test", + baseDir, + home, + settings, + projectRepository, + runtimeRepository, + }), + ), + ); + + const olderThread = yield* service + .readThreadBreakdown({ ...WINDOW, refreshToken: "older-thread" }) + .pipe( + Effect.tapCause(() => Deferred.succeed(threadAggregationStarted, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(threadAggregationStarted); + yield* Effect.promise(() => NodeFSP.writeFile(newerTranscript, claudeLine(2, 7))); + yield* service.readSummary({ + ...WINDOW, + timeZone: "America/Los_Angeles", + refreshToken: "newer-summary", + }); + yield* Deferred.succeed(releaseThreadAggregation, undefined); + yield* Fiber.join(olderThread); + + const persisted = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))( + yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(baseDir, "userdata", "usage-scan-cache.json"), "utf8"), + ), + ); + assert.isTrue( + typeof persisted === "object" && + persisted !== null && + "files" in persisted && + typeof persisted.files === "object" && + persisted.files !== null && + Object.hasOwn(persisted.files, newerTranscript), + ); + }).pipe(Effect.scoped), + ); + it.live("updates fresh source data for a new manual refresh token", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f2c2b7d8ada1..7668936e7a24 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -455,12 +455,14 @@ export const make = Effect.gen(function* () { interface SourceSnapshot { readonly completedAtMs: number; + readonly scanRevision: number; readonly windowStartMs: number; readonly sourceKey: string; readonly dirs: readonly ScannedDir[]; } let sourceSnapshot: SourceSnapshot | null = null; + let sourceScanRevision = 0; let lastRefreshToken: string | null = null; const sourceScanSemaphore = yield* Semaphore.make(1); @@ -539,6 +541,8 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. + sourceScanRevision += 1; + const scanRevision = sourceScanRevision; const [, dirs] = yield* Effect.all( [ensureRates(false), collectDirs(scanWindowStartMs, settings)], { concurrency: 2 }, @@ -547,6 +551,7 @@ export const make = Effect.gen(function* () { const completedAtMs = Math.max(now, (currentSnapshot?.completedAtMs ?? now - 1) + 1); const nextSnapshot = { completedAtMs, + scanRevision, windowStartMs: scanWindowStartMs, sourceKey, dirs, @@ -668,14 +673,19 @@ export const make = Effect.gen(function* () { }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheRevision += 1; - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheRevision += 1; + yield* persistScanCache(); + } const aggregated = aggregator.finish(); const finishedAtMs = yield* Clock.currentTimeMillis; @@ -711,7 +721,7 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, - input.refreshToken ?? null, + input.refreshToken === undefined ? null : "refresh", priceOverrides, ]); @@ -872,7 +882,7 @@ export const make = Effect.gen(function* () { // Thread rows and the summary must fold the same transcript snapshot. In // particular, a file that grows during the source-cache TTL belongs to the // next refresh on both RPCs instead of appearing in the drill-down alone. - const currentSnapshot = yield* getSourceSnapshot(windowStartMs, undefined, settings); + const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); const resolveProject = yield* resolveProjects(); const accumulator = new ThreadUsageAccumulator({ @@ -917,16 +927,21 @@ export const make = Effect.gen(function* () { } } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); - if (pruned > 0) cacheRevision += 1; - // A thread-only client must warm and bound the same durable cache as the - // summary RPC, otherwise restarts repeat parsing and stale entries grow. - yield* persistScanCache(); + // A newer source walk may have populated files after this snapshot left + // the scan lane. Only the latest walk can prove that an unseen path + // disappeared and persist the resulting cache. + if (currentSnapshot.scanRevision === sourceScanRevision) { + const pruned = pruneScanCache(fileCache, { + livePaths, + walkedRoots, + windowStartMs, + retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, + }); + if (pruned > 0) cacheRevision += 1; + // A thread-only client must warm and bound the same durable cache as the + // summary RPC, otherwise restarts repeat parsing and stale entries grow. + yield* persistScanCache(); + } const attribution = yield* loadThreadAttribution(); const folded = foldThreadRows(accumulator.finish(), attribution, { diff --git a/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx new file mode 100644 index 000000000000..034c8a7c7e7a --- /dev/null +++ b/apps/web/src/components/usage/UsageProviderChart.interaction.test.tsx @@ -0,0 +1,81 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { UsageProviderChart } from "./UsageProviderChart"; + +const days = ["2026-09-01", "2026-09-02", "2026-09-03"]; +let renderer: ReactTestRenderer; +const onZoomToDays = vi.fn(); +const captures = new Set(); +const plot = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 300, height: 260 }), + hasPointerCapture: (id: number) => captures.has(id), + setPointerCapture: (id: number) => captures.add(id), + releasePointerCapture: (id: number) => captures.delete(id), +}; + +function chart(windowDays: readonly string[], resolution: "day" | "hour" = "day") { + return ( + + ); +} + +function pointer(name: "onPointerDown" | "onPointerUp", clientX: number) { + renderer.root + .find((node) => node.type === "div" && node.props.onPointerDown !== undefined) + .props[name]({ button: 0, isPrimary: true, pointerId: 1, clientX, currentTarget: plot }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onZoomToDays.mockClear(); + captures.clear(); + await act(() => { + renderer = create(chart(days), { + createNodeMock: (element) => (element.type === "div" ? plot : null), + }); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("usage chart brush ownership", () => { + it("cancels a brush if date-field blur replaces its window before pointer-up", async () => { + await act(() => pointer("onPointerDown", 0)); + expect(captures.has(1)).toBe(true); + await act(() => renderer.update(chart(["2026-08-01", "2026-08-02", "2026-08-03"]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); + + it("keeps a brush when the same days are supplied by a fresh array", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart([...days]))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).toHaveBeenCalledExactlyOnceWith(days[0], days[2]); + }); + + it("cancels a brush when the view switches to hourly resolution", async () => { + await act(() => pointer("onPointerDown", 0)); + await act(() => renderer.update(chart(days, "hour"))); + await act(() => pointer("onPointerUp", 300)); + expect(onZoomToDays).not.toHaveBeenCalled(); + expect(captures.has(1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 3978fc046efa..988757af014b 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -247,6 +247,7 @@ export function UsageProviderChart({ const [brush, setBrush] = useState<{ readonly start: number; readonly end: number } | null>(null); const brushRef = useRef<{ readonly pointerId: number; + readonly days: readonly string[]; readonly start: number; readonly end: number; } | null>(null); @@ -255,6 +256,23 @@ export function UsageProviderChart({ const tooltipRef = useRef(null); const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); + useLayoutEffect(() => { + const activeBrush = brushRef.current; + if ( + activeBrush === null || + (zoomable && + activeBrush.days.length === days.length && + activeBrush.days.every((day, index) => day === days[index])) + ) + return; + brushRef.current = null; + setBrush(null); + const plot = plotRef.current; + if (plot?.hasPointerCapture(activeBrush.pointerId)) { + plot.releasePointerCapture(activeBrush.pointerId); + } + }, [days, zoomable]); + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { @@ -402,11 +420,11 @@ export function UsageProviderChart({ event.currentTarget.setPointerCapture(event.pointerId); hoverPositionRef.current = null; setHoverIndex(null); - const nextBrush = { pointerId: event.pointerId, start: index, end: index }; + const nextBrush = { pointerId: event.pointerId, days, start: index, end: index }; brushRef.current = nextBrush; setBrush(nextBrush); }, - [indexAt, zoomable], + [days, indexAt, zoomable], ); const finishBrush = useCallback( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index 04dd380cdf11..9b58c7455859 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -10,10 +10,37 @@ import { type UsageView, } from "./usage"; -const testState = vi.hoisted(() => ({ environments: [] as EnvironmentUsageStatus[] })); +function deferred() { + let resolve = () => {}; + let reject = (_reason?: unknown) => {}; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const testState = vi.hoisted(() => ({ + environments: [] as EnvironmentUsageStatus[], + windowLabel: "", + runAtomCommand: vi.fn(), + executeAtomQuery: vi.fn(), + refreshAtom: vi.fn(), +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: testState.runAtomCommand, + executeAtomQuery: testState.executeAtomQuery, +})); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: testState.refreshAtom }, +})); vi.mock("@effect/atom-react", async (importOriginal) => ({ ...(await importOriginal()), - useAtomValue: () => testState.environments, + useAtomValue: (atom: { readonly label?: readonly [string, string] }) => { + testState.windowLabel = atom.label?.[0] ?? ""; + return testState.environments; + }, })); const input = { @@ -80,8 +107,14 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU let renderer: ReactTestRenderer | undefined; let latest: UsageView; -function Probe({ selected }: { selected: ReadonlySet | null }) { - const usage = useUsage(input, selected); +function Probe({ + selected, + refreshThreads = false, +}: { + selected: ReadonlySet | null; + refreshThreads?: boolean; +}) { + const usage = useUsage(input, selected, undefined, refreshThreads); useLayoutEffect(() => { latest = usage; }, [usage]); @@ -95,6 +128,9 @@ async function select(...ids: string[]) { } beforeEach(async () => { + testState.runAtomCommand.mockReset(); + testState.executeAtomQuery.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + testState.refreshAtom.mockReset(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; await act(() => { @@ -184,3 +220,49 @@ describe("usage refresh attempts", () => { expect(second.untouched).toBe("keep"); }); }); + +describe("independent environment refresh", () => { + it("rescans a healthy environment without waiting for another rate request", async () => { + const fast = deferred(); + const slow = deferred(); + testState.runAtomCommand.mockImplementation( + (_registry, _command, { environmentId }: { environmentId: EnvironmentId }) => + environmentId === "a" ? fast.promise : slow.promise, + ); + await select("a", "b"); + await act(() => latest.refresh()); + expect(testState.runAtomCommand).toHaveBeenCalledTimes(2); + await act(() => fast.resolve()); + const fastTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(fastTokens.a).toEqual(expect.any(String)); + expect(fastTokens.b).toBeUndefined(); + await act(() => slow.reject(new Error("Rates unavailable"))); + const finalTokens = JSON.parse( + testState.windowLabel.slice("web-usage:window:".length), + ).refreshTokens; + expect(finalTokens.a).toBe(fastTokens.a); + expect(finalTokens.b).toBe(fastTokens.a); + }); + + it("waits for an environment summary before refreshing its thread rows", async () => { + const rates = deferred(); + const summary = deferred(); + testState.runAtomCommand.mockReturnValue(rates.promise); + testState.executeAtomQuery.mockReturnValue(summary.promise); + await act(() => { + renderer?.update( + , + ); + }); + await act(() => latest.refresh()); + + await act(() => rates.resolve()); + expect(testState.executeAtomQuery).toHaveBeenCalledOnce(); + expect(testState.refreshAtom).not.toHaveBeenCalled(); + + await act(() => summary.resolve()); + expect(testState.refreshAtom).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 5e58c5d2e5bd..8d7561a7ee82 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -18,7 +18,7 @@ import { type UsageThreadBreakdownInput, type UsageThreadRow, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { executeAtomQuery, runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -211,43 +211,58 @@ export function useUsage( ); // Give every selected environment a fresh token after refreshing model - // prices. When the thread table is mounted, refresh its provider-owned query - // at the same time so both views recount the same transcript state. + // prices. When the thread table is mounted, wait for that environment's + // summary before refreshing its thread rows so both views use one snapshot. const refresh = useCallback(() => { const currentInput = JSON.parse(rangeKey) as UsageSummaryInput; - const rateRefreshes = selectedEnvironments.map(({ environmentId }) => - runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ), - ); - void Promise.allSettled(rateRefreshes).then(() => { - const selectedIds = selectedEnvironments.map(({ environmentId }) => environmentId); - const attemptId = randomUUID(); - setRefreshTokens((current) => - withUsageRefreshAttempt(current, selectedIds, answered, attemptId), - ); - if (!refreshThreads) return; - for (const contribution of filterProviderContributionsForProject( - projectFilter, - merged.providerContributions, - )) { - if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; - appAtomRegistry.refresh( - serverEnvironment.usageThreadBreakdown({ - environmentId: contribution.environmentId, - input: makeThreadBreakdownInput( - currentInput, - projectFilter, - contribution.providers, - contribution.environmentId, - ), + const attemptId = randomUUID(); + for (const { environmentId } of selectedEnvironments) { + void Promise.allSettled([ + runAtomCommand( + appAtomRegistry, + serverEnvironment.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ), + ]).then(async () => { + const refreshToken = withUsageRefreshAttempt({}, [environmentId], answered, attemptId)[ + environmentId + ]; + setRefreshTokens((current) => + withUsageRefreshAttempt(current, [environmentId], answered, attemptId), + ); + if (!refreshThreads) return; + await executeAtomQuery( + appAtomRegistry, + serverEnvironment.usageSummary({ + environmentId, + input: { ...currentInput, refreshToken }, }), + { reportFailure: false, refresh: true }, ); - } - }); + for (const contribution of filterProviderContributionsForProject( + projectFilter, + merged.providerContributions, + )) { + if ( + contribution.environmentId !== environmentId || + contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE + ) + continue; + appAtomRegistry.refresh( + serverEnvironment.usageThreadBreakdown({ + environmentId, + input: makeThreadBreakdownInput( + currentInput, + projectFilter, + contribution.providers, + environmentId, + ), + }), + ); + } + }); + } }, [ answered, merged.providerContributions, diff --git a/packages/contracts/src/usage.test.ts b/packages/contracts/src/usage.test.ts new file mode 100644 index 000000000000..2f742e778a84 --- /dev/null +++ b/packages/contracts/src/usage.test.ts @@ -0,0 +1,22 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { UsageThreadBreakdownInput } from "./usage.ts"; + +const decodeThreadInput = Schema.decodeUnknownSync(UsageThreadBreakdownInput); + +describe("UsageThreadBreakdownInput", () => { + const input = { + sinceDay: "2026-08-01", + untilDay: "2026-08-02", + timeZone: "UTC", + }; + + it("accepts and trims a refresh token", () => { + expect(decodeThreadInput({ ...input, refreshToken: " turn-2 " }).refreshToken).toBe("turn-2"); + }); + + it("rejects a blank refresh token", () => { + expect(() => decodeThreadInput({ ...input, refreshToken: " " })).toThrow(); + }); +}); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index a27de437026a..db97969c606c 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -251,6 +251,8 @@ export const UsageThreadBreakdownInput = Schema.Struct({ sinceTime: Schema.optional(TrimmedNonEmptyString), /** Exclusive UTC instant for a rolling window such as Past 24h. */ untilTime: Schema.optional(TrimmedNonEmptyString), + /** Changed by callers that need to bypass the short-lived source snapshot. */ + refreshToken: Schema.optional(TrimmedNonEmptyString), /** * Restrict to one project's records: a namespaced stable key selects that * project, `null` selects records outside every project, absent applies no From 9169c05c88c4993700af9fd64c0d310498050a13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:44:13 +1000 Subject: [PATCH 66/78] test(client): type usage scan failure fixture --- packages/client-runtime/src/state/usage.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 55f3cd89b0f5..f96f7cbccf58 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -6,6 +6,7 @@ import { type UsageSummaryInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it } from "vite-plus/test"; @@ -13,6 +14,10 @@ import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; import { refreshUsage } from "./usage.ts"; +class UsageScanTestError extends Schema.TaggedError()("UsageScanTestError", { + cause: Schema.Defect(), +}) {} + const input = { sinceDay: UsageDay.make("2026-09-05"), untilDay: UsageDay.make("2026-09-05"), @@ -47,9 +52,12 @@ function harness(ids = ["a"]) { connection: { phase: "connected" }, } as EnvironmentPresentation | null); const query = Atom.make( - Effect.promise(() => { - scanStarted.resolve(); - return scan.promise; + Effect.tryPromise({ + try: () => { + scanStarted.resolve(); + return scan.promise; + }, + catch: (cause) => new UsageScanTestError({ cause }), }), ); return { environmentId, rates, scan, scanStarted, presentation, query }; @@ -151,7 +159,7 @@ describe("manual usage refresh", () => { it("reports a scan failure after the other selected environment finishes", async () => { const { environments, refresh } = harness(["failed", "healthy"]); const [failed, healthy] = environments; - const failure = new Error("Usage scan failed"); + const failure = new UsageScanTestError({ cause: "Usage scan failed" }); failed!.query = Atom.make(Effect.fail(failure)); let finished = false; const refreshing = refresh().then( From d669ba201ebba88cff91d738f71417c1b789350c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:56:46 +1000 Subject: [PATCH 67/78] fix(contracts): keep usage thread RPC declaration private --- packages/contracts/src/rpc.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 606775862a0f..e5f5430f7567 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -582,14 +582,11 @@ const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); -export const WsServerGetUsageThreadBreakdownRpc = Rpc.make( - WS_METHODS.serverGetUsageThreadBreakdown, - { - payload: UsageThreadBreakdownInput, - success: UsageThreadBreakdown, - error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), - }, -); +const WsServerGetUsageThreadBreakdownRpc = Rpc.make(WS_METHODS.serverGetUsageThreadBreakdown, { + payload: UsageThreadBreakdownInput, + success: UsageThreadBreakdown, + error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), +}); /** * Refetches the model rate table ahead of its daily TTL, so a model released From 762e7b8985aa456892c20cd13de3de524d18d22b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:01:09 +1000 Subject: [PATCH 68/78] test(server): wait for concurrent usage scan enrollment --- apps/server/src/usage/UsageService.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 96d5005e7e03..93ef1375f56c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -15,6 +15,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as Tracer from "effect/Tracer"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -194,8 +195,20 @@ describe("UsageService", () => { "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, }, }); - const second = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); - yield* Effect.yieldNow; + const secondScanStarted = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + if (span.name === "UsageService.scanSummary") { + Deferred.doneUnsafe(secondScanStarted, Effect.void); + } + return span; + }, + }); + const second = yield* service + .readSummary(WINDOW) + .pipe(Effect.withTracer(tracer), Effect.forkChild); + yield* Deferred.await(secondScanStarted); yield* Deferred.succeed(releaseRates, undefined); const original = yield* Fiber.join(first); From c6e0eaeaef0cb5874083ef7bca1db42a8f9fd8c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:25:49 +1000 Subject: [PATCH 69/78] fix(usage): normalize custom window time zones --- packages/shared/src/usageFormat.test.ts | 1 + packages/shared/src/usageFormat.ts | 45 ++++++++++++++----------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index a75b54d9ec60..b79626f23410 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -81,6 +81,7 @@ describe("hourly usage formatting", () => { expect(makeWindow(1, now, "hour").timeZone).toBe("UTC"); expect(makeWindow(30, now).timeZone).toBe("UTC"); + expect(makeCustomWindow("2026-08-01", "2026-08-11").timeZone).toBe("UTC"); } finally { resolvedOptions.mockRestore(); } diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 6b07b90a60b0..d484bb83ed40 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -195,6 +195,29 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone); } +function viewerDayFormat(): { timeZone: string; format: Intl.DateTimeFormat } { + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + 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 crash the page. + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } + return { timeZone, format }; +} + /** * A daily window over an explicit inclusive day range, in the viewer's zone. * Bounds arrive from date inputs or a chart brush; out-of-order bounds are @@ -217,7 +240,7 @@ export function makeCustomWindow(sinceDay: string, untilDay: string): UsageSumma return { sinceDay: UsageDay.make(first), untilDay: UsageDay.make(last), - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + timeZone: viewerDayFormat().timeZone, resolution: "day", }; } @@ -231,25 +254,7 @@ export function makeWindow( now = new Date(), resolution: UsageResolution = "day", ): UsageSummaryInput { - let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - 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 crash the page. - timeZone = "UTC"; - format = new Intl.DateTimeFormat("en-CA", { - timeZone: "UTC", - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - } + const { timeZone, format } = viewerDayFormat(); const untilDay = format.format(now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an From 16934cc9bd6318183e52d08589e52b4912e636ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:27:18 +1000 Subject: [PATCH 70/78] fix(usage): refresh thread costs after price edits --- packages/client-runtime/src/state/server.ts | 1 + .../src/state/serverUsage.test.ts | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 06905edc9b9f..9904f24498cd 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1054,6 +1054,7 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:usage-thread-breakdown", tag: WS_METHODS.serverGetUsageThreadBreakdown, staleTimeMs: 60_000, + refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), }), configProjection, welcome, diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index 9cf2667d8bc2..8baaa1f45f3c 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -7,6 +7,7 @@ import { type ServerConfig, type ServerConfigStreamEvent, type ServerSettings, + type UsageThreadBreakdown, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; @@ -57,7 +58,21 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( const events = yield* Queue.unbounded(); let settings = DEFAULT_SERVER_SETTINGS; let requests = 0; + let threadRequests = 0; const client = { + [WS_METHODS.serverGetUsageThreadBreakdown]: () => + Effect.sync(() => { + threadRequests += 1; + return { + ...INPUT, + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-09-04T12:00:00Z", + rows: [], + truncatedRows: 0, + scanDurationMs: + settings.usagePriceOverrides["custom-model"]?.inputCostPerMillionTokens ?? 0, + } satisfies UsageThreadBreakdown; + }), [WS_METHODS.subscribeServerConfig]: () => Stream.concat( Stream.make({ version: 1 as const, type: "snapshot" as const, config: CONFIG }), @@ -168,6 +183,8 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( return { registry, requests: () => requests, + threadRequests: () => threadRequests, + threads: atoms.usageThreadBreakdown({ environmentId: TARGET.environmentId, input: INPUT }), updateSettings, summary: (input = INPUT) => atoms.usageSummary({ environmentId: TARGET.environmentId, input }), }; @@ -261,3 +278,36 @@ it.effect("restarts a pending usage read after a price change", () => }), ), ); + +it.effect("refreshes mounted thread breakdowns when override prices change", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + const unmount = harness.registry.mount(harness.threads); + const waitForPrice = (price: number) => + AtomRegistry.toStream(harness.registry, harness.threads).pipe( + Stream.filter( + (result) => + AsyncResult.isSuccess(result) && + !result.waiting && + result.value.scanDurationMs === price, + ), + Stream.runHead, + ); + yield* waitForPrice(0); + expect(harness.threadRequests()).toBe(1); + yield* harness.updateSettings({ + ...DEFAULT_SERVER_SETTINGS, + usagePriceOverrides: { + "custom-model": { inputCostPerMillionTokens: 3, outputCostPerMillionTokens: 9 }, + }, + }); + yield* waitForPrice(3); + expect(harness.threadRequests()).toBe(2); + yield* harness.updateSettings(DEFAULT_SERVER_SETTINGS); + yield* waitForPrice(0); + expect(harness.threadRequests()).toBe(3); + unmount(); + }), + ), +); From d3c2a9e3c7d32bdc66188b4b15fba6ab53a6a94f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:28:44 +1000 Subject: [PATCH 71/78] fix(usage): include contract nine thread breakdowns --- apps/web/src/state/usage.test.tsx | 39 ++++++++++++++++++------------- packages/contracts/src/usage.ts | 2 +- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index dadd8ea169d1..a2931b9a8c4d 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -199,20 +199,27 @@ describe("usage environment selection", () => { }); describe("thread breakdown refresh", () => { - it("waits for summary publication before refreshing the mounted thread rows", async () => { - const summary = deferred(); - testState.refreshUsage.mockReturnValue(summary.promise); - await act(() => { - renderer?.update( - , - ); - }); - const refreshing = latest.refresh(); - expect(testState.refreshAtom).not.toHaveBeenCalled(); - await act(async () => { - summary.resolve(); - await refreshing; - }); - expect(testState.refreshAtom).toHaveBeenCalledOnce(); - }); + it.each([9, USAGE_CONTRACT_VERSION])( + "waits for summary publication before refreshing contract-%i thread rows", + async (contractVersion) => { + testState.environments = testState.environments.map((entry) => ({ + ...entry, + summary: entry.summary === null ? null : { ...entry.summary, contractVersion }, + })); + const summary = deferred(); + testState.refreshUsage.mockReturnValue(summary.promise); + await act(() => { + renderer?.update( + , + ); + }); + const refreshing = latest.refresh(); + expect(testState.refreshAtom).not.toHaveBeenCalled(); + await act(async () => { + summary.resolve(); + await refreshing; + }); + expect(testState.refreshAtom).toHaveBeenCalledOnce(); + }, + ); }); diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 1cadf0da230e..6bfd685ad80e 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -37,7 +37,7 @@ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; /** First contract version that explicitly distinguishes outside from unknown attribution. */ export const USAGE_PROJECT_ATTRIBUTION_SINCE = 8 as const; /** First contract version that exposes the current thread-breakdown RPC. */ -export const USAGE_THREAD_BREAKDOWN_SINCE = 10 as const; +export const USAGE_THREAD_BREAKDOWN_SINCE = 9 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; From 123bd68394dfe6a43da243365f17c124b2cb46f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:00:07 +1000 Subject: [PATCH 72/78] fix(usage): retain committed refresh statuses --- apps/mobile/src/state/usage.ts | 6 ++++-- apps/web/src/state/usage.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index efc0b4ce6728..7b7530002ed0 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -27,7 +27,7 @@ import { } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; @@ -112,7 +112,9 @@ export function useUsage( const currentEnvironments = useAtomValue(atom); const settledStatuses = useRef | null>(null); const retained = retainUsageStatuses(windowKey, currentEnvironments, settledStatuses.current); - settledStatuses.current = retained.settled; + useEffect(() => { + settledStatuses.current = retained.settled; + }, [retained.settled]); const environments = retained.visible; const selectedEnvironments = useMemo( () => diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index bb283d42eb39..0ac6892e581c 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -23,7 +23,7 @@ import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { mergeUsage, @@ -129,7 +129,9 @@ export function useUsage( const currentEnvironments = useAtomValue(atom); const settledStatuses = useRef | null>(null); const retained = retainUsageStatuses(windowKey, currentEnvironments, settledStatuses.current); - settledStatuses.current = retained.settled; + useEffect(() => { + settledStatuses.current = retained.settled; + }, [retained.settled]); const environments = retained.visible; const selectedEnvironments = useMemo( () => From dbe124fb21803494274d3cf515c775fe4e34d393 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:00:10 +1000 Subject: [PATCH 73/78] fix(usage): scope thread failures to selected environments --- .../src/components/usage/UsagePage.test.tsx | 21 +++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 7b631da9abf5..72653132fc99 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -446,3 +446,24 @@ describe("UsagePage model breakdown", () => { ]); }); }); + +it("excludes deselected environments from thread failure counts", () => { + testState.breakdown = "thread"; + const usage = testState.useUsage(); + const excluded = { + ...environments[0]!, + environmentId: EnvironmentId.make("excluded"), + label: "Excluded", + error: "offline", + summary: null, + }; + testState.useUsage.mockReturnValue({ + ...usage, + environments: [...usage.environments, excluded], + selectedEnvironments: usage.selectedEnvironments, + }); + renderToStaticMarkup(); + expect(testState.usageThreadTable.mock.calls[0]?.[0]).toMatchObject({ + summaryFailedEnvironments: 0, + }); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 0baf1dc4894b..ab4d2c16a5d8 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -636,7 +636,7 @@ export function UsagePage() { }} providerContributions={merged.providerContributions} summaryFailedEnvironments={ - environments.filter( + selectedEnvironments.filter( (environment) => (environment.error !== null || merged.staleEnvironments.includes(environment.environmentId)) && From 77631f26c63352e4bb8c018a551837c24a52fa62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:00:12 +1000 Subject: [PATCH 74/78] fix(web): remove unused usage legend helper --- .../src/components/usage/UsageThreadTable.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/apps/web/src/components/usage/UsageThreadTable.tsx b/apps/web/src/components/usage/UsageThreadTable.tsx index 04e6a260fd99..71a4b3f8b29d 100644 --- a/apps/web/src/components/usage/UsageThreadTable.tsx +++ b/apps/web/src/components/usage/UsageThreadTable.tsx @@ -16,7 +16,6 @@ import { } from "@t3tools/shared/usageFormat"; import type { EnvironmentProviderContribution } from "@t3tools/shared/usageMerge"; -import { cn } from "../../lib/utils"; import { useUsageThreads, type UsageThreadRowWithEnvironment } from "../../state/usage"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -451,22 +450,6 @@ export function UsageThreadDailyChart({
); } - -function LegendSwatch({ - className, - label, -}: { - readonly className: string; - readonly label: string; -}) { - return ( - - - {label} - - ); -} - function ProviderMark({ provider }: { readonly provider: UsageProviderKind }) { const Mark = PROVIDER_PRESENTATION[provider].mark; return ; From ef1ff45da6604969405ca003887835e575daa997 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:57:13 +1000 Subject: [PATCH 75/78] fix(usage): preserve custom zoom ranges and stable calendar dates --- .../src/components/usage/UsagePage.test.tsx | 35 +++++++++++++++++-- apps/web/src/components/usage/UsagePage.tsx | 21 +++++++++-- docs/user/usage.md | 2 +- packages/shared/src/usageFormat.test.ts | 20 +++++++++++ packages/shared/src/usageFormat.ts | 17 ++++++--- 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 72653132fc99..68ba0c2c3e97 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -5,6 +5,9 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ + customWindow: false, + zoomToDays: undefined as ((since: string, until: string) => void) | undefined, + resetZoom: undefined as (() => void) | undefined, useUsage: vi.fn(), usageThreadTable: vi.fn((_props: unknown) => null), metric: "cost" as "cost" | "tokens" | "limits", @@ -25,7 +28,8 @@ vi.mock("react", async (importOriginal) => { ? { metric: testState.metric, windowDays: 30 } : typeof initial === "function" ? { - days: 1, + days: testState.customWindow ? 30 : 1, + custom: testState.customWindow, window: { sinceDay: "2026-08-10", untilDay: "2026-08-11", @@ -86,7 +90,16 @@ vi.mock("../WorkspaceBreadcrumb", () => ({ })); vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); -vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsageProviderChart", () => ({ + UsageProviderChart: (props: { + onZoomToDays?: (since: string, until: string) => void; + onResetZoom?: () => void; + }) => { + testState.zoomToDays = props.onZoomToDays; + testState.resetZoom = props.onResetZoom; + return
; + }, +})); vi.mock("./UsageThreadTable", () => ({ UsageThreadTable: testState.usageThreadTable })); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); vi.mock("./usageProviders", async (importOriginal) => { @@ -188,6 +201,9 @@ const projectTotals = Object.freeze([ ]); beforeEach(() => { + testState.customWindow = false; + testState.zoomToDays = undefined; + testState.resetZoom = undefined; testState.metric = "cost"; testState.breakdown = "time"; testState.projectFilter = undefined; @@ -467,3 +483,18 @@ it("excludes deselected environments from thread failure counts", () => { summaryFailedEnvironments: 0, }); }); + +it("restores the original custom window after repeated chart zooms", () => { + testState.customWindow = true; + renderToStaticMarkup(); + expect(testState.zoomToDays).toBeTypeOf("function"); + testState.zoomToDays?.("2026-08-10", "2026-08-10"); + testState.zoomToDays?.("2026-08-11", "2026-08-11"); + testState.resetZoom?.(); + expect(testState.setWindowSelection).toHaveBeenLastCalledWith( + expect.objectContaining({ + custom: true, + window: expect.objectContaining({ sinceDay: "2026-08-10", untilDay: "2026-08-11" }), + }), + ); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index ab4d2c16a5d8..bb0a290394ad 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -111,6 +111,7 @@ export function UsagePage() { preferences.windowDays === 1 ? "hour" : "day", ), })); + const preZoomSelection = useRef(null); const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); @@ -204,6 +205,7 @@ export function UsagePage() { const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; + preZoomSelection.current = null; const nextPreferences = { metric, windowDays: days }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); @@ -214,12 +216,27 @@ export function UsagePage() { }); }; const selectCustomWindow = (sinceDay: string, untilDay: string) => { + preZoomSelection.current = null; setWindowSelection({ days: windowDays, custom: true, window: makeCustomWindow(sinceDay, untilDay), }); }; + const zoomToDays = (sinceDay: string, untilDay: string) => { + preZoomSelection.current ??= windowSelection; + setWindowSelection({ + days: windowDays, + custom: true, + window: makeCustomWindow(sinceDay, untilDay), + }); + }; + const resetZoom = () => { + const original = preZoomSelection.current; + preZoomSelection.current = null; + if (original?.custom) setWindowSelection(original); + else selectWindow(original?.days ?? windowDays); + }; const selectMetric = (nextMetric: UsageMetric) => { const nextPreferences = { metric: nextMetric, windowDays }; setPreferences(nextPreferences); @@ -553,8 +570,8 @@ export function UsagePage() { {...(isPast24Hours ? {} : { - onZoomToDays: selectCustomWindow, - onResetZoom: () => selectWindow(windowDays), + onZoomToDays: zoomToDays, + onResetZoom: resetZoom, })} />
diff --git a/docs/user/usage.md b/docs/user/usage.md index 81505f8daca6..2dc4508ba389 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -19,7 +19,7 @@ source data first. The Refresh action always requests an update. Updates parse o transcript content. Any daily chart zooms: drag across it to make the selection the new date window, and double-click -to return to the preset. The date fields beside the presets accept custom ranges up to 90 days. +to return to the range selected before zooming. The date fields beside the presets accept custom ranges up to 90 days. The breakdown's **Thread** view drills into where the spend went: sessions group into the T3 Code thread they belong to, with sessions that never ran through T3 Code listed under the first thing diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index b79626f23410..641b04fb8a5a 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -116,3 +116,23 @@ describe("makeCustomWindow", () => { expect(() => makeCustomWindow("10000-01-01", "9999-12-31")).toThrow(RangeError); }); }); + +describe("locale-independent usage windows", () => { + it.each(["day", "hour"] as const)("builds %s bounds from numeric date parts", (resolution) => { + const descriptor = Object.getOwnPropertyDescriptor(Intl.DateTimeFormat.prototype, "format"); + if (descriptor === undefined) throw new Error("Expected the Intl format accessor"); + const formatted = vi.fn(() => () => "09/09/2026"); + Object.defineProperty(Intl.DateTimeFormat.prototype, "format", { + configurable: true, + get: formatted, + }); + try { + const window = makeWindow(1, new Date("2026-09-09T12:00:00Z"), resolution); + expect(window.sinceDay).toMatch(/^2026-09-\d{2}$/); + expect(window.untilDay).toMatch(/^2026-09-\d{2}$/); + expect(formatted).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(Intl.DateTimeFormat.prototype, "format", descriptor); + } + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index d484bb83ed40..8fda861cd704 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -185,8 +185,8 @@ export function formatRelativeHourShort( month: "2-digit", day: "2-digit", }); - const instantDay = Date.parse(`${dayFormat.format(instant)}T00:00:00Z`); - const referenceDay = Date.parse(`${dayFormat.format(reference)}T00:00:00Z`); + const instantDay = Date.parse(`${formatUsageDay(dayFormat, instant)}T00:00:00Z`); + const referenceDay = Date.parse(`${formatUsageDay(dayFormat, reference)}T00:00:00Z`); const calendarDaysAgo = Math.round((referenceDay - instantDay) / (24 * HOUR_MS)); const hour = formatHourShort(hourStart, timeZone); @@ -195,6 +195,13 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone); } +function formatUsageDay(format: Intl.DateTimeFormat, instant: Date): string { + const parts = Object.fromEntries( + format.formatToParts(instant).map(({ type, value }) => [type, value]), + ); + return `${parts.year}-${parts.month}-${parts.day}`; +} + function viewerDayFormat(): { timeZone: string; format: Intl.DateTimeFormat } { let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; let format: Intl.DateTimeFormat; @@ -255,7 +262,7 @@ export function makeWindow( resolution: UsageResolution = "day", ): UsageSummaryInput { const { timeZone, format } = viewerDayFormat(); - const untilDay = format.format(now); + const untilDay = formatUsageDay(format, now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an // exact rolling 24-hour duration. Fixed-duration buckets remain correct @@ -265,8 +272,8 @@ export function makeWindow( const sinceTime = new Date(sinceTimeMs); const untilTime = new Date(untilTimeMs); return { - sinceDay: UsageDay.make(format.format(sinceTime)), - untilDay: UsageDay.make(format.format(untilTime)), + sinceDay: UsageDay.make(formatUsageDay(format, sinceTime)), + untilDay: UsageDay.make(formatUsageDay(format, untilTime)), timeZone, resolution, sinceTime: sinceTime.toISOString(), From 47c331a7321cb0b9dc6998f0a12bce1ef8c30c8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:57:14 +1000 Subject: [PATCH 76/78] fix(usage): refresh thread queries with published provider sets --- apps/web/src/state/usage.test.tsx | 41 ++++++++++++++++++++++++++++++- apps/web/src/state/usage.ts | 18 ++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index a2931b9a8c4d..26ccce7d813d 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -1,4 +1,6 @@ import { EnvironmentId, UsageDay, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { serverEnvironment } from "./server"; import { act, useLayoutEffect } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -21,10 +23,11 @@ const testState = vi.hoisted(() => ({ runAtomCommand: vi.fn(), refreshUsage: vi.fn(), refreshAtom: vi.fn(), + readSummary: vi.fn(), })); vi.mock("@t3tools/client-runtime/state/usage", () => ({ refreshUsage: testState.refreshUsage })); vi.mock("../rpc/atomRegistry", () => ({ - appAtomRegistry: { refresh: testState.refreshAtom }, + appAtomRegistry: { refresh: testState.refreshAtom, get: testState.readSummary }, })); vi.mock("@effect/atom-react", async (importOriginal) => ({ ...(await importOriginal()), @@ -122,6 +125,9 @@ beforeEach(async () => { testState.runAtomCommand.mockReset(); testState.refreshUsage.mockReset().mockResolvedValue(undefined); testState.refreshAtom.mockReset(); + testState.readSummary + .mockReset() + .mockImplementation(() => AsyncResult.success(testState.environments[0]?.summary)); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); testState.environments = [environment("a", 10), environment("b", 20), environment("slow", null)]; @@ -223,3 +229,36 @@ describe("thread breakdown refresh", () => { }, ); }); + +it("refreshes thread keys with the new window and newly published providers", async () => { + await act(() => + renderer?.update(), + ); + const nextInput = { + ...input, + sinceDay: UsageDay.make("2026-09-05"), + untilDay: UsageDay.make("2026-09-06"), + }; + const changed = environment("a", 50).summary!; + testState.readSummary.mockReturnValue( + AsyncResult.success({ + ...changed, + ...nextInput, + buckets: changed.buckets.map((bucket) => ({ ...bucket, provider: "claude" })), + sources: changed.sources.map((source) => ({ + ...source, + fingerprint: { ...source.fingerprint, provider: "claude" }, + })), + }), + ); + const query = vi.spyOn(serverEnvironment, "usageThreadBreakdown"); + try { + await latest.refresh(nextInput); + expect(query).toHaveBeenCalledWith({ + environmentId: EnvironmentId.make("a"), + input: expect.objectContaining({ ...nextInput, providers: ["claude"] }), + }); + } finally { + query.mockRestore(); + } +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 0ac6892e581c..f9a26ed4388d 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -174,9 +174,23 @@ export function useUsage( input: currentInput, }); if (!refreshThreads) return; + const refreshed = mergeUsage( + selectedEnvironments.flatMap(({ environmentId, label }) => { + const summary = Option.getOrNull( + AsyncResult.value( + appAtomRegistry.get( + serverEnvironment.usageSummary({ environmentId, input: currentInput }), + ), + ), + ); + return summary === null ? [] : [{ environmentId, label, summary }]; + }), + USAGE_CONTRACT_VERSION, + projectFilter === undefined ? undefined : { projectFilter }, + ); for (const contribution of filterProviderContributionsForProject( projectFilter, - merged.providerContributions, + refreshed.providerContributions, )) { if (contribution.contractVersion < USAGE_THREAD_BREAKDOWN_SINCE) continue; appAtomRegistry.refresh( @@ -192,7 +206,7 @@ export function useUsage( ); } }, - [merged.providerContributions, projectFilter, windowKey, refreshThreads, selectedEnvironments], + [projectFilter, windowKey, refreshThreads, selectedEnvironments], ); const relevantEnvironments = filterUsageEnvironmentsForProject( From 819ea716ef440e082344d0c012eef25636b2c4eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:14:17 +1000 Subject: [PATCH 77/78] fix(usage): preserve unzoomed ranges and early calendar years --- apps/web/src/components/usage/UsagePage.test.tsx | 8 ++++++++ apps/web/src/components/usage/UsagePage.tsx | 5 +++-- packages/shared/src/usageFormat.test.ts | 10 ++++++++++ packages/shared/src/usageFormat.ts | 10 +++++----- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 68ba0c2c3e97..05d942fbe486 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -498,3 +498,11 @@ it("restores the original custom window after repeated chart zooms", () => { }), ); }); + +it("keeps an unzoomed custom range when the plot is double-clicked", () => { + testState.customWindow = true; + renderToStaticMarkup(); + expect(testState.resetZoom).toBeTypeOf("function"); + testState.resetZoom?.(); + expect(testState.setWindowSelection).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index bb0a290394ad..0eb0f5016b89 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -233,9 +233,10 @@ export function UsagePage() { }; const resetZoom = () => { const original = preZoomSelection.current; + if (original === null) return; preZoomSelection.current = null; - if (original?.custom) setWindowSelection(original); - else selectWindow(original?.days ?? windowDays); + if (original.custom) setWindowSelection(original); + else selectWindow(original.days); }; const selectMetric = (nextMetric: UsageMetric) => { const nextPreferences = { metric: nextMetric, windowDays }; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index 641b04fb8a5a..0dc9b136f118 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -136,3 +136,13 @@ describe("locale-independent usage windows", () => { } }); }); + +it("preserves four-digit early years in both daily bounds", () => { + const window = makeWindow(1, new Date("0001-07-15T12:00:00Z")); + expect(window.sinceDay).toMatch(/^0001-/); + expect(window.untilDay).toBe(window.sinceDay); +}); + +it("rejects years outside the four-digit usage contract", () => { + expect(() => makeWindow(1, new Date("+010000-07-15T12:00:00Z"))).toThrow(RangeError); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 8fda861cd704..64dcf05045f2 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -199,7 +199,10 @@ function formatUsageDay(format: Intl.DateTimeFormat, instant: Date): string { const parts = Object.fromEntries( format.formatToParts(instant).map(({ type, value }) => [type, value]), ); - return `${parts.year}-${parts.month}-${parts.day}`; + const year = parts.year?.padStart(4, "0"); + if (year === undefined || year.length !== 4) + throw new RangeError("Usage years must have four digits"); + return `${year}-${parts.month}-${parts.day}`; } function viewerDayFormat(): { timeZone: string; format: Intl.DateTimeFormat } { @@ -283,10 +286,7 @@ export function makeWindow( // Subtracting fixed milliseconds from `now` lands on the wrong calendar day // around a DST transition. The window start is pure calendar arithmetic on // the local end 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 - (days - 1))); + const start = new Date(Date.parse(`${untilDay}T00:00:00Z`) - (days - 1) * DAY_MS); return { sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), untilDay: UsageDay.make(untilDay), From e24c0e6ff14d1bb4a3730e0d715b0d6ade12a7fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:14:37 +1000 Subject: [PATCH 78/78] perf(usage): reuse request attribution and record calendar days --- apps/server/src/usage/UsageService.test.ts | 31 ++++++++++++ apps/server/src/usage/UsageService.ts | 48 ++++++++++--------- .../server/src/usage/usageAggregation.test.ts | 21 ++++++++ apps/server/src/usage/usageAggregation.ts | 26 +++++++--- apps/server/src/usage/usageThreads.ts | 12 +++-- 5 files changed, 104 insertions(+), 34 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index cc65b892f93f..a6a383e746e5 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -840,3 +840,34 @@ describe("shortSessionLabel", () => { ); }); }); + +it.live("shares project reads within a thread request and reloads them for the next request", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + let projectReads = 0; + const unused = Effect.die(new Error("unused project operation")); + const projectRepository: ProjectionProjectRepository["Service"] = { + upsert: () => unused, + getById: () => unused, + listAll: () => + Effect.sync(() => { + projectReads += 1; + return []; + }), + deleteById: () => unused, + }; + const dependencies = yield* Layer.build( + serviceLayers({ + prefix: "usage-one-project-snapshot", + home, + settings, + projectRepository, + }), + ); + const service = yield* UsageService.make.pipe(Effect.provide(dependencies)); + yield* service.readThreadBreakdown(WINDOW); + assert.equal(projectReads, 1); + yield* service.readThreadBreakdown(WINDOW); + assert.equal(projectReads, 2); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 32eb5d6b0577..b5b2bd365941 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -312,24 +312,31 @@ export const make = Effect.gen(function* () { ]; }); - /** - * Builds the cwd โ†’ project-title resolver for one scan. - * - * Projects are re-read every scan so a project created or renamed since the - * last refresh attributes correctly. A repository failure degrades to "no - * attribution" rather than failing the page. - */ - const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* () { + const loadProjectThreads = Effect.gen(function* () { const projects = yield* projectRepository .listAll() .pipe(Effect.catch(() => Effect.succeed(null))); - if (projects === null) return undefined; - const projectRoots = yield* Effect.forEach( + if (projects === null) return null; + return yield* Effect.forEach( projects, Effect.fnUntraced(function* (project) { const threads = yield* threadRepository .listByProjectId({ projectId: project.projectId }) .pipe(Effect.catchCause(() => Effect.succeed([]))); + return { project, threads }; + }), + { concurrency: 8 }, + ); + }); + + /** Project names and worktree ownership are re-read for each request. */ + const resolveProjects = Effect.fn("UsageService.resolveProjects")(function* ( + snapshot: typeof loadProjectThreads = loadProjectThreads, + ) { + const projects = yield* snapshot; + if (projects === null) return undefined; + return makeProjectResolver( + projects.flatMap(({ project, threads }) => { const root = { projectId: project.projectId, workspaceRoot: project.workspaceRoot, @@ -343,9 +350,7 @@ export const make = Effect.gen(function* () { ), ]; }), - { concurrency: 8 }, ); - return makeProjectResolver(projectRoots.flat()); }); /** @@ -762,19 +767,16 @@ export const make = Effect.gen(function* () { * worktree map instead; sessions that never ran through T3 Code stay * session-granular. */ - const loadThreadAttribution = Effect.fn("UsageService.loadThreadAttribution")(function* () { + const loadThreadAttribution = Effect.fn("UsageService.loadThreadAttribution")(function* ( + snapshot: typeof loadProjectThreads = loadProjectThreads, + ) { const sessionToThread = new Map(); const worktreeToThread = new Map(); const titles = new Map(); - const projects = yield* projectRepository - .listAll() - .pipe(Effect.catch(() => Effect.succeed([]))); + const projects = yield* snapshot; const worktreeClaims = new Map(); - for (const project of projects) { - const threads = yield* threadRepository - .listByProjectId({ projectId: project.projectId }) - .pipe(Effect.catchCause(() => Effect.succeed([]))); + for (const { project, threads } of projects ?? []) { for (const thread of threads) { const title = thread.title.trim(); if (title.length > 0) titles.set(thread.threadId, title); @@ -875,7 +877,6 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; const settings = yield* readSettings; - yield* ensureRates(false); yield* ensureScanCacheLoaded; const windowStartMs = @@ -885,7 +886,8 @@ export const make = Effect.gen(function* () { // next refresh on both RPCs instead of appearing in the drill-down alone. const currentSnapshot = yield* getSourceSnapshot(windowStartMs, input.refreshToken, settings); - const resolveProject = yield* resolveProjects(); + const projectSnapshot = yield* Effect.cached(loadProjectThreads); + const resolveProject = yield* resolveProjects(projectSnapshot); const accumulator = new ThreadUsageAccumulator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -945,7 +947,7 @@ export const make = Effect.gen(function* () { yield* persistScanCache(); } - const attribution = yield* loadThreadAttribution(); + const attribution = yield* loadThreadAttribution(projectSnapshot); const folded = foldThreadRows(accumulator.finish(), attribution, { cap: THREAD_ROW_CAP, ...(input.projectKey === undefined ? {} : { projectFilter: input.projectKey }), diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 52f4b9b1e810..1f557d81db26 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -1,3 +1,4 @@ +import { vi } from "vite-plus/test"; import { ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -399,3 +400,23 @@ describe("makeProjectResolver", () => { expect(windowsResolver("c:/work/app/src")).toEqual({ projectId: appId, title: "App" }); }); }); + +it("formats a retained record once across provider counts and final folding", () => { + const format = vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts"); + try { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + aggregator.add(record()); + for (const provider of ["codex", "claude", "grok"] as const) + aggregator.distinctSessions(provider); + const result = aggregator.finish(); + expect(result.buckets[0]?.day).toBe("2026-08-07"); + expect(format).toHaveBeenCalledOnce(); + } finally { + format.mockRestore(); + } +}); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index c926b49b896b..5c09d2ce2239 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -27,8 +27,8 @@ import { cacheSavingsUsd, cacheWriteUsd, priceUsage, type RateTable } from "./us /** * Formats an instant as a `YYYY-MM-DD` day in `timeZone`. * - * `en-CA` yields ISO-ordered parts, which is why it is used here rather than - * assembling the day from `Date` getters (those are host-local only). + * Numeric parts preserve the requested time zone without depending on the + * locale's punctuation or date ordering. */ export function makeDayFormatter(timeZone: string): (timestampMs: number) => string { let format: Intl.DateTimeFormat; @@ -48,7 +48,12 @@ export function makeDayFormatter(timeZone: string): (timestampMs: number) => str day: "2-digit", }); } - return (timestampMs) => format.format(new Date(timestampMs)); + return (timestampMs) => { + const parts = Object.fromEntries( + format.formatToParts(new Date(timestampMs)).map(({ type, value }) => [type, value]), + ); + return `${parts.year?.padStart(4, "0")}-${parts.month}-${parts.day}`; + }; } const HOUR_MS = 60 * 60 * 1000; @@ -154,6 +159,7 @@ export class UsageAggregator { readonly #recordsByKey = new Map(); readonly #unkeyedRecords: UsageRecord[] = []; readonly #toDay: (timestampMs: number) => string; + readonly #recordDays = new WeakMap(); readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; #duplicatesDropped = 0; @@ -192,6 +198,14 @@ export class UsageAggregator { return inWindow; } + #dayFor(record: UsageRecord): string { + const cached = this.#recordDays.get(record); + if (cached !== undefined) return cached; + const day = this.#toDay(record.timestampMs); + this.#recordDays.set(record, day); + return day; + } + #isInWindow(record: UsageRecord): boolean { if ( this.#hourlyWindow !== null && @@ -201,7 +215,7 @@ export class UsageAggregator { return false; } - const day = this.#toDay(record.timestampMs); + const day = this.#dayFor(record); if ( this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) @@ -215,7 +229,7 @@ export class UsageAggregator { distinctSessions(provider: UsageRecord["provider"]): number { const sessionIds = new Set(); const addSession = (record: UsageRecord): void => { - if (this.#isInWindow(record) && record.provider === provider && record.sessionId.length > 0) { + if (record.provider === provider && this.#isInWindow(record) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } }; @@ -225,7 +239,7 @@ export class UsageAggregator { } #foldRecord(record: UsageRecord, buckets: Map): void { - const day = this.#toDay(record.timestampMs); + const day = this.#dayFor(record); const hourStart = this.#hourlyWindow === null diff --git a/apps/server/src/usage/usageThreads.ts b/apps/server/src/usage/usageThreads.ts index ad72e0a9c85e..544dd0340c49 100644 --- a/apps/server/src/usage/usageThreads.ts +++ b/apps/server/src/usage/usageThreads.ts @@ -348,12 +348,12 @@ function addDailyCosts( function worktreeThreadForCwd( cwd: string, - worktreeToThread: ReadonlyMap, + worktreeToThread: Iterable, ): ThreadRef | undefined { const normalizedCwd = normalizeUsagePath(cwd); let deepest: { readonly pathLength: number; readonly ref: ThreadRef } | undefined; for (const [worktree, ref] of worktreeToThread) { - const normalizedWorktree = normalizeUsagePath(worktree); + const normalizedWorktree = worktree; const prefix = normalizedWorktree.endsWith("/") ? normalizedWorktree : `${normalizedWorktree}/`; if (normalizedCwd !== normalizedWorktree && !normalizedCwd.startsWith(prefix)) continue; if (deepest === undefined || normalizedWorktree.length > deepest.pathLength) { @@ -416,6 +416,10 @@ export function foldThreadRows( options: FoldThreadRowsOptions, ): FoldedThreadRows { const byKey = new Map(); + const worktrees = Array.from( + attribution.worktreeToThread, + ([worktree, ref]) => [normalizeUsagePath(worktree), ref] as const, + ); for (const group of groups) { if ( @@ -428,9 +432,7 @@ export function foldThreadRows( const ref = attribution.sessionToThread.get(group.sessionKey) ?? - (group.cwd.length > 0 - ? worktreeThreadForCwd(group.cwd, attribution.worktreeToThread) - : undefined); + (group.cwd.length > 0 ? worktreeThreadForCwd(group.cwd, worktrees) : undefined); const rowKey = ref === undefined ? JSON.stringify(["session", group.provider, group.projectKey, group.sessionKey])