From 768a1bf137bd655833d795fbe8e2c4f3cf7e4f37 Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:42:27 +0200
Subject: [PATCH] fix(app): keep mounted usage times and translations current
Pass time and translation values into usage rendering so React Compiler invalidates cached labels when they change. Use the same localized window names and percentage meanings in sidebar summaries.
---
packages/app/e2e/browser/usage-modal.spec.ts | 45 ++++++++++-
packages/app/src/usage/agent-usage.tsx | 5 +-
packages/app/src/usage/balance-bar.tsx | 6 +-
packages/app/src/usage/bars.browser.test.tsx | 53 +++++++++++++
packages/app/src/usage/card.tsx | 29 +++----
packages/app/src/usage/copy.ts | 76 ++-----------------
packages/app/src/usage/format.test.ts | 7 +-
packages/app/src/usage/format.ts | 50 +++++++-----
packages/app/src/usage/host-usage-section.tsx | 5 +-
packages/app/src/usage/model.test.ts | 12 +--
packages/app/src/usage/model.ts | 53 ++++++++-----
packages/app/src/usage/options.tsx | 21 ++---
packages/app/src/usage/pinned.test.ts | 26 +++++++
packages/app/src/usage/pinned.ts | 10 ++-
packages/app/src/usage/queries.ts | 20 +++--
packages/app/src/usage/refresh-button.tsx | 5 +-
packages/app/src/usage/sidebar-item.tsx | 3 +-
packages/app/src/usage/usage-modal.tsx | 5 +-
packages/app/src/usage/usage-section.tsx | 16 ++--
.../app/src/usage/use-relative-time-tick.ts | 11 +--
packages/app/src/usage/window-bar.tsx | 20 ++---
packages/app/vitest.config.ts | 21 +++++
22 files changed, 313 insertions(+), 186 deletions(-)
create mode 100644 packages/app/src/usage/bars.browser.test.tsx
diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts
index 7aafc40b62..9a6a4734af 100644
--- a/packages/app/e2e/browser/usage-modal.spec.ts
+++ b/packages/app/e2e/browser/usage-modal.spec.ts
@@ -600,6 +600,7 @@ for (const compact of [false, true]) {
}) => {
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
try {
+ await page.clock.install();
await page.setViewportSize(
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
@@ -617,6 +618,7 @@ for (const compact of [false, true]) {
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42% used");
+ await advanceUsageClock(page, 60_000);
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
@@ -642,6 +644,7 @@ for (const compact of [false, true]) {
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43% used");
+ await advanceUsageClock(page, 60_000);
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
@@ -675,13 +678,14 @@ test("usage percentages state their meaning and follow the selected language", a
test.setTimeout(120_000);
const login = await installLoginUsage({
status: "available",
- windows: [{ id: "session", label: "Session", usedPct: 70 }],
+ windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
});
try {
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
await switchToJapanese(page);
+ await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
await openJapaneseUsage(page);
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
@@ -693,3 +697,42 @@ test("usage percentages state their meaning and follow the selected language", a
await login.cleanup();
}
});
+
+test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
+ test.setTimeout(120_000);
+ const now = new Date();
+ const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
+ const login = await installLoginUsage({
+ status: "available",
+ windows: [
+ { id: "session", label: "Session", usedPct: 42, resetsAt: reset },
+ { id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
+ ],
+ balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
+ });
+ try {
+ await openCompactUsageWithClock(page, now);
+ const card = page.getByTestId("usage-report-login-journey:account");
+ await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
+ await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
+ await advanceUsageClock(page, 120_000);
+ await expect(card.getByTestId("usage-freshness")).toHaveText("Updated 2m ago");
+ await expect(card.getByText("resets 28m", { exact: false }).first()).toBeVisible();
+ await expect(card.getByText("runs out 28m", { exact: false })).toBeVisible();
+ await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText("resets 28m");
+ } finally {
+ await login.cleanup();
+ }
+});
+
+async function openCompactUsageWithClock(page: Page, now: Date) {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.clock.install({ time: now });
+ await gotoAppShell(page);
+ await openCompactSidebar(page);
+ await openUsageFromIcon(page);
+}
+
+async function advanceUsageClock(page: Page, milliseconds: number) {
+ await page.clock.fastForward(milliseconds);
+}
diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx
index 95f39d1513..bf10054db6 100644
--- a/packages/app/src/usage/agent-usage.tsx
+++ b/packages/app/src/usage/agent-usage.tsx
@@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { UsageCard } from "./card";
-import { usageCopy } from "./copy";
import { useUsageDisplay } from "./display";
import { useAgentUsage } from "./queries";
import type { UsageReportEntry } from "./types";
@@ -22,7 +21,7 @@ export function AgentUsage({
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
refreshable: boolean;
}) {
- useTranslation();
+ const { t } = useTranslation();
const view = useAgentUsage(serverId, agentId);
if (view.kind === "none") return null;
return (
@@ -37,7 +36,7 @@ export function AgentUsage({
/>
) : (
- {view.kind === "loading" ? usageCopy.loading : view.message}
+ {view.kind === "loading" ? t("providerUsage.loading") : view.message}
)}
>
diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx
index a632d03972..def723c554 100644
--- a/packages/app/src/usage/balance-bar.tsx
+++ b/packages/app/src/usage/balance-bar.tsx
@@ -62,10 +62,10 @@ export function UsageBalanceBar({
retained?: boolean;
}) {
const { t, i18n } = useTranslation();
- useRelativeTimeTick({ enabled: balance.resetsAt != null });
+ const now = useRelativeTimeTick({ enabled: balance.resetsAt != null });
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const tone = balance.tone ?? "default";
- const resetLabel = formatResetLabel(balance.resetsAt, retained);
+ const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
const fillStyle = useMemo>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
@@ -76,7 +76,7 @@ export function UsageBalanceBar({
- {formatProviderUsageLabel(balance.id, balance.label)}
+ {formatProviderUsageLabel(balance.id, balance.label, t)}
diff --git a/packages/app/src/usage/bars.browser.test.tsx b/packages/app/src/usage/bars.browser.test.tsx
new file mode 100644
index 0000000000..7b978f7ced
--- /dev/null
+++ b/packages/app/src/usage/bars.browser.test.tsx
@@ -0,0 +1,53 @@
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { i18n } from "@/i18n/i18next";
+import { UsageWindowBar } from "./window-bar";
+import type { UsageBalance, UsageWindow } from "./types";
+import { UsageBalanceBar } from "./balance-bar";
+
+const window: UsageWindow = { id: "session", label: "Session", usedPct: 70 };
+const balance: UsageBalance = { id: "credits", label: "Credits", unit: "usd", remaining: 12 };
+const ignorePin = () => {};
+
+let root: Root;
+let container: HTMLDivElement;
+beforeEach(async () => {
+ vi.stubGlobal("React", React);
+ await i18n.changeLanguage("en");
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ act(() => root.unmount());
+ container.remove();
+ await i18n.changeLanguage("en");
+ vi.unstubAllGlobals();
+});
+
+it("translates mounted usage rows when the language changes", async () => {
+ act(() =>
+ root.render(
+ <>
+
+
+ >,
+ ),
+ );
+ expect(container.textContent).toContain("Session70% used");
+ expect(container.textContent).toContain("Credits");
+ await act(async () => {
+ await i18n.changeLanguage("ja");
+ });
+ expect(container.textContent).toContain("セッション70% 使用済み");
+ expect(container.textContent).toContain("クレジット");
+});
diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx
index da93c67ee3..7d0f9903da 100644
--- a/packages/app/src/usage/card.tsx
+++ b/packages/app/src/usage/card.tsx
@@ -13,11 +13,10 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
-import { i18n } from "@/i18n/i18next";
+import type { TFunction } from "i18next";
import { UsageBalanceBar } from "./balance-bar";
-import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
-import { formatAgo } from "./format";
+import { formatAgo, formatProviderUsageLabel } from "./format";
import { resolveUsageReport, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
@@ -25,11 +24,11 @@ import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
import { UsageWindowBar } from "./window-bar";
import { useRelativeTimeTick } from "./use-relative-time-tick";
-function statusText(report: UsageReport): string | null {
+function statusText(report: UsageReport, t: TFunction): string | null {
if (report.status === "available") return null;
return report.status === "error"
- ? i18n.t("providerUsage.status.error")
- : i18n.t("providerUsage.status.unavailable");
+ ? t("providerUsage.status.error")
+ : t("providerUsage.status.unavailable");
}
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -54,16 +53,16 @@ export function UsageCard({
refreshable: boolean;
compact?: boolean;
}) {
- useTranslation();
+ const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact || !refreshable;
const usage = entry.report;
- const status = statusText(usage);
+ const status = statusText(usage, t);
const footer = entry.account.label ?? null;
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
- resolveUsageReport(entry);
+ resolveUsageReport(entry, t);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -162,7 +161,7 @@ export function UsageCard({
{refreshState === "failed" ? (
- {usageCopy.refreshFailed}
+ {t("providerUsage.refreshFailed")}
) : null}
@@ -182,6 +181,7 @@ function CardWindowBar({
display: UsageDisplay;
pinnable: boolean;
}) {
+ const { t } = useTranslation();
const pin = useMemo(
() => ({ sourceId: entry.sourceId, windowId: window.id }),
[entry.sourceId, window.id],
@@ -196,7 +196,7 @@ function CardWindowBar({
pinnable={pinnable}
pinned={display.isPinned(pin)}
onTogglePin={toggle}
- pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
+ pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`}
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
/>
);
@@ -218,6 +218,7 @@ function UsageRefreshButton({
onRefresh: () => void;
compact: boolean;
}) {
+ const { t } = useTranslation();
const isPending = refreshState === "pending";
const iconSize = paneContentToolbarIconSize(compact);
const freshness = useMemo(
@@ -233,7 +234,7 @@ function UsageRefreshButton({
);
return (
{
+ problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): string => {
if (problem.kind === "no_quota") return problem.detail;
const remedy = problem.refreshedBy
- ? i18n.t("providerUsage.runToRefresh", { command: problem.refreshedBy })
- : i18n.t("providerUsage.signInAgain");
+ ? t("providerUsage.runToRefresh", { command: problem.refreshedBy })
+ : t("providerUsage.signInAgain");
if (problem.kind === "rejected")
- return i18n.t("providerUsage.loginRejected", { status: problem.status, remedy });
- return i18n.t("providerUsage.loginExpired", {
- relativeTime: formatAgo(problem.expiresAt, now.getTime()),
+ return t("providerUsage.loginRejected", { status: problem.status, remedy });
+ return t("providerUsage.loginExpired", {
+ relativeTime: formatAgo(problem.expiresAt, now.getTime(), t),
remedy,
});
},
- get title() {
- return i18n.t("providerUsage.title");
- },
- get planUsage() {
- return i18n.t("providerUsage.title");
- },
- get options() {
- return i18n.t("providerUsage.options");
- },
- get refresh() {
- return i18n.t("providerUsage.refresh");
- },
- get refreshAll() {
- return i18n.t("providerUsage.refreshAll");
- },
- get refreshing() {
- return i18n.t("providerUsage.refreshing");
- },
- get refreshFailed() {
- return i18n.t("providerUsage.refreshFailed");
- },
- get loading() {
- return i18n.t("providerUsage.loading");
- },
- get empty() {
- return i18n.t("providerUsage.empty");
- },
- get noHosts() {
- return i18n.t("providerUsage.noHosts");
- },
- get errorTitle() {
- return i18n.t("providerUsage.errorTitle");
- },
- agentError: (reason: string) => i18n.t("providerUsage.agentError", { reason }),
- hostUnavailable: (host: string) => i18n.t("providerUsage.hostUnavailable", { host }),
- hostUpgradeRequired: (host: string) => i18n.t("providerUsage.hostUpgradeRequired", { host }),
get clientUnavailable() {
return i18n.t("providerUsage.clientUnavailable");
},
- get retry() {
- return i18n.t("providerUsage.retry");
- },
- get pin() {
- return i18n.t("providerUsage.pin");
- },
- get unpin() {
- return i18n.t("providerUsage.unpin");
- },
- get displayAs() {
- return i18n.t("providerUsage.displayAs");
- },
- get displayUsed() {
- return i18n.t("providerUsage.displayUsed");
- },
- get displayRemaining() {
- return i18n.t("providerUsage.displayRemaining");
- },
- get showInSidebar() {
- return i18n.t("providerUsage.showInSidebar");
- },
- get showInSidebarHint() {
- return i18n.t("providerUsage.showInSidebarHint");
- },
} as const;
diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts
index 8fed67dff2..b7b81f0e5a 100644
--- a/packages/app/src/usage/format.test.ts
+++ b/packages/app/src/usage/format.test.ts
@@ -1,4 +1,3 @@
-import { usageCopy } from "./copy";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
@@ -109,7 +108,7 @@ it("says that a usage window percentage is consumed", () => {
it("translates the usage refresh control into Japanese", async () => {
try {
await i18n.changeLanguage("ja");
- expect(usageCopy.refresh).toBe("更新");
+ expect(i18n.t("providerUsage.refresh")).toBe("更新");
} finally {
await i18n.changeLanguage("en");
}
@@ -118,8 +117,8 @@ it("translates the usage refresh control into Japanese", async () => {
it("translates the neighboring usage percentage settings", async () => {
try {
await i18n.changeLanguage("ja");
- expect(usageCopy.displayUsed).toBe("使用済み");
- expect(usageCopy.displayRemaining).toBe("残り");
+ expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み");
+ expect(i18n.t("providerUsage.displayRemaining")).toBe("残り");
} finally {
await i18n.changeLanguage("en");
}
diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts
index f502abfe69..e7a6ba6f7b 100644
--- a/packages/app/src/usage/format.ts
+++ b/packages/app/src/usage/format.ts
@@ -1,3 +1,4 @@
+import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
@@ -11,17 +12,17 @@ const providerUsageLabels = {
monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" },
} as const;
-export function formatProviderUsageLabel(id: string, label: string): string {
+export function formatProviderUsageLabel(id: string, label: string, t: TFunction = i18n.t): string {
const reviewPrefix = "Code review · ";
if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) {
const periodId = id.slice("code_review:".length);
const periodLabel = label.slice(reviewPrefix.length);
- return `${i18n.t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel)}`;
+ return `${t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel, t)}`;
}
const standard =
providerUsageLabels[id as keyof typeof providerUsageLabels] ??
(id === "five_hour" ? providerUsageLabels.session : undefined);
- return standard?.label === label ? i18n.t(standard.key) : label;
+ return standard?.label === label ? t(standard.key) : label;
}
export function clampPct(value: number): number {
@@ -41,11 +42,12 @@ export function formatDisplayPct(
value: number,
displayAs: UsageDisplayAs,
locale?: string,
+ t: TFunction = i18n.t,
): string {
const percentage = formatPct(value, locale);
return displayAs === "used"
- ? i18n.t("providerUsage.values.used", { percentage })
- : i18n.t("providerUsage.values.remaining", { amount: percentage });
+ ? t("providerUsage.values.used", { percentage })
+ : t("providerUsage.values.remaining", { amount: percentage });
}
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
@@ -66,22 +68,22 @@ function relativeDuration(iso: string, now: number): RelativeDuration | null {
return { unit: "minutes", count: diffMinutes };
}
-function formatRelativeDuration(duration: RelativeDuration): string | null {
+function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
switch (duration.unit) {
case "now":
return null;
case "days":
- return i18n.t("providerUsage.duration.days", {
+ return t("providerUsage.duration.days", {
count: duration.count,
value: formatCount(duration.count),
});
case "hours":
- return i18n.t("providerUsage.duration.hours", {
+ return t("providerUsage.duration.hours", {
count: duration.count,
value: formatCount(duration.count),
});
case "minutes":
- return i18n.t("providerUsage.duration.minutes", {
+ return t("providerUsage.duration.minutes", {
count: duration.count,
value: formatCount(duration.count),
});
@@ -92,49 +94,55 @@ export function formatResetLabel(
iso: string | null | undefined,
retained = false,
now = Date.now(),
+ t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
- if (rel.unit === "now" && retained) return i18n.t("providerUsage.timing.resetTimePassed");
- if (rel.unit === "now") return i18n.t("providerUsage.timing.resettingNow");
- const duration = formatRelativeDuration(rel);
- return duration ? i18n.t("providerUsage.timing.resetsIn", { duration }) : null;
+ if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
+ if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
+ const duration = formatRelativeDuration(rel, t);
+ return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
}
export function formatRunsOutLabel(
iso: string | null | undefined,
now = Date.now(),
+ t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
- if (rel.unit === "now") return i18n.t("providerUsage.timing.runsOutNow");
- const duration = formatRelativeDuration(rel);
- return duration ? i18n.t("providerUsage.timing.runsOutIn", { duration }) : null;
+ if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
+ const duration = formatRelativeDuration(rel, t);
+ return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
}
-export function formatAgo(iso: string | null | undefined, now = Date.now()): string | null {
+export function formatAgo(
+ iso: string | null | undefined,
+ now = Date.now(),
+ t: TFunction = i18n.t,
+): string | null {
if (!iso) return null;
const diffMs = now - new Date(iso).getTime();
if (!Number.isFinite(diffMs)) return null;
- if (diffMs < 60_000) return i18n.t("providerUsage.timing.justNow");
+ if (diffMs < 60_000) return t("providerUsage.timing.justNow");
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
- return i18n.t("providerUsage.timing.daysAgo", {
+ return t("providerUsage.timing.daysAgo", {
count: diffDays,
value: formatCount(diffDays),
});
}
if (diffHours > 0) {
- return i18n.t("providerUsage.timing.hoursAgo", {
+ return t("providerUsage.timing.hoursAgo", {
count: diffHours,
value: formatCount(diffHours),
});
}
- return i18n.t("providerUsage.timing.minutesAgo", {
+ return t("providerUsage.timing.minutesAgo", {
count: diffMinutes,
value: formatCount(diffMinutes),
});
diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx
index 23e56d1d57..e80e839680 100644
--- a/packages/app/src/usage/host-usage-section.tsx
+++ b/packages/app/src/usage/host-usage-section.tsx
@@ -1,18 +1,17 @@
import { useTranslation } from "react-i18next";
-import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
/** A host's usage reports, for its settings page. */
export function HostUsageSection({ serverId }: { serverId: string }) {
- useTranslation();
+ const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsagePreferences(serverId);
return (
,
},
};
- expect(resolveUsageReport(failure)).toEqual({
+ expect(resolveUsageReport(failure, i18n.t)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -355,7 +355,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: true,
messages: ["Rate limited"],
});
- expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
+ expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({
windows: [],
balances: [],
details: [],
@@ -364,7 +364,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: false,
messages: ["Rate limited"],
});
- expect(resolveUsageReport(success)).toEqual({
+ expect(resolveUsageReport(success, i18n.t)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -392,7 +392,7 @@ it("orders failed polls by attempt time even when successful data is older", ()
it("available and retained usage show the same successful completion time", () => {
const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" };
if (success.report.status !== "available") throw new Error("fixture must be available");
- expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
+ expect(resolveUsageReport(success, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z");
const failure: UsageReportEntry = {
...success,
successfulAt: undefined,
@@ -400,6 +400,8 @@ it("available and retained usage show the same successful completion time", () =
report: { status: "error", error: "Rate limited" },
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
};
- expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
+ expect(resolveUsageReport(failure, i18n.t).successfulAt).toBe(
+ resolveUsageReport(success, i18n.t).successfulAt,
+ );
expect(upsertReport([failure], success)).toEqual([failure]);
});
diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts
index df4250a889..48143ad23f 100644
--- a/packages/app/src/usage/model.ts
+++ b/packages/app/src/usage/model.ts
@@ -1,3 +1,5 @@
+import type { TFunction } from "i18next";
+import { i18n } from "@/i18n/i18next";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
@@ -102,16 +104,26 @@ export interface UsageQueryState {
isFetching: boolean;
}
-export function resolveUsageView(input: {
- hostLabel: string;
- isConnected: boolean;
- supportsUsage: boolean;
- query: UsageQueryState | undefined;
-}): UsageView {
+export function resolveUsageView(
+ input: {
+ hostLabel: string;
+ isConnected: boolean;
+ supportsUsage: boolean;
+ query: UsageQueryState | undefined;
+ },
+ t: TFunction = i18n.t,
+): UsageView {
const { hostLabel, isConnected, supportsUsage, query } = input;
- if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) };
+ if (!isConnected)
+ return {
+ kind: "unavailable",
+ message: t("providerUsage.hostUnavailable", { host: hostLabel }),
+ };
if (!supportsUsage) {
- return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) };
+ return {
+ kind: "unavailable",
+ message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
+ };
}
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
@@ -132,17 +144,20 @@ export type AgentUsageView =
| { kind: "error"; message: string }
| { kind: "ready"; reports: UsageReportEntry[] };
-export function resolveAgentUsageView(input: {
- canReport: boolean;
- query: UsageQueryState;
-}): AgentUsageView {
+export function resolveAgentUsageView(
+ input: {
+ canReport: boolean;
+ query: UsageQueryState;
+ },
+ t: TFunction = i18n.t,
+): AgentUsageView {
const { canReport, query } = input;
if (!canReport) return { kind: "none" };
// A failed request keeps the reports from before it, or those that streamed in before it failed;
// shown alone they would pass for the agent's complete, current usage.
if (query.error) {
const reason = query.error instanceof Error ? query.error.message : String(query.error);
- return { kind: "error", message: usageCopy.agentError(reason) };
+ return { kind: "error", message: t("providerUsage.agentError", { reason }) };
}
if (query.data) {
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
@@ -188,7 +203,7 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
}
/** Current outcome owns messages; only a successful poll owns values and freshness. */
-export function resolveUsageReport(entry: UsageReportEntry) {
+export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
const available = entry.report.status === "available";
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
@@ -198,10 +213,12 @@ export function resolveUsageReport(entry: UsageReportEntry) {
if (!available) {
if (entry.loginErrors)
messages.push(
- ...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
+ ...entry.loginErrors.map(
+ (login) => `${login.harness}: ${usageProblemText(login.report, t)}`,
+ ),
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
- else messages.push(usageProblemText(entry.report));
+ else messages.push(usageProblemText(entry.report, t));
}
return {
windows: content?.windows ?? [],
@@ -218,8 +235,8 @@ export function resolveUsageReport(entry: UsageReportEntry) {
};
}
-function usageProblemText(report: UsageReport): string {
+function usageProblemText(report: UsageReport, t: TFunction): string {
if (report.status === "error") return report.error;
- if (report.status === "unavailable") return usageCopy.problem(report.problem);
+ if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t);
return "";
}
diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx
index 9e61aa8d6c..b735b612aa 100644
--- a/packages/app/src/usage/options.tsx
+++ b/packages/app/src/usage/options.tsx
@@ -9,7 +9,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { settingsStyles } from "@/styles/settings";
-import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
@@ -18,10 +17,14 @@ const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
- useTranslation();
+ const { t } = useTranslation();
const displayAsOptions: SegmentedControlOption[] = [
- { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
- { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
+ { value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
+ {
+ value: "remaining",
+ label: t("providerUsage.displayRemaining"),
+ testID: "usage-display-remaining",
+ },
];
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
@@ -30,7 +33,7 @@ export function UsageOptions() {
@@ -42,19 +45,19 @@ export function UsageOptions() {
-
+
{
+ const { i18n } = await import("@/i18n/i18next");
+ await i18n.changeLanguage("ja");
+ try {
+ const entries = [
+ report({
+ sourceId: "test",
+ sourceLabel: "Test",
+ windows: [
+ { id: "session", label: "Session", summary: true, usedPct: 70 },
+ { id: "weekly", label: "My allowance", summary: true, usedPct: 20 },
+ ],
+ }),
+ ];
+ expect(windows(entries, preferences(null)).map((w) => [w.label, w.shortLabel])).toEqual([
+ ["Test セッション 70% 使用済み", "セッション"],
+ ["Test My allowance 20% 使用済み", "My allowance"],
+ ]);
+ expect(windows(entries, preferences(null, "remaining"))[0].label).toBe(
+ "Test セッション 残り 30%",
+ );
+ } finally {
+ await i18n.changeLanguage("en");
+ }
+});
diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts
index 547b2c3e48..21fd4cf06c 100644
--- a/packages/app/src/usage/pinned.ts
+++ b/packages/app/src/usage/pinned.ts
@@ -1,4 +1,6 @@
-import { formatPct } from "./format";
+import type { TFunction } from "i18next";
+import { i18n } from "@/i18n/i18next";
+import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import { displayPercent } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
@@ -68,9 +70,9 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[]
export function resolvePinnedUsage(
reports: readonly UsageReportEntry[],
preferences: UsagePreferences,
+ t: TFunction = i18n.t,
): PinnedUsageSource[] {
const pins = effectiveUsagePins(preferences, reports);
- const meaning = preferences.displayAs === "remaining" ? "left" : "used";
return groupBySource(reports).flatMap((entry) => {
if (entry.report.status !== "available") return [];
const windows = entry.report.windows
@@ -84,8 +86,8 @@ export function resolvePinnedUsage(
const percentText = formatPct(percent);
return {
key: `${entry.id}/${window.id}`,
- label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
- shortLabel: window.shortLabel ?? window.label,
+ label: `${describe(entry, formatProviderUsageLabel(window.id, window.label, t))} ${formatDisplayPct(percent, preferences.displayAs, undefined, t)}`,
+ shortLabel: window.shortLabel ?? formatProviderUsageLabel(window.id, window.label, t),
percent,
percentText,
tone: windowTone(window),
diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts
index 7b6f210543..c5ec32bd2d 100644
--- a/packages/app/src/usage/queries.ts
+++ b/packages/app/src/usage/queries.ts
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
import { useCallback, useMemo } from "react";
import {
@@ -144,13 +145,17 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const refresh = useCallback(() => {
void refreshReports(queryClient, serverId).catch(() => undefined);
}, [queryClient, serverId]);
+ const { t } = useTranslation();
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
- const view = resolveUsageView({
- hostLabel,
- isConnected,
- supportsUsage: isSupported,
- query: toQueryState(query),
- });
+ const view = resolveUsageView(
+ {
+ hostLabel,
+ isConnected,
+ supportsUsage: isSupported,
+ query: toQueryState(query),
+ },
+ t,
+ );
return { view, refresh };
}
@@ -196,7 +201,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
retry: false,
staleTimeMs: REPORTS_STALE_TIME_MS,
});
- return resolveAgentUsageView({ canReport, query: toQueryState(query) });
+ const { t } = useTranslation();
+ return resolveAgentUsageView({ canReport, query: toQueryState(query) }, t);
}
/** Whether a host is connected and reports usage, read without fetching anything. */
diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx
index e95cd72773..473e4442cc 100644
--- a/packages/app/src/usage/refresh-button.tsx
+++ b/packages/app/src/usage/refresh-button.tsx
@@ -5,7 +5,6 @@ import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
-import { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -15,12 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
* header's island, so its highlight is round.
*/
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
- useTranslation();
+ const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
resolvePinnedUsage(reports, preferences), [preferences, reports]);
+ return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx
index 88685729c3..bf92eaa60f 100644
--- a/packages/app/src/usage/usage-modal.tsx
+++ b/packages/app/src/usage/usage-modal.tsx
@@ -4,7 +4,6 @@ import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
-import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
@@ -22,12 +21,12 @@ interface UsageModalProps {
* title row.
*/
export function UsageModal(props: UsageModalProps) {
- useTranslation();
+ const { t } = useTranslation();
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
-
+
);
}
diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx
index ffb40e985d..26699fd447 100644
--- a/packages/app/src/usage/usage-section.tsx
+++ b/packages/app/src/usage/usage-section.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -5,7 +6,6 @@ import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
-import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { UsageControls } from "./controls";
import { UsageList } from "./list";
@@ -50,26 +50,32 @@ export function UsageBody({
display: UsageDisplay;
onRefresh: () => void;
}) {
+ const { t } = useTranslation();
if (view.kind === "unavailable") {
return ;
}
if (view.kind === "loading") {
- return ;
+ return ;
}
if (view.kind === "error") {
return (
-
+
);
}
if (view.reports.length === 0) {
- return ;
+ return ;
}
return ;
diff --git a/packages/app/src/usage/use-relative-time-tick.ts b/packages/app/src/usage/use-relative-time-tick.ts
index 2e59bd9ddc..c21c1398cb 100644
--- a/packages/app/src/usage/use-relative-time-tick.ts
+++ b/packages/app/src/usage/use-relative-time-tick.ts
@@ -1,12 +1,13 @@
-import { useEffect, useReducer } from "react";
+import { useEffect, useState } from "react";
import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker";
-/** Re-renders a mounted usage label when its relative time can change. */
-export function useRelativeTimeTick({ enabled }: { enabled: boolean }): void {
- const [, tick] = useReducer((value: number) => value + 1, 0);
+/** The current time as a render input, updated while a relative-time label is mounted. */
+export function useRelativeTimeTick({ enabled }: { enabled: boolean }): number {
+ const [now, setNow] = useState(Date.now);
useEffect(() => {
if (!enabled) return undefined;
- return subscribeToRelativeTimeTick("minute", tick);
+ return subscribeToRelativeTimeTick("minute", () => setNow(Date.now()));
}, [enabled]);
+ return now;
}
diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx
index 8ecf3d045d..9082f2dbac 100644
--- a/packages/app/src/usage/window-bar.tsx
+++ b/packages/app/src/usage/window-bar.tsx
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { usageCopy } from "./copy";
import {
formatDisplayPct,
formatProviderUsageLabel,
@@ -46,24 +45,24 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
- const { i18n } = useTranslation();
+ const { t, i18n } = useTranslation();
const shownPct = displayPercent(window, displayAs);
const tone = windowTone(window);
+ const now = useRelativeTimeTick({ enabled: window.runsOutAt != null || window.resetsAt != null });
const isAtRisk =
window.runsOutAt != null &&
window.shortfallPct != null &&
- (!retained || Date.parse(window.runsOutAt) > Date.now());
- useRelativeTimeTick({ enabled: (isAtRisk ? window.runsOutAt : window.resetsAt) != null });
+ (!retained || Date.parse(window.runsOutAt) > now);
const trailing = isAtRisk
- ? formatRunsOutLabel(window.runsOutAt)
- : formatResetLabel(window.resetsAt, retained);
+ ? formatRunsOutLabel(window.runsOutAt, now, t)
+ : formatResetLabel(window.resetsAt, retained, now, t);
const value =
- shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage) : "—";
+ shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
const content = {
- label: formatProviderUsageLabel(window.id, window.label),
+ label: formatProviderUsageLabel(window.id, window.label, t),
value,
trailing,
isAtRisk,
@@ -152,6 +151,7 @@ function WindowRowContent({
const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
+ const { t } = useTranslation();
const iconMapping = useMemo(
() => (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
@@ -170,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
- {pinned ? usageCopy.unpin : usageCopy.pin}
+
+ {pinned ? t("providerUsage.unpin") : t("providerUsage.pin")}
+
);
diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts
index e2d67aca70..7a45d4a771 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -2,6 +2,7 @@ import { defineConfig, configDefaults } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
+import { transformSync } from "@babel/core";
// Set this before Vite resolves dependencies and Vitest starts its workers.
Object.assign(process.env, { NODE_ENV: "test" });
@@ -16,6 +17,25 @@ const resolvePackageEntry = (packageName: string) => {
};
export default defineConfig({
+ plugins: [
+ {
+ name: "compile-usage-components",
+ enforce: "pre",
+ transform(source, id) {
+ // These labels must react to time and language in the compiled app too.
+ if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return;
+ const result = transformSync(source, {
+ filename: id,
+ babelrc: false,
+ configFile: false,
+ parserOpts: { plugins: ["typescript", "jsx"] },
+ plugins: [["babel-plugin-react-compiler", { target: "19" }]],
+ sourceMaps: true,
+ });
+ if (result?.code) return { code: result.code, map: result.map };
+ },
+ },
+ ],
test: {
environment: "node",
exclude: [...configDefaults.exclude, "e2e/**"],
@@ -82,6 +102,7 @@ export default defineConfig({
// Bundle the CJS dependencies of the excluded gesture-handler package for the browser.
include: [
"react/jsx-runtime",
+ "react/compiler-runtime",
"react-native-gesture-handler > hoist-non-react-statics",
"react-native-gesture-handler > invariant",
],