From f92a02aab770543a619e3a0ce2e4fcc14487eeee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 27 Sep 2026 13:48:03 +0200 Subject: [PATCH] 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. --- .../e2e/browser/usage-composer-pill.spec.ts | 10 +- packages/app/e2e/browser/usage-screen.spec.ts | 59 ++++++- packages/app/src/usage/card.tsx | 147 ++++++++++++++---- packages/app/src/usage/composer-pill.tsx | 55 +------ packages/app/src/usage/host-usage-section.tsx | 8 +- packages/app/src/usage/list.tsx | 10 +- packages/app/src/usage/model.test.ts | 15 ++ packages/app/src/usage/model.ts | 13 ++ packages/app/src/usage/queries.ts | 49 +++--- packages/app/src/usage/usage-screen.tsx | 1 + packages/app/src/usage/usage-section.tsx | 16 +- 11 files changed, 271 insertions(+), 112 deletions(-) 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 }) {