diff --git a/packages/app/e2e/browser/usage-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts index 72f9a7a6d3..0c266183c6 100644 --- a/packages/app/e2e/browser/usage-composer-pill.spec.ts +++ b/packages/app/e2e/browser/usage-composer-pill.spec.ts @@ -4,11 +4,18 @@ import { expectComposerVisible, submitMessage } from "../support/helpers/compose import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; -function agentEntry(usedPct: number): UsageReportEntry { +const REPORT_ID = "fixture:fixture-account"; + +// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock. +function twoHoursAgo(): string { + return new Date(Date.now() - 2 * 60 * 60_000).toISOString(); +} + +function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry { return { - id: "fixture:fixture-account", - account: {}, - fetchedAt: "2026-01-01T00:00:00.000Z", + id: REPORT_ID, + account: { label: "Fixture account" }, + fetchedAt, sourceId: "fixture", sourceLabel: "Fixture plan", icon: '', @@ -35,30 +42,118 @@ async function openMockAgent(page: Page) { } test.describe("usage composer pill", () => { - test("shows the headline percent and opens the usage card with a forced refresh", async ({ + test("opens the cached report without a request and shows when it was fetched", async ({ page, }) => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { - agentReportIds: ["fixture:fixture-account"], - lists: [[agentEntry(42)], [agentEntry(64)]], + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42, twoHoursAgo())]], }); const session = await openMockAgent(page); try { const pill = page.getByTestId("usage-composer-pill"); await expect(pill).toContainText("42%", { timeout: 30_000 }); expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId }); + expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]); - const requestsBeforeOpen = usage.listRequests().length; await pill.click(); - await usage.waitForListRequests(requestsBeforeOpen + 1); - expect(usage.listRequests().at(-1)?.forceRefresh).toBe(true); - const popover = page.getByTestId("usage-composer-popover"); await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible(); await expect(popover.getByText("Test plan")).toBeVisible(); + await expect(popover.getByText("42%")).toBeVisible(); + + await popover.getByTestId("usage-card-header").hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago"); + await expect(popover.getByTestId("usage-freshness")).toHaveCount(0); + expect(usage.listRequests()).toHaveLength(1); + } finally { + await session.cleanup(); + } + }); + + test("refreshes only its report from the popover", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + await pill.click(); + const popover = page.getByTestId("usage-composer-popover"); + await expect(popover.getByText("42%")).toBeVisible(); + + await popover.getByTestId("usage-refresh").click(); + await usage.waitForListRequests(2); + expect(usage.listRequests()).toEqual([ + { forceRefresh: false, reportIds: [REPORT_ID] }, + { forceRefresh: true, reportIds: [REPORT_ID] }, + ]); await expect(popover.getByText("64%")).toBeVisible(); await expect(pill).toContainText("64%"); + await expect(popover.getByTestId("usage-refresh")).toHaveText("Refresh"); + + await popover.getByTestId("usage-card-header").hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now"); + expect(usage.listRequests()).toHaveLength(2); + } finally { + await session.cleanup(); + } + }); + + test("keeps the previous report and says so when a refresh fails", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [ + [agentEntry(42, twoHoursAgo())], + { error: "Usage source timed out" }, + () => [agentEntry(64, new Date().toISOString())], + ], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + await pill.click(); + const popover = page.getByTestId("usage-composer-popover"); + const refresh = popover.getByTestId("usage-refresh"); + + await refresh.click(); + await usage.waitForListRequests(2); + await expect(popover.getByTestId("usage-refresh-error")).toHaveText( + "Unable to refresh usage", + ); + await expect(popover.getByText("42%")).toBeVisible(); + await expect(pill).toContainText("42%"); + + await refresh.click(); + await usage.waitForListRequests(3); + await expect(popover.getByText("64%")).toBeVisible(); + await expect(popover.getByTestId("usage-refresh-error")).toHaveText(""); + } finally { + await session.cleanup(); + } + }); + + test("prints the freshness on the card where there is no hover", async ({ page }) => { + test.setTimeout(180_000); + await page.setViewportSize({ width: 390, height: 844 }); + const usage = await installUsageReportsFixture(page, { + agentReportIds: [REPORT_ID], + lists: [[agentEntry(42, twoHoursAgo())]], + }); + const session = await openMockAgent(page); + try { + const pill = page.getByTestId("usage-composer-pill"); + await expect(pill).toContainText("42%", { timeout: 30_000 }); + await pill.click(); + const popover = page.getByTestId("usage-composer-popover"); + await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago"); + expect(usage.listRequests()).toHaveLength(1); } finally { await session.cleanup(); } @@ -67,7 +162,7 @@ test.describe("usage composer pill", () => { test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { - agentReportIds: ["fixture:fixture-account"], + agentReportIds: [REPORT_ID], lists: [[agentEntry(42)]], }); const session = await openMockAgent(page); @@ -107,7 +202,7 @@ test.describe("usage composer pill", () => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { usageSources: false, - agentReportIds: ["fixture:fixture-account"], + agentReportIds: [REPORT_ID], lists: [[agentEntry(42)]], }); const session = await openMockAgent(page); diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 6b2cbbc1c1..6a43d0fe30 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -10,14 +10,19 @@ export interface UsageReportsFixture { } interface UsageReportsFixtureOptions { - /** Successive `usage.list_reports` responses; the last one repeats. */ - lists?: UsageReportEntry[][]; + /** + * Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that + * request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`). + */ + lists?: Array UsageListResponse)>; /** Successive agent report IDs; the last one repeats. */ agentReportIds?: Array; /** Advertise `features.usageSources`. False simulates a host from before usage sources. */ usageSources?: boolean; } +type UsageListResponse = UsageReportEntry[] | { error: string }; + type WebSocketMessage = string | Buffer; function parseJson(message: WebSocketMessage): unknown { @@ -106,9 +111,28 @@ export async function installUsageReportsFixture( forceRefresh: request.forceRefresh === true, reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined, }); - const available = pick(options.lists ?? [[]], listRequests.length - 1); + const scripted = pick(options.lists ?? [[]], listRequests.length - 1); + const response = typeof scripted === "function" ? scripted() : scripted; + if ("error" in response) { + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId, + requestType: "usage.list_reports.request", + error: response.error, + code: "transport", + }, + }, + }), + ); + listCounter.increment(); + return; + } const ids = Array.isArray(request.reportIds) ? request.reportIds : null; - const reports = ids ? available.filter((entry) => ids.includes(entry.id)) : available; + const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response; ws.send( JSON.stringify({ type: "session", diff --git a/packages/app/src/usage/composer-pill.tsx b/packages/app/src/usage/composer-pill.tsx index b21065c2fb..db679201a3 100644 --- a/packages/app/src/usage/composer-pill.tsx +++ b/packages/app/src/usage/composer-pill.tsx @@ -1,6 +1,7 @@ -import { useCallback } from "react"; +import { RefreshCw } from "lucide-react-native"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -14,7 +15,7 @@ import { } from "@/components/ui/toolbar-label-trigger"; import { UsageCard } from "./card"; import { usageCopy } from "./copy"; -import type { UsagePill } from "./model"; +import type { UsagePill, UsageRefresh } from "./model"; import { useAgentUsage } from "./queries"; import { UsageSourceIcon } from "./source-icon"; @@ -22,20 +23,17 @@ function pillTriggerStyle(state: MenuTriggerState) { return [toolbarLabelTriggerStyle(state), styles.trigger]; } -/** The agent's plan usage beside the composer controls. Opens the usage card. */ +/** + * The agent's plan usage beside the composer controls. Opens the cached usage card; + * only Refresh asks the source for a new report. + */ export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) { - const { pill, entry, refresh } = useAgentUsage(serverId, agentId); - const handleOpenChange = useCallback( - (open: boolean) => { - if (open) refresh(); - }, - [refresh], - ); + const { pill, entry, refresh, refreshState } = useAgentUsage(serverId, agentId); if (!pill || !entry) return null; return ( - + + ); } +function UsageRefreshRow({ + refreshState, + onRefresh, +}: { + refreshState: UsageRefresh; + onRefresh: () => void; +}) { + const isPending = refreshState === "pending"; + return ( + + + {refreshState === "failed" ? usageCopy.refreshFailed : null} + + + + ); +} + function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) { return ( <> @@ -82,5 +110,20 @@ const styles = StyleSheet.create((theme) => ({ }, popover: { padding: theme.spacing[3], + gap: theme.spacing[2], + }, + refreshRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + // The ghost button's padding hangs past the card's right rail so its label lands on it. + refreshButton: { + marginRight: -theme.spacing[3], + }, + refreshError: { + flex: 1, + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, }, })); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 4c8b622eae..ba0c8b0a46 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -5,6 +5,7 @@ export const usageCopy = { planUsage: "Plan usage", refresh: "Refresh", refreshing: "Refreshing...", + refreshFailed: "Unable to refresh usage", updated: "Updated", loading: "Loading usage...", empty: "No usage data", diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 6773742f60..2e4d96aa99 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -4,6 +4,7 @@ import { groupUsageByHost, headlineWindow, resolveUsagePill, + resolveUsageRefresh, resolveUsageView, type UsageQueryState, } from "./model"; @@ -179,3 +180,17 @@ describe("formatUsageFreshness", () => { expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15"); }); }); + +describe("resolveUsageRefresh", () => { + it("is pending while a refresh runs, even after an earlier failure", () => { + expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending"); + }); + + it("is failed after a refresh errors", () => { + expect(resolveUsageRefresh({ isPending: false, error: new Error("boom") })).toBe("failed"); + }); + + it("is idle before and after a successful refresh", () => { + expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle"); + }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index b8c50a5bd7..79d2f9eb4b 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -43,6 +43,18 @@ export function formatUsageFreshness(compactTimeAgo: string): string { return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; } +/** A user-requested refresh of one report. The previous report stays on screen throughout. */ +export type UsageRefresh = "idle" | "pending" | "failed"; + +export function resolveUsageRefresh(mutation: { + isPending: boolean; + error: unknown; +}): UsageRefresh { + if (mutation.isPending) return "pending"; + if (mutation.error) return "failed"; + return "idle"; +} + export interface UsageQueryState { data: UsageReportEntry[] | undefined; error: unknown; diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 905458ecce..0397425d26 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useShallow } from "zustand/shallow"; import { useFetchQueries, useFetchQuery } from "@/data/query"; import { @@ -13,10 +13,12 @@ import { usageCopy } from "./copy"; import { groupUsageByHost, resolveUsagePill, + resolveUsageRefresh, resolveUsageView, type UsageHostGroup, type UsagePill, type UsageQueryState, + type UsageRefresh, } from "./model"; import type { UsageReportEntry, UsageView } from "./types"; @@ -156,14 +158,20 @@ export function useUsageByHost(): { /** * The usage report for the account an agent is spending. The daemon resolves the - * account at fetch time, so the query refetches when the model changes (a new key) - * and when a turn completes (the query is paused while the agent runs, and its - * data is always stale, so resuming refetches). + * report ID, which is re-resolved when the model changes (a new key) and when a + * turn completes (the query is paused while the agent runs, and its data is always + * stale, so resuming refetches). The report itself is read from the shared cache. + * `refresh` is the only path that forces the source to fetch, and only this report. */ export function useAgentUsage( serverId: string, agentId: string, -): { pill: UsagePill | null; entry: UsageReportEntry | null; refresh: () => void } { +): { + pill: UsagePill | null; + entry: UsageReportEntry | null; + refresh: () => void; + refreshState: UsageRefresh; +} { const queryClient = useQueryClient(); const isConnected = useHostRuntimeIsConnected(serverId); const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); @@ -189,16 +197,19 @@ export function useAgentUsage( dataShape: "value", staleTimeMs: REPORTS_STALE_TIME_MS, }); + const refreshMutation = useMutation({ + mutationFn: (id: string) => getReport(serverId, id, true), + onSuccess: (report, id) => queryClient.setQueryData(reportQueryKey(serverId, id), report), + }); + const { mutate } = refreshMutation; const refresh = useCallback(() => { - if (!reportId) return; - void queryClient - .fetchQuery({ - queryKey: reportQueryKey(serverId, reportId), - queryFn: () => getReport(serverId, reportId, true), - staleTime: 0, - }) - .catch(() => undefined); - }, [queryClient, reportId, serverId]); + if (reportId) mutate(reportId); + }, [mutate, reportId]); const entry = isSupported ? (query.data ?? null) : null; - return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry, refresh }; + return { + pill: resolveUsagePill({ supportsUsage: isSupported, entry }), + entry, + refresh, + refreshState: resolveUsageRefresh(refreshMutation), + }; }