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",
],