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),
+ };
}