From ca64d3b3e5e6bb78e8e7bb099541e4e81353ecc7 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 31 Jul 2026 16:03:23 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(desktop):=20NIP-AM=20agent-usage=20UI?= =?UTF-8?q?=20=E2=80=94=20bars,=20range=20tabs,=20focused=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full agent-usage UI feature (stacked on the backend base PR). Merges only after the backend and plumbing phases ship. - `desktop/src/features/agent-usage/`: daily bars chart, 7/30-day range tabs, focused agent view with caveats, hooks, and lib utilities. All state is driven by the `get_agent_usage_series` Tauri command added in the base PR. - Profile panel: agent usage section wired into agent profiles; `UserProfilePanelTabs` and `UserProfilePanelSections` updated. - `AgentsView`/`AgentsScreen`: reorder to surface active agents. - `ProfilePanelContext`: extended for usage navigation state. - `desktop/src/testing/e2eBridge.ts` + `desktop/tests/helpers/bridge.ts`: `get_agent_usage_series` mock handler + fixture types for E2E. - `desktop/tests/e2e/agent-usage.spec.ts`: 19-test Playwright spec covering loading skeleton, empty state, bars, range switching, error/retry, focused view, caveats, and cache-invalidation on refetch. - `desktop/tests/e2e/agent-usage-screenshots.spec.ts`: 3-test screenshot spec. - `desktop/playwright.config.ts`: agent-usage spec added to suite. - `desktop/package.json`: recharts dependency. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 2 + .../src/features/agent-usage/hooks.test.mjs | 346 ++++++ desktop/src/features/agent-usage/hooks.ts | 143 +++ .../agent-usage/lib/agentUsage.test.mjs | 803 +++++++++++++ .../features/agent-usage/lib/agentUsage.ts | 625 ++++++++++ .../agent-usage/ui/AgentUsageDailyBars.tsx | 316 +++++ .../agent-usage/ui/AgentUsageFocusedView.tsx | 431 +++++++ .../agent-usage/ui/AgentUsageRangeTabs.tsx | 211 ++++ .../agent-usage/ui/AgentUsageSection.tsx | 310 +++++ .../src/features/agents/ui/AgentsScreen.tsx | 3 +- desktop/src/features/agents/ui/AgentsView.tsx | 7 + .../features/profile/ui/UserProfilePanel.tsx | 116 +- .../profile/ui/UserProfilePanelSections.tsx | 11 + .../profile/ui/UserProfilePanelTabs.tsx | 23 +- .../profile/ui/UserProfilePanelUtils.test.mjs | 1 + .../profile/ui/UserProfilePanelUtils.ts | 4 +- .../shared/context/ProfilePanelContext.tsx | 2 + desktop/src/testing/e2eBridge.ts | 120 +- .../tests/e2e/agent-usage-screenshots.spec.ts | 366 ++++++ desktop/tests/e2e/agent-usage.spec.ts | 1027 +++++++++++++++++ desktop/tests/helpers/bridge.ts | 71 +- 21 files changed, 4875 insertions(+), 63 deletions(-) create mode 100644 desktop/src/features/agent-usage/hooks.test.mjs create mode 100644 desktop/src/features/agent-usage/hooks.ts create mode 100644 desktop/src/features/agent-usage/lib/agentUsage.test.mjs create mode 100644 desktop/src/features/agent-usage/lib/agentUsage.ts create mode 100644 desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx create mode 100644 desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx create mode 100644 desktop/src/features/agent-usage/ui/AgentUsageRangeTabs.tsx create mode 100644 desktop/src/features/agent-usage/ui/AgentUsageSection.tsx create mode 100644 desktop/tests/e2e/agent-usage-screenshots.spec.ts create mode 100644 desktop/tests/e2e/agent-usage.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b9..d5ab5ba447b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -134,6 +134,8 @@ export default defineConfig({ "**/inbox-live-update.spec.ts", "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", + "**/agent-usage.spec.ts", + "**/agent-usage-screenshots.spec.ts", "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", diff --git a/desktop/src/features/agent-usage/hooks.test.mjs b/desktop/src/features/agent-usage/hooks.test.mjs new file mode 100644 index 00000000000..36c82a05fc9 --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.test.mjs @@ -0,0 +1,346 @@ +/** + * Fake-timer proof for `useLocalDayBoundaries`: verifies it schedules exactly + * one `setTimeout` per local midnight, rebuilding boundaries and rescheduling + * the next fire each time — never `setInterval` (which would drift across DST). + * Uses `node:test`'s `mock.timers` to drive the wall clock deterministically. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { mock } from "node:test"; + +function installDOMShim() { + class EventTargetShim { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((l) => l !== listener), + ); + } + + dispatchEvent(event) { + for (const listener of this.listeners.get(event.type) ?? []) + listener(event); + return true; + } + } + + class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.tagName = tagName; + this.nodeName = tagName.toUpperCase(); + this.nodeType = 1; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.children = []; + this.childNodes = []; + this.style = {}; + this.parentNode = null; + } + + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children.at(-1) ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + child.parentNode = null; + return child; + } + + insertBefore(child, reference) { + if (!reference) return this.appendChild(child); + const index = this.children.indexOf(reference); + if (index < 0) return this.appendChild(child); + this.children.splice(index, 0, child); + this.childNodes.splice(index, 0, child); + child.parentNode = this; + return child; + } + + contains(node) { + return ( + this === node || this.children.some((child) => child.contains(node)) + ); + } + } + + class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + + createElement(tagName) { + return new NodeShim(tagName); + } + + createTextNode(value) { + const node = new NodeShim("#text"); + node.nodeType = 3; + node.nodeValue = value; + return node; + } + + createComment(value) { + const node = new NodeShim("#comment"); + node.nodeType = 8; + node.nodeValue = value; + return node; + } + + get activeElement() { + return null; + } + } + + globalThis.document = new DocumentShim(); + globalThis.HTMLIFrameElement = NodeShim; + globalThis.HTMLElement = NodeShim; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + // React's scheduler uses MessageChannel (native in Node) as a fallback, so + // mocking setTimeout/Date cannot stall commits — rAF just needs to exist. + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.CSS = { escape: (value) => value }; +} + +installDOMShim(); + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { useLocalDayBoundaries } from "./hooks.ts"; + +function Harness({ onBoundaries, range }) { + const boundaries = useLocalDayBoundaries(range); + onBoundaries(boundaries); + return null; +} + +const SEVEN_DAY_RANGE = { kind: "preset", days: 7 }; + +/** Local midnight as unix seconds, the unit the hook emits. */ +function midnight(year, monthIndex, day) { + return Math.floor( + new Date(year, monthIndex, day, 0, 0, 0, 0).getTime() / 1_000, + ); +} + +/** + * Mounts the hook with `initialRange` and calls `run` with a live view: + * `boundaries` = newest emission, `renders` = emission count, + * `render(range)` re-renders, `tick(ms)` advances the mocked clock. + */ +async function withMountedHook(initialRange, run) { + const emissions = []; + const root = createRoot(document.createElement("div")); + const render = async (range) => { + await act(async () => { + root.render( + React.createElement(Harness, { + onBoundaries: (boundaries) => emissions.push(boundaries), + range, + }), + ); + }); + }; + + try { + await render(initialRange); + await run({ + emissions, + get boundaries() { + return emissions.at(-1); + }, + get renders() { + return emissions.length; + }, + render, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + /** Advance the mocked wall clock, flushing any React work it triggers. */ + tick: async (ms) => { + await act(async () => { + mock.timers.tick(ms); + }); + }, + }); + } finally { + await act(async () => { + root.unmount(); + }); + } +} + +/** Runs `fn` with the wall clock frozen one minute before a local midnight. */ +async function atOneMinuteToMidnight(fn) { + mock.timers.enable({ + apis: ["setTimeout", "Date"], + now: new Date(2026, 5, 15, 23, 59, 0).getTime(), + }); + try { + await fn(); + } finally { + mock.timers.reset(); + } +} + +test("useLocalDayBoundaries reschedules across two local-midnight rollovers, rebuilding boundaries each time", async () => { + await atOneMinuteToMidnight(async () => { + await withMountedHook(SEVEN_DAY_RANGE, async (hook) => { + assert.equal( + hook.boundaries.length, + 8, + "7-day window yields 8 boundaries", + ); + const initial = hook.boundaries; + + // Crosses the Jun 16 local midnight. The single scheduled `setTimeout` + // must fire, bump `rolloverTick`, and rebuild the boundary set. + await hook.tick(60_000); + + assert.notDeepEqual( + hook.boundaries, + initial, + "boundaries must rebuild after the first midnight rollover fires", + ); + assert.equal( + hook.boundaries.length, + 8, + "boundary count is unchanged by a rollover", + ); + assert.equal( + hook.boundaries.at(-1), + midnight(2026, 5, 17), + "newest boundary must shift forward to tomorrow of the new window", + ); + const afterFirst = hook.boundaries; + + // Crossing the Jun 17 local midnight only fires if the first rollover's + // effect RESCHEDULED a fresh `setTimeout` rather than going silent. + await hook.tick(24 * 60 * 60 * 1_000); + + assert.notDeepEqual( + hook.boundaries, + afterFirst, + "boundaries must rebuild again after the second rollover, proving the timer rescheduled itself", + ); + assert.equal( + hook.boundaries.at(-1), + midnight(2026, 5, 18), + "newest boundary must shift forward again after the rescheduled rollover", + ); + }); + }); +}); + +test("useLocalDayBoundaries clears its scheduled timeout on unmount (no post-unmount rollover)", async () => { + await atOneMinuteToMidnight(async () => { + await withMountedHook(SEVEN_DAY_RANGE, async (hook) => { + const rendersAtUnmount = hook.renders; + await hook.unmount(); + + // Crossing the midnight the pending timeout targeted must not throw or + // invoke a setState-after-unmount path — `clearTimeout` in the effect's + // cleanup must have already cancelled it. + await hook.tick(60_000); + + assert.equal( + hook.renders, + rendersAtUnmount, + "no render (and no error) after the component unmounted", + ); + }); + }); +}); + +test("useLocalDayBoundaries returns the same boundary array when re-rendered with an equal range literal", async () => { + await withMountedHook({ kind: "preset", days: 7 }, async (hook) => { + // A fresh object literal each render — the memo must key on the range's + // fields, not its identity, or every render would refetch. + await hook.render({ kind: "preset", days: 7 }); + + assert.ok(hook.renders >= 2, "component rendered at least twice"); + assert.equal( + hook.boundaries, + hook.emissions[0], + "an equal range literal must reuse the memoized boundary array", + ); + }); +}); + +test("useLocalDayBoundaries rebuilds when the range changes to a custom range", async () => { + await withMountedHook(SEVEN_DAY_RANGE, async (hook) => { + assert.equal(hook.boundaries.length, 8); + + await hook.render({ + kind: "custom", + startDate: "2026-01-01", + endDate: "2026-01-03", + }); + + assert.equal( + hook.boundaries.length, + 4, + "a 3-day custom range yields 4 boundaries closing the final day", + ); + assert.equal( + hook.boundaries[0], + midnight(2026, 0, 1), + "first boundary opens the requested start date in local time", + ); + }); +}); + +test("useLocalDayBoundaries returns no boundaries for an invalid custom range", async () => { + // Inverted range — `useAgentUsageSeries` gates its query on + // `boundaries.length >= 2`, so this must issue no request rather than one + // the backend would reject. + const invertedRange = { + kind: "custom", + startDate: "2026-03-10", + endDate: "2026-03-01", + }; + await withMountedHook(invertedRange, async (hook) => { + assert.deepEqual(hook.boundaries, []); + }); +}); diff --git a/desktop/src/features/agent-usage/hooks.ts b/desktop/src/features/agent-usage/hooks.ts new file mode 100644 index 00000000000..ebd5e55c5ff --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.ts @@ -0,0 +1,143 @@ +import * as React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + getAgentUsageSeries, + onAgentMetricsChanged, + type AgentUsageSeries, +} from "@/shared/api/tauriArchive"; +import { + buildRangeBoundaries, + DEFAULT_USAGE_RANGE, + deriveUsageIngressTrailing, + msUntilNextLocalMidnight, + type UsageRange, +} from "./lib/agentUsage"; + +/** Root query key for the whole `agent-usage` family — invalidated en masse on any agent-metric change (M4/A13). */ +export const agentUsageQueryKeyRoot = ["agent-usage"] as const; + +/** + * Stable key incorporating the exact boundary set (so a midnight rollover or + * 7d/30d switch produces a new cache entry) and the optional author filter. + */ +export function agentUsageQueryKey( + boundaries: readonly number[], + agentPubkey?: string, +) { + return [ + ...agentUsageQueryKeyRoot, + boundaries.join(","), + agentPubkey ?? null, + ] as const; +} + +/** + * Local-day boundaries for the given range that recompute automatically at + * every local midnight (M4) — no `setInterval` (which would drift across + * DST), just a single scheduled `setTimeout` that reschedules itself each + * time it fires. Split out of {@link useAgentUsageSeries} so the rollover + * mechanics are testable without a `QueryClientProvider`. + * + * A custom range is anchored to explicit dates, so midnight rollover leaves + * it unchanged; the timer still runs so a later switch back to a preset is + * immediately correct. + */ +export function useLocalDayBoundaries(range: UsageRange): number[] { + // Bumped once per local midnight so `boundaries` below recomputes even + // though `range` hasn't changed. + const [rolloverTick, setRolloverTick] = React.useState(0); + + // `rolloverTick` is the only intended dependency: each fire reschedules + // against a freshly computed `Date.now()`, never a fixed interval that + // would drift across DST. + // biome-ignore lint/correctness/useExhaustiveDependencies: rolloverTick is read to reschedule, not to avoid a stale closure + React.useEffect(() => { + const timeoutId = setTimeout(() => { + setRolloverTick((tick) => tick + 1); + }, msUntilNextLocalMidnight()); + return () => clearTimeout(timeoutId); + }, [rolloverTick]); + + // Depend on the range's fields rather than the object so a caller passing a + // fresh literal each render doesn't rebuild boundaries (and refetch) every + // time. `rolloverTick` intentionally forces a recompute at local midnight + // even though it carries no boundary data itself. + const rangeKey = serializeRange(range); + // biome-ignore lint/correctness/useExhaustiveDependencies: rangeKey stands in for `range`; rolloverTick drives recompute, not boundary data + return React.useMemo( + () => buildRangeBoundaries(range), + [rangeKey, rolloverTick], + ); +} + +/** Stable string identity for a range, for memo/query keys. */ +function serializeRange(range: UsageRange): string { + return range.kind === "preset" + ? `preset:${range.days}` + : `custom:${range.startDate}:${range.endDate}`; +} + +/** + * Local NIP-AM usage series for the Agents overview or a single agent's + * profile drill-in. Rebuilds boundaries once per local midnight (M4, no + * polling) and invalidates on `onAgentMetricsChanged` — new archived + * metrics, or a kind-44200 subscription toggle — instead of a refetch + * interval. + * + * An invalid custom range produces no boundaries; the query stays disabled + * rather than issuing a request the backend would reject. + */ +export function useAgentUsageSeries({ + agentPubkey, + range, + enabled = true, +}: { + agentPubkey?: string; + range: UsageRange; + enabled?: boolean; +}) { + const queryClient = useQueryClient(); + const boundaries = useLocalDayBoundaries(range); + + React.useEffect( + () => + onAgentMetricsChanged(() => { + void queryClient.invalidateQueries({ + queryKey: agentUsageQueryKeyRoot, + }); + }), + [queryClient], + ); + + return useQuery({ + queryKey: agentUsageQueryKey(boundaries, agentPubkey), + queryFn: () => + getAgentUsageSeries({ bucketBoundaries: boundaries, agentPubkey }), + enabled: enabled && boundaries.length >= 2, + staleTime: 60_000, + gcTime: 5 * 60_000, + }); +} + +/** + * Trailing text for the profile Info tab's usage ingress row (plan:328). + * + * Owns its own 7-day window deliberately: the row summarises recent usage + * independently of the focused view's 7d/30d selector, so the two must not + * share a query. Returns `undefined` while the query is disabled or still + * loading, which the row renders as no trailing text at all. + * + * `enabled` gates the query off entirely when the row won't render. + */ +export function useUsageIngress( + agentPubkey: string | null, + enabled: boolean, +): string | undefined { + const query = useAgentUsageSeries({ + agentPubkey: agentPubkey ?? undefined, + range: DEFAULT_USAGE_RANGE, + enabled, + }); + return query.data ? deriveUsageIngressTrailing(query.data) : undefined; +} diff --git a/desktop/src/features/agent-usage/lib/agentUsage.test.mjs b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs new file mode 100644 index 00000000000..a0880a7e3e2 --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs @@ -0,0 +1,803 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bigintRatio, + buildCustomDayBoundaries, + buildLocalDayBoundaries, + buildRangeBoundaries, + countRangeDays, + describeRange, + deriveDisplayTotal, + deriveUsageIngressTrailing, + formatCoverageDate, + formatEstimatedCostUsd, + formatLocalDate, + formatTokenCountCompact, + formatTokenCountExact, + isPartialField, + isUnknownField, + MAX_RANGE_DAYS, + msUntilNextLocalMidnight, + parseLocalDate, + parseTokenCount, + sortAgentsByDisplayTotal, + sortModelsByDisplayTotal, + sumKnownBucketTotals, + validateCustomRange, +} from "./agentUsage.ts"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function usageField(overrides = {}) { + return { value: null, incomplete: false, ...overrides }; +} + +function reportedUsage(overrides = {}) { + return { + inputTokens: usageField(), + outputTokens: usageField(), + totalTokens: usageField(), + estimatedCostUsd: usageField(), + ...overrides, + }; +} + +function agentUsage(pubkey, totalTokensValue, overrides = {}) { + return { + agentPubkey: pubkey, + usage: reportedUsage({ + totalTokens: usageField({ value: totalTokensValue }), + }), + buckets: [], + models: [], + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +function modelUsage(model, totalTokensValue, overrides = {}) { + return { + harness: null, + model, + usage: reportedUsage({ + totalTokens: usageField({ value: totalTokensValue }), + }), + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +// ── buildLocalDayBoundaries ────────────────────────────────────────────────── + +test("buildLocalDayBoundaries yields days+1 strictly increasing boundaries ending at tomorrow's local midnight", () => { + const now = new Date(2026, 5, 15, 14, 30, 0); // June 15, 2026, 14:30 local + for (const days of [7, 30]) { + const boundaries = buildLocalDayBoundaries(days, now); + assert.equal(boundaries.length, days + 1); + assertStrictlyIncreasing(boundaries); + assert.equal( + boundaries.at(-1), + Math.floor(new Date(2026, 5, 16, 0, 0, 0, 0).getTime() / 1000), + ); + } + // Result must be independent of time-of-day within the reference date. + assert.deepEqual( + buildLocalDayBoundaries(7, new Date(2026, 5, 15, 0, 0, 1)), + buildLocalDayBoundaries(7, new Date(2026, 5, 15, 23, 59, 59)), + ); +}); + +// ── TZ helpers ─────────────────────────────────────────────────────────────── +// `process.env.TZ` is read on every `Date` field access (not pinned at start), +// so mutating it inline is safe; `finally` restores the original value. + +function withTz(tz, fn) { + const original = process.env.TZ; + process.env.TZ = tz; + try { + return fn(); + } finally { + if (original === undefined) delete process.env.TZ; + else process.env.TZ = original; + } +} + +function assertStrictlyIncreasing(boundaries) { + for (let i = 1; i < boundaries.length; i++) { + assert.ok( + boundaries[i] > boundaries[i - 1], + `boundary ${i} (${boundaries[i]}) must exceed boundary ${i - 1} (${boundaries[i - 1]})`, + ); + } +} + +test("buildLocalDayBoundaries stays strictly increasing across DST transitions", () => { + const cases = [ + // Mar 12, after the Mar 10 spring-forward. + ["America/New_York", new Date(2024, 2, 12, 10, 0, 0), "Daylight"], + // Nov 6, after the Nov 3 fall-back. + ["America/New_York", new Date(2024, 10, 6, 10, 0, 0), "Standard"], + ]; + for (const [tz, now, marker] of cases) { + withTz(tz, () => { + assert.ok( + now.toString().includes(marker), + `sanity: expected ${marker} time for ${now.toString()}`, + ); + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + }); + } +}); + +test("buildLocalDayBoundaries emits days+1 distinct midnights across Pacific/Apia's skipped 2011-12-30 civil date", () => { + withTz("Pacific/Apia", () => { + // Samoa skipped 2011-12-30 when it crossed the Date Line (UTC-11→UTC+13); + // Dec 29 was immediately followed by Dec 31. Boundaries must not collapse + // the two distinct local midnights into one duplicate. + const now = new Date(2011, 11, 31, 12, 0, 0); + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + for (const [label, date] of [ + ["Dec 29", new Date(2011, 11, 29, 0, 0, 0)], + ["Dec 31", new Date(2011, 11, 31, 0, 0, 0)], + ]) { + assert.ok( + boundaries.includes(Math.floor(date.getTime() / 1000)), + `expected ${label} local midnight as a boundary`, + ); + } + + const boundaries30 = buildLocalDayBoundaries(30, now); + assert.equal(boundaries30.length, 31); + assertStrictlyIncreasing(boundaries30); + }); +}); + +// ── msUntilNextLocalMidnight ───────────────────────────────────────────────── + +test("msUntilNextLocalMidnight returns exact gap to the next local midnight, always positive", () => { + // Basic: 23:00 → 1h gap; exactly at midnight → 24h gap. + assert.equal( + msUntilNextLocalMidnight(new Date(2026, 5, 15, 23, 0, 0, 0)), + 60 * 60 * 1000, + ); + assert.equal( + msUntilNextLocalMidnight(new Date(2026, 5, 15, 0, 0, 0, 0)), + 24 * 60 * 60 * 1000, + ); + // DST/date-line TZs: must be positive and land exactly on local midnight. + for (const [tz, now] of [ + ["America/New_York", new Date(2024, 2, 9, 23, 30, 0)], // eve of spring-forward + ["Australia/Lord_Howe", new Date(2024, 9, 5, 23, 45, 0)], // eve of 30-min shift + ["Pacific/Apia", new Date(2011, 11, 29, 23, 0, 0)], // eve of the skipped date + ]) { + withTz(tz, () => { + const ms = msUntilNextLocalMidnight(now); + assert.ok(ms > 0, `${tz}: expected positive ms, got ${ms}`); + const landed = new Date(now.getTime() + ms); + assert.equal(landed.getHours(), 0, `${tz}: expected local midnight`); + assert.equal(landed.getMinutes(), 0, `${tz}: expected :00 minutes`); + }); + } +}); + +// ── parseTokenCount ────────────────────────────────────────────────────────── + +test("parseTokenCount parses decimal strings to bigint, preserving u64 precision", () => { + for (const [wire, expected] of [ + ["12345", 12345n], + ["0", 0n], + // Past Number.MAX_SAFE_INTEGER — a Number round-trip would lose digits. + ["18446744073709551615", 18446744073709551615n], + ]) { + assert.equal(parseTokenCount(wire), expected, wire); + } +}); + +test("parseTokenCount fails closed on null or malformed wire data instead of throwing", () => { + for (const malformed of [null, "", "-1", "1.5", "abc", "1e10", " 1", "1 "]) { + assert.equal( + parseTokenCount(malformed), + null, + `expected null for ${JSON.stringify(malformed)}`, + ); + } +}); + +// ── Formatters ────────────────────────────────────────────────────────────── + +test("formatTokenCountCompact abbreviates by magnitude and keeps the sign", () => { + for (const [count, expected] of [ + [999n, "999"], + [1_234n, "1.2K"], + [1_000_000n, "1M"], + [1_500_000_000n, "1.5B"], + [-1_234n, "-1.2K"], + ]) { + assert.equal(formatTokenCountCompact(count), expected); + } +}); + +test("formatTokenCountExact renders full grouped digits, never abbreviated", () => { + assert.equal(formatTokenCountExact(1_234_567n), "1,234,567"); + assert.equal(formatTokenCountExact(0n), "0"); +}); + +test("formatEstimatedCostUsd renders two-decimal USD currency", () => { + assert.equal(formatEstimatedCostUsd(1.5), "$1.50"); + assert.equal(formatEstimatedCostUsd(0), "$0.00"); +}); + +test("formatCoverageDate renders unknown for null and omits the year for real timestamps", () => { + assert.equal(formatCoverageDate(null), "unknown"); + const unixSeconds = 1_737_849_600; // 2025-01-26T00:00:00Z + const localDate = new Date(unixSeconds * 1000); + const formatted = formatCoverageDate(unixSeconds); + assert.match(formatted, new RegExp(`\\b${localDate.getDate()}\\b`)); + assert.doesNotMatch(formatted, new RegExp(`${localDate.getFullYear()}`)); +}); + +// ── bigintRatio ────────────────────────────────────────────────────────────── + +test("bigintRatio computes bounded ratios without losing bigint precision", () => { + const whole = 18_446_744_073_709_551_614n; // largest even value near u64::MAX + assert.equal(bigintRatio(whole / 2n, whole), 0.5); + for (const [part, w, expected] of [ + [5n, 0n, 0], + [5n, -10n, 0], + [-5n, 100n, 0], + [200n, 100n, 1], + ]) { + assert.equal(bigintRatio(part, w), expected, `${part}/${w}`); + } +}); + +// ── deriveDisplayTotal ──────────────────────────────────────────────────────── + +test("deriveDisplayTotal classifies each usage shape, failing closed on a half-known split", () => { + const cases = [ + [ + "a reported total is exact", + { inputTokens: "800", outputTokens: "200", totalTokens: "1100" }, + { kind: "exact", value: 1100n, partial: false }, + ], + [ + "an exact total flagged incomplete carries partial", + { totalTokens: ["900", true] }, + { kind: "exact", value: 900n, partial: true }, + ], + [ + "a null total with both i/o known is approximate", + { inputTokens: "800", outputTokens: "200" }, + { kind: "approximate", value: 1000n, partial: false }, + ], + [ + "an incomplete i/o field makes the approximation partial", + { inputTokens: ["800", true], outputTokens: "200" }, + { kind: "approximate", value: 1000n, partial: true }, + ], + // Fail-closed: half of a split is never enough to publish a total. + [ + "input alone is unknown", + { inputTokens: "500" }, + { kind: "unknown", value: null, partial: false }, + ], + [ + "output alone is unknown", + { outputTokens: "300" }, + { kind: "unknown", value: null, partial: false }, + ], + [ + "no reported field at all is unknown", + {}, + { kind: "unknown", value: null, partial: false }, + ], + ]; + + for (const [label, fields, expected] of cases) { + const usage = reportedUsage( + Object.fromEntries( + Object.entries(fields).map(([name, field]) => { + const [value, incomplete = false] = Array.isArray(field) + ? field + : [field]; + return [name, usageField({ value, incomplete })]; + }), + ), + ); + const dt = deriveDisplayTotal(usage); + assert.deepEqual( + { kind: dt.kind, value: dt.value, partial: dt.partial }, + expected, + label, + ); + } +}); + +// ── sortAgentsByDisplayTotal / sortModelsByDisplayTotal ───────────────────── + +/** An agent whose total is null but whose i/o sum is known — approximate tier. */ +function approxAgent(pubkey, input, output) { + return agentUsage(pubkey, null, { + usage: reportedUsage({ + inputTokens: usageField({ value: input }), + outputTokens: usageField({ value: output }), + }), + }); +} + +test("sortAgentsByDisplayTotal ranks by tier first, then value descending, then pubkey", () => { + const cases = [ + [ + "known exact totals sort descending", + [ + agentUsage("a1", "100"), + agentUsage("a2", "300"), + agentUsage("a3", "200"), + ], + ["a2", "a3", "a1"], + ], + [ + // exact(50) < approximate(18000) numerically, but the exact tier wins. + "an exact tier outranks a larger approximate total", + [approxAgent("approx", "9000", "9000"), agentUsage("exact", "50")], + ["exact", "approx"], + ], + [ + "an approximate tier outranks an unknown total", + [agentUsage("unknown", null), approxAgent("approx", "100", "50")], + ["approx", "unknown"], + ], + [ + "a mixed population lands in exact → approximate → unknown order", + [ + agentUsage("u1", null), + agentUsage("e1", "100"), + approxAgent("a1", "400", "100"), + agentUsage("u2", null), + agentUsage("e2", "300"), + approxAgent("a2", "150", "50"), + ], + ["e2", "e1", "a1", "a2", "u1", "u2"], + ], + [ + "equal totals and unknown totals alike tiebreak by pubkey", + [agentUsage("b", "100"), agentUsage("a", "100"), agentUsage("c", null)], + ["a", "b", "c"], + ], + ]; + + for (const [label, agents, expected] of cases) { + assert.deepEqual( + sortAgentsByDisplayTotal(agents).map((a) => a.agentPubkey), + expected, + label, + ); + } +}); + +test("sortModelsByDisplayTotal ranks by tier, then tiebreaks harness before model with nulls last", () => { + const equalTotals = [ + modelUsage(null, "100"), + modelUsage("gpt-4", "100"), + modelUsage("claude", "100"), + ]; + assert.deepEqual( + sortModelsByDisplayTotal(equalTotals).map((m) => m.model), + ["claude", "gpt-4", null], + "a null model ('Unknown model') sorts last among ties", + ); + + const sameModelManyHarnesses = [ + modelUsage("m", "100", { harness: "z-harness" }), + modelUsage("m", "100", { harness: "a-harness" }), + modelUsage("m", "100", { harness: null }), + ]; + assert.deepEqual( + sortModelsByDisplayTotal(sameModelManyHarnesses).map((m) => m.harness), + ["a-harness", "z-harness", null], + "the same model via several harnesses stays distinct, in harness order", + ); + + const mixedTiers = [ + modelUsage("big-approx", null, { + usage: reportedUsage({ + inputTokens: usageField({ value: "9999" }), + outputTokens: usageField({ value: "9999" }), + }), + }), + modelUsage("small-model", "10"), + ]; + assert.deepEqual( + sortModelsByDisplayTotal(mixedTiers).map((m) => m.model), + ["small-model", "big-approx"], + "the exact tier outranks a larger approximate total", + ); +}); + +// ── isPartialField / isUnknownField ────────────────────────────────────────── + +test("isPartialField and isUnknownField classify usage fields correctly", () => { + assert.equal( + isPartialField(usageField({ value: "10", incomplete: true })), + true, + ); + assert.equal( + isPartialField(usageField({ value: "10", incomplete: false })), + false, + ); + assert.equal( + isPartialField(usageField({ value: null, incomplete: true })), + false, + ); + assert.equal(isUnknownField(usageField({ value: null })), true); + assert.equal(isUnknownField(usageField({ value: "0" })), false); +}); + +// ── sumKnownBucketTotals ────────────────────────────────────────────────────── + +function bucket(overrides = {}) { + return { + start: 1_700_000_000, + end: 1_700_086_400, + usage: reportedUsage(), + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +/** A report-bearing bucket whose total is exactly `value`. */ +function exactBucket(value, incomplete = false) { + return bucket({ + usage: reportedUsage({ totalTokens: usageField({ value, incomplete }) }), + reportCount: 1, + }); +} + +/** A report-bearing bucket with no total but a known i/o split. */ +function approxBucket(input, output, incomplete = false) { + return bucket({ + usage: reportedUsage({ + inputTokens: usageField({ value: input, incomplete }), + outputTokens: usageField({ value: output }), + }), + reportCount: 1, + }); +} + +/** A report-bearing bucket with nothing countable at all. */ +function unknownBucket() { + return bucket({ + usage: reportedUsage(), + reportCount: 1, + hasUnknownUsage: true, + }); +} + +test("sumKnownBucketTotals aggregates buckets without erasing or fabricating a subtotal", () => { + const cases = [ + [ + "a window of empty buckets is unknown, not zero", + [bucket({ reportCount: 0 }), bucket({ reportCount: 0 })], + { kind: "unknown", value: null, partial: false }, + ], + [ + "fully-known totals sum exactly", + [exactBucket("100"), exactBucket("200")], + { kind: "exact", value: 300n, partial: false }, + ], + [ + "an incomplete (known lower-bound) total marks the sum partial", + [exactBucket("100", true), exactBucket("200")], + { kind: "exact", value: 300n, partial: true }, + ], + [ + "an unknown sibling preserves the known exact subtotal as partial", + [exactBucket("100"), unknownBucket()], + { kind: "exact", value: 100n, partial: true }, + ], + [ + "all-null totals with known i/o sum to an approximation", + [approxBucket("800", "200"), approxBucket("400", "100")], + { kind: "approximate", value: 1500n, partial: false }, + ], + [ + "an incomplete i/o field marks the approximation partial", + [approxBucket("800", "200", true), approxBucket("400", "100")], + { kind: "approximate", value: 1500n, partial: true }, + ], + [ + "mixed exact and approximate buckets aggregate as approximate", + [exactBucket("1000"), approxBucket("300", "200")], + { kind: "approximate", value: 1500n, partial: false }, + ], + [ + "an unknown sibling preserves the known approximate subtotal as partial", + [approxBucket("400", "100"), unknownBucket()], + { kind: "approximate", value: 500n, partial: true }, + ], + [ + "a lone report-bearing bucket with no countable field is unknown", + [unknownBucket()], + { kind: "unknown", value: null, partial: false }, + ], + [ + "a window where no report-bearing bucket has a display value is unknown", + [unknownBucket(), unknownBucket()], + { kind: "unknown", value: null, partial: false }, + ], + ]; + + for (const [label, buckets, expected] of cases) { + const result = sumKnownBucketTotals(buckets); + assert.deepEqual( + { kind: result.kind, value: result.value, partial: result.partial }, + expected, + label, + ); + } +}); + +// ── deriveUsageIngressTrailing ──────────────────────────────────────────────── + +function baseSeries(overrides = {}) { + return { + collectionEnabled: true, + buckets: [], + agents: [], + coverage: { + firstArchivedAt: null, + firstReportedAt: null, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: null, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: null, + ...overrides, + }; +} + +test("deriveUsageIngressTrailing summarizes the series, marking a known lower bound Partial", () => { + const io = (input, output, incomplete = false) => + reportedUsage({ + inputTokens: usageField({ value: input, incomplete }), + outputTokens: usageField({ value: output }), + }); + + const cases = [ + ["collection disabled", { collectionEnabled: false }, "Collection off"], + ["collection on with no agents", { agents: [] }, "No recent data"], + [ + "a known non-partial total", + { agents: [agentUsage("a", "1500")] }, + "1.5K", + ], + [ + "a total that is only a known lower bound", + { + agents: [ + agentUsage("a", null, { + usage: reportedUsage({ + totalTokens: usageField({ value: "1500", incomplete: true }), + }), + }), + ], + }, + "1.5K · Partial", + ], + [ + "i/o known but no total", + { agents: [agentUsage("a", null, { usage: io("800", "200") })] }, + "Input/output reported", + ], + [ + "i/o known but one field incomplete", + { agents: [agentUsage("a", null, { usage: io("800", "200", true) })] }, + "Input/output reported · Partial", + ], + // Fail-closed: an agent present with nothing countable is not "0". + [ + "every usage field unknown", + { agents: [agentUsage("a", null)] }, + "No recent data", + ], + ]; + + for (const [label, overrides, expected] of cases) { + assert.equal( + deriveUsageIngressTrailing(baseSeries(overrides)), + expected, + label, + ); + } +}); + +// ── Custom-range parsing, validation, and boundary construction ────────────── + +test("parseLocalDate resolves a YYYY-MM-DD string to local midnight, not UTC midnight", () => { + withTz("America/New_York", () => { + const parsed = parseLocalDate("2026-03-15"); + assert.notEqual(parsed, null); + // `new Date("2026-03-15")` is UTC midnight = Mar 14 20:00 in New York. + assert.equal(parsed.getFullYear(), 2026); + assert.equal(parsed.getMonth(), 2); + assert.equal(parsed.getDate(), 15); + assert.equal(parsed.getHours(), 0); + assert.notEqual(parsed.getTime(), new Date("2026-03-15").getTime()); + }); +}); + +test("parseLocalDate rejects malformed input and dates that do not exist", () => { + for (const value of [ + "", + "x", + "2026-3-15", + "15/03/2026", + "2026-03-15T00:00", + // `new Date(2026, 1, 30)` silently normalizes to Mar 2 — querying the + // wrong civil day. The guard must reject these outright. + "2026-02-30", + "2026-13-01", + "2026-00-10", + "2026-02-29", // not a leap year + ]) { + assert.equal(parseLocalDate(value), null, `expected null for ${value}`); + } + assert.notEqual( + parseLocalDate("2024-02-29"), + null, + "a leap day in a leap year is valid", + ); +}); + +test("formatLocalDate round-trips through parseLocalDate", () => { + withTz("America/New_York", () => { + for (const value of ["2026-01-01", "2026-03-08", "2026-12-31"]) { + assert.equal(formatLocalDate(parseLocalDate(value)), value); + } + }); +}); + +test("countRangeDays counts civil days and returns null for inverted or malformed ranges", () => { + assert.equal(countRangeDays("2026-05-04", "2026-05-04"), 1, "inclusive"); + withTz("America/New_York", () => { + // Mar 8 2026 is spring-forward: 3 civil days, not 3 × 24h. + assert.equal(countRangeDays("2026-03-07", "2026-03-09"), 3); + }); + assert.ok( + countRangeDays("1900-01-01", "2100-01-01") > MAX_RANGE_DAYS, + "an absurd range reports over-cap rather than walking it", + ); + assert.equal(countRangeDays("2026-05-10", "2026-05-01"), null); + assert.equal(countRangeDays("nope", "2026-05-01"), null); + assert.equal(countRangeDays("2026-05-01", "2026-02-30"), null); +}); + +test("validateCustomRange accepts the cap exactly and rejects over-cap, inverted, and malformed ranges", () => { + // 2024 is a leap year: Jan 1 – Dec 31 inclusive is exactly 366 civil days. + assert.deepEqual(validateCustomRange("2024-01-01", "2024-12-31"), { + ok: true, + days: MAX_RANGE_DAYS, + }); + const overCap = validateCustomRange("2024-01-01", "2025-01-01"); + assert.equal(overCap.ok, false); + assert.match(overCap.message, /366 days or fewer/); + const inverted = validateCustomRange("2026-05-10", "2026-05-01"); + assert.equal(inverted.ok, false); + assert.match(inverted.message, /on or before/); + for (const [start, end] of [ + ["", "2026-05-01"], + ["2026-05-01", ""], + ["2026-02-30", "2026-05-01"], + ]) { + const r = validateCustomRange(start, end); + assert.equal(r.ok, false); + assert.match(r.message, /start and an end date/); + } +}); + +test("buildCustomDayBoundaries returns days+1 strictly increasing boundaries closing the final day", () => { + withTz("America/New_York", () => { + // Three-day range: 4 boundaries. + const boundaries = buildCustomDayBoundaries("2026-05-01", "2026-05-03"); + assert.equal(boundaries.length, 4); + assertStrictlyIncreasing(boundaries); + assert.equal( + boundaries[0], + Math.floor(new Date(2026, 4, 1).getTime() / 1_000), + ); + assert.equal( + boundaries.at(-1), + Math.floor(new Date(2026, 4, 4).getTime() / 1_000), + "final boundary opens the day after the requested end date", + ); + // Single-day range: exactly 2 boundaries. + const single = buildCustomDayBoundaries("2026-05-04", "2026-05-04"); + assert.equal(single.length, 2); + assertStrictlyIncreasing(single); + }); +}); + +test("buildCustomDayBoundaries stays strictly increasing across a spring-forward DST transition", () => { + withTz("America/New_York", () => { + const boundaries = buildCustomDayBoundaries("2026-03-06", "2026-03-10"); + assert.equal(boundaries.length, 6); + assertStrictlyIncreasing(boundaries); + }); +}); + +test("buildCustomDayBoundaries emits no duplicate boundary across a skipped civil date", () => { + withTz("Pacific/Apia", () => { + // 2011-12-30 does not exist in Apia (date-line move): distinct midnights required. + const boundaries = buildCustomDayBoundaries("2011-12-28", "2011-12-31"); + assertStrictlyIncreasing(boundaries); + assert.equal(new Set(boundaries).size, boundaries.length); + }); +}); + +test("buildCustomDayBoundaries produces the maximum boundary count at the cap", () => { + withTz("America/New_York", () => { + const boundaries = buildCustomDayBoundaries("2024-01-01", "2024-12-31"); + assert.equal(boundaries.length, MAX_RANGE_DAYS + 1); + assertStrictlyIncreasing(boundaries); + }); +}); + +test("buildCustomDayBoundaries returns no boundaries for a range the picker rejects", () => { + assert.deepEqual(buildCustomDayBoundaries("2026-05-10", "2026-05-01"), []); + assert.deepEqual(buildCustomDayBoundaries("2024-01-01", "2025-01-01"), []); + assert.deepEqual(buildCustomDayBoundaries("", ""), []); +}); + +test("buildRangeBoundaries delegates to buildLocalDayBoundaries for presets and buildCustomDayBoundaries for custom", () => { + const now = new Date(2026, 5, 15, 12, 0, 0); + for (const days of [1, 7, 30]) { + assert.deepEqual( + buildRangeBoundaries({ kind: "preset", days }, now), + buildLocalDayBoundaries(days, now), + `preset ${days}d must not diverge from the shared day walk`, + ); + } + // 1-day preset: exactly 2 boundaries, today's and tomorrow's local midnight. + const oneDayBounds = buildRangeBoundaries({ kind: "preset", days: 1 }, now); + assert.equal(oneDayBounds.length, 2); + assert.equal( + oneDayBounds[0], + Math.floor(new Date(2026, 5, 15).getTime() / 1_000), + ); + assert.equal( + oneDayBounds[1], + Math.floor(new Date(2026, 5, 16).getTime() / 1_000), + ); + // Custom range delegates to buildCustomDayBoundaries. + assert.deepEqual( + buildRangeBoundaries({ + kind: "custom", + startDate: "2026-05-01", + endDate: "2026-05-03", + }), + buildCustomDayBoundaries("2026-05-01", "2026-05-03"), + ); +}); + +test("describeRange renders preset copy and custom date spans", () => { + assert.equal(describeRange({ kind: "preset", days: 1 }), "the last day"); + assert.equal(describeRange({ kind: "preset", days: 7 }), "the last 7 days"); + assert.equal(describeRange({ kind: "preset", days: 30 }), "the last 30 days"); + const custom = describeRange({ + kind: "custom", + startDate: "2026-05-01", + endDate: "2026-05-03", + }); + assert.match(custom, /2026/); + assert.match(custom, /–/); +}); diff --git a/desktop/src/features/agent-usage/lib/agentUsage.ts b/desktop/src/features/agent-usage/lib/agentUsage.ts new file mode 100644 index 00000000000..41fe276d6f4 --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.ts @@ -0,0 +1,625 @@ +//! Frontend-owned local-day boundary construction, bigint-safe token +//! handling, and truthful-state derivation for the NIP-AM local agent usage +//! feature. +//! +//! Rust request validation (`agent_usage.rs::validate_request`) only bounds +//! query span and shape — per M5, the trusted frontend is the single source +//! of local-midnight civil-day construction. Every consumer of +//! `AgentUsageSeriesRequest.bucketBoundaries` must build them here. + +import type { + AgentUsage, + AgentUsageModel, + AgentUsageSeries, + AgentUsageSeriesBucket, + CostField, + UsageField, +} from "@/shared/api/tauriArchive"; + +// ── Local-day boundary construction (M5, A9) ───────────────────────────────── + +export type UsageWindowDays = number; + +/** + * A selected usage window. Preset ranges are a trailing day count ending + * today; a custom range is an explicit inclusive local-date pair chosen in + * the picker. + */ +export type UsageRange = + | { kind: "preset"; days: 1 | 7 | 30 } + | { kind: "custom"; startDate: string; endDate: string }; + +export const DEFAULT_USAGE_RANGE: UsageRange = { kind: "preset", days: 7 }; + +/** + * Largest number of daily buckets a range may cover — one leap year. Mirrors + * `MAX_BOUNDARIES = 367` (bucket count + 1) in + * `desktop/src-tauri/src/archive/agent_usage.rs`. The picker clamps to this + * so the backend's fail-closed arity check is never the UX error path. + */ +export const MAX_RANGE_DAYS = 366; + +const DISTINCT_MIDNIGHT_MAX_STEP = 3; + +/** + * The local midnight strictly before `from` (which must itself be a local + * midnight), found via `Date#setDate` day-arithmetic so ordinary DST + * transitions land on the correct calendar day. `Date#setDate` normalizes a + * *nonexistent* local date (a full civil day dropped by a date-line move, + * e.g. `Pacific/Apia`'s 2011-12-30) forward to the next real one, which can + * renormalize back to `from` itself — so this widens the step by one + * calendar day at a time until it actually lands on a distinct instant. + */ +function previousDistinctLocalMidnight(from: Date): Date { + let probe = from; + for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) { + probe = new Date(from); + probe.setDate(probe.getDate() - step); + probe.setHours(0, 0, 0, 0); + if (probe.getTime() !== from.getTime()) return probe; + } + return probe; +} + +/** The local midnight strictly after `from`; see {@link previousDistinctLocalMidnight}. */ +function nextDistinctLocalMidnight(from: Date): Date { + let probe = from; + for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) { + probe = new Date(from); + probe.setDate(probe.getDate() + step); + probe.setHours(0, 0, 0, 0); + if (probe.getTime() !== from.getTime()) return probe; + } + return probe; +} + +/** + * Build `days + 1` exact local-midnight Unix-second boundaries ending at the + * start of tomorrow's local day, covering the trailing `days` calendar days + * (today plus `days - 1` prior days). + * + * Walks to each boundary's *distinct* local midnight one civil day at a + * time (never independent `Date#setDate` offsets from one shared base date, + * and never `N * 86_400`), so boundaries stay correct across DST + * transitions — including 30-minute offset zones (e.g. Lord Howe Island), + * where a "day" is 23.5h or 24.5h — and across a skipped local civil date + * (e.g. `Pacific/Apia`'s 2011 date-line move), where independently offsetting + * from one base date would normalize the nonexistent date forward and emit + * a duplicate boundary. A skipped date instead produces one interval + * spanning the elapsed real time between the two surviving distinct + * midnights (which can exceed the ordinary 24h, up to the 48h band + * `validate_request`'s `MAX_INTERVAL_SECS` (A9) admits) rather than a + * duplicate. `referenceNow` is injectable for deterministic tests and the + * midnight-rollover timer (M4). + */ +export function buildLocalDayBoundaries( + days: UsageWindowDays, + referenceNow: Date = new Date(), +): number[] { + const todayMidnight = new Date(referenceNow); + todayMidnight.setHours(0, 0, 0, 0); + + const tomorrowMidnight = nextDistinctLocalMidnight(todayMidnight); + + // Oldest boundary is `days - 1` distinct local midnights before today's; + // the window covers today plus the (days - 1) preceding calendar days. + const priorMidnights: Date[] = []; + let cursor = todayMidnight; + for (let i = 0; i < days - 1; i++) { + cursor = previousDistinctLocalMidnight(cursor); + priorMidnights.push(cursor); + } + priorMidnights.reverse(); + + return [...priorMidnights, todayMidnight, tomorrowMidnight].map((d) => + Math.floor(d.getTime() / 1_000), + ); +} + +/** + * Milliseconds until the next local midnight after `referenceNow`, for the + * single-`setTimeout` rollover (M4). Recompute and reschedule each time the + * timer fires — never use `setInterval`, which drifts across DST. + */ +export function msUntilNextLocalMidnight( + referenceNow: Date = new Date(), +): number { + const nextMidnight = new Date(referenceNow); + nextMidnight.setHours(24, 0, 0, 0); + return nextMidnight.getTime() - referenceNow.getTime(); +} + +/** + * Local midnight opening the civil day named by a `YYYY-MM-DD` string. + * Parsed field-wise into the local zone — never `new Date("YYYY-MM-DD")`, + * which JS parses as *UTC* midnight and so lands on the previous civil day + * for every negative-offset zone. + * + * Returns `null` for a malformed string or a field triple that isn't a real + * calendar date (e.g. `2026-02-30`), which `Date` would silently roll forward. + */ +export function parseLocalDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (match === null) return null; + const [year, month, day] = [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + ]; + const parsed = new Date(year, month - 1, day); + parsed.setHours(0, 0, 0, 0); + // Reject a rolled-forward nonexistent date. A civil date genuinely skipped + // by a date-line move still normalizes to a different day-of-month, so it + // is rejected here too rather than silently querying the wrong day. + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day + ) { + return null; + } + return parsed; +} + +/** Local `YYYY-MM-DD` for a date, for round-tripping through the picker's ``. */ +export function formatLocalDate(date: Date): string { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Number of distinct local civil days in the inclusive `[startDate, endDate]` + * range, or `null` if either date is malformed or the range is inverted. + * Counts by walking distinct local midnights, so it agrees exactly with the + * boundary count {@link buildRangeBoundaries} produces across DST and skipped + * civil dates. Stops counting past {@link MAX_RANGE_DAYS} so an absurd range + * can't spin — callers treat an over-cap result as a validation failure. + */ +export function countRangeDays( + startDate: string, + endDate: string, +): number | null { + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + if (start === null || end === null) return null; + if (start.getTime() > end.getTime()) return null; + + let days = 1; + let cursor = start; + while (cursor.getTime() < end.getTime()) { + cursor = nextDistinctLocalMidnight(cursor); + days += 1; + if (days > MAX_RANGE_DAYS) return days; + } + return days; +} + +/** + * Validation result for a custom range, carrying the human-facing reason so + * the picker can surface it instead of letting a rejected request surface a + * raw Rust error string. + */ +export type RangeValidation = + | { ok: true; days: number } + | { ok: false; message: string }; + +/** Validate a custom range against the picker's contract: real dates, ordered, within one year. */ +export function validateCustomRange( + startDate: string, + endDate: string, +): RangeValidation { + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + if (start === null || end === null) { + return { ok: false, message: "Enter both a start and an end date." }; + } + if (start.getTime() > end.getTime()) { + return { ok: false, message: "Start date must be on or before end date." }; + } + const days = countRangeDays(startDate, endDate); + if (days === null) { + return { ok: false, message: "Enter both a start and an end date." }; + } + if (days > MAX_RANGE_DAYS) { + return { + ok: false, + message: `Pick a range of ${MAX_RANGE_DAYS} days or fewer.`, + }; + } + return { ok: true, days }; +} + +/** + * Local-midnight boundaries covering the inclusive civil-day range + * `[startDate, endDate]` — `days + 1` entries, ending at the midnight that + * closes `endDate`. Walks distinct local midnights exactly like + * {@link buildLocalDayBoundaries}, so DST transitions, 30-minute-offset + * zones, and skipped civil dates behave identically. + * + * Returns `[]` for a range that fails {@link validateCustomRange}, so a + * malformed or over-cap range yields no query rather than a rejected one. + */ +export function buildCustomDayBoundaries( + startDate: string, + endDate: string, +): number[] { + const validation = validateCustomRange(startDate, endDate); + if (!validation.ok) return []; + + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + if (start === null || end === null) return []; + + const midnights: Date[] = [start]; + let cursor = start; + while (cursor.getTime() < end.getTime()) { + cursor = nextDistinctLocalMidnight(cursor); + midnights.push(cursor); + } + // Close the final civil day so the last bucket is end-exclusive. + midnights.push(nextDistinctLocalMidnight(cursor)); + + return midnights.map((d) => Math.floor(d.getTime() / 1_000)); +} + +/** + * Boundaries for any {@link UsageRange}. The single entry point the query + * layer uses, so presets and custom ranges cannot diverge in how civil days + * are constructed. + */ +export function buildRangeBoundaries( + range: UsageRange, + referenceNow: Date = new Date(), +): number[] { + return range.kind === "preset" + ? buildLocalDayBoundaries(range.days, referenceNow) + : buildCustomDayBoundaries(range.startDate, range.endDate); +} + +/** + * Human-facing label for the window, used in empty-state and a11y copy. + * Phrased to read after "for" — "for the last 7 days", "for Jan 1, 2026 – + * Feb 1, 2026" — so both range kinds fit the same sentence. + */ +export function describeRange(range: UsageRange): string { + if (range.kind === "preset") { + return range.days === 1 ? "the last day" : `the last ${range.days} days`; + } + return `${formatRangeEndpoint(range.startDate)} – ${formatRangeEndpoint(range.endDate)}`; +} + +/** Short, year-bearing display for a custom endpoint; falls back to the raw string if unparseable. */ +function formatRangeEndpoint(value: string): string { + const parsed = parseLocalDate(value); + if (parsed === null) return value; + return parsed.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +// ── Bigint-safe token parsing/formatting ───────────────────────────────────── + +/** + * Parse a decimal token-count string to `bigint`, fail-closed. The wire + * sends token counters as decimal strings specifically so the full valid + * `u64` range survives the Tauri boundary — never round-trip through + * `Number(...)`, which loses precision above 2^53. + * + * Returns `null` for a null/missing value or a string that isn't a plain + * non-negative decimal integer (defensive: malformed wire data becomes + * "unknown", not a thrown parse error that would crash the panel). + */ +export function parseTokenCount(value: string | null): bigint | null { + if (value === null) return null; + if (!/^\d+$/.test(value)) return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +/** Compact display, e.g. `1234` -> "1.2K", `1_000_000` -> "1M". Never lossy for exact copy — use `formatTokenCountExact` for that. */ +export function formatTokenCountCompact(value: bigint): string { + const abs = value < 0n ? -value : value; + const units: Array<[bigint, string]> = [ + [1_000_000_000n, "B"], + [1_000_000n, "M"], + [1_000n, "K"], + ]; + for (const [threshold, suffix] of units) { + if (abs >= threshold) { + // One decimal place, computed in bigint math to stay exact until the + // final float division (bounded to a single small ratio, not the + // original magnitude, so no precision loss that matters visually). + const scaled = Number((value * 10n) / threshold) / 10; + return `${scaled}${suffix}`; + } + } + return value.toString(); +} + +/** Exact grouped display, e.g. `1234567` -> "1,234,567". Safe for arbitrary `bigint` magnitude. */ +export function formatTokenCountExact(value: bigint): string { + return value.toLocaleString("en-US"); +} + +/** Exact USD display, e.g. `1.5` -> "$1.50". `null` callers should render "Estimated" copy elsewhere, never "$0.00". */ +export function formatEstimatedCostUsd(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); +} + +/** Short coverage-date display, e.g. `1737849600` -> "Jan 25". `null` renders "unknown". */ +export function formatCoverageDate(unixSeconds: number | null): string { + if (unixSeconds === null) return "unknown"; + return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +/** + * Bigint-safe ratio in `[0, 1]` for a relative bar, e.g. `part` tokens against + * `whole` tokens. Never converts the full magnitude through `Number(...)`; + * only the final small ratio is a float. Returns `0` when `whole` is zero or + * negative (guards a divide-by-zero, not a real data case). + */ +export function bigintRatio(part: bigint, whole: bigint): number { + if (whole <= 0n) return 0; + const clampedPart = part < 0n ? 0n : part > whole ? whole : part; + // Scale into an integer permille before the single float division so the + // division only ever operates on bounded small integers. + const permille = (clampedPart * 1000n) / whole; + return Number(permille) / 1000; +} + +// ── Display total derivation (A2 presentation layer) ───────────────────────── + +/** + * A provenance-bearing display total for the usage UI. Only one of three + * states is ever active: + * + * - `exact`: `totalTokens.value` is present and parsed. `partial` mirrors the + * wire field's `incomplete` flag. + * - `approximate`: `totalTokens.value` is absent and BOTH `inputTokens` and + * `outputTokens` are known; `value` is their bigint-safe sum. `partial` is + * `inputTokens.incomplete || outputTokens.incomplete`. Callers MUST render + * `≈` to distinguish this from a provider total. A missing category is + * unknown, not zero — one-sided i/o yields `unknown`, not `approximate`. + * - `unknown`: no token counts are available at all; `value` is `null`. + * + * This is a *display* value only — it is NEVER written to the wire or stored. + * NIP-AM's "MUST NOT derive total = input + output" governs published/stored + * data; this label lives entirely in the presentation layer. + */ +export type DisplayTotal = + | { kind: "exact"; value: bigint; partial: boolean } + | { kind: "approximate"; value: bigint; partial: boolean } + | { kind: "unknown"; value: null; partial: false }; + +export function deriveDisplayTotal(usage: { + inputTokens: UsageField; + outputTokens: UsageField; + totalTokens: UsageField; +}): DisplayTotal { + const exact = parseTokenCount(usage.totalTokens.value); + if (exact !== null) { + return { + kind: "exact", + value: exact, + partial: isPartialField(usage.totalTokens), + }; + } + const input = parseTokenCount(usage.inputTokens.value); + const output = parseTokenCount(usage.outputTokens.value); + if (input !== null && output !== null) { + return { + kind: "approximate", + value: input + output, + partial: + isPartialField(usage.inputTokens) || isPartialField(usage.outputTokens), + }; + } + return { kind: "unknown", value: null, partial: false }; +} + +// ── Ranking (A2: rank by display total — exact > approximate > unknown) ────── + +type DisplayTierKey = 0 | 1 | 2; // 0 = exact, 1 = approximate, 2 = unknown + +type RankedWithDisplay = { + item: T; + displayTotal: DisplayTotal; + tierKey: DisplayTierKey; +}; + +function tierOf(dt: DisplayTotal): DisplayTierKey { + if (dt.kind === "exact") return 0; + if (dt.kind === "approximate") return 1; + return 2; +} + +/** + * Sort items by their display total: + * 1. Exact totals rank first, descending by value. + * 2. Approximate totals (≈ in+out) rank next, descending by value. + * 3. Unknown totals rank last, unordered beyond the tiebreak. + * Within the same tier and value, `tiebreak` resolves the order. + */ +function rankByDisplayTotal( + items: readonly T[], + getUsage: (item: T) => { + inputTokens: UsageField; + outputTokens: UsageField; + totalTokens: UsageField; + }, + tiebreak: (a: T, b: T) => number, +): T[] { + const withDisplay: RankedWithDisplay[] = items.map((item) => { + const dt = deriveDisplayTotal(getUsage(item)); + return { item, displayTotal: dt, tierKey: tierOf(dt) }; + }); + + return withDisplay + .sort((a, b) => { + if (a.tierKey !== b.tierKey) return a.tierKey - b.tierKey; + // Same tier — for exact/approximate, sort descending by value. + if (a.displayTotal.value !== null && b.displayTotal.value !== null) { + if (a.displayTotal.value !== b.displayTotal.value) { + return a.displayTotal.value > b.displayTotal.value ? -1 : 1; + } + } + return tiebreak(a.item, b.item); + }) + .map((ranked) => ranked.item); +} + +/** Agents sort by display total (exact → approximate → unknown), descending by value within tier, then normalized pubkey. */ +export function sortAgentsByDisplayTotal( + agents: readonly AgentUsage[], +): AgentUsage[] { + return rankByDisplayTotal( + agents, + (agent) => agent.usage, + (a, b) => a.agentPubkey.localeCompare(b.agentPubkey), + ); +} + +/** Model rows use the same display-total ranking, tiebroken by harness name + * (null harness sorts last), then by model name (null model sorts last). + * Ordinal (`<`/`>`) comparators are used so ordering is locale-independent + * and matches the Rust backend's `String::cmp` byte order. */ +export function sortModelsByDisplayTotal( + models: readonly AgentUsageModel[], +): AgentUsageModel[] { + return rankByDisplayTotal( + models, + (model) => model.usage, + (a, b) => { + const harnessCmp = + a.harness === b.harness + ? 0 + : a.harness === null + ? 1 + : b.harness === null + ? -1 + : a.harness < b.harness + ? -1 + : 1; + if (harnessCmp !== 0) return harnessCmp; + if (a.model === b.model) return 0; + if (a.model === null) return 1; + if (b.model === null) return -1; + return a.model < b.model ? -1 : 1; + }, + ); +} + +// ── Coverage / partial-state copy helpers ──────────────────────────────────── + +/** A field is a "Partial" lower bound when it has a known value that is flagged incomplete. Distinct from fully unknown (`value === null`), which renders as an omitted/unknown state, never zero. */ +export function isPartialField(field: UsageField | CostField): boolean { + return field.value !== null && field.incomplete; +} + +/** True when a field has no known value at all — omit from totals/bars, never render as zero. */ +export function isUnknownField(field: UsageField | CostField): boolean { + return field.value === null; +} + +/** + * Truthful trailing summary for the profile Info-tab Usage ingress row + * (plan:328): the viewer's own agent's 7-day known total, `Partial` when + * incomplete, `Input/output reported` when only those fields are known, + * or `No recent data` when nothing in the window is known. Never renders + * the placeholder `"View"` the ingress row used to show unconditionally. + */ +export function deriveUsageIngressTrailing(series: AgentUsageSeries): string { + if (!series.collectionEnabled) return "Collection off"; + + const agent = series.agents[0]; + if (agent === undefined) return "No recent data"; + + const { inputTokens, outputTokens, totalTokens } = agent.usage; + const knownTotal = parseTokenCount(totalTokens.value); + if (knownTotal !== null) { + const compact = formatTokenCountCompact(knownTotal); + return isPartialField(totalTokens) ? `${compact} · Partial` : compact; + } + if ( + parseTokenCount(inputTokens.value) !== null || + parseTokenCount(outputTokens.value) !== null + ) { + const ioPartial = + isPartialField(inputTokens) || isPartialField(outputTokens); + return ioPartial + ? "Input/output reported · Partial" + : "Input/output reported"; + } + return "No recent data"; +} + +/** + * Aggregate the per-bucket display totals across a daily series into a single + * provenance-bearing `DisplayTotal` for the overview/focused-view header. + * + * Aggregation rules: + * - `exact`: every report-bearing bucket contributed an exact display total. + * - `approximate`: at least one bucket contributed an approximate value; + * `partial` is the union of contributing buckets' `DisplayTotal.partial`. + * Unknown-bucket peers set `partial = true` but do NOT erase the known sum — + * the result surfaces a labeled lower bound rather than hiding measured data. + * - `unknown`: NO report-bearing bucket has any display value at all. + * - Empty window (no report-bearing buckets): `{ kind: "unknown", value: null, partial: false }`. + * + * `partial` reflects i/o and total completeness of contributing buckets. + * An approximate aggregate with complete i/o and no exact totals carries + * `partial: false` — total absence alone does NOT trigger partial. + * + * The returned value is a *display* value only — never stored or wired. + */ +export function sumKnownBucketTotals( + buckets: readonly AgentUsageSeriesBucket[], +): DisplayTotal { + let sumValue = 0n; + let sawAny = false; // any report-bearing bucket processed + let anyApprox = false; // at least one approximate bucket contributed a value + let anyWithValue = false; // at least one bucket contributed a numeric value + let partial = false; + + for (const bucket of buckets) { + if (bucket.reportCount === 0) continue; + sawAny = true; + const dt = deriveDisplayTotal(bucket.usage); + if (dt.kind === "exact" || dt.kind === "approximate") { + sumValue += dt.value; + anyWithValue = true; + if (dt.partial) partial = true; + if (dt.kind === "approximate") anyApprox = true; + } else { + // Report-bearing bucket with no display value — sets partial but does NOT + // erase the sum already accumulated from sibling buckets. + partial = true; + } + } + + if (!sawAny) { + // Truly empty window — no report-bearing buckets at all. + return { kind: "unknown", value: null, partial: false }; + } + if (!anyWithValue) { + // Report-bearing buckets exist but none had any display value. + return { kind: "unknown", value: null, partial: false }; + } + if (anyApprox) { + return { kind: "approximate", value: sumValue, partial }; + } + return { kind: "exact", value: sumValue, partial }; +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx new file mode 100644 index 00000000000..4c659684628 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx @@ -0,0 +1,316 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import type { AgentUsageSeriesBucket } from "@/shared/api/tauriArchive"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { + bigintRatio, + deriveDisplayTotal, + formatTokenCountCompact, + formatTokenCountExact, + isPartialField, + parseTokenCount, +} from "../lib/agentUsage"; + +const BAR_TRACK_HEIGHT_PX = 56; +const UNKNOWN_BASELINE_HEIGHT_PX = 10; +const KNOWN_MIN_HEIGHT_PX = 3; + +/** + * Above this bucket count the per-bar value labels and every date tick stop + * fitting, so values move into the tooltip only and date ticks thin out to + * first/last plus regular intervals. 31 buckets (the 30d preset) still fits + * date ticks at an interval; a custom year-long range does not. + */ +const DENSE_BUCKET_THRESHOLD = 14; + +// A hatched, non-zero baseline for "activity happened but the total +// couldn't be counted" — deliberately never a zero-height bar, so unknown +// usage is never visually indistinguishable from a day with no activity +// (plan:306/329: "does not encode unknown as zero"). +const UNKNOWN_BAR_STYLE: React.CSSProperties = { + backgroundImage: + "repeating-linear-gradient(45deg, var(--muted-foreground) 0, var(--muted-foreground) 1px, transparent 1px, transparent 5px)", + backgroundColor: "transparent", + height: UNKNOWN_BASELINE_HEIGHT_PX, + opacity: 0.35, +}; + +function dateLabelOf(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +/** + * One bar's derived render state, computed from backend truth rather than + * the field's `value` alone: `reportCount === 0` is a genuine zero-activity + * day (the field is `null` because nothing happened), which is a different + * state from `hasUnknownUsage` (activity happened but the total could not + * be fully counted) even though both leave `usage.totalTokens.value` null. + * + * When no genuine total is available but i/o counts are known, the bar falls + * back to an approx i/o sum (`kind: "approx"`) rather than the hatched + * unknown baseline — the bar height becomes meaningful and is labeled `≈`. + */ +function deriveBarState(bucket: AgentUsageSeriesBucket) { + const total = bucket.usage.totalTokens; + const known = parseTokenCount(total.value); + const dateLabel = dateLabelOf(bucket.start); + + if (bucket.reportCount === 0) { + return { + accessibleLabel: `${dateLabel} · no usage reported`, + dateLabel, + kind: "empty" as const, + knownTokens: 0n, + }; + } + if (known !== null) { + const partial = isPartialField(total); + return { + accessibleLabel: `${dateLabel} · ${formatTokenCountCompact(known)} reported tokens${ + partial ? " (partial)" : "" + }`, + dateLabel, + kind: partial ? ("partial" as const) : ("known" as const), + knownTokens: known, + }; + } + // Genuine total unknown — derive the display total for the bar. + const dt = deriveDisplayTotal(bucket.usage); + if (dt.kind === "approximate") { + return { + accessibleLabel: `${dateLabel} · ≈ ${formatTokenCountCompact(dt.value)} tokens${ + dt.partial ? " (partial)" : "" + }`, + dateLabel, + kind: dt.partial ? ("approx-partial" as const) : ("approx" as const), + knownTokens: dt.value, + }; + } + return { + accessibleLabel: `${dateLabel} · unknown usage`, + dateLabel, + kind: "unknown" as const, + knownTokens: null, + }; +} + +/** + * The compact value rendered directly on the bar. Carries the same + * provenance markers the header uses: `≈` for an in+out approximation, `≥` + * for a known-but-incomplete lower bound, a trailing `*` when partial, and + * `—` when nothing is countable. + */ +function barValueText( + kind: ReturnType["kind"], + knownTokens: bigint | null, +): string { + if (kind === "unknown") return "—"; + const compact = formatTokenCountCompact(knownTokens ?? 0n); + switch (kind) { + case "partial": + return `≥${compact}`; + case "approx-partial": + return `≈${compact}*`; + case "approx": + return `≈${compact}`; + default: + return compact; + } +} + +/** + * Exact per-field breakdown for the hover tooltip, so total/input/output are + * legible without opening the focused view. Each field is reported + * independently: a null field renders "unknown", never zero, and the total + * keeps its exact/≈/unknown provenance rather than being derived from the + * i/o pair shown beside it. + */ +function barBreakdown(bucket: AgentUsageSeriesBucket): { + total: string; + input: string; + output: string; +} { + const dt = deriveDisplayTotal(bucket.usage); + const total = + dt.kind === "exact" + ? formatTokenCountExact(dt.value) + : dt.kind === "approximate" + ? `≈ ${formatTokenCountExact(dt.value)}` + : "unknown"; + const totalSuffix = dt.kind !== "unknown" && dt.partial ? " (partial)" : ""; + + const field = (f: { value: string | null; incomplete: boolean }): string => { + const parsed = parseTokenCount(f.value); + if (parsed === null) return "unknown"; + return `${formatTokenCountExact(parsed)}${isPartialField(f) ? " (partial)" : ""}`; + }; + + return { + total: `${total}${totalSuffix}`, + input: field(bucket.usage.inputTokens), + output: field(bucket.usage.outputTokens), + }; +} + +/** + * CSS-only daily bar chart for a usage series (plan:305-306/329). Columns are + * equal CSS-grid fractions of the container so the chart never causes + * horizontal overflow regardless of window width or bucket count (2, 8, 31, + * or a custom range up to a year). + * + * Each bar renders its token value directly on the bar, labels the **date** + * beneath it on the x-axis, and exposes a hover tooltip with the exact + * total/input/output breakdown so the split is readable without opening the + * focused view. Accessible `aria-label`s carry the same `date · value` truth + * for screen readers. A day with reported-but-uncountable usage renders a + * fixed hatched baseline, never a zero-height bar. + * + * Dense ranges (more than {@link DENSE_BUCKET_THRESHOLD} buckets) drop the + * on-bar value text, which cannot fit legibly, and thin the date ticks to + * first, last, and a regular interval. The tooltip still carries every bar's + * full breakdown, so no information is lost. + */ +export function AgentUsageDailyBars({ + buckets, +}: { + buckets: AgentUsageSeriesBucket[]; +}) { + const maxKnownTotal = React.useMemo( + () => + buckets.reduce((max, bucket) => { + const total = parseTokenCount(bucket.usage.totalTokens.value); + if (total !== null) return total > max ? total : max; + // Fall back to the display total's approximate value so bars scale + // correctly when no bucket reports a genuine total. + const dt = deriveDisplayTotal(bucket.usage); + return dt.kind === "approximate" && dt.value > max ? dt.value : max; + }, 0n), + [buckets], + ); + + if (buckets.length === 0) return null; + + const dense = buckets.length > DENSE_BUCKET_THRESHOLD; + // At most ~7 date ticks in a dense range; first and last are always shown. + const tickInterval = dense ? Math.ceil(buckets.length / 7) : 1; + + return ( +
+ {buckets.map((bucket, index) => ( + + ))} +
+ ); +} + +function DailyBar({ + bucket, + maxKnownTotal, + showDateLabel, + showValueLabel, +}: { + bucket: AgentUsageSeriesBucket; + maxKnownTotal: bigint; + showDateLabel: boolean; + showValueLabel: boolean; +}) { + const { accessibleLabel, dateLabel, kind, knownTokens } = + deriveBarState(bucket); + const breakdown = barBreakdown(bucket); + + const knownHeightPx = + knownTokens !== null && maxKnownTotal > 0n + ? Math.max( + Math.round( + bigintRatio(knownTokens, maxKnownTotal) * BAR_TRACK_HEIGHT_PX, + ), + knownTokens > 0n ? KNOWN_MIN_HEIGHT_PX : 1, + ) + : KNOWN_MIN_HEIGHT_PX; + + return ( + + +
+ {showValueLabel ? ( + + {barValueText(kind, knownTokens)} + + ) : null} +
+ {kind === "unknown" ? ( +
+ ) : ( +
+ )} +
+ {/* Non-breaking space holds the tick row's height when a dense + range hides this bar's date, so bars stay baseline-aligned. */} + + {showDateLabel ? dateLabel : "\u00A0"} + +
+ + + {dateLabel} + Total: {breakdown.total} + Input: {breakdown.input} + Output: {breakdown.output} + + + ); +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx new file mode 100644 index 00000000000..4d7f677ba1e --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx @@ -0,0 +1,431 @@ +import * as React from "react"; +import { RefreshCw } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import type { + AgentUsageModel, + AgentUsageSeries, +} from "@/shared/api/tauriArchive"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { useAgentUsageSeries } from "../hooks"; +import { + DEFAULT_USAGE_RANGE, + deriveDisplayTotal, + describeRange, + formatCoverageDate, + formatEstimatedCostUsd, + formatTokenCountCompact, + formatTokenCountExact, + isPartialField, + isUnknownField, + parseTokenCount, + sortModelsByDisplayTotal, + type DisplayTotal, + type UsageRange, +} from "../lib/agentUsage"; +import { AgentUsageDailyBars } from "./AgentUsageDailyBars"; +import { AgentUsageRangeTabs } from "./AgentUsageRangeTabs"; + +/** + * Per-agent Usage focused subview, rendered from the profile panel when + * `view === 'usage'` (M4/A9/A13, frozen Rev 3 plan). Owns its own window + * selector and author-filtered query — independent of the Agents overview. + * + * A13 fail-closed: eligibility is ownership (`canViewUsage`) OR archived + * evidence for a historical/deleted agent (`hasArchivedEvidence === true`). + * A hand-authored `?profileView=usage` URL with neither falls back to the + * summary view via `onIneligible` — but only once the query resolves, so a + * still-loading owner-eligible or evidence-eligible agent is never bounced. + */ +export function AgentUsageFocusedView({ + agentPubkey, + canViewUsage, + onIneligible, +}: { + agentPubkey: string; + canViewUsage: boolean; + onIneligible: () => void; +}) { + const [range, setRange] = React.useState(DEFAULT_USAGE_RANGE); + const query = useAgentUsageSeries({ agentPubkey, range }); + const { onOpenSettings } = useAppShell(); + + React.useEffect(() => { + if (canViewUsage || !query.data) return; + if (query.data.hasArchivedEvidence !== true) onIneligible(); + }, [canViewUsage, onIneligible, query.data]); + + return ( +
+ + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageFocusedSkeleton() { + return ( + + + + + + ); +} + +function AgentUsageFocusedContent({ + onOpenSettings, + range, + series, +}: { + onOpenSettings: ((section: "local-archive") => void) | null; + range: UsageRange; + series: AgentUsageSeries; +}) { + const agent = series.agents[0]; + const collectionOff = !series.collectionEnabled; + const hasRetainedData = series.coverage.reportCount > 0; + // Invalid-only: in-window invalid rows exist but none were bucketed (A5/A11). + // Distinct from outside-window history — we have evidence in this window, + // it just couldn't be counted. Must not be mislabeled as outside-window. + const hasInvalidOnlyInWindow = + agent === undefined && + series.collectionEnabled && + series.coverage.invalidReportCount > 0; + const hasEvidenceOutsideWindow = + agent === undefined && + !hasInvalidOnlyInWindow && + series.hasArchivedEvidence === true; + + if ( + !collectionOff && + agent === undefined && + !hasEvidenceOutsideWindow && + !hasInvalidOnlyInWindow + ) { + return ( +

+ No locally archived usage in {describeRange(range)}. Usage appears after + this agent completes a usage-reporting turn. +

+ ); + } + + return ( +
+ {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {agent ? ( + + ) : hasEvidenceOutsideWindow ? ( +

+ No locally archived usage in {describeRange(range)}, but this agent + has reported usage previously. Try a wider window. +

+ ) : hasInvalidOnlyInWindow ? ( +

+ Usage was collected in {describeRange(range)} but could not be counted + — reports with unreadable timestamps or missing session totals are + excluded and are not assigned to any day. +

+ ) : null} +
+ ); +} + +function AgentUsageFocusedTotals({ + agent, + coverage, +}: { + agent: AgentUsageSeries["agents"][number]; + coverage: AgentUsageSeries["coverage"]; +}) { + const { estimatedCostUsd, inputTokens, outputTokens } = agent.usage; + const models = sortModelsByDisplayTotal(agent.models); + // Each caveat sentence is gated only on the condition that proves it: + // - unknown-intervals sentence: direct i/o incompleteness — true when at + // least one input or output field is known but flagged incomplete. This + // is the condition the copy claims ("input/output usage could not be + // counted"). `hasUnknownUsage` is NOT used here because it ORs total + // and cost incompleteness too, which cannot prove an i/o interval claim. + // - invalid-reports sentence: `coverage.invalidReportCount > 0` — true + // when rows were excluded from buckets due to bad timestamps or missing + // session cumulative totals. + // We do NOT trigger on totalTokens.value being null — that's the permanent + // state for all real publishers today, not a data quality problem. + const showUnknownIntervalsCaveat = + isPartialField(inputTokens) || isPartialField(outputTokens); + const showInvalidReportsCaveat = coverage.invalidReportCount > 0; + + // Display total for the Total tokens stat. + const displayTotal = deriveDisplayTotal(agent.usage); + + return ( + +
+ + + + +
+ + {agent.buckets.length > 0 ? ( +
+

Daily usage

+ +
+ ) : null} + + {models.length > 0 ? ( +
+

By model

+ {models.map((model) => ( +
+ + {model.model ?? "Unknown model"} + {model.harness !== null ? ( + + {model.harness} + + ) : null} + + + {isUnknownField(model.usage.totalTokens) + ? formatModelIndependentFields(model) + : formatTokenCountExact( + parseTokenCount(model.usage.totalTokens.value) ?? 0n, + )} + {isPartialField(model.usage.totalTokens) || + isModelIoPartial(model) ? ( + + Partial + + ) : null} + +
+ ))} +
+ ) : null} + +
+

+ {agent.reportCount} reported turn{agent.reportCount === 1 ? "" : "s"} + {" · "} + {formatCoverageRange(coverage)} +

+ {showUnknownIntervalsCaveat ? ( +

+ Some input/output usage could not be counted and is omitted rather + than shown as zero. +

+ ) : null} + {showInvalidReportsCaveat ? ( +

+ {coverage.invalidReportCount === 1 + ? "1 report" + : `${coverage.invalidReportCount} reports`}{" "} + excluded: reports with an unreadable timestamp or a cumulative total + missing its session are not assigned to any day. +

+ ) : null} +
+
+ ); +} + +function TokenStat({ + field, + label, +}: { + field: { value: string | null; incomplete: boolean }; + label: string; +}) { + const parsed = parseTokenCount(field.value); + return ( + + ); +} + +/** + * Total-tokens stat that falls back to an `≈` approximation (in+out) when + * the genuine total is unavailable. The `≈` prefix keeps the approximation + * honest without hiding that real token activity was counted. + */ +function ApproxTokenStat({ + displayTotal, + label, +}: { + displayTotal: DisplayTotal; + label: string; +}) { + const display = + displayTotal.kind === "exact" + ? formatTokenCountExact(displayTotal.value) + : displayTotal.kind === "approximate" + ? `≈ ${formatTokenCountExact(displayTotal.value)}` + : null; + return ( + + ); +} + +function UsageStat({ + display, + isPartial, + label, + testId, +}: { + display: string | null; + isPartial: boolean; + label: string; + testId?: string; +}) { + return ( +
+

{label}

+

+ {display ?? "—"} +

+ {isPartial ? Partial : null} +
+ ); +} + +/** + * Human-readable coverage range for the focused view's footer, from the + * exact first/last reported timestamps the backend already computes + * (plan:329's "coverage dates"). `null` on either end means no reported row + * fell in this window (the caller only renders this once `agent` exists, + * so both are actually set in practice, but the fallback stays honest). + */ +function formatCoverageRange(coverage: AgentUsageSeries["coverage"]): string { + const { firstReportedAt, lastReportedAt } = coverage; + if (firstReportedAt === null || lastReportedAt === null) { + return "coverage unknown"; + } + if (firstReportedAt === lastReportedAt) { + return `reported ${formatCoverageDate(firstReportedAt)}`; + } + return `${formatCoverageDate(firstReportedAt)} – ${formatCoverageDate(lastReportedAt)}`; +} + +/** + * Render known model I/O fields when the model total is unknown — never + * collapses to "No usage reported" when input or output is actually known + * (A2 per-field completeness). Mirrors `formatIndependentFields` in the + * overview row. + */ +function formatModelIndependentFields(model: AgentUsageModel): string { + const input = parseTokenCount(model.usage.inputTokens.value); + const output = parseTokenCount(model.usage.outputTokens.value); + if (input !== null || output !== null) { + const parts: string[] = []; + if (input !== null) parts.push(`in ${formatTokenCountCompact(input)}`); + if (output !== null) parts.push(`out ${formatTokenCountCompact(output)}`); + return parts.join(" · "); + } + return "No usage reported"; +} + +/** + * True when a model has no known total but its displayed I/O fields carry + * incomplete truth — so the Partial badge must still appear (A2). + */ +function isModelIoPartial(model: AgentUsageModel): boolean { + return ( + isUnknownField(model.usage.totalTokens) && + (isPartialField(model.usage.inputTokens) || + isPartialField(model.usage.outputTokens)) + ); +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageRangeTabs.tsx b/desktop/src/features/agent-usage/ui/AgentUsageRangeTabs.tsx new file mode 100644 index 00000000000..da93781fc03 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageRangeTabs.tsx @@ -0,0 +1,211 @@ +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { + buildLocalDayBoundaries, + formatLocalDate, + validateCustomRange, + type UsageRange, +} from "../lib/agentUsage"; + +const PRESET_DAYS = [1, 7, 30] as const; + +type PresetDays = (typeof PRESET_DAYS)[number]; + +/** + * Window selector shared by the Agents-overview and per-agent usage views: + * the `1d`/`7d`/`30d` presets plus a `Custom` tab whose popover takes an + * arbitrary inclusive start/end date pair. + * + * Selecting `Custom` opens the picker without changing the active range — + * the range only moves once a valid pair is applied, so an in-progress edit + * never issues a query. Validation is local ({@link validateCustomRange}), + * so the user sees "pick a range of 366 days or fewer" rather than the + * backend's fail-closed arity error. + * + * `testIdPrefix` keeps the two mounted instances addressable independently + * (`agent-usage-window-*` on the overview, `agent-usage-focused-window-*` in + * the profile panel). + */ +export function AgentUsageRangeTabs({ + onRangeChange, + range, + testIdPrefix, +}: { + onRangeChange: (range: UsageRange) => void; + range: UsageRange; + testIdPrefix: string; +}) { + const [pickerOpen, setPickerOpen] = React.useState(false); + const customTriggerRef = React.useRef(null); + + return ( + // `PopoverAnchor`, not `PopoverTrigger`: the trigger would spread its own + // `data-state` ("open"/"closed") onto the tab and clobber the tab's + // "active"/"inactive" state. The anchor only positions, so the picker + // opens from an explicit click and the tab keeps its own state. + + { + const days = Number(value); + if (isPresetDays(days)) onRangeChange({ kind: "preset", days }); + }} + value={range.kind === "preset" ? String(range.days) : "custom"} + > + + {PRESET_DAYS.map((days) => ( + + {days}d + + ))} + + setPickerOpen(true)} + ref={customTriggerRef} + value="custom" + > + Custom + + + + + + { + // No `PopoverTrigger` to return focus to, so restore it manually. + event.preventDefault(); + customTriggerRef.current?.focus(); + }} + > + { + onRangeChange(applied); + setPickerOpen(false); + }} + range={range} + testIdPrefix={testIdPrefix} + /> + + + ); +} + +function CustomRangeForm({ + onApply, + range, + testIdPrefix, +}: { + onApply: (range: UsageRange) => void; + range: UsageRange; + testIdPrefix: string; +}) { + // Seeded once per mount; Radix unmounts the popover's content when it + // closes, so each open re-seeds from the range that is active then. + const [draft, setDraft] = React.useState(() => initialDraft(range)); + const { startDate, endDate } = draft; + + const validation = validateCustomRange(startDate, endDate); + const today = formatLocalDate(new Date()); + + return ( +
{ + event.preventDefault(); + if (validation.ok) onApply({ kind: "custom", startDate, endDate }); + }} + > +

Custom range

+
+ + setDraft((current) => ({ + ...current, + startDate: event.target.value, + })) + } + type="date" + value={startDate} + /> + to + + setDraft((current) => ({ ...current, endDate: event.target.value })) + } + type="date" + value={endDate} + /> +
+ {validation.ok ? ( +

+ {validation.days} day{validation.days === 1 ? "" : "s"} selected. +

+ ) : ( +

+ {validation.message} +

+ )} + +
+ ); +} + +function isPresetDays(days: number): days is PresetDays { + return (PRESET_DAYS as readonly number[]).includes(days); +} + +/** + * Pre-fill for the picker: the active custom range when there is one, + * otherwise the span the active preset already covers, so applying without + * edits is a no-op rather than an empty form. Endpoints come from + * {@link buildLocalDayBoundaries} so the pre-filled dates are exactly the + * civil days the preset queried. + */ +function initialDraft(range: UsageRange): { + startDate: string; + endDate: string; +} { + if (range.kind === "custom") { + return { startDate: range.startDate, endDate: range.endDate }; + } + const boundaries = buildLocalDayBoundaries(range.days); + // Boundaries close the final day, so the last covered day starts at the + // second-to-last boundary. + const first = boundaries[0] ?? 0; + const lastDayStart = boundaries[boundaries.length - 2] ?? first; + return { + startDate: formatLocalDate(new Date(first * 1_000)), + endDate: formatLocalDate(new Date(lastDayStart * 1_000)), + }; +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx new file mode 100644 index 00000000000..d2ab86246fa --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx @@ -0,0 +1,310 @@ +import * as React from "react"; +import { RefreshCw } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { AgentUsage, AgentUsageSeries } from "@/shared/api/tauriArchive"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { Progress } from "@/shared/ui/progress"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { useAgentUsageSeries } from "../hooks"; +import { + bigintRatio, + DEFAULT_USAGE_RANGE, + deriveDisplayTotal, + describeRange, + formatCoverageDate, + formatTokenCountCompact, + sortAgentsByDisplayTotal, + sumKnownBucketTotals, + type UsageRange, +} from "../lib/agentUsage"; +import { AgentUsageDailyBars } from "./AgentUsageDailyBars"; +import { AgentUsageRangeTabs } from "./AgentUsageRangeTabs"; + +/** + * Compact "Usage" section on the Agents page: local NIP-AM usage totals for + * the selected window (1d/7d/30d preset or a custom date range), broken down + * per agent, with a click-through to the per-agent focused view in the + * profile panel (M4/A9/A13, frozen Rev 3 plan). + */ +export function AgentUsageSection({ + onOpenAgentProfile, +}: { + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}) { + const [range, setRange] = React.useState(DEFAULT_USAGE_RANGE); + const query = useAgentUsageSeries({ range }); + const { onOpenSettings } = useAppShell(); + + const agents = React.useMemo( + () => sortAgentsByDisplayTotal(query.data?.agents ?? []), + [query.data?.agents], + ); + const pubkeys = React.useMemo( + () => agents.map((agent) => agent.agentPubkey), + [agents], + ); + const usersBatchQuery = useUsersBatchQuery(pubkeys, { + enabled: pubkeys.length > 0, + }); + + return ( +
+ + } + description="Locally archived, agent-reported usage." + title="Usage" + /> + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageSkeleton() { + return ( + + + + + + + ); +} + +function AgentUsageCard({ + agents, + onOpenAgentProfile, + onOpenSettings, + profiles, + range, + series, +}: { + agents: AgentUsage[]; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + onOpenSettings: ((section: "local-archive") => void) | null; + profiles: UserProfileLookup | undefined; + range: UsageRange; + series: AgentUsageSeries; +}) { + const hasRows = agents.length > 0; + const collectionOff = !series.collectionEnabled; + const hasRetainedData = series.coverage.reportCount > 0; + // True when the window has in-window invalid rows but no valid/bucketed rows. + // These rows are correctly excluded from buckets (A5/A11) but the window is + // not empty — coverage.hasUnknownUsage reflects this via the F1 roll-up. + const hasInvalidOnlyInWindow = + !hasRows && + series.collectionEnabled && + series.coverage.invalidReportCount > 0; + + // Relative bars are decorative (aria-hidden, per plan) — scale each agent's + // display total (exact or approximate) against the largest such value in the + // current window so the sorted-by-display-total list also reads as a bar chart. + const maxDisplayValue = React.useMemo( + () => + agents.reduce((max, agent) => { + const dt = deriveDisplayTotal(agent.usage); + return dt.value !== null && dt.value > max ? dt.value : max; + }, 0n), + [agents], + ); + + const overallTotal = React.useMemo( + () => sumKnownBucketTotals(series.buckets), + [series.buckets], + ); + + return ( + + {series.buckets.length > 0 ? ( +
+
+

Daily usage

+ + {overallTotal.kind === "exact" + ? `${formatTokenCountCompact(overallTotal.value)} tokens` + : overallTotal.kind === "approximate" + ? `≈ ${formatTokenCountCompact(overallTotal.value)} tokens` + : hasInvalidOnlyInWindow + ? "Usage uncountable" + : "No usage reported"} + {(overallTotal.kind !== "unknown" && overallTotal.partial) || + hasInvalidOnlyInWindow ? ( + + Partial + + ) : null} + +
+ +
+ ) : null} + + {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {hasRows ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : ( +

+ {collectionOff + ? "Turn on collection to start tracking agent usage." + : hasInvalidOnlyInWindow + ? `Usage was collected in ${describeRange(range)} but could not be counted — reports with unreadable timestamps or missing session totals are excluded.` + : `No locally archived usage in ${describeRange(range)}. Usage appears after an agent completes a usage-reporting turn.`} +

+ )} +
+ ); +} + +function AgentUsageRow({ + agent, + label, + maxDisplayValue, + onOpenAgentProfile, + profileAvatarUrl, + range, +}: { + agent: AgentUsage; + label: string; + maxDisplayValue: bigint; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + profileAvatarUrl: string | null; + range: UsageRange; +}) { + const dt = deriveDisplayTotal(agent.usage); + + const trailing = + dt.kind === "exact" + ? formatTokenCountCompact(dt.value) + : dt.kind === "approximate" + ? `≈ ${formatTokenCountCompact(dt.value)}` + : "No usage reported"; + + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50d8..bbb528c5a87 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -72,7 +72,8 @@ export function AgentsScreen() { profile: pubkey, profilePersona: null, profileTab: options?.tab === "info" ? null : (options?.tab ?? null), - profileView: null, + profileView: + options?.view === "summary" ? null : (options?.view ?? null), }); }, [applyPatch], diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index e1e1f37f35f..2dc11ee20d0 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -4,6 +4,7 @@ import { consumePendingSnapshotImport, subscribeSnapshotImport, } from "@/features/agents/openSnapshotImportFromUrlEvent"; +import { AgentUsageSection } from "@/features/agent-usage/ui/AgentUsageSection"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -291,6 +292,12 @@ export function AgentsView() { personas={personas.libraryPersonas} teams={teamActions.teams} /> + + { + openProfilePanel?.(pubkey, options); + }} + />
diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index cb188dd0089..50ef8c5044c 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -67,6 +67,8 @@ import { ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; +import { AgentUsageFocusedView } from "@/features/agent-usage/ui/AgentUsageFocusedView"; +import { useUsageIngress } from "@/features/agent-usage/hooks"; import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; @@ -160,17 +162,14 @@ export function UserProfilePanel({ >(undefined); // Open the Edit Agent dialog when `requestOpenEditAgent(pubkey)` fires from - // a card or other non-panel surface (e.g. `ConfigNudgeCard`). Mirrors the - // `subscribeOpenCreateAgent` pattern in AgentsView. + // a card or other surface. Mirrors `subscribeOpenCreateAgent` in AgentsView. React.useEffect(() => { if (!pubkey) return; - // Consume any pending request that arrived before this panel mounted. const pending = consumePendingOpenEditAgent(pubkey); if (pending !== false) { setEditAgentFocus(pending === true ? undefined : pending); setEditAgentOpen(true); } - // Subscribe for events that arrive while the panel is mounted. return subscribeOpenEditAgent(pubkey, (focus) => { setEditAgentFocus(focus); setEditAgentOpen(true); @@ -299,18 +298,13 @@ export function UserProfilePanel({ const isBot = Boolean(relayAgent || managedAgent || resolvedPersona) || isAgentByOaOwner; const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null); - // Does THIS desktop hold the agent's seckey (or is this an editable persona)? - // Gates edit (which needs the key) and grants owner access when managed locally. + // Does THIS desktop hold the agent's seckey (or editable persona)? + // Gates edit and grants owner access when managed locally. const isOwner = resolvedPersona ? true : managedAgentOwner; - // Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This - // is the right signal for viewing owner-scoped data (activity feed, memory): - // the relay routes and the client decrypts those frames with the owner's OWN - // key, so the agent's seckey is never needed. Computed here (before the gates - // that consume it) so visibility keys off declared ownership, not key custody. + // Is the viewer the declared NIP-OA owner? Right signal for owner-scoped + // data (activity, memory) — relay routes/decrypts with the owner's own key. const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey); - // The viewer may see owner-scoped data if they declared-own the agent OR they - // manage it locally (older agents may not advertise an owner pubkey). Every - // real boundary is server-side, so this only controls what UI we paint. + // Viewer sees owner-scoped data if declared-own OR managed locally; real boundary is server-side. const viewerIsOwner = isCurrentUserOwner || isOwner === true; const activityAgent = React.useMemo( @@ -325,9 +319,6 @@ export function UserProfilePanel({ }), [effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner], ); - // Observer ingestion (frame decryption + derived active-turn liveness) is - // owner-global — mounted once in AppShell via useAgentObserverIngestion — - // covering both locally managed agents and declared-owned relay agents. const canEditAgent = isOwner === true && (managedAgent !== undefined || resolvedPersona !== undefined); @@ -342,6 +333,8 @@ export function UserProfilePanel({ viewerIsOwner && Boolean(effectivePubkey) && canOpenAgentActivity(effectivePubkey); + const canViewUsage = viewerIsOwner && isBot && Boolean(effectivePubkey); + const usageIngressTrailing = useUsageIngress(effectivePubkey, canViewUsage); const canOpenAgentLogs = isOwner === true && managedAgent?.backend.type === "local"; const canInstantiateAgent = @@ -621,8 +614,7 @@ export function UserProfilePanel({ [deletePersonaMutation.mutateAsync, onClose], ); - // Count of managed-agent instances backed by the persona being deleted. - // Shown in the confirm dialog so the user knows what will be cascade-deleted. + // Count of instances backed by the persona being deleted (shown in confirm dialog). const personaDeleteInstanceCount = React.useMemo( () => personaToDelete @@ -791,6 +783,8 @@ export function UserProfilePanel({ canInstantiateAgent={canInstantiateAgent} canOpenAgentLogs={canOpenAgentLogs} canViewActivity={canViewActivity} + canViewUsage={canViewUsage} + usageIngressTrailing={usageIngressTrailing} callerChannelId={callerChannelId} channelCount={profileChannels.length} channelIdToName={channelIdToName} @@ -826,6 +820,7 @@ export function UserProfilePanel({ onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} onOpenInstructions={() => setView("instructions")} + onOpenUsage={() => setView("usage")} onTabChange={setTab} onOpenDm={onOpenDm} onCreateCard={ @@ -856,6 +851,13 @@ export function UserProfilePanel({ viewerIsOwner={viewerIsOwner} /> ) : null} + {view === "usage" && effectivePubkey ? ( + setView("summary", { replace: true })} + /> + ) : null} {view === "info" ? ( ) : null} @@ -936,45 +938,43 @@ export function UserProfilePanel({ /> ) : null; const personaDialogs = ( - <> - setCardMintTarget(null)} - onCloseDelete={() => setPersonaToDelete(null)} - onCloseDialog={() => setPersonaDialogState(null)} - onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)} - onConfirmDelete={(selectedPersona) => { - void handleConfirmDeletePersona(selectedPersona); - }} - onExportSnapshot={setPersonaToExportSnapshot} - onSubmit={handleSubmitPersona} - /> - + setCardMintTarget(null)} + onCloseDelete={() => setPersonaToDelete(null)} + onCloseDialog={() => setPersonaDialogState(null)} + onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)} + onConfirmDelete={(selectedPersona) => { + void handleConfirmDeletePersona(selectedPersona); + }} + onExportSnapshot={setPersonaToExportSnapshot} + onSubmit={handleSubmitPersona} + /> ); return ( ; channels: ProfileChannelLink[]; @@ -97,6 +99,7 @@ export type ProfileSummaryViewProps = { onOpenChannel: (channelId: string) => void; onOpenDiagnostics: () => void; onOpenInstructions: () => void; + onOpenUsage: () => void; onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void; onOpenDm?: (pubkeys: string[]) => Promise | void; /** Mint an agent trading card. Present only for owner-managed personas. */ @@ -178,6 +181,8 @@ export function ProfileSummaryView({ canEditAgent, canOpenAgentLogs, canViewActivity, + canViewUsage, + usageIngressTrailing, channelCount, channelIdToName, channels, @@ -212,6 +217,7 @@ export function ProfileSummaryView({ onOpenChannel, onOpenDiagnostics, onOpenInstructions, + onOpenUsage, onTabChange, onOpenDm, onCreateCard, @@ -250,11 +256,13 @@ export function ProfileSummaryView({ diagnosticsFields.some((field) => field.label !== "Status") || canOpenAgentLogs; const showActivityIngress = canViewActivity; + const showUsageIngress = canViewUsage; const showInfoTab = agentInfoFields.length > 0 || instances.length > 1 || isArchived || showActivityIngress || + showUsageIngress || !showRuntimeTab; const diagnosticsErrorField = diagnosticsFields.find( @@ -419,8 +427,11 @@ export function ProfileSummaryView({ isArchived={isArchived} onOpenActivity={onOpenActivity} onOpenInstance={onOpenInstance} + onOpenUsage={onOpenUsage} pubkey={pubkey} showActivityIngress={showActivityIngress} + showUsageIngress={showUsageIngress} + usageIngressTrailing={usageIngressTrailing} /> ) : null} {activeTab === "runtime" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index be6d5add431..3789447a09d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react"; import { Activity, Archive, + BarChart3, ChevronRight, Info, RefreshCw, @@ -292,8 +293,11 @@ export function ProfileInfoTabContent({ isArchived, onOpenActivity, onOpenInstance, + onOpenUsage, pubkey, showActivityIngress, + showUsageIngress, + usageIngressTrailing, }: { activeTurns: ActiveTurnSummary[]; activityAgent: ProfileActivityAgent | null; @@ -304,8 +308,11 @@ export function ProfileInfoTabContent({ isArchived: boolean; onOpenActivity: (channelId?: string | null) => void; onOpenInstance: (pubkey: string) => void; + onOpenUsage: () => void; pubkey: string | null; showActivityIngress: boolean; + showUsageIngress: boolean; + usageIngressTrailing: string | undefined; }) { const infoFields: ProfileField[] = isArchived ? [ @@ -325,7 +332,12 @@ export function ProfileInfoTabContent({ const showLiveActivityEmbed = showActivityIngress && (feedScope.isLive || feedScope.hasFeedContent); - if (!hasInfoFields && !showActivityIngress && !hasInstances) { + if ( + !hasInfoFields && + !showActivityIngress && + !hasInstances && + !showUsageIngress + ) { return null; } @@ -351,6 +363,15 @@ export function ProfileInfoTabContent({ /> ) ) : null} + {showUsageIngress ? ( + + ) : null} {hasInfoFields ? : null} {hasInstances ? ( { "memories", "channels", "logs", + "usage", ]) { assert.equal(parseProfilePanelView(view), view); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4a..f547a407d70 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -25,7 +25,8 @@ export type ProfilePanelView = | "diagnostics" | "memories" | "channels" - | "logs"; + | "logs" + | "usage"; export type ProfilePanelTab = "info" | "runtime" | "channels" | "memories"; @@ -38,6 +39,7 @@ export const PROFILE_PANEL_VIEW_TITLES: Record = { memories: "Memories", channels: "Channels", logs: "Harness Log", + usage: "Usage", }; const PROFILE_PANEL_VIEWS = new Set( diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx index 3f47364c5a3..4127172f06f 100644 --- a/desktop/src/shared/context/ProfilePanelContext.tsx +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -1,9 +1,11 @@ import * as React from "react"; +import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanelUtils"; import type { AgentPersona } from "@/shared/api/types"; export type ProfilePanelOpenOptions = { tab?: "info" | "runtime" | "channels" | "memories"; + view?: ProfilePanelView; }; type ProfilePanelContextValue = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6961488fefa..f4e9f542019 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -154,6 +154,78 @@ type MockSearchProfileSeed = { isAgent?: boolean; }; +// ── Agent usage (NIP-AM) mock wire shapes ──────────────────────────────────── +// Mirrors `desktop/src/shared/api/tauriArchive.ts`'s camelCase types +// field-for-field, kept independent of that module so the bridge has no +// runtime dependency on the feature slice it's mocking for. + +type RawUsageField = { value: string | null; incomplete: boolean }; +type RawCostField = { value: number | null; incomplete: boolean }; + +type RawReportedUsage = { + inputTokens: RawUsageField; + outputTokens: RawUsageField; + totalTokens: RawUsageField; + estimatedCostUsd: RawCostField; +}; + +type RawAgentUsageSeriesBucket = { + start: number; + end: number; + usage: RawReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +type RawAgentUsageModel = { + harness: string | null; + model: string | null; + usage: RawReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +type RawAgentUsage = { + agentPubkey: string; + usage: RawReportedUsage; + buckets: RawAgentUsageSeriesBucket[]; + models: RawAgentUsageModel[]; + reportCount: number; + hasUnknownUsage: boolean; +}; + +type RawAgentUsageSeries = { + collectionEnabled: boolean; + buckets: RawAgentUsageSeriesBucket[]; + agents: RawAgentUsage[]; + coverage: { + firstArchivedAt: number | null; + lastArchivedAt: number | null; + firstReportedAt: number | null; + lastReportedAt: number | null; + reportCount: number; + invalidReportCount: number; + hasUnknownUsage: boolean; + }; + hasArchivedEvidence: boolean | null; +}; + +const DEFAULT_MOCK_AGENT_USAGE_SERIES: RawAgentUsageSeries = { + collectionEnabled: true, + buckets: [], + agents: [], + coverage: { + firstArchivedAt: null, + lastArchivedAt: null, + firstReportedAt: null, + lastReportedAt: null, + reportCount: 0, + invalidReportCount: 0, + hasUnknownUsage: false, + }, + hasArchivedEvidence: null, +}; + type MockHuddleMemberSeed = { pubkey: string; role: "owner" | "admin" | "member" | "guest" | "bot"; @@ -169,7 +241,6 @@ type MockHuddleSeed = { ttsEnabled?: boolean; isCreator?: boolean; }; - type E2eConfig = { mode?: "mock" | "relay"; mock?: { @@ -437,6 +508,23 @@ type E2eConfig = { // snake_case wire shape the Rust backend returns so tests can drive the // LocalArchiveSettingsCard without a real SQLite database. agentMetricArchiveDefaultEnabled?: boolean; + /** + * Response for `get_agent_usage_series` (NIP-AM local agent usage, + * `desktop/src/features/agent-usage`). Mirrors + * `desktop/src/shared/api/tauriArchive.ts`'s `AgentUsageSeries` wire + * shape field-for-field so specs can seed exact fixtures without a real + * SQLite archive. Omitted → an empty, collection-enabled series (no + * agents, no coverage, `hasArchivedEvidence: null`), which renders the + * "no locally archived usage" empty state. + */ + agentUsageSeries?: RawAgentUsageSeries; + /** Sequenced `get_agent_usage_series` failures, call-count indexed + * (mirrors `addChannelMembersErrors`): a string rejects that call; + * `null` succeeds. When exhausted, the last entry repeats. Drives the + * retry error state without deleting mock config mid-test. */ + agentUsageErrors?: (string | null)[]; + /** Delay (ms) before `get_agent_usage_series` resolves; drives the loading-skeleton state. */ + agentUsageDelayMs?: number; saveSubscriptions?: Array<{ scope_type: string; scope_value: string; @@ -7629,6 +7717,7 @@ let installCallCount = 0; const installCallCountByRuntime: Record = {}; let addChannelMembersCallCount = 0; let setGlobalAgentConfigCallCount = 0; +let agentUsageSeriesCallCount = 0; let mockGlobalAgentConfig: { env_vars: Record; provider: string | null; @@ -12989,6 +13078,35 @@ export function maybeInstallE2eTauriMocks() { case "archive_events": // Returns the ArchiveBatchResult shape the UI expects. return { persisted: 0, dropped: 0 }; + case "get_agent_usage_series": { + // `AgentUsageSeriesRequest` is validated Rust-side; the mock trusts + // the seeded fixture as-is and ignores `bucketBoundaries`/`agentPubkey` + // filtering — specs seed the exact series they want per window/agent. + const configuredErrors = activeConfig?.mock?.agentUsageErrors; + if (configuredErrors && configuredErrors.length > 0) { + const index = Math.min( + agentUsageSeriesCallCount, + configuredErrors.length - 1, + ); + agentUsageSeriesCallCount += 1; + const error = configuredErrors[index]; + if (error) { + throw new Error(error); + } + } else { + agentUsageSeriesCallCount += 1; + } + const usageDelayMs = activeConfig?.mock?.agentUsageDelayMs ?? 0; + if (usageDelayMs > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, usageDelayMs), + ); + } + return ( + activeConfig?.mock?.agentUsageSeries ?? + DEFAULT_MOCK_AGENT_USAGE_SERIES + ); + } case "agent_metric_archive_default_enabled": return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? true; case "set_prevent_sleep_active": diff --git a/desktop/tests/e2e/agent-usage-screenshots.spec.ts b/desktop/tests/e2e/agent-usage-screenshots.spec.ts new file mode 100644 index 00000000000..ebcde9edf1c --- /dev/null +++ b/desktop/tests/e2e/agent-usage-screenshots.spec.ts @@ -0,0 +1,366 @@ +import { expect, test } from "@playwright/test"; + +import { + installMockBridge, + type MockAgentUsage, + type MockAgentUsageSeries, +} from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const SHOTS = "test-results/agent-usage-screenshots"; + +const usageField = (value: string | null, incomplete = false) => ({ + value, + incomplete, +}); +const costField = (value: number | null, incomplete = false) => ({ + value, + incomplete, +}); + +function reportedUsage( + overrides: Partial<{ + inputTokens: string | null; + outputTokens: string | null; + totalTokens: string | null; + estimatedCostUsd: number | null; + }> = {}, +) { + return { + estimatedCostUsd: costField(overrides.estimatedCostUsd ?? null), + inputTokens: usageField(overrides.inputTokens ?? null), + outputTokens: usageField(overrides.outputTokens ?? null), + totalTokens: usageField(overrides.totalTokens ?? null), + }; +} + +function mockAgentUsage( + agentPubkey: string, + overrides: Partial = {}, +): MockAgentUsage { + return { + agentPubkey, + buckets: [], + hasUnknownUsage: false, + models: [], + reportCount: 1, + usage: reportedUsage({ inputTokens: "1200", outputTokens: "300" }), + ...overrides, + }; +} + +function mockUsageSeries( + overrides: Partial = {}, +): MockAgentUsageSeries { + return { + agents: [], + buckets: [], + collectionEnabled: true, + coverage: { + firstArchivedAt: null, + firstReportedAt: null, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: null, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: null, + ...overrides, + }; +} + +async function openAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-usage-section")).toBeVisible({ + timeout: 10_000, + }); +} + +async function addGenericAgent( + page: import("@playwright/test").Page, + agentName: string, +): Promise { + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const channelId = await page + .getByTestId("channel-general") + .getAttribute("data-channel-id"); + if (!channelId) throw new Error("channel-general is missing data-channel-id"); + + await page.waitForFunction(() => + Boolean( + (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown }) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__, + ), + ); + + return page.evaluate( + async ({ agentName, channelId }): Promise => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise<{ agent?: { pubkey: string } }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge not installed."); + + const created = await invoke("create_managed_agent", { + input: { + name: agentName, + spawnAfterCreate: true, + systemPrompt: "Help when asked.", + }, + }); + const pubkey = created.agent?.pubkey; + if (!pubkey) + throw new Error("create_managed_agent did not return pubkey"); + + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + + await ( + window as Window & { + __BUZZ_E2E_QUERY_CLIENT__?: { + invalidateQueries: () => Promise; + }; + } + ).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries(); + + return pubkey; + }, + { agentName, channelId }, + ); +} + +async function seedSeries( + page: import("@playwright/test").Page, + series: MockAgentUsageSeries, +) { + await page.evaluate((next) => { + const w = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + w.__BUZZ_E2E__ ??= {}; + w.__BUZZ_E2E__.mock ??= {}; + w.__BUZZ_E2E__.mock.agentUsageSeries = next; + }, series); + await page.getByTestId("open-agents-view").click(); +} + +test.describe("agent usage screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + }); + + test("01-overview-usage-section", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "Usage Bot"); + + // Four buckets: known, partial/unknown, gap (—), and zero — mirrors the + // daily-bars accessible-label test in agent-usage.spec.ts. + const base = 1_700_000_000; + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + hasUnknownUsage: true, + usage: { + estimatedCostUsd: costField(null), + inputTokens: usageField(null), + outputTokens: usageField(null), + totalTokens: usageField("1500", true), + }, + }), + ], + buckets: [ + { + start: base, + end: base + 86_400, + usage: reportedUsage({ totalTokens: "700" }), + reportCount: 1, + hasUnknownUsage: false, + }, + { + start: base + 86_400, + end: base + 2 * 86_400, + usage: reportedUsage({ totalTokens: null }), + reportCount: 1, + hasUnknownUsage: true, + }, + { + start: base + 2 * 86_400, + end: base + 3 * 86_400, + usage: reportedUsage({ totalTokens: null }), + reportCount: 0, + hasUnknownUsage: false, + }, + { + start: base + 3 * 86_400, + end: base + 4 * 86_400, + usage: reportedUsage({ totalTokens: "0" }), + reportCount: 1, + hasUnknownUsage: false, + }, + ], + coverage: { + firstArchivedAt: base, + firstReportedAt: base, + hasUnknownUsage: true, + invalidReportCount: 0, + lastArchivedAt: base + 3 * 86_400, + lastReportedAt: base + 3 * 86_400, + reportCount: 3, + }, + }), + ); + + await expect(page.getByTestId("agent-usage-card")).toBeVisible(); + await expect( + page.getByTestId(`agent-usage-row-${agentPubkey}`), + ).toBeVisible(); + + const card = page.getByTestId("agent-usage-card"); + await waitForAnimations(page); + await card.screenshot({ path: `${SHOTS}/01-overview-usage-section.png` }); + }); + + test("02-focused-usage-view", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "Drilldown Bot"); + + const bucketStart = 1_700_000_000; + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + reportCount: 5, + buckets: [ + { + start: bucketStart, + end: bucketStart + 86_400, + usage: reportedUsage({ + totalTokens: "2400", + inputTokens: "1800", + outputTokens: "600", + }), + reportCount: 3, + hasUnknownUsage: false, + }, + { + start: bucketStart + 86_400, + end: bucketStart + 2 * 86_400, + usage: reportedUsage({ + totalTokens: "1200", + inputTokens: "900", + outputTokens: "300", + }), + reportCount: 2, + hasUnknownUsage: false, + }, + ], + models: [ + { + harness: "claude-code", + hasUnknownUsage: false, + model: "claude-opus-4-5", + reportCount: 4, + usage: reportedUsage({ + totalTokens: "2800", + inputTokens: "2100", + outputTokens: "700", + estimatedCostUsd: 0.35, + }), + }, + { + harness: "goose", + hasUnknownUsage: false, + model: "claude-sonnet-4-5", + reportCount: 1, + usage: reportedUsage({ + totalTokens: "800", + inputTokens: "600", + outputTokens: "200", + estimatedCostUsd: 0.04, + }), + }, + ], + usage: reportedUsage({ + estimatedCostUsd: 0.39, + inputTokens: "2700", + outputTokens: "900", + totalTokens: "3600", + }), + }), + ], + coverage: { + firstArchivedAt: bucketStart, + firstReportedAt: bucketStart, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: bucketStart + 2 * 86_400, + lastReportedAt: bucketStart + 2 * 86_400, + reportCount: 5, + }, + }), + ); + + await expect( + page.getByTestId(`agent-usage-row-${agentPubkey}`), + ).toBeVisible(); + + // Click the row to open the focused view. + await page.getByTestId(`agent-usage-row-${agentPubkey}`).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + + await expect( + page.getByTestId("agent-usage-model-harness-label").first(), + ).toBeVisible(); + + const panel = page.getByTestId("user-profile-panel"); + await waitForAnimations(page); + await panel.screenshot({ path: `${SHOTS}/02-focused-usage-view.png` }); + }); + + test("03-custom-range-picker", async ({ page }) => { + await installMockBridge(page, { agentUsageSeries: mockUsageSeries() }); + await openAgentsView(page); + + await page.getByTestId("agent-usage-window-custom").click(); + await expect( + page.getByTestId("agent-usage-window-custom-popover"), + ).toBeVisible(); + await page + .getByTestId("agent-usage-window-custom-start") + .fill("2026-01-05"); + await page.getByTestId("agent-usage-window-custom-end").fill("2026-01-19"); + await expect( + page.getByTestId("agent-usage-window-custom-summary"), + ).toBeVisible(); + + await waitForAnimations(page); + // Full-page shot: the popover is portalled outside the card element. + await page.screenshot({ path: `${SHOTS}/03-custom-range-picker.png` }); + }); +}); diff --git a/desktop/tests/e2e/agent-usage.spec.ts b/desktop/tests/e2e/agent-usage.spec.ts new file mode 100644 index 00000000000..156683a04c3 --- /dev/null +++ b/desktop/tests/e2e/agent-usage.spec.ts @@ -0,0 +1,1027 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { + installMockBridge, + TEST_IDENTITIES, + type MockAgentUsage, + type MockAgentUsageSeries, +} from "../helpers/bridge"; + +const DAY = 86_400; +const BASE = 1_700_000_000; + +/** + * A non-owned, non-managed agent (declared owner is `outsider`, not the mock + * viewer) — used by the A13 fail-closed tests where eligibility must come + * from archived evidence alone, not ownership. + */ +const HISTORICAL_AGENT_PUBKEY = "6".repeat(64); + +/** + * A non-owned, non-managed agent seeded purely via `searchProfiles` (no + * managed-agent creation) — used by the ingress-visibility test's non-owner + * leg, which must be hidden regardless of archived evidence. + */ +const NON_OWNER_AGENT_PUBKEY = "7".repeat(64); + +function getHashSearchParam(page: Page, name: string) { + const hash = new URL(page.url()).hash.replace(/^#/, ""); + const queryStart = hash.indexOf("?"); + if (queryStart === -1) { + return null; + } + return new URLSearchParams(hash.slice(queryStart + 1)).get(name); +} + +async function expectHashSearchParam( + page: Page, + name: string, + value: string | null, +) { + await expect.poll(() => getHashSearchParam(page, name)).toBe(value); +} + +function usageField(value: string | null, incomplete = false) { + return { value, incomplete }; +} + +function costField(value: number | null, incomplete = false) { + return { value, incomplete }; +} + +function reportedUsage( + overrides: Partial<{ + inputTokens: string | null; + outputTokens: string | null; + totalTokens: string | null; + estimatedCostUsd: number | null; + }> = {}, +) { + return { + estimatedCostUsd: costField(overrides.estimatedCostUsd ?? null), + inputTokens: usageField(overrides.inputTokens ?? null), + outputTokens: usageField(overrides.outputTokens ?? null), + totalTokens: usageField(overrides.totalTokens ?? null), + }; +} + +function mockAgentUsage( + agentPubkey: string, + overrides: Partial = {}, +): MockAgentUsage { + return { + agentPubkey, + buckets: [], + hasUnknownUsage: false, + models: [], + reportCount: 1, + // Default: i/o known, no totalTokens — real prod shape, exercises ≈ path. + usage: reportedUsage({ inputTokens: "1200", outputTokens: "300" }), + ...overrides, + }; +} + +function mockUsageSeries( + overrides: Partial = {}, +): MockAgentUsageSeries { + return { + agents: [], + buckets: [], + collectionEnabled: true, + coverage: { + firstArchivedAt: null, + firstReportedAt: null, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: null, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: null, + ...overrides, + }; +} + +/** A fully-populated coverage block for a window that did archive evidence. */ +function archivedCoverage(reportCount: number) { + return { + firstArchivedAt: BASE, + firstReportedAt: BASE, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: BASE + DAY, + lastReportedAt: BASE + DAY, + reportCount, + }; +} + +function dayBucket( + dayIndex: number, + usage: ReturnType, + overrides: Partial<{ reportCount: number; hasUnknownUsage: boolean }> = {}, +) { + return { + start: BASE + dayIndex * DAY, + end: BASE + (dayIndex + 1) * DAY, + usage, + reportCount: overrides.reportCount ?? 1, + hasUnknownUsage: overrides.hasUnknownUsage ?? false, + }; +} + +async function addGenericAgent( + page: Page, + channelName: string, + agentName: string, +): Promise { + await page.getByTestId(`channel-${channelName}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelName); + const channelId = await page + .getByTestId(`channel-${channelName}`) + .getAttribute("data-channel-id"); + if (!channelId) { + throw new Error(`Channel ${channelName} is missing a data-channel-id.`); + } + + await page.waitForFunction(() => { + return Boolean( + (window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown }) + .__BUZZ_E2E_INVOKE_MOCK_COMMAND__, + ); + }); + return page.evaluate( + async ({ agentName, channelId }): Promise => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise<{ agent?: { pubkey: string } }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) { + throw new Error("Mock bridge is not installed."); + } + + const created = (await invoke("create_managed_agent", { + input: { + name: agentName, + spawnAfterCreate: true, + systemPrompt: "Watch the channel and help when asked.", + }, + })) as { agent?: { pubkey: string } }; + const pubkey = created.agent?.pubkey; + if (!pubkey) { + throw new Error("Mock managed agent creation did not return a pubkey."); + } + + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + + await ( + window as Window & { + __BUZZ_E2E_QUERY_CLIENT__?: { + invalidateQueries: () => Promise; + }; + } + ).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries(); + + return pubkey; + }, + { agentName, channelId }, + ); +} + +async function openAgentsView(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-usage-section")).toBeVisible({ + timeout: 10_000, + }); +} + +/** Swap in a usage series and re-enter the Agents view so it is queried. */ +async function seedSeries(page: Page, series: MockAgentUsageSeries) { + await page.evaluate((next) => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } }; + }; + testWindow.__BUZZ_E2E__ ??= {}; + testWindow.__BUZZ_E2E__.mock ??= {}; + testWindow.__BUZZ_E2E__.mock.agentUsageSeries = next; + }, series); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-usage-section")).toBeVisible(); +} + +async function navigateToProfile( + page: Page, + pubkey: string, + profileView?: "usage", +) { + await page.evaluate( + ({ profileView, pubkey }) => { + ( + window as Window & { + __TSR_ROUTER__?: { + navigate: (opts: Record) => void; + }; + } + ).__TSR_ROUTER__?.navigate({ + to: "/agents", + search: profileView + ? { profile: pubkey, profileView } + : { profile: pubkey }, + }); + }, + { profileView, pubkey }, + ); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); +} + +test("shows a loading skeleton while the usage series is in flight", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries(), + agentUsageDelayMs: 2_000, + }); + + await openAgentsView(page); + + await expect(page.getByTestId("agent-usage-skeleton")).toBeVisible(); + await expect(page.getByTestId("agent-usage-card")).toBeVisible({ + timeout: 5_000, + }); + await expect(page.getByTestId("agent-usage-skeleton")).toHaveCount(0); +}); + +test("renders ranked agent rows and switches between the 1d, 7d, and 30d presets", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Token Bot"); + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + usage: reportedUsage({ + inputTokens: "1200", + outputTokens: "300", + totalTokens: "1500", + }), + }), + ], + coverage: archivedCoverage(1), + }), + ); + + const row = page.getByTestId(`agent-usage-row-${agentPubkey}`); + await expect(row).toBeVisible(); + await expect(row).toContainText("1.5K"); + await expect(row).toContainText("Token Bot"); + + await expect(page.getByTestId("agent-usage-window-7")).toHaveAttribute( + "data-state", + "active", + ); + + for (const preset of ["30", "1"]) { + await page.getByTestId(`agent-usage-window-${preset}`).click(); + await expect( + page.getByTestId(`agent-usage-window-${preset}`), + ).toHaveAttribute("data-state", "active"); + // Switching presets must never blank the card. + await expect(page.getByTestId("agent-usage-card")).toBeVisible(); + } +}); + +test("clicking an agent row opens the profile panel's Usage focused view", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Drilldown Bot"); + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + models: [ + { + harness: "goose", + hasUnknownUsage: false, + model: "claude-opus", + reportCount: 1, + usage: reportedUsage({ totalTokens: "1500" }), + }, + ], + usage: reportedUsage({ + estimatedCostUsd: 0.42, + inputTokens: "1200", + outputTokens: "300", + totalTokens: "1500", + }), + }), + ], + }), + ); + + await page.getByTestId(`agent-usage-row-${agentPubkey}`).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + await expect(page.getByTestId("agent-usage-focused-totals")).toContainText( + "1,500", + ); + const models = page.getByTestId("agent-usage-focused-models"); + await expect(models).toContainText("claude-opus"); + await expect(models).toContainText("goose"); + + // Also verify the Info-tab ingress row entry point, not just row-click. + await page.getByTestId("user-profile-panel-back").click(); + await expect(page.getByTestId("user-profile-tab-info")).toBeVisible(); + await page.getByTestId(`user-profile-view-usage-${agentPubkey}`).click(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); +}); + +test("Info-tab Usage ingress is visible for an owner-viewed agent, and absent for a human or a non-owner agent", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries(), + searchProfiles: [ + { + pubkey: NON_OWNER_AGENT_PUBKEY, + displayName: "Someone Else's Bot", + isAgent: true, + ownerPubkey: TEST_IDENTITIES.outsider.pubkey, + }, + ], + }); + + // Owner case: locally-managed agent is owned by the mock viewer → canViewUsage true. + await openAgentsView(page); + const ownedAgentPubkey = await addGenericAgent(page, "general", "Own Bot"); + await seedSeries( + page, + mockUsageSeries({ agents: [mockAgentUsage(ownedAgentPubkey)] }), + ); + await page.getByTestId(`agent-usage-row-${ownedAgentPubkey}`).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await page.getByTestId("user-profile-panel-back").click(); + await expect(page.getByTestId("user-profile-tab-info")).toBeVisible(); + await expect( + page.getByTestId(`user-profile-view-usage-${ownedAgentPubkey}`), + ).toBeVisible(); + + // Human case: canViewUsage requires isBot — row is absent for any human profile. + await navigateToProfile(page, TEST_IDENTITIES.bob.pubkey); + await expect( + page.getByTestId(`user-profile-view-usage-${TEST_IDENTITIES.bob.pubkey}`), + ).toHaveCount(0); + + // Non-owner agent case: isBot=true but viewerIsOwner=false → row stays hidden. + await navigateToProfile(page, NON_OWNER_AGENT_PUBKEY); + await expect( + page.getByTestId(`user-profile-view-usage-${NON_OWNER_AGENT_PUBKEY}`), + ).toHaveCount(0); +}); + +test("surfaces a retry affordance when the usage query fails, and recovers on retry", async ({ + page, +}) => { + // React Query auto-retries once (queryClient.ts `retry: 1`), consuming one + // sequence entry silently — two failures are needed before the error UI shows. + await installMockBridge(page, { + agentUsageErrors: ["archive unavailable", "archive unavailable", null], + }); + + await openAgentsView(page); + + const error = page.getByTestId("agent-usage-error"); + await expect(error).toBeVisible(); + await expect(error).toContainText("Couldn't load usage data."); + + await error.getByRole("button", { name: "Retry" }).click(); + + await expect(page.getByTestId("agent-usage-card")).toBeVisible(); + await expect(page.getByTestId("agent-usage-error")).toHaveCount(0); +}); + +test("shows the empty state when collection is on but nothing has been archived yet", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries(), + }); + + await openAgentsView(page); + + const empty = page.getByTestId("agent-usage-empty"); + await expect(empty).toBeVisible(); + await expect(empty).toContainText("No locally archived usage"); + await expect(page.getByTestId("agent-usage-collection-off")).toHaveCount(0); +}); + +test("shows the collection-off banner with a settings deep link", async ({ + page, +}) => { + await installMockBridge(page, { + agentUsageSeries: mockUsageSeries({ collectionEnabled: false }), + }); + + await openAgentsView(page); + + const banner = page.getByTestId("agent-usage-collection-off"); + await expect(banner).toBeVisible(); + await expect(banner).toContainText("Local usage collection is off."); + await expect(page.getByTestId("agent-usage-empty")).toContainText( + "Turn on collection", + ); + + await banner + .getByRole("button", { name: "Open Local Archive settings" }) + .click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await expect(page.getByTestId("settings-local-archive")).toBeVisible({ + timeout: 10_000, + }); + + await page.goBack(); + await expect(page.getByTestId("agents-usage-section")).toBeVisible(); +}); + +// A13 fail-closed: focused view eligibility = ownership OR archived evidence, +// resolved once the author-filtered query completes. These tests deep-link +// straight to `?profileView=usage` for a non-owned agent to bypass ownership. +async function openUsageViewForHistoricalAgent( + page: Page, + agentUsageSeries: MockAgentUsageSeries, +) { + await installMockBridge(page, { + agentUsageSeries, + searchProfiles: [ + { + pubkey: HISTORICAL_AGENT_PUBKEY, + displayName: "Historical Bot", + isAgent: true, + ownerPubkey: TEST_IDENTITIES.outsider.pubkey, + }, + ], + }); + await page.goto("/#/agents", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("agents-usage-section")).toBeVisible({ + timeout: 10_000, + }); + await navigateToProfile(page, HISTORICAL_AGENT_PUBKEY, "usage"); +} + +test("a historical author with 30d-only archived evidence gets a valid empty 7d focused view, not a redirect", async ({ + page, +}) => { + await openUsageViewForHistoricalAgent( + page, + mockUsageSeries({ agents: [], hasArchivedEvidence: true }), + ); + + const outsideWindow = page.getByTestId("agent-usage-focused-outside-window"); + await expect(outsideWindow).toBeVisible(); + await expect(outsideWindow).toContainText("Try a wider window."); + + // Eligible via archived evidence alone (no ownership) — never bounced. + await expectHashSearchParam(page, "profileView", "usage"); + const panel = page.getByTestId("user-profile-panel"); + await expect( + panel.getByRole("heading", { level: 2, name: "Usage" }), + ).toBeVisible(); +}); + +test("a hand-authored usage URL with no ownership and no archived evidence falls back to summary", async ({ + page, +}) => { + await openUsageViewForHistoricalAgent( + page, + mockUsageSeries({ agents: [], hasArchivedEvidence: null }), + ); + + // The redirect from "usage" → null may fire before the first poll + // observes profileView=usage — skip that transient assertion; the null + // landing + summary heading carry the contract. + await expectHashSearchParam(page, "profileView", null); + await expect(page.getByTestId("agent-usage-focused-view")).toHaveCount(0); + const panel = page.getByTestId("user-profile-panel"); + await expect( + panel.getByRole("heading", { level: 2, name: "Profile" }), + ).toBeVisible(); +}); + +test("daily bars label the date on the axis and the total on the bar, and their tooltips report unknown fields as unknown", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Axis Bot"); + + const knownStart = BASE; + const unknownStart = BASE + DAY; + await seedSeries( + page, + mockUsageSeries({ + agents: [mockAgentUsage(agentPubkey, { buckets: [] })], + buckets: [ + dayBucket( + 0, + reportedUsage({ + inputTokens: "1200", + outputTokens: "300", + totalTokens: "1500", + }), + ), + dayBucket( + 1, + reportedUsage({ + inputTokens: "1200", + outputTokens: null, + totalTokens: null, + }), + { hasUnknownUsage: true }, + ), + ], + }), + ); + await expect(page.getByTestId("agent-usage-overall-bars")).toBeVisible(); + + // x-axis tick must be the DATE, not the token total — asserted via the same + // toLocaleDateString call the component makes (locale/TZ-independent). + const expectedDate = await page.evaluate( + (start) => + new Date(start * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }), + knownStart, + ); + const dateTick = page.getByTestId(`agent-usage-daily-bar-date-${knownStart}`); + await expect(dateTick).toHaveText(expectedDate); + // The pre-fix chart labeled the axis with the value — ensure that's not the case. + await expect(dateTick).not.toContainText("1.5K"); + + // Token total renders ON the bar; an uncountable day shows em-dash, not zero. + await expect( + page.getByTestId(`agent-usage-daily-bar-value-${knownStart}`), + ).toHaveText("1.5K"); + await expect( + page.getByTestId(`agent-usage-daily-bar-value-${unknownStart}`), + ).toHaveText("—"); + + // Aria labels: countable day → "reported tokens", unknown day → "unknown usage". + const ariaLabel = (start: number) => + page + .getByTestId(`agent-usage-daily-bar-${start}`) + .locator("[aria-label]") + .first() + .getAttribute("aria-label"); + expect(await ariaLabel(knownStart)).toMatch(/reported tokens/i); + expect(await ariaLabel(unknownStart)).toMatch(/unknown usage/i); + + // Hover tooltip: exact breakdown must show without opening the focused view. + await page.getByTestId(`agent-usage-daily-bar-${knownStart}`).hover(); + const knownTooltip = page + .getByTestId(`agent-usage-daily-bar-tooltip-${knownStart}`) + .first(); + await expect(knownTooltip).toBeVisible({ timeout: 10_000 }); + await expect(knownTooltip).toContainText("Total: 1,500"); + await expect(knownTooltip).toContainText("Input: 1,200"); + await expect(knownTooltip).toContainText("Output: 300"); +}); + +test("a bar tooltip reports an unknown field as unknown rather than zero", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Halfknown Bot"); + + const bucketStart = BASE + DAY; + await seedSeries( + page, + mockUsageSeries({ + agents: [mockAgentUsage(agentPubkey, { buckets: [] })], + buckets: [ + // Output is genuinely unreported: it must read "unknown", and the + // total must stay unknown rather than being derived from input. + dayBucket( + 1, + reportedUsage({ + inputTokens: "1200", + outputTokens: null, + totalTokens: null, + }), + { hasUnknownUsage: true }, + ), + ], + }), + ); + await expect(page.getByTestId("agent-usage-overall-bars")).toBeVisible(); + + await page.getByTestId(`agent-usage-daily-bar-${bucketStart}`).hover(); + const tooltip = page + .getByTestId(`agent-usage-daily-bar-tooltip-${bucketStart}`) + .first(); + await expect(tooltip).toBeVisible({ timeout: 10_000 }); + await expect(tooltip).toContainText("Total: unknown"); + await expect(tooltip).toContainText("Input: 1,200"); + await expect(tooltip).toContainText("Output: unknown"); + await expect(tooltip).not.toContainText("Output: 0"); +}); + +// Regression guard: null totalTokens with known i/o must render ≈, not "No usage reported". +test("overview row, header, daily bar, and focused total all render ≈ when totalTokens is null but i/o is known", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Approx Bot"); + // Real prod shape — no provider total, both i/o known. + const approxUsage = reportedUsage({ + inputTokens: "800", + outputTokens: "200", + totalTokens: null, + }); + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + buckets: [dayBucket(0, approxUsage)], + usage: approxUsage, + }), + ], + buckets: [dayBucket(0, approxUsage)], + coverage: archivedCoverage(1), + }), + ); + + const row = page.getByTestId(`agent-usage-row-${agentPubkey}`); + await expect(row).toBeVisible(); + await expect(row).toContainText("≈"); + await expect(row).not.toContainText("No usage reported"); + + // Assert value node directly so the header fails independently of daily bars. + const headerValue = page.getByTestId("agent-usage-header-value"); + await expect(headerValue).toContainText("≈"); + await expect(headerValue).not.toContainText("No usage reported"); + + await expect(page.getByTestId("agent-usage-daily-bars")).toContainText("≈"); + + // Assert focused total value node directly so it fails independently. + await row.click(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + const focusedTotalValue = page.getByTestId("agent-usage-focused-total-value"); + await expect(focusedTotalValue).toContainText("≈"); + await expect(focusedTotalValue).not.toHaveText("—"); +}); +test("the custom range picker applies an arbitrary date span and labels it in the empty state", async ({ + page, +}) => { + await installMockBridge(page, { agentUsageSeries: mockUsageSeries() }); + await openAgentsView(page); + + await page.getByTestId("agent-usage-window-custom").click(); + await expect( + page.getByTestId("agent-usage-window-custom-popover"), + ).toBeVisible(); + + await page.getByTestId("agent-usage-window-custom-start").fill("2026-01-05"); + await page.getByTestId("agent-usage-window-custom-end").fill("2026-01-09"); + await expect( + page.getByTestId("agent-usage-window-custom-summary"), + ).toContainText("5 days selected"); + + await page.getByTestId("agent-usage-window-custom-apply").click(); + await expect( + page.getByTestId("agent-usage-window-custom-popover"), + ).toHaveCount(0); + + // Custom tab is now active; empty-state names the applied span, not "last N days". + await expect(page.getByTestId("agent-usage-window-custom")).toHaveAttribute( + "data-state", + "active", + ); + const empty = page.getByTestId("agent-usage-empty"); + await expect(empty).toBeVisible(); + await expect(empty).toContainText("2026"); + await expect(empty).not.toContainText("the last 7 days"); +}); + +test("the custom range picker blocks an inverted range and a span over one year", async ({ + page, +}) => { + await installMockBridge(page, { agentUsageSeries: mockUsageSeries() }); + await openAgentsView(page); + + await page.getByTestId("agent-usage-window-custom").click(); + const apply = page.getByTestId("agent-usage-window-custom-apply"); + const error = page.getByTestId("agent-usage-window-custom-error"); + + await page.getByTestId("agent-usage-window-custom-start").fill("2026-03-10"); + await page.getByTestId("agent-usage-window-custom-end").fill("2026-03-01"); + await expect(error).toContainText("on or before"); + await expect(apply).toBeDisabled(); + + // 2024-01-01 → 2025-01-01 is 367 civil days: one past the cap. + // The picker must block it locally, not let it surface a Rust error. + await page.getByTestId("agent-usage-window-custom-start").fill("2024-01-01"); + await page.getByTestId("agent-usage-window-custom-end").fill("2025-01-01"); + await expect(error).toContainText("366 days or fewer"); + await expect(apply).toBeDisabled(); + + // Pulling the end back to the cap clears the error and enables Apply. + await page.getByTestId("agent-usage-window-custom-end").fill("2024-12-31"); + await expect( + page.getByTestId("agent-usage-window-custom-summary"), + ).toContainText("366 days selected"); + await expect(apply).toBeEnabled(); +}); + +test("the focused view exposes its own independent range selector", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Focused Bot"); + await seedSeries( + page, + mockUsageSeries({ agents: [mockAgentUsage(agentPubkey)] }), + ); + + await page.getByTestId(`agent-usage-row-${agentPubkey}`).click(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + + await page.getByTestId("agent-usage-focused-window-1").click(); + await expect( + page.getByTestId("agent-usage-focused-window-1"), + ).toHaveAttribute("data-state", "active"); + + // The overview's own selector is unaffected — the two windows are independent. + await expect(page.getByTestId("agent-usage-window-7")).toHaveAttribute( + "data-state", + "active", + ); +}); + +test("the usage section renders below the agents and teams sections", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const order = await page.evaluate(() => { + const testIds = [ + "agents-library-personas", + "agents-library-teams", + "agents-usage-section", + ]; + return testIds.map((testId) => { + const element = document.querySelector(`[data-testid="${testId}"]`); + return element === null + ? null + : element.getBoundingClientRect().top + window.scrollY; + }); + }); + + const [personasTop, teamsTop, usageTop] = order; + expect(personasTop).not.toBeNull(); + expect(teamsTop).not.toBeNull(); + expect(usageTop).not.toBeNull(); + expect(usageTop as number).toBeGreaterThan(personasTop as number); + expect(usageTop as number).toBeGreaterThan(teamsTop as number); +}); + +// ── Branch-coverage tests (restored compact form after trim, per Thufir pass-1) ─ + +test("Partial badge renders in the row, bar, and ingress when total is an incomplete lower bound", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Partial Bot"); + // null total + incomplete input → approx-partial display: row shows Partial + // badge, bar shows ≈N* (approx-partial trailing), ingress trailing says "Partial". + const partialBucket = { + start: BASE, + end: BASE + DAY, + hasUnknownUsage: false, + reportCount: 1, + usage: { + estimatedCostUsd: costField(null), + inputTokens: usageField("800", true), // incomplete + outputTokens: usageField("200", false), + totalTokens: usageField(null), + }, + }; + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + buckets: [partialBucket], + usage: { + estimatedCostUsd: costField(null), + inputTokens: usageField("800", true), // incomplete + outputTokens: usageField("200", false), + totalTokens: usageField(null), + }, + }), + ], + buckets: [partialBucket], + }), + ); + + const row = page.getByTestId(`agent-usage-row-${agentPubkey}`); + await expect(row).toBeVisible(); + // Row: Partial badge present (dt.partial = true because inputTokens.incomplete). + await expect(row.getByText("Partial", { exact: true })).toBeVisible(); + + // Bar: approx-partial bucket renders ≈N* (the trailing * is the partial signal). + const dailyBars = page.getByTestId("agent-usage-daily-bars"); + await expect(dailyBars).toBeVisible(); + await expect(dailyBars).toContainText("≈1K*"); + + // Ingress: navigate to the Info tab and verify the trailing shows "Partial". + await row.click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await page.getByTestId("user-profile-panel-back").click(); + await expect(page.getByTestId("user-profile-tab-info")).toBeVisible(); + const ingressRow = page.getByTestId(`user-profile-view-usage-${agentPubkey}`); + await expect(ingressRow).toBeVisible(); + await expect(ingressRow).toContainText("Partial"); +}); + +test("focused view renders the unknown-intervals and invalid-reports caveats under their independent gate conditions", async ({ + page, +}) => { + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Coverage Bot"); + + // State 1: incomplete I/O (inputTokens.incomplete=true) with invalidReportCount=0. + // Only the unknown-intervals caveat must fire; invalid-reports must be absent. + // This proves the unknown-intervals gate is I/O-incompleteness, not invalidReportCount. + await test.step("incomplete I/O, no invalid reports → only unknown-intervals caveat", async () => { + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + hasUnknownUsage: true, + reportCount: 2, + usage: { + estimatedCostUsd: costField(null), + inputTokens: usageField("400", true), // incomplete → unknown-intervals fires + outputTokens: usageField("100", false), + totalTokens: usageField("500"), + }, + }), + ], + coverage: { + firstArchivedAt: BASE, + firstReportedAt: BASE, + hasUnknownUsage: true, + invalidReportCount: 0, // absent → invalid-reports must NOT fire + lastArchivedAt: BASE + DAY, + lastReportedAt: BASE + DAY, + reportCount: 2, + }, + }), + ); + await page.getByTestId(`agent-usage-row-${agentPubkey}`).click(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + await expect(page.getByTestId("agent-usage-focused-totals")).toBeVisible(); + await expect( + page.getByTestId("agent-usage-focused-unknown-intervals-caveat"), + ).toBeVisible(); + await expect( + page.getByTestId("agent-usage-focused-invalid-reports-caveat"), + ).toHaveCount(0); + }); + + // State 2: complete I/O (no incomplete flag) with invalidReportCount>0, + // AND hasUnknownUsage=true (totalTokens incomplete) to decouple hasUnknownUsage + // from I/O incompleteness. Per the gate comment in AgentUsageFocusedView.tsx, + // hasUnknownUsage ORs total/cost incompleteness — which cannot prove an I/O + // interval claim. A regression gating unknown-intervals on hasUnknownUsage + // would wrongly fire here and fail the toHaveCount(0) assertion. + // seedSeries navigates back to the agents overview, triggering a fresh query. + await test.step("complete I/O, invalid reports present → only invalid-reports caveat", async () => { + await seedSeries( + page, + mockUsageSeries({ + agents: [ + mockAgentUsage(agentPubkey, { + hasUnknownUsage: true, // totalTokens incomplete → hasUnknownUsage true + reportCount: 2, + usage: { + estimatedCostUsd: costField(null), + inputTokens: usageField("400", false), // complete → unknown-intervals must NOT fire + outputTokens: usageField("100", false), + totalTokens: usageField("500", true), // incomplete total → hasUnknownUsage + }, + }), + ], + coverage: { + firstArchivedAt: BASE, + firstReportedAt: BASE, + hasUnknownUsage: true, // mirrors agent-level: total incomplete, not I/O + invalidReportCount: 1, // > 0 → invalid-reports fires + lastArchivedAt: BASE + DAY, + lastReportedAt: BASE + DAY, + reportCount: 2, + }, + }), + ); + // The focused-view query has staleTime=60s — explicitly invalidate the React + // Query cache so state 2 re-fetches from the updated mock rather than serving + // state 1's cached response. + await page.evaluate(() => + ( + window as Window & { + __BUZZ_E2E_QUERY_CLIENT__?: { + invalidateQueries: () => Promise; + }; + } + ).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries(), + ); + await page.getByTestId(`agent-usage-row-${agentPubkey}`).click(); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + await expect(page.getByTestId("agent-usage-focused-totals")).toBeVisible(); + await expect( + page.getByTestId("agent-usage-focused-invalid-reports-caveat"), + ).toBeVisible(); + await expect( + page.getByTestId("agent-usage-focused-unknown-intervals-caveat"), + ).toHaveCount(0); + }); +}); + +test("overview and focused view distinguish an invalid-only window from ordinary empty windows", async ({ + page, +}) => { + // An invalid-only window: invalidReportCount > 0 but agents[] and buckets[] + // are empty (invalid rows are excluded from bucketing per A5/A11). The + // overview must NOT say "No locally archived usage" (ordinary-absent text) + // and the focused view must NOT show "outside-window" — both would mislabel + // in-window-but-uncountable evidence as absent. + await installMockBridge(page); + await openAgentsView(page); + + const agentPubkey = await addGenericAgent(page, "general", "Invalid Bot"); + await seedSeries( + page, + mockUsageSeries({ + agents: [], + buckets: [], + coverage: { + firstArchivedAt: BASE, + firstReportedAt: null, + hasUnknownUsage: true, + invalidReportCount: 2, // the signal — no valid rows, but evidence exists + lastArchivedAt: BASE + DAY, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: true, // A13: true for invalid-only windows too + }), + ); + + // Overview: must reflect uncountable evidence, not ordinary absence. + const empty = page.getByTestId("agent-usage-empty"); + await expect(empty).toBeVisible(); + await expect(empty).toContainText("could not be counted"); + await expect(empty).not.toContainText("No locally archived usage"); + + // Focused view: invalid-only state, NOT outside-window. + await navigateToProfile(page, agentPubkey, "usage"); + await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible(); + await expect( + page.getByTestId("agent-usage-focused-invalid-only"), + ).toBeVisible(); + await expect( + page.getByTestId("agent-usage-focused-outside-window"), + ).toHaveCount(0); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f7a6c4ccecc..a46f2961abc 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -128,6 +128,62 @@ export type MockAgentMemoryListing = { fetchedAt: number; }; +// ── Agent usage (NIP-AM) mock wire shapes ──────────────────────────────────── +// Mirrors `desktop/src/shared/api/tauriArchive.ts`'s camelCase types +// field-for-field, kept local (no cross-file type import) to match this +// file's existing Mock* type convention. + +export type MockUsageField = { value: string | null; incomplete: boolean }; +export type MockCostField = { value: number | null; incomplete: boolean }; + +export type MockReportedUsage = { + inputTokens: MockUsageField; + outputTokens: MockUsageField; + totalTokens: MockUsageField; + estimatedCostUsd: MockCostField; +}; + +export type MockAgentUsageSeriesBucket = { + start: number; + end: number; + usage: MockReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type MockAgentUsageModel = { + harness: string | null; + model: string | null; + usage: MockReportedUsage; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type MockAgentUsage = { + agentPubkey: string; + usage: MockReportedUsage; + buckets: MockAgentUsageSeriesBucket[]; + models: MockAgentUsageModel[]; + reportCount: number; + hasUnknownUsage: boolean; +}; + +export type MockAgentUsageSeries = { + collectionEnabled: boolean; + buckets: MockAgentUsageSeriesBucket[]; + agents: MockAgentUsage[]; + coverage: { + firstArchivedAt: number | null; + lastArchivedAt: number | null; + firstReportedAt: number | null; + lastReportedAt: number | null; + reportCount: number; + invalidReportCount: number; + hasUnknownUsage: boolean; + }; + hasArchivedEvidence: boolean | null; +}; + /** Result returned by the `install_acp_runtime` mock command. */ type MockInstallRuntimeResult = { success: boolean; @@ -143,7 +199,6 @@ type MockInstallRuntimeResult = { /** Install log the failure message points at. Omitted = no log was written. */ log_path?: string | null; }; - type MockBridgeOptions = { /** Tauri window label exposed to the app. Defaults to the main window. */ windowLabel?: string; @@ -337,6 +392,20 @@ type MockBridgeOptions = { websocketConnectErrors?: string[]; stallWebsocketSends?: boolean; userSearchDelayMs?: number; + /** + * Response for the mocked `get_agent_usage_series` command (NIP-AM local + * agent usage). Mirrors `desktop/src/shared/api/tauriArchive.ts`'s + * `AgentUsageSeries` wire shape field-for-field; see e2eBridge mock config + * for the same shape and default (empty, collection-enabled series). + */ + agentUsageSeries?: MockAgentUsageSeries; + /** Sequenced `get_agent_usage_series` failures, call-count indexed + * (mirrors `addChannelMembersErrors`): a string rejects that call; `null` + * succeeds. When exhausted, the last entry repeats. Drives the retry + * error state without deleting mock config mid-test. */ + agentUsageErrors?: (string | null)[]; + /** Delay (ms) before `get_agent_usage_series` resolves; drives the loading-skeleton state. */ + agentUsageDelayMs?: number; // NIP-IA gate inputs — drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. /** From d71c2a0010faed52f930169f4b014749d931ad2a Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 10 Aug 2026 11:59:47 -0400 Subject: [PATCH 2/2] chore(desktop): absorb ReportedUsage M2 cache-field drift into agent-usage mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's ReportedUsage now carries three required fields added by the M2 cache-column expansion (D6): cacheReadTokens, cacheWriteTokens, and freshInputTokens. Add them to every mock/fixture ReportedUsage site so the bridge wire shapes and e2e fixture helpers match the live type. All new fields default to { value: null, incomplete: false } — the canonical "no cache data in scope" shape per field docs. Displaying the cache fields in the UI is out of scope for this PR. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../features/agent-usage/lib/agentUsage.test.mjs | 3 +++ desktop/src/testing/e2eBridge.ts | 3 +++ desktop/tests/e2e/agent-usage-screenshots.spec.ts | 6 ++++++ desktop/tests/e2e/agent-usage.spec.ts | 15 +++++++++++++++ desktop/tests/helpers/bridge.ts | 3 +++ 5 files changed, 30 insertions(+) diff --git a/desktop/src/features/agent-usage/lib/agentUsage.test.mjs b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs index a0880a7e3e2..0ec696680a4 100644 --- a/desktop/src/features/agent-usage/lib/agentUsage.test.mjs +++ b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs @@ -39,6 +39,9 @@ function reportedUsage(overrides = {}) { outputTokens: usageField(), totalTokens: usageField(), estimatedCostUsd: usageField(), + cacheReadTokens: usageField(), + cacheWriteTokens: usageField(), + freshInputTokens: usageField(), ...overrides, }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f4e9f542019..2016919d487 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -167,6 +167,9 @@ type RawReportedUsage = { outputTokens: RawUsageField; totalTokens: RawUsageField; estimatedCostUsd: RawCostField; + cacheReadTokens: RawUsageField; + cacheWriteTokens: RawUsageField; + freshInputTokens: RawUsageField; }; type RawAgentUsageSeriesBucket = { diff --git a/desktop/tests/e2e/agent-usage-screenshots.spec.ts b/desktop/tests/e2e/agent-usage-screenshots.spec.ts index ebcde9edf1c..c1145c644d9 100644 --- a/desktop/tests/e2e/agent-usage-screenshots.spec.ts +++ b/desktop/tests/e2e/agent-usage-screenshots.spec.ts @@ -31,6 +31,9 @@ function reportedUsage( inputTokens: usageField(overrides.inputTokens ?? null), outputTokens: usageField(overrides.outputTokens ?? null), totalTokens: usageField(overrides.totalTokens ?? null), + cacheReadTokens: usageField(null), + cacheWriteTokens: usageField(null), + freshInputTokens: usageField(null), }; } @@ -187,6 +190,9 @@ test.describe("agent usage screenshots", () => { inputTokens: usageField(null), outputTokens: usageField(null), totalTokens: usageField("1500", true), + cacheReadTokens: usageField(null), + cacheWriteTokens: usageField(null), + freshInputTokens: usageField(null), }, }), ], diff --git a/desktop/tests/e2e/agent-usage.spec.ts b/desktop/tests/e2e/agent-usage.spec.ts index 156683a04c3..87b621bf35e 100644 --- a/desktop/tests/e2e/agent-usage.spec.ts +++ b/desktop/tests/e2e/agent-usage.spec.ts @@ -62,6 +62,9 @@ function reportedUsage( inputTokens: usageField(overrides.inputTokens ?? null), outputTokens: usageField(overrides.outputTokens ?? null), totalTokens: usageField(overrides.totalTokens ?? null), + cacheReadTokens: usageField(null), + cacheWriteTokens: usageField(null), + freshInputTokens: usageField(null), }; } @@ -832,6 +835,9 @@ test("Partial badge renders in the row, bar, and ingress when total is an incomp inputTokens: usageField("800", true), // incomplete outputTokens: usageField("200", false), totalTokens: usageField(null), + cacheReadTokens: usageField(null), + cacheWriteTokens: usageField(null), + freshInputTokens: usageField(null), }, }; await seedSeries( @@ -845,6 +851,9 @@ test("Partial badge renders in the row, bar, and ingress when total is an incomp inputTokens: usageField("800", true), // incomplete outputTokens: usageField("200", false), totalTokens: usageField(null), + cacheReadTokens: usageField(null), + cacheWriteTokens: usageField(null), + freshInputTokens: usageField(null), }, }), ], @@ -896,6 +905,9 @@ test("focused view renders the unknown-intervals and invalid-reports caveats und inputTokens: usageField("400", true), // incomplete → unknown-intervals fires outputTokens: usageField("100", false), totalTokens: usageField("500"), + cacheReadTokens: usageField(null), + cacheWriteTokens: usageField(null), + freshInputTokens: usageField(null), }, }), ], @@ -941,6 +953,9 @@ test("focused view renders the unknown-intervals and invalid-reports caveats und inputTokens: usageField("400", false), // complete → unknown-intervals must NOT fire outputTokens: usageField("100", false), totalTokens: usageField("500", true), // incomplete total → hasUnknownUsage + cacheReadTokens: usageField(null), + cacheWriteTokens: usageField(null), + freshInputTokens: usageField(null), }, }), ], diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index a46f2961abc..5fe88cae30c 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -141,6 +141,9 @@ export type MockReportedUsage = { outputTokens: MockUsageField; totalTokens: MockUsageField; estimatedCostUsd: MockCostField; + cacheReadTokens: MockUsageField; + cacheWriteTokens: MockUsageField; + freshInputTokens: MockUsageField; }; export type MockAgentUsageSeriesBucket = {