feat(usage): refresh a report from its card header

Refresh is an icon button at the right edge of every usage card header,
in the pill popover and on the Usage screen. It force-refreshes only that
report, writes the result to the report's cache and its host list, shows
a spinner and disables itself while pending, and prints "Unable to
refresh usage" in the card on failure. Its tooltip carries the
freshness; without hover the freshness stays in the card footer. The
status indicator sits just left of the button, and the popover's
separate refresh row is gone.
This commit is contained in:
Mohamed Boudra
2026-09-27 13:48:03 +02:00
parent e20430f4de
commit f92a02aab7
11 changed files with 271 additions and 112 deletions
@@ -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();
}
+58 -1
View File
@@ -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();
+119 -28
View File
@@ -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 (
<View style={containerStyle}>
<Tooltip delayDuration={300} enabledOnDesktop={!isNative}>
<TooltipTrigger style={styles.header} accessible={false} testID="usage-card-header">
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
</TooltipTrigger>
<TooltipContent side="top" align="start" testID="usage-freshness-tooltip">
<UsageFreshness fetchedAt={entry.fetchedAt} style={styles.tooltipText} />
</TooltipContent>
</Tooltip>
<View style={styles.header}>
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
fetchedAt={entry.fetchedAt}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
/>
</View>
{usage.error ? (
<Text style={styles.error} numberOfLines={3}>
@@ -106,24 +123,96 @@ export function UsageCard({
{footer}
</Text>
{showsFreshnessInline ? (
<UsageFreshness fetchedAt={entry.fetchedAt} style={styles.freshness} />
<UsageFreshness
fetchedAt={entry.fetchedAt}
style={styles.freshness}
testID="usage-freshness"
/>
) : null}
</View>
) : null}
{refreshState === "failed" ? (
<Text style={styles.error} testID="usage-refresh-error">
{usageCopy.refreshFailed}
</Text>
) : null}
</View>
);
}
/** 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(
() => (
<UsageFreshness
fetchedAt={fetchedAt}
style={styles.tooltipText}
testID="usage-freshness-tooltip"
/>
),
[fetchedAt],
);
return (
<ToolbarButton
label={`${usageCopy.refresh} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
disabled={isPending}
onPress={onRefresh}
style={compact ? styles.refreshButtonCompact : styles.refreshButton}
testID="usage-refresh"
>
{isPending ? (
<ThemedLoadingSpinner size={iconSize} uniProps={mutedIconColorMapping} />
) : (
<ThemedRefreshIcon size={iconSize} uniProps={mutedIconColorMapping} />
)}
</ToolbarButton>
);
}
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
function UsageFreshness({ fetchedAt, style }: { fetchedAt: string; style: StyleProp<TextStyle> }) {
function UsageFreshness({
fetchedAt,
style,
testID,
}: {
fetchedAt: string;
style: StyleProp<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
return (
<Text style={style} numberOfLines={1} testID="usage-freshness">
<Text style={style} numberOfLines={1} testID={testID}>
{formatUsageFreshness(elapsed)}
</Text>
);
}
// 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),
}));
+4 -51
View File
@@ -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}
>
<View style={styles.popover}>
<UsageCard entry={entry} compact />
<UsageRefreshRow refreshState={refreshState} onRefresh={refresh} />
<UsageCard serverId={serverId} entry={entry} compact />
</View>
</DropdownMenuContent>
</DropdownMenu>
);
}
function UsageRefreshRow({
refreshState,
onRefresh,
}: {
refreshState: UsageRefresh;
onRefresh: () => void;
}) {
const isPending = refreshState === "pending";
return (
<View style={styles.refreshRow}>
<Text style={styles.refreshError} numberOfLines={2} testID="usage-refresh-error">
{refreshState === "failed" ? usageCopy.refreshFailed : null}
</Text>
<Button
variant="ghost"
size="xs"
leftIcon={RefreshCw}
loading={isPending}
onPress={onRefresh}
style={styles.refreshButton}
accessibilityLabel={usageCopy.refresh}
testID="usage-refresh"
>
{isPending ? usageCopy.refreshing : usageCopy.refresh}
</Button>
</View>
);
}
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,
},
}));
@@ -6,6 +6,12 @@ import { UsageSection } from "./usage-section";
export function HostUsageSection({ serverId }: { serverId: string }) {
const { view, refresh } = useHostUsage(serverId);
return (
<UsageSection title={usageCopy.planUsage} view={view} onRefresh={refresh} testID="usage-card" />
<UsageSection
serverId={serverId}
title={usageCopy.planUsage}
view={view}
onRefresh={refresh}
testID="usage-card"
/>
);
}
+8 -2
View File
@@ -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 (
<View style={settingsStyles.card}>
{reports.map((entry, index) => (
<Fragment key={entry.id}>
{index > 0 ? <View style={styles.divider} /> : null}
<UsageCard entry={entry} />
<UsageCard serverId={serverId} entry={entry} />
</Fragment>
))}
</View>
+15
View File
@@ -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]);
});
});
+13
View File
@@ -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;
+27 -22
View File
@@ -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<UsageReportEntry[]>(usageReportsQueryKey(serverId), (reports) =>
reports ? replaceReport(reports, reportId, report) : reports,
);
},
});
const { mutate } = mutation;
const refresh = useCallback(() => mutate(), [mutate]);
return { refresh, refreshState: resolveUsageRefresh(mutation) };
}
+1
View File
@@ -47,6 +47,7 @@ function HostUsageGroup({
const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]);
return (
<UsageSection
serverId={group.serverId}
title={group.label}
view={group.view}
onRefresh={handleRefresh}
+13 -3
View File
@@ -11,11 +11,13 @@ import { UsageList } from "./list";
import type { UsageView } from "./types";
export function UsageSection({
serverId,
title,
view,
onRefresh,
testID,
}: {
serverId: string;
title: string;
view: UsageView;
onRefresh: () => void;
@@ -42,12 +44,20 @@ export function UsageSection({
return (
<SettingsSection title={title} testID={testID} trailing={refreshButton}>
<UsageBody view={view} onRefresh={onRefresh} />
<UsageBody serverId={serverId} view={view} onRefresh={onRefresh} />
</SettingsSection>
);
}
function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void }) {
function UsageBody({
serverId,
view,
onRefresh,
}: {
serverId: string;
view: UsageView;
onRefresh: () => void;
}) {
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
@@ -70,7 +80,7 @@ function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void
return <UsageMessage text={usageCopy.empty} />;
}
return <UsageList reports={view.reports} />;
return <UsageList serverId={serverId} reports={view.reports} />;
}
export function UsageMessage({ text }: { text: string }) {