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