From a68dc55490812605a174d7f01b45a62e0a0a74f5 Mon Sep 17 00:00:00 2001 From: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:03:57 +0000 Subject: [PATCH] fix(app): refresh visible usage summaries periodically (#7806) --- .../e2e/browser/usage-sidebar-item.spec.ts | 108 +++++++++++++++++- packages/app/src/usage/queries.ts | 21 ++-- packages/app/src/usage/sidebar-item.tsx | 9 +- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 9151403904..8d206f35e8 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -8,7 +8,7 @@ import { openProjectViaDaemon, } from "../support/helpers/new-workspace"; import { getServerId } from "../support/helpers/server-id"; -import { openSettingsHostSection } from "../support/helpers/settings"; +import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings"; import { expectFooterItemSetting, expectFooterSeparator, @@ -42,6 +42,7 @@ import { import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { waitForSettledPosition } from "../support/helpers/sheet-layout"; import { createTempGitRepo } from "../support/helpers/workspace"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -70,7 +71,112 @@ async function footerClip(page: Page) { return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!; } +async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise { + await page.evaluate((state) => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => state === "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + }, visibility); +} + test.describe("Usage item", () => { + test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => { + let usedPct = 31; + const usage = await installUsageReportsFixture(page, { + lists: [ + () => [ + { + ...claudeAndCodexReports()[0]!, + report: { + status: "available", + windows: [ + { id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct }, + ], + }, + }, + ], + ], + }); + await page.clock.install(); + await page.setViewportSize(WIDE); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h"]); + const startingUrl = page.url(); + await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" }); + const requestsBeforeRefresh = usage.listRequests(); + + usedPct = 42; + await page.clock.fastForward(5 * 60_000); + await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" }); + await expectPinnedUsage(page, ["42% 5h"]); + await expect(usageModal(page)).toHaveCount(0); + await expect(page).toHaveURL(startingUrl); + expect(usage.listRequests()).toEqual([ + ...requestsBeforeRefresh, + { forceRefresh: false, reportIds: undefined, agentId: undefined }, + ]); + + await setPageVisibility(page, "hidden"); + const requestsBeforeBackgrounding = usage.listRequests(); + usedPct = 47; + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding); + await expectPinnedUsage(page, ["42% 5h"]); + await setPageVisibility(page, "visible"); + await page.clock.fastForward(5 * 60_000); + await expectPinnedUsage(page, ["47% 5h"]); + expect(usage.listRequests()).toEqual([ + ...requestsBeforeBackgrounding, + { forceRefresh: false, reportIds: undefined, agentId: undefined }, + ]); + + await page.setViewportSize(COMPACT); + await expect(usageItem(page)).toHaveCount(0); + const requestsBeforeHiding = usage.listRequests(); + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeHiding); + + await openCompactSidebar(page); + await expectPinnedUsage(page, ["47% 5h"]); + await expect(page.getByTestId("agent-list-backdrop")).toBeVisible(); + usedPct = 53; + await page.clock.fastForward(5 * 60_000); + await expectPinnedUsage(page, ["53% 5h"]); + await expect(usageModal(page)).toHaveCount(0); + + await page.setViewportSize(WIDE); + await openSidebarNavSettings(page); + await setFooterItemVisible(page, "usage", false); + await leaveSettings(page); + await expectNoUsageItem(page); + const requestsBeforeDisabling = usage.listRequests(); + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeDisabling); + }); + + test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => { + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.clock.install(); + await page.setViewportSize(COMPACT); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await openCompactSettings(page, buildOpenProjectRoute()); + await openSettingsHostSection(page, getServerId(), "usage"); + await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible(); + await expect(usageItem(page)).toHaveCount(0); + const requestsBeforeWaiting = usage.listRequests(); + + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeWaiting); + await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible(); + }); + test("both compact footer entry points open Usage as a sheet", async ({ page }) => { await page.setViewportSize(COMPACT); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 524972a010..bac8bd7ffa 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -33,9 +33,10 @@ import { } from "./model"; import type { UsageReportEntry, UsageView } from "./types"; -// The daemon caches each report for five minutes, so re-reading it is cheap. Only -// an explicit refresh passes `forceRefresh` and reaches the source's API. +// The daemon caches each report for five minutes, so re-reading it is cheap. +// An explicit refresh passes `forceRefresh` to bypass that cache. const REPORTS_STALE_TIME_MS = 60_000; +const REPORTS_REFETCH_INTERVAL_MS = 5 * 60_000; /** Every report list of a host: its own and each agent's. */ function hostUsageQueryKey(serverId: string) { @@ -164,11 +165,16 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const NO_REPORTS: UsageReportEntry[] = []; -/** - * The reports of the sidebar's usage host, which is connected and reports usage; none until they - * load, or without a host. - */ -export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { +interface UsageHostReportsInput { + serverId: string | null; + shouldPoll?: boolean; +} + +/** A host's reports, with periodic refresh enabled only by the visible sidebar summary. */ +export function useUsageHostReports({ + serverId, + shouldPoll = false, +}: UsageHostReportsInput): UsageReportEntry[] { const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const query = useFetchQuery({ @@ -177,6 +183,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, + refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false, }); return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS; } diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 437c0a929c..bd4dfeb65e 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -16,6 +16,8 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useAppVisible } from "@/hooks/use-app-visible"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useUsageDisplay } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; @@ -36,9 +38,14 @@ import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ export function useUsageSummary(): readonly PinnedUsageSource[] { const { t } = useTranslation(); + const active = useRetainedPanelActive(); + const isAppVisible = useAppVisible(); const { inSidebar } = useUsageInSidebar(); const serverId = useUsageHostId(); - const reports = useUsageHostReports(inSidebar ? serverId : null); + const reports = useUsageHostReports({ + serverId: inSidebar ? serverId : null, + shouldPoll: active && inSidebar && isAppVisible, + }); const { preferences } = useUsageDisplay(reports); return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); }