diff --git a/packages/app/e2e/browser/usage-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts
index 0c266183c6..42df720db7 100644
--- a/packages/app/e2e/browser/usage-composer-pill.spec.ts
+++ b/packages/app/e2e/browser/usage-composer-pill.spec.ts
@@ -63,7 +63,7 @@ test.describe("usage composer pill", () => {
await expect(popover.getByText("Test plan")).toBeVisible();
await expect(popover.getByText("42%")).toBeVisible();
- await popover.getByTestId("usage-card-header").hover();
+ await popover.getByTestId("usage-refresh").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);
@@ -94,9 +94,11 @@ test.describe("usage composer pill", () => {
]);
await expect(popover.getByText("64%")).toBeVisible();
await expect(pill).toContainText("64%");
- await expect(popover.getByTestId("usage-refresh")).toHaveText("Refresh");
+ await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
- await popover.getByTestId("usage-card-header").hover();
+ // Pressing closes the tooltip; it reopens on the next hover.
+ await popover.getByText("Fixture plan", { exact: true }).hover();
+ await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
expect(usage.listRequests()).toHaveLength(2);
} finally {
@@ -133,7 +135,7 @@ test.describe("usage composer pill", () => {
await refresh.click();
await usage.waitForListRequests(3);
await expect(popover.getByText("64%")).toBeVisible();
- await expect(popover.getByTestId("usage-refresh-error")).toHaveText("");
+ await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
} finally {
await session.cleanup();
}
diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts
index a504af468a..70d2ae10b8 100644
--- a/packages/app/e2e/browser/usage-screen.spec.ts
+++ b/packages/app/e2e/browser/usage-screen.spec.ts
@@ -1,3 +1,4 @@
+import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
@@ -49,11 +50,67 @@ test.describe("usage screen", () => {
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
- await group.getByTestId("usage-card-header").first().hover();
+ await group.getByTestId("usage-refresh").first().hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
});
+ test("refreshes one report from its card", async ({ page }) => {
+ test.setTimeout(120_000);
+ const serverId = getServerId();
+ const beta: UsageReportEntry = {
+ id: "beta:b",
+ account: {},
+ fetchedAt: twoHoursAgo(),
+ sourceId: "beta",
+ sourceLabel: "Beta plan",
+ report: {
+ status: "available",
+ windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
+ },
+ };
+ const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
+ id: "alpha:a",
+ account: {},
+ fetchedAt,
+ sourceId: "alpha",
+ sourceLabel: "Alpha plan",
+ report: {
+ status: "available",
+ windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
+ },
+ });
+ const usage = await installUsageReportsFixture(page, {
+ lists: [
+ [alpha(31, twoHoursAgo()), beta],
+ () => [
+ alpha(58, new Date().toISOString()),
+ { ...beta, fetchedAt: new Date().toISOString() },
+ ],
+ ],
+ });
+
+ await gotoAppShell(page);
+ await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
+ const group = page.getByTestId(`usage-host-${serverId}`);
+ await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
+
+ const alphaRefresh = group.getByTestId("usage-refresh").first();
+ await alphaRefresh.click();
+ await usage.waitForListRequests(2);
+ expect(usage.listRequests()).toEqual([
+ { forceRefresh: false, reportIds: undefined },
+ { forceRefresh: true, reportIds: ["alpha:a"] },
+ ]);
+ await expect(group.getByText("58%")).toBeVisible();
+ await expect(group.getByText("12%")).toBeVisible();
+
+ await group.getByTestId("usage-refresh").nth(1).hover();
+ await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
+ await alphaRefresh.hover();
+ await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
+ });
+
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx
index 62ea24efc1..605dd76cfd 100644
--- a/packages/app/src/usage/card.tsx
+++ b/packages/app/src/usage/card.tsx
@@ -1,13 +1,22 @@
+import { RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
-import { StyleSheet } from "react-native-unistyles";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import {
+ iconButtonChromeGlyphSize,
+ mutedIconColorMapping,
+ smallIconButtonChromeFrameSize,
+} from "@/components/ui/icon-button-chrome";
+import { LoadingSpinner } from "@/components/ui/loading-spinner";
+import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { StatusBadge } from "@/components/ui/status-badge";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
import { UsageBalanceBar } from "./balance-bar";
-import { formatUsageFreshness } from "./model";
+import { usageCopy } from "./copy";
+import { formatUsageFreshness, type UsageRefresh } from "./model";
+import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import type { UsageReport, UsageReportEntry } from "./types";
import { UsageWindowBar } from "./window-bar";
@@ -17,15 +26,21 @@ function statusText(report: UsageReport): string | null {
return report.status === "error" ? "Error" : "Unavailable";
}
+const ThemedRefreshIcon = withUnistyles(RefreshCw);
+const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
+
export function UsageCard({
+ serverId,
entry,
compact = false,
}: {
+ serverId: string;
entry: UsageReportEntry;
compact?: boolean;
}) {
const isCompact = useIsCompactFormFactor();
- // Where there is no hover the freshness is printed on the card; elsewhere the header's tooltip.
+ const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
+ // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact;
const usage = entry.report;
const status = statusText(usage);
@@ -48,25 +63,27 @@ export function UsageCard({
return (
-
-
-
-
- {entry.sourceLabel}
-
- {usage.planLabel ? : null}
-
- {status ? (
-
-
- {status}
-
- ) : null}
-
-
-
-
-
+
+
+
+ {entry.sourceLabel}
+
+ {usage.planLabel ? : null}
+
+ {status ? (
+
+
+ {status}
+
+ ) : null}
+
+
{usage.error ? (
@@ -106,24 +123,96 @@ export function UsageCard({
{footer}
{showsFreshnessInline ? (
-
+
) : null}
) : null}
+
+ {refreshState === "failed" ? (
+
+ {usageCopy.refreshFailed}
+
+ ) : null}
);
}
+/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
+function UsageRefreshButton({
+ sourceLabel,
+ fetchedAt,
+ refreshState,
+ onRefresh,
+ compact,
+}: {
+ sourceLabel: string;
+ fetchedAt: string;
+ refreshState: UsageRefresh;
+ onRefresh: () => void;
+ compact: boolean;
+}) {
+ const isPending = refreshState === "pending";
+ const iconSize = iconButtonChromeGlyphSize("small", compact);
+ const freshness = useMemo(
+ () => (
+
+ ),
+ [fetchedAt],
+ );
+ return (
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
/** Its own component so the relative-time clock re-renders one ``, not the card. */
-function UsageFreshness({ fetchedAt, style }: { fetchedAt: string; style: StyleProp }) {
+function UsageFreshness({
+ fetchedAt,
+ style,
+ testID,
+}: {
+ fetchedAt: string;
+ style: StyleProp;
+ testID: string;
+}) {
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
return (
-
+
{formatUsageFreshness(elapsed)}
);
}
+// The Refresh glyph lands on the card's right rail and the header keeps its text height;
+// the button's larger hitbox overhangs both instead of pushing them.
+function iconHitboxOverhang(compact: boolean) {
+ const overhang =
+ (smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2;
+ return { marginRight: -overhang, marginVertical: -overhang };
+}
+
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
@@ -211,7 +300,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
tooltipText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.base,
+ color: theme.colors.popoverForeground,
+ fontSize: theme.fontSize.sm,
},
+ refreshButton: iconHitboxOverhang(false),
+ refreshButtonCompact: iconHitboxOverhang(true),
}));
diff --git a/packages/app/src/usage/composer-pill.tsx b/packages/app/src/usage/composer-pill.tsx
index db679201a3..26cd77793c 100644
--- a/packages/app/src/usage/composer-pill.tsx
+++ b/packages/app/src/usage/composer-pill.tsx
@@ -1,7 +1,5 @@
-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,
@@ -15,7 +13,7 @@ import {
} from "@/components/ui/toolbar-label-trigger";
import { UsageCard } from "./card";
import { usageCopy } from "./copy";
-import type { UsagePill, UsageRefresh } from "./model";
+import type { UsagePill } from "./model";
import { useAgentUsage } from "./queries";
import { UsageSourceIcon } from "./source-icon";
@@ -25,10 +23,10 @@ function pillTriggerStyle(state: MenuTriggerState) {
/**
* The agent's plan usage beside the composer controls. Opens the cached usage card;
- * only Refresh asks the source for a new report.
+ * only the card's Refresh asks the source for a new report.
*/
export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) {
- const { pill, entry, refresh, refreshState } = useAgentUsage(serverId, agentId);
+ const { pill, entry } = useAgentUsage(serverId, agentId);
if (!pill || !entry) return null;
@@ -53,43 +51,13 @@ export function UsageComposerPill({ serverId, agentId }: { serverId: string; age
sheetTitle={usageCopy.planUsage}
>
-
-
+
);
}
-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 (
<>
@@ -110,20 +78,5 @@ 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/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx
index 857a56e955..54dd4ef939 100644
--- a/packages/app/src/usage/host-usage-section.tsx
+++ b/packages/app/src/usage/host-usage-section.tsx
@@ -6,6 +6,12 @@ import { UsageSection } from "./usage-section";
export function HostUsageSection({ serverId }: { serverId: string }) {
const { view, refresh } = useHostUsage(serverId);
return (
-
+
);
}
diff --git a/packages/app/src/usage/list.tsx b/packages/app/src/usage/list.tsx
index 8cb0b9f5bc..91aa4d4b50 100644
--- a/packages/app/src/usage/list.tsx
+++ b/packages/app/src/usage/list.tsx
@@ -5,13 +5,19 @@ import { settingsStyles } from "@/styles/settings";
import { UsageCard } from "./card";
import type { UsageReportEntry } from "./types";
-export function UsageList({ reports }: { reports: UsageReportEntry[] }) {
+export function UsageList({
+ serverId,
+ reports,
+}: {
+ serverId: string;
+ reports: UsageReportEntry[];
+}) {
return (
{reports.map((entry, index) => (
{index > 0 ? : null}
-
+
))}
diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts
index 2e4d96aa99..54ebc4df87 100644
--- a/packages/app/src/usage/model.test.ts
+++ b/packages/app/src/usage/model.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
formatUsageFreshness,
groupUsageByHost,
+ replaceReport,
headlineWindow,
resolveUsagePill,
resolveUsageRefresh,
@@ -194,3 +195,17 @@ describe("resolveUsageRefresh", () => {
expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle");
});
});
+
+describe("replaceReport", () => {
+ const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
+ const beta = entry({ sourceId: "beta" });
+
+ it("swaps only the refreshed report, in place", () => {
+ const refreshed = entry({ sourceId: "alpha", planLabel: "New" });
+ expect(replaceReport([alpha, beta], alpha.id, refreshed)).toEqual([refreshed, beta]);
+ });
+
+ it("drops a report the daemon no longer knows", () => {
+ expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]);
+ });
+});
diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts
index 79d2f9eb4b..c3cddc70fe 100644
--- a/packages/app/src/usage/model.ts
+++ b/packages/app/src/usage/model.ts
@@ -55,6 +55,19 @@ export function resolveUsageRefresh(mutation: {
return "idle";
}
+/**
+ * A host's report list with one report swapped for its refreshed copy, in place.
+ * `null` means the daemon no longer knows the ID, so the report leaves the list.
+ */
+export function replaceReport(
+ reports: readonly UsageReportEntry[],
+ reportId: string,
+ refreshed: UsageReportEntry | null,
+): UsageReportEntry[] {
+ if (!refreshed) return reports.filter((report) => report.id !== reportId);
+ return reports.map((report) => (report.id === reportId ? refreshed : report));
+}
+
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 0397425d26..a7a717b627 100644
--- a/packages/app/src/usage/queries.ts
+++ b/packages/app/src/usage/queries.ts
@@ -12,6 +12,7 @@ import { useSessionStore, type SessionState } from "@/stores/session-store";
import { usageCopy } from "./copy";
import {
groupUsageByHost,
+ replaceReport,
resolveUsagePill,
resolveUsageRefresh,
resolveUsageView,
@@ -161,18 +162,11 @@ export function useUsageByHost(): {
* 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;
- refreshState: UsageRefresh;
-} {
- const queryClient = useQueryClient();
+): { pill: UsagePill | null; entry: UsageReportEntry | null } {
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const { model, isRunning } = useSessionStore(
@@ -197,19 +191,30 @@ 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) mutate(reportId);
- }, [mutate, reportId]);
const entry = isSupported ? (query.data ?? null) : null;
- return {
- pill: resolveUsagePill({ supportsUsage: isSupported, entry }),
- entry,
- refresh,
- refreshState: resolveUsageRefresh(refreshMutation),
- };
+ return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry };
+}
+
+/**
+ * Forces the source to fetch one report, and only that report. The result replaces
+ * the report wherever it is cached — its own entry and its host's list — so every
+ * surface showing it moves together; until then the previous report stays on screen.
+ */
+export function useReportRefresh(
+ serverId: string,
+ reportId: string,
+): { refresh: () => void; refreshState: UsageRefresh } {
+ const queryClient = useQueryClient();
+ const mutation = useMutation({
+ mutationFn: () => getReport(serverId, reportId, true),
+ onSuccess: (report) => {
+ queryClient.setQueryData(reportQueryKey(serverId, reportId), report);
+ queryClient.setQueryData(usageReportsQueryKey(serverId), (reports) =>
+ reports ? replaceReport(reports, reportId, report) : reports,
+ );
+ },
+ });
+ const { mutate } = mutation;
+ const refresh = useCallback(() => mutate(), [mutate]);
+ return { refresh, refreshState: resolveUsageRefresh(mutation) };
}
diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx
index b37c377c8d..96006f7728 100644
--- a/packages/app/src/usage/usage-screen.tsx
+++ b/packages/app/src/usage/usage-screen.tsx
@@ -47,6 +47,7 @@ function HostUsageGroup({
const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]);
return (
void;
@@ -42,12 +44,20 @@ export function UsageSection({
return (
-
+
);
}
-function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void }) {
+function UsageBody({
+ serverId,
+ view,
+ onRefresh,
+}: {
+ serverId: string;
+ view: UsageView;
+ onRefresh: () => void;
+}) {
if (view.kind === "unavailable") {
return ;
}
@@ -70,7 +80,7 @@ function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void
return ;
}
- return ;
+ return ;
}
export function UsageMessage({ text }: { text: string }) {