diff --git a/packages/app/e2e/browser/usage-off.spec.ts b/packages/app/e2e/browser/usage-off.spec.ts index 0f5c78aaec..a0114c8e33 100644 --- a/packages/app/e2e/browser/usage-off.spec.ts +++ b/packages/app/e2e/browser/usage-off.spec.ts @@ -1,6 +1,6 @@ -import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, @@ -17,34 +17,41 @@ test("Check usage applies to the host and Turn on restores cards", async ({ e2eWorkerClient, }) => { test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); - await page.setViewportSize({ width: 1440, height: 1000 }); - await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); - await gotoAppShell(page); - await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); - await openUsageFromIcon(page); - await usage.waitForListRequests(1); - await openUsageOptions(page); - await page.getByRole("switch", { name: "Check usage", exact: true }).click(); - await closeUsageOptions(page); - await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0); - expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false }); - await expectNoUsageItem(page); - const before = usage.listRequests().length; - await closeUsage(page); - await page.reload(); - await openUsageFromIcon(page); - await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); - expect(usage.listRequests()).toHaveLength(before); - await page.getByRole("button", { name: "Turn on", exact: true }).click(); - await expect(page.getByText("Claude", { exact: true })).toBeVisible(); - expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true }); - // A separate client changes the policy, as another device would. - await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } }); - await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); - await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } }); - await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true }; + try { + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await openUsageFromIcon(page); + await usage.waitForListRequests(1); + await openUsageOptions(page); + await page.getByRole("switch", { name: "Check usage", exact: true }).click(); + await closeUsageOptions(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false }); + await expectNoUsageItem(page); + const before = usage.listRequests().length; + await closeUsage(page); + await page.reload(); + await openUsageFromIcon(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + expect(usage.listRequests()).toHaveLength(before); + await page.getByRole("button", { name: "Turn on", exact: true }).click(); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true }); + // A separate client changes the policy, as another device would. + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } }); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } }); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + } finally { + await e2eWorkerClient.patchDaemonConfig({ usage: previous }); + } }); test("older hosts keep usage without a Check usage switch", async ({ page }) => { diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 6d7e54050e..9151403904 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -276,7 +276,7 @@ test.describe("Usage item", () => { await waitForSettledPosition(sheet); const sheetBox = (await sheet.boundingBox())!; // The sheet leaves the screen above it as its backdrop. - expect(sheetBox.y).toBeGreaterThan(0); + expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8); await qaScreenshot(page, "compact-sheet"); await closeUsageFromBackdrop(page); diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index b9f907196e..44205fbddd 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -1,6 +1,3 @@ -import { Alert } from "@/components/ui/alert"; -import { useUsageSetting } from "./setting"; -import { useHosts } from "@/runtime/host-runtime"; import { useTranslation } from "react-i18next"; import { View } from "react-native"; import { Settings } from "lucide-react-native"; @@ -11,7 +8,10 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane- import { useIsCompactFormFactor } from "@/constants/layout"; import { SettingsRow, SettingsSwitch } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +import { Alert } from "@/components/ui/alert"; +import { useHosts } from "@/runtime/host-runtime"; import { settingsStyles } from "@/styles/settings"; +import { useUsageSetting } from "./setting"; import { useUsageDisplay } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; @@ -56,15 +56,13 @@ export function UsageOptions({ serverId }: { serverId: string | null }) { {setting.error ? : null} {setting.supported ? ( - - - + ) : null} listReports(queryClient, serverId) : skipToken, dataShape: "list", diff --git a/packages/app/src/usage/setting.ts b/packages/app/src/usage/setting.ts index 054b114bc0..e6916cf8c8 100644 --- a/packages/app/src/usage/setting.ts +++ b/packages/app/src/usage/setting.ts @@ -18,7 +18,7 @@ export function useUsageSetting(serverId: string | null) { supported, error: mutation.error, // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. - enabled: !supported || config?.usage?.enabled === true, + enabled: !supported || (config !== null && config.usage?.enabled !== false), ready: !supported || config !== null, setEnabled, turnOn, diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 54e2238bea..945a82a2ac 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -1,4 +1,3 @@ -import { useUsageInSidebar } from "./in-sidebar"; import { createContext, Fragment, @@ -19,6 +18,7 @@ import { import { StyleSheet } from "react-native-unistyles"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useUsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; @@ -34,11 +34,11 @@ import { import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ -function useUsageSummary(fetch = true): readonly PinnedUsageSource[] { +function useUsageSummary(requestReports = true): readonly PinnedUsageSource[] { const { t } = useTranslation(); const { inSidebar } = useUsageInSidebar(); const serverId = useUsageHostId(); - const reports = useUsageHostReports(inSidebar ? serverId : null, fetch); + const reports = useUsageHostReports(inSidebar ? serverId : null, requestReports); const { preferences } = useUsageDisplay(reports); return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } @@ -57,7 +57,8 @@ export function UsageSidebarItem() { const { t } = useTranslation(); const label = t(builtinSidebarNavLabelKey("usage")); const openUsage = useOpenSidebarUsage(); - // The footer owns fetching; this row reads the same cached summary. + // The row mounts after reports arrive; observing without requesting avoids + // the fetch-query policy’s unconditional refetch on mount. const sources = useUsageSummary(false); if (sources.length === 0) return null; return ; diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index ed121b9e5f..9c1e9354c1 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -1,4 +1,3 @@ -import { useUsageSetting } from "./setting"; import { useTranslation } from "react-i18next"; import { useMemo } from "react"; import { Text, View } from "react-native"; @@ -8,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import type { UsageDisplay } from "./display"; +import { useUsageSetting } from "./setting"; import { UsageControls } from "./controls"; import { UsageList } from "./list"; import type { UsageView } from "./types"; diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts index a44e715bb9..cd1e2e8801 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -51,8 +51,19 @@ export class UsageSession { async handleLegacyList( msg: Extract, ): Promise { + if (!this.options.isEnabled()) { + this.options.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: "Usage is off on this host.", + code: "provider_usage_list_failed", + }, + }); + return; + } try { - if (!this.options.isEnabled()) throw new Error("Usage is off on this host."); if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); const usage = await this.options.runtime.listLegacyUsage(); this.options.emit({ @@ -71,10 +82,7 @@ export class UsageSession { payload: { requestId: msg.requestId, requestType: msg.type, - error: - err.message === "Usage is off on this host." - ? err.message - : `Failed to list provider usage: ${err.message}`, + error: `Failed to list provider usage: ${err.message}`, code: "provider_usage_list_failed", }, });