Clarify and localize provider usage (#7376)

* Localize provider usage UI

* fix: restore compact provider usage meter

* fix: localize compact context percentage

* fix(app): deduplicate context meter refresh

* fix(app): localize provider usage time counts

* fix(app): stabilize compact provider usage row

* fix(app): localize provider usage plurals

* test(app): allow locale-specific plural keys

* fix(app): show compact quota without context usage

* fix(app): preserve context overflow percentage

* fix(app): truncate provider balance reset values

* fix(app): add Korean provider usage translations

* fix(app): refresh provider usage timing

* fix(app): localize provider usage labels

* fix(app): clarify compact usage policies

* fix(app): localize monthly provider usage

* test(app): require Arabic provider usage plurals

* docs(i18n): keep migration guidance focused

* fix(app): hide empty compact usage slot

* fix(app): keep usage in context tooltip

* fix(app): align usage with sidebar reports

* test(app): pass usage timing clock explicitly

* fix(app): restore context usage details

* fix(app): make context usage tooltip interactive

* fix(app): support keyboard tooltip controls

* fix(app): keep focused tooltip controls open

* fix(app): address usage localization review

* fix(app): localize bulk usage refresh

* fix(app): localize Claude session window

* fix(app): complete provider usage localization

* 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.

* test(desktop): wait for restored tabs before shortcut input

Document load completes before the workspace reconnects. Wait for the restored selection so Ctrl+1 reaches the mounted workspace rather than the startup screen.

* test(app): allow the shared usage clock minute phase

The browser clock advances independently of daemon timestamps, and the shared ticker samples on wall-minute boundaries. Assert the bounded one-to-two-minute age and matching countdown progress instead of a single phase-dependent value.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-19 18:01:45 +02:00
committed by GitHub
co-authored by Michael Wu
parent 91154ba4e8
commit eda3e4318f
38 changed files with 1375 additions and 192 deletions
@@ -149,7 +149,7 @@ for (const layout of [
await expect(details.getByText(/tokens$/)).toHaveCount(0);
const account = usageCard(details, "login-journey:account");
await expect(account.getByText("Session", { exact: true })).toBeVisible();
await expect(account.getByText("31%", { exact: true })).toBeVisible();
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
await qaScreenshot(page, `context-without-data-${layout.name}`);
await layout.close(page);
});
@@ -161,7 +161,7 @@ for (const layout of [
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
await expect(
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
).toBeVisible();
});
} finally {
+99 -14
View File
@@ -4,9 +4,10 @@ import path from "node:path";
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { openSettingsSection } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import {
@@ -131,7 +132,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
await refreshAllUsage(page);
await usage.waitForListRequests(requestCount + 1);
const group = page.getByTestId(`usage-host-${getServerId()}`);
await expect(group.getByText("41%", { exact: true })).toBeVisible();
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
}
@@ -178,7 +179,7 @@ test.describe("usage modal", () => {
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("31% used")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -240,15 +241,15 @@ test.describe("usage modal", () => {
await gotoAppShell(page);
await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await expect
.poll(() => forcedRefreshes(usage))
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
await expect(group.getByText("58% used")).toBeVisible();
await expect(group.getByText("12% used")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
@@ -332,7 +333,7 @@ test.describe("usage modal", () => {
await hostFilter(page).click();
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-modal-picked-host");
@@ -347,7 +348,7 @@ test.describe("usage modal", () => {
await expectPinnedUsage(page, ["12% Weekly"]);
await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
).toBeVisible({ timeout: 30_000 });
} finally {
await secondary.close().catch(() => undefined);
@@ -513,7 +514,7 @@ for (const theme of ["light", "dark"] as const) {
await expect(screen.getByText("69% left")).toBeVisible();
await showUsageAs(page, "used");
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
/^Pin Claude Session, 31% · resets /,
/^Pin Claude Session, 31% used · resets /,
);
await openUsageOptions(page);
await closeUsageOptions(page);
@@ -576,7 +577,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
},
]);
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageOnly(page, "42% used");
} finally {
await fixture.cleanup();
}
@@ -599,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 },
);
@@ -615,7 +617,8 @@ for (const compact of [false, true]) {
// Move only the browser clock; real daemon fetches continue using the host clock.
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
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" });
@@ -640,8 +643,9 @@ for (const compact of [false, true]) {
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43%");
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
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 {
await fixture.cleanup();
@@ -651,7 +655,88 @@ for (const compact of [false, true]) {
async function expectRetainedLoginUsage(page: Page, message: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
async function switchToJapanese(page: Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await openSettings(page);
await openSettingsSection(page, "general");
await page.getByRole("button", { name: "System", exact: true }).click();
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
await page.getByRole("button", { name: "戻る", exact: true }).click();
}
async function openJapaneseUsage(page: Page) {
await page.getByRole("button", { name: "使用状況", exact: true }).click();
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
}
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
test.setTimeout(120_000);
const login = await installLoginUsage({
status: "available",
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();
await page.getByRole("button", { name: "設定", exact: true }).last().click();
await expect(page.getByText("割合", { exact: true })).toBeVisible();
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
await expect(page.getByText("残り", { exact: true })).toBeVisible();
} finally {
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();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets (28|29)m$/,
);
} 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);
}
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("70% used")).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("usage-card");
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
await refreshAllUsage(page);
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
await expect(card.getByText("64%")).toBeVisible();
await expect(card.getByText("64% used")).toBeVisible();
});
test("asks to update a host without usage support and never calls it", async ({ page }) => {
@@ -11,6 +11,7 @@ import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
serverId: string;
@@ -150,7 +151,7 @@ export function ContextWindowMeter({
showPercentage = false,
glyphSize,
}: ContextWindowMeterProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
const showsUsage = useHostReportsUsage(serverId);
@@ -192,7 +193,9 @@ export function ContextWindowMeter({
);
const percentageLabel =
showPercentage && context ? (
<Text style={styles.percentageLabel}>{`${context.percentage}%`}</Text>
<Text style={styles.percentageLabel}>
{formatContextPercentage(context.percentage, i18n.resolvedLanguage)}
</Text>
) : null;
const accessibilityLabel = context
? t("contextWindow.accessibility", { percentage: context.percentage })
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { formatContextPercentage } from "./context-window-meter.utils";
describe("context window meter formatting", () => {
it("localizes percentages without hiding context overflow", () => {
expect(formatContextPercentage(125, "en")).toBe("125%");
expect(formatContextPercentage(42, "fr")).toBe("42\u00a0%");
});
});
@@ -1,3 +1,10 @@
export function formatContextPercentage(value: number, locale?: string): string {
return new Intl.NumberFormat(locale, {
style: "percent",
maximumFractionDigits: 0,
}).format(value / 100);
}
export function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
+42 -9
View File
@@ -20,6 +20,30 @@ function flattenKeys(value: unknown, prefix = ""): string[] {
return entries.flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key));
}
const pluralSuffixPattern = /_(zero|one|two|few|many|other)$/;
const arabicPluralSuffixes = ["zero", "one", "two", "few", "many", "other"] as const;
const arabicProviderUsagePluralKeys = [
"providerUsage.duration.days",
"providerUsage.duration.hours",
"providerUsage.duration.minutes",
"providerUsage.timing.daysAgo",
"providerUsage.timing.hoursAgo",
"providerUsage.timing.minutesAgo",
] as const;
function canonicalKeys(value: unknown): string[] {
const keys = flattenKeys(value);
const keySet = new Set(keys);
return [
...new Set(
keys.map((key) => {
const baseKey = key.replace(pluralSuffixPattern, "");
return baseKey !== key && keySet.has(baseKey) ? baseKey : key;
}),
),
].sort();
}
function flattenStrings(value: unknown, prefix = ""): Record<string, string> {
if (typeof value === "string") {
return { [prefix]: value };
@@ -144,15 +168,24 @@ function findUntranslatedConnectionErrors(): string[] {
describe("translation resources", () => {
it("keeps all supported language keys in sync with English", () => {
const englishKeys = flattenKeys(en).sort();
expect(flattenKeys(ar).sort()).toEqual(englishKeys);
expect(flattenKeys(es).sort()).toEqual(englishKeys);
expect(flattenKeys(fr).sort()).toEqual(englishKeys);
expect(flattenKeys(ja).sort()).toEqual(englishKeys);
expect(flattenKeys(ko).sort()).toEqual(englishKeys);
expect(flattenKeys(ptBR).sort()).toEqual(englishKeys);
expect(flattenKeys(ru).sort()).toEqual(englishKeys);
expect(flattenKeys(zhCN).sort()).toEqual(englishKeys);
const englishKeys = canonicalKeys(en);
expect(canonicalKeys(ar)).toEqual(englishKeys);
expect(canonicalKeys(es)).toEqual(englishKeys);
expect(canonicalKeys(fr)).toEqual(englishKeys);
expect(canonicalKeys(ja)).toEqual(englishKeys);
expect(canonicalKeys(ko)).toEqual(englishKeys);
expect(canonicalKeys(ptBR)).toEqual(englishKeys);
expect(canonicalKeys(ru)).toEqual(englishKeys);
expect(canonicalKeys(zhCN)).toEqual(englishKeys);
});
it("includes every Arabic form for provider-usage durations", () => {
const arabicStrings = flattenStrings(ar);
for (const key of arabicProviderUsagePluralKeys) {
for (const suffix of arabicPluralSuffixes) {
expect(arabicStrings[`${key}_${suffix}`]).toBeTypeOf("string");
}
}
});
it("keeps non-English supported languages translated beyond fallback labels", () => {
+100
View File
@@ -2046,6 +2046,106 @@ export const ar: TranslationResources = {
saveAccessibility: "حفظ تعليق المراجعة",
},
},
providerUsage: {
options: "الإعدادات",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
agentError: "تعذّر تحميل الاستخدام: {{reason}}",
pin: "تثبيت",
unpin: "إلغاء التثبيت",
displayAs: "النسب المئوية",
displayUsed: "المستخدم",
displayRemaining: "المتبقي",
showInSidebar: "ملخص في الشريط الجانبي",
showInSidebarHint: "تظهر الفترات المثبتة في أسفل الشريط الجانبي",
runToRefresh: "شغّل {{command}} للتحديث.",
signInAgain: "سجّل الدخول مجددًا.",
loginRejected: "تم رفض تسجيل الدخول (HTTP {{status}}). {{remedy}}",
loginExpired: "انتهت صلاحية تسجيل الدخول {{relativeTime}}. {{remedy}}",
title: "استخدام الخطة",
refresh: "تحديث",
refreshAll: "تحديث الكل",
refreshing: "جارٍ التحديث...",
loading: "جارٍ تحميل بيانات الاستخدام...",
empty: "لا توجد بيانات استخدام",
errorTitle: "تعذّر تحميل بيانات الاستخدام",
hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد",
hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد",
clientUnavailable: "اتصال المضيف غير جاهز",
retry: "حاول مرة أخرى",
tooltipLoading: "جارٍ تحميل استخدام الخطة...",
status: {
error: "خطأ",
unavailable: "غير متاح",
},
values: {
used: "تم استخدام {{percentage}}",
remaining: "متبقٍ {{amount}}",
},
labels: {
session: "الجلسة",
weekly: "أسبوعي",
monthly: "شهري",
codeReview: "مراجعة التعليمات البرمجية",
credits: "الأرصدة",
monthlyCredits: "أرصدة شهرية",
},
duration: {
days: "{{value}} يوم",
days_zero: "{{value}} يوم",
days_one: "يوم واحد",
days_two: "يومين",
days_few: "{{value}} أيام",
days_many: "{{value}} يومًا",
days_other: "{{value}} يوم",
hours: "{{value}} ساعة",
hours_zero: "{{value}} ساعة",
hours_one: "ساعة واحدة",
hours_two: "ساعتين",
hours_few: "{{value}} ساعات",
hours_many: "{{value}} ساعة",
hours_other: "{{value}} ساعة",
minutes: "{{value}} دقيقة",
minutes_zero: "{{value}} دقيقة",
minutes_one: "دقيقة واحدة",
minutes_two: "دقيقتين",
minutes_few: "{{value}} دقائق",
minutes_many: "{{value}} دقيقة",
minutes_other: "{{value}} دقيقة",
},
timing: {
lastUpdated: "آخر تحديث {{relativeTime}}",
noSuccessfulUpdate: "لا يوجد تحديث ناجح",
resetTimePassed: "مضى وقت إعادة التعيين",
resettingNow: "تتم إعادة التعيين الآن",
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
runsOutNow: "ينفد الآن",
runsOutIn: "ينفد خلال {{duration}}",
justNow: "الآن",
daysAgo: "قبل {{value}} يومًا",
daysAgo_zero: "قبل {{value}} يوم",
daysAgo_one: "قبل يوم واحد",
daysAgo_two: "قبل يومين",
daysAgo_few: "قبل {{value}} أيام",
daysAgo_many: "قبل {{value}} يومًا",
daysAgo_other: "قبل {{value}} يوم",
hoursAgo: "قبل {{value}} ساعة",
hoursAgo_zero: "قبل {{value}} ساعة",
hoursAgo_one: "قبل ساعة واحدة",
hoursAgo_two: "قبل ساعتين",
hoursAgo_few: "قبل {{value}} ساعات",
hoursAgo_many: "قبل {{value}} ساعة",
hoursAgo_other: "قبل {{value}} ساعة",
minutesAgo: "قبل {{value}} دقيقة",
minutesAgo_zero: "قبل {{value}} دقيقة",
minutesAgo_one: "قبل دقيقة واحدة",
minutesAgo_two: "قبل دقيقتين",
minutesAgo_few: "قبل {{value}} دقائق",
minutesAgo_many: "قبل {{value}} دقيقة",
minutesAgo_other: "قبل {{value}} دقيقة",
updated: "تم التحديث {{relativeTime}}",
},
},
settings: {
title: "إعدادات",
loading: "جارٍ تحميل الإعدادات...",
+79 -1
View File
@@ -2071,6 +2071,70 @@ export const en = {
saveAccessibility: "Save review comment",
},
},
providerUsage: {
options: "Settings",
refreshFailed: "Unable to refresh usage",
noHosts: "No connected hosts",
agentError: "Unable to load usage: {{reason}}",
pin: "Pin",
unpin: "Unpin",
displayAs: "Percentages",
displayUsed: "Used",
displayRemaining: "Remaining",
showInSidebar: "Summary in sidebar",
showInSidebarHint: "Pinned windows show in the sidebar footer",
runToRefresh: "Run {{command}} to refresh it.",
signInAgain: "Sign in again.",
loginRejected: "Login rejected (HTTP {{status}}). {{remedy}}",
loginExpired: "Login expired {{relativeTime}}. {{remedy}}",
title: "Plan usage",
refresh: "Refresh",
refreshAll: "Refresh all",
refreshing: "Refreshing...",
loading: "Loading usage...",
empty: "No usage data",
errorTitle: "Unable to load usage",
hostUnavailable: "Connect to {{host}} to see usage",
hostUpgradeRequired: "Update {{host}} to see usage",
clientUnavailable: "Host connection is not ready",
retry: "Try again",
tooltipLoading: "Loading plan usage...",
status: {
error: "Error",
unavailable: "Unavailable",
},
values: {
used: "{{percentage}} used",
remaining: "{{amount}} left",
},
labels: {
session: "Session",
weekly: "Weekly",
monthly: "Monthly",
codeReview: "Code review",
credits: "Credits",
monthlyCredits: "Monthly credits",
},
duration: {
days: "{{value}}d",
hours: "{{value}}h",
minutes: "{{value}}m",
},
timing: {
lastUpdated: "Last updated {{relativeTime}}",
noSuccessfulUpdate: "No successful update",
resetTimePassed: "Reset time passed",
resettingNow: "resetting now",
resetsIn: "resets {{duration}}",
runsOutNow: "runs out now",
runsOutIn: "runs out {{duration}}",
justNow: "just now",
daysAgo: "{{value}}d ago",
hoursAgo: "{{value}}h ago",
minutesAgo: "{{value}}m ago",
updated: "Updated {{relativeTime}}",
},
},
settings: {
title: "Settings",
loading: "Loading settings...",
@@ -2973,4 +3037,18 @@ type WidenStringLeaves<T> = {
[K in keyof T]: T[K] extends string ? string : WidenStringLeaves<T[K]>;
};
export type TranslationResources = WidenStringLeaves<typeof en>;
type TranslationResourcesBase = WidenStringLeaves<typeof en>;
type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
type DurationPluralKey = `${"days" | "hours" | "minutes"}_${PluralCategory}`;
type AgoPluralKey = `${"daysAgo" | "hoursAgo" | "minutesAgo"}_${PluralCategory}`;
// i18next plural categories differ by locale, so locale catalogs may add the
// explicit v4 suffixes they need while retaining the canonical fallback keys.
export type TranslationResources = TranslationResourcesBase & {
providerUsage: TranslationResourcesBase["providerUsage"] & {
duration: TranslationResourcesBase["providerUsage"]["duration"] &
Partial<Record<DurationPluralKey, string>>;
timing: TranslationResourcesBase["providerUsage"]["timing"] &
Partial<Record<AgoPluralKey, string>>;
};
};
+64
View File
@@ -2094,6 +2094,70 @@ export const es: TranslationResources = {
saveAccessibility: "Guardar comentario de revisión",
},
},
providerUsage: {
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
agentError: "No se pudo cargar el uso: {{reason}}",
pin: "Fijar",
unpin: "Desfijar",
displayAs: "Porcentajes",
displayUsed: "Usado",
displayRemaining: "Restante",
showInSidebar: "Resumen en la barra lateral",
showInSidebarHint: "Los períodos fijados aparecen al pie de la barra lateral",
runToRefresh: "Ejecuta {{command}} para actualizar.",
signInAgain: "Vuelve a iniciar sesión.",
loginRejected: "Inicio de sesión rechazado (HTTP {{status}}). {{remedy}}",
loginExpired: "La sesión caducó {{relativeTime}}. {{remedy}}",
title: "Uso del plan",
refresh: "Actualizar",
refreshAll: "Actualizar todo",
refreshing: "Actualizando...",
loading: "Cargando uso...",
empty: "No hay datos de uso",
errorTitle: "No se pudo cargar el uso",
hostUnavailable: "Conéctate a {{host}} para ver el uso del proveedor",
hostUpgradeRequired: "Actualiza {{host}} para ver el uso del proveedor",
clientUnavailable: "La conexión con el host no está lista",
retry: "Intentar de nuevo",
tooltipLoading: "Cargando uso del plan...",
status: {
error: "Error",
unavailable: "No disponible",
},
values: {
used: "Usado: {{percentage}}",
remaining: "Quedan {{amount}}",
},
labels: {
session: "Sesión",
weekly: "Semanal",
monthly: "Mensual",
codeReview: "Revisión de código",
credits: "Créditos",
monthlyCredits: "Créditos mensuales",
},
duration: {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Última actualización {{relativeTime}}",
noSuccessfulUpdate: "No hay actualizaciones correctas",
resetTimePassed: "Pasó la hora de restablecimiento",
resettingNow: "se restablece ahora",
resetsIn: "se restablece en {{duration}}",
runsOutNow: "se agota ahora",
runsOutIn: "se agota en {{duration}}",
justNow: "ahora mismo",
daysAgo: "hace {{value}} d",
hoursAgo: "hace {{value}} h",
minutesAgo: "hace {{value}} min",
updated: "Actualizado {{relativeTime}}",
},
},
settings: {
title: "Ajustes",
loading: "Cargando configuración...",
+64
View File
@@ -2094,6 +2094,70 @@ export const fr: TranslationResources = {
saveAccessibility: "Enregistrer le commentaire de revue",
},
},
providerUsage: {
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
agentError: "Impossible de charger l’utilisation : {{reason}}",
pin: "Épingler",
unpin: "Désépingler",
displayAs: "Pourcentages",
displayUsed: "Utilisé",
displayRemaining: "Restant",
showInSidebar: "Résumé dans la barre latérale",
showInSidebarHint: "Les périodes épinglées apparaissent en bas de la barre latérale",
runToRefresh: "Exécutez {{command}} pour actualiser.",
signInAgain: "Reconnectez-vous.",
loginRejected: "Connexion refusée (HTTP {{status}}). {{remedy}}",
loginExpired: "Connexion expirée {{relativeTime}}. {{remedy}}",
title: "Utilisation du forfait",
refresh: "Actualiser",
refreshAll: "Tout actualiser",
refreshing: "Actualisation...",
loading: "Chargement de l’utilisation...",
empty: "Aucune donnée d’utilisation",
errorTitle: "Impossible de charger l’utilisation",
hostUnavailable: "Connectez-vous à {{host}} pour voir l’utilisation du fournisseur",
hostUpgradeRequired: "Mettez à jour {{host}} pour voir l’utilisation du fournisseur",
clientUnavailable: "La connexion à l’hôte n’est pas prête",
retry: "Réessayer",
tooltipLoading: "Chargement de l’utilisation du forfait...",
status: {
error: "Erreur",
unavailable: "Indisponible",
},
values: {
used: "Utilisé : {{percentage}}",
remaining: "Il reste {{amount}}",
},
labels: {
session: "Session",
weekly: "Hebdomadaire",
monthly: "Mensuel",
codeReview: "Révision de code",
credits: "Crédits",
monthlyCredits: "Crédits mensuels",
},
duration: {
days: "{{value}} j",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Dernière mise à jour {{relativeTime}}",
noSuccessfulUpdate: "Aucune mise à jour réussie",
resetTimePassed: "Heure de réinitialisation dépassée",
resettingNow: "réinitialisation en cours",
resetsIn: "se réinitialise dans {{duration}}",
runsOutNow: "s’épuise maintenant",
runsOutIn: "s’épuise dans {{duration}}",
justNow: "à l’instant",
daysAgo: "il y a {{value}} j",
hoursAgo: "il y a {{value}} h",
minutesAgo: "il y a {{value}} min",
updated: "Mis à jour {{relativeTime}}",
},
},
settings: {
title: "Paramètres",
loading: "Chargement des paramètres…",
+64
View File
@@ -2067,6 +2067,70 @@ export const ja: TranslationResources = {
saveAccessibility: "レビューコメントを保存",
},
},
providerUsage: {
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
agentError: "使用状況を読み込めません: {{reason}}",
pin: "ピン留め",
unpin: "ピン留めを解除",
displayAs: "割合",
displayUsed: "使用済み",
displayRemaining: "残り",
showInSidebar: "サイドバーに概要を表示",
showInSidebarHint: "ピン留めした期間をサイドバーの下部に表示します",
runToRefresh: "{{command}} を実行して更新してください。",
signInAgain: "再度サインインしてください。",
loginRejected: "ログインが拒否されました (HTTP {{status}})。{{remedy}}",
loginExpired: "ログインは{{relativeTime}}に期限切れになりました。{{remedy}}",
title: "プラン使用量",
refresh: "更新",
refreshAll: "すべて更新",
refreshing: "更新中...",
loading: "使用状況を読み込み中...",
empty: "使用状況データがありません",
errorTitle: "使用状況を読み込めません",
hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください",
hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください",
clientUnavailable: "ホスト接続の準備ができていません",
retry: "再試行",
tooltipLoading: "プラン使用状況を読み込み中...",
status: {
error: "エラー",
unavailable: "利用不可",
},
values: {
used: "{{percentage}} 使用済み",
remaining: "残り {{amount}}",
},
labels: {
session: "セッション",
weekly: "週間",
monthly: "月間",
codeReview: "コードレビュー",
credits: "クレジット",
monthlyCredits: "月間クレジット",
},
duration: {
days: "{{value}}日",
hours: "{{value}}時間",
minutes: "{{value}}分",
},
timing: {
lastUpdated: "最終更新: {{relativeTime}}",
noSuccessfulUpdate: "正常な更新はまだありません",
resetTimePassed: "リセット時刻を過ぎています",
resettingNow: "まもなくリセット",
resetsIn: "{{duration}}後にリセット",
runsOutNow: "まもなく上限に到達",
runsOutIn: "{{duration}}後に上限に到達",
justNow: "たった今",
daysAgo: "{{value}}日前",
hoursAgo: "{{value}}時間前",
minutesAgo: "{{value}}分前",
updated: "更新: {{relativeTime}}",
},
},
settings: {
title: "設定",
loading: "設定を読み込み中...",
+64
View File
@@ -2056,6 +2056,70 @@ export const ko: TranslationResources = {
saveAccessibility: "리뷰 댓글 저장",
},
},
providerUsage: {
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
agentError: "사용량을 불러올 수 없습니다: {{reason}}",
pin: "고정",
unpin: "고정 해제",
displayAs: "백분율",
displayUsed: "사용됨",
displayRemaining: "남음",
showInSidebar: "사이드바에 요약 표시",
showInSidebarHint: "고정된 기간이 사이드바 하단에 표시됩니다",
runToRefresh: "{{command}} 명령을 실행하여 새로 고치세요.",
signInAgain: "다시 로그인하세요.",
loginRejected: "로그인이 거부되었습니다 (HTTP {{status}}). {{remedy}}",
loginExpired: "로그인이 {{relativeTime}}에 만료되었습니다. {{remedy}}",
title: "플랜 사용량",
refresh: "새로 고침",
refreshAll: "모두 새로 고침",
refreshing: "새로 고치는 중...",
loading: "사용량 불러오는 중...",
empty: "사용량 데이터가 없습니다",
errorTitle: "사용량을 불러올 수 없습니다",
hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요",
hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요",
clientUnavailable: "호스트 연결이 준비되지 않았습니다",
retry: "다시 시도",
tooltipLoading: "플랜 사용량 불러오는 중...",
status: {
error: "오류",
unavailable: "사용할 수 없음",
},
values: {
used: "{{percentage}} 사용됨",
remaining: "{{amount}} 남음",
},
labels: {
session: "세션",
weekly: "주간",
monthly: "월간",
codeReview: "코드 검토",
credits: "크레딧",
monthlyCredits: "월간 크레딧",
},
duration: {
days: "{{value}}일",
hours: "{{value}}시간",
minutes: "{{value}}분",
},
timing: {
lastUpdated: "마지막 업데이트: {{relativeTime}}",
noSuccessfulUpdate: "성공한 업데이트가 없습니다",
resetTimePassed: "초기화 시간이 지났습니다",
resettingNow: "지금 재설정 중",
resetsIn: "{{duration}} 후 재설정",
runsOutNow: "지금 한도에 도달",
runsOutIn: "{{duration}} 후 한도에 도달",
justNow: "방금",
daysAgo: "{{value}}일 전",
hoursAgo: "{{value}}시간 전",
minutesAgo: "{{value}}분 전",
updated: "{{relativeTime}}에 업데이트됨",
},
},
settings: {
title: "설정",
loading: "설정 불러오는 중...",
+64
View File
@@ -2079,6 +2079,70 @@ export const ptBR: TranslationResources = {
saveAccessibility: "Salvar comentário de revisão",
},
},
providerUsage: {
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
agentError: "Não foi possível carregar o uso: {{reason}}",
pin: "Fixar",
unpin: "Desafixar",
displayAs: "Porcentagens",
displayUsed: "Usado",
displayRemaining: "Restante",
showInSidebar: "Resumo na barra lateral",
showInSidebarHint: "Os períodos fixados aparecem no rodapé da barra lateral",
runToRefresh: "Execute {{command}} para atualizar.",
signInAgain: "Entre novamente.",
loginRejected: "Login rejeitado (HTTP {{status}}). {{remedy}}",
loginExpired: "O login expirou {{relativeTime}}. {{remedy}}",
title: "Uso do plano",
refresh: "Atualizar",
refreshAll: "Atualizar tudo",
refreshing: "Atualizando...",
loading: "Carregando uso...",
empty: "Sem dados de uso",
errorTitle: "Não foi possível carregar o uso",
hostUnavailable: "Conecte-se a {{host}} para ver o uso do provedor",
hostUpgradeRequired: "Atualize {{host}} para ver o uso do provedor",
clientUnavailable: "A conexão com o host não está pronta",
retry: "Tentar novamente",
tooltipLoading: "Carregando uso do plano...",
status: {
error: "Erro",
unavailable: "Indisponível",
},
values: {
used: "{{percentage}} usado",
remaining: "{{amount}} restantes",
},
labels: {
session: "Sessão",
weekly: "Semanal",
monthly: "Mensal",
codeReview: "Revisão de código",
credits: "Créditos",
monthlyCredits: "Créditos mensais",
},
duration: {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
},
timing: {
lastUpdated: "Última atualização {{relativeTime}}",
noSuccessfulUpdate: "Nenhuma atualização bem-sucedida",
resetTimePassed: "O horário de redefinição passou",
resettingNow: "redefinindo agora",
resetsIn: "redefine em {{duration}}",
runsOutNow: "esgota agora",
runsOutIn: "esgota em {{duration}}",
justNow: "agora mesmo",
daysAgo: "há {{value}} d",
hoursAgo: "há {{value}} h",
minutesAgo: "há {{value}} min",
updated: "Atualizado {{relativeTime}}",
},
},
settings: {
title: "Configurações",
loading: "Carregando configurações...",
+64
View File
@@ -2079,6 +2079,70 @@ export const ru: TranslationResources = {
saveAccessibility: "Сохранить комментарий к ревью",
},
},
providerUsage: {
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
agentError: "Не удалось загрузить использование: {{reason}}",
pin: "Закрепить",
unpin: "Открепить",
displayAs: "Проценты",
displayUsed: "Использовано",
displayRemaining: "Осталось",
showInSidebar: "Сводка на боковой панели",
showInSidebarHint: "Закреплённые периоды отображаются внизу боковой панели",
runToRefresh: "Выполните {{command}} для обновления.",
signInAgain: "Войдите снова.",
loginRejected: "Вход отклонён (HTTP {{status}}). {{remedy}}",
loginExpired: "Срок входа истёк {{relativeTime}}. {{remedy}}",
title: "Использование плана",
refresh: "Обновить",
refreshAll: "Обновить всё",
refreshing: "Обновление...",
loading: "Загрузка данных об использовании...",
empty: "Нет данных об использовании",
errorTitle: "Не удалось загрузить данные об использовании",
hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера",
hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера",
clientUnavailable: "Соединение с хостом не готово",
retry: "Повторить",
tooltipLoading: "Загрузка использования плана...",
status: {
error: "Ошибка",
unavailable: "Недоступно",
},
values: {
used: "Использовано: {{percentage}}",
remaining: "Осталось: {{amount}}",
},
labels: {
session: "Сессия",
weekly: "Еженедельно",
monthly: "Ежемесячно",
codeReview: "Проверка кода",
credits: "Кредиты",
monthlyCredits: "Ежемесячные кредиты",
},
duration: {
days: "{{value}} дн.",
hours: "{{value}} ч",
minutes: "{{value}} мин",
},
timing: {
lastUpdated: "Последнее обновление {{relativeTime}}",
noSuccessfulUpdate: "Успешных обновлений нет",
resetTimePassed: "Время сброса прошло",
resettingNow: "сброс сейчас",
resetsIn: "сброс через {{duration}}",
runsOutNow: "закончится сейчас",
runsOutIn: "закончится через {{duration}}",
justNow: "только что",
daysAgo: "{{value}} дн. назад",
hoursAgo: "{{value}} ч назад",
minutesAgo: "{{value}} мин назад",
updated: "Обновлено {{relativeTime}}",
},
},
settings: {
title: "Настройки",
loading: "Загрузка настроек...",
+64
View File
@@ -2024,6 +2024,70 @@ export const zhCN: TranslationResources = {
saveAccessibility: "保存 review 评论",
},
},
providerUsage: {
options: "设置",
refreshFailed: "无法刷新用量",
noHosts: "没有已连接的主机",
agentError: "无法加载用量:{{reason}}",
pin: "固定",
unpin: "取消固定",
displayAs: "百分比",
displayUsed: "已使用",
displayRemaining: "剩余",
showInSidebar: "在侧边栏显示摘要",
showInSidebarHint: "固定的时间窗口显示在侧边栏底部",
runToRefresh: "运行 {{command}} 以刷新。",
signInAgain: "请重新登录。",
loginRejected: "登录被拒绝(HTTP {{status}})。{{remedy}}",
loginExpired: "登录已于{{relativeTime}}过期。{{remedy}}",
title: "套餐用量",
refresh: "刷新",
refreshAll: "全部刷新",
refreshing: "刷新中...",
loading: "正在加载用量...",
empty: "暂无用量数据",
errorTitle: "无法加载用量",
hostUnavailable: "连接到 {{host}} 以查看提供商用量",
hostUpgradeRequired: "更新 {{host}} 以查看提供商用量",
clientUnavailable: "主机连接尚未就绪",
retry: "重试",
tooltipLoading: "正在加载套餐用量...",
status: {
error: "错误",
unavailable: "不可用",
},
values: {
used: "已使用 {{percentage}}",
remaining: "剩余 {{amount}}",
},
labels: {
session: "会话",
weekly: "每周",
monthly: "每月",
codeReview: "代码审查",
credits: "积分",
monthlyCredits: "每月积分",
},
duration: {
days: "{{value}}天",
hours: "{{value}}小时",
minutes: "{{value}}分钟",
},
timing: {
lastUpdated: "上次更新于 {{relativeTime}}",
noSuccessfulUpdate: "尚无成功更新",
resetTimePassed: "重置时间已过",
resettingNow: "正在重置",
resetsIn: "将在 {{duration}}后重置",
runsOutNow: "即将用尽",
runsOutIn: "将在 {{duration}}后用尽",
justNow: "刚刚",
daysAgo: "{{value}}天前",
hoursAgo: "{{value}}小时前",
minutesAgo: "{{value}}分钟前",
updated: "更新于 {{relativeTime}}",
},
},
settings: {
title: "设置",
loading: "正在加载设置...",
+3 -2
View File
@@ -1,7 +1,7 @@
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";
@@ -21,6 +21,7 @@ export function AgentUsage({
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
refreshable: boolean;
}) {
const { t } = useTranslation();
const view = useAgentUsage(serverId, agentId);
if (view.kind === "none") return null;
return (
@@ -35,7 +36,7 @@ export function AgentUsage({
/>
) : (
<Text style={styles.message}>
{view.kind === "loading" ? usageCopy.loading : view.message}
{view.kind === "loading" ? t("providerUsage.loading") : view.message}
</Text>
)}
</>
+37 -11
View File
@@ -1,16 +1,26 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
import type { UsageBalance, UsageTone } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
interface ResolvedBalance {
amountText: string;
usedPct: number | null;
}
function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
function resolveBalance({
balance,
t,
locale,
}: {
balance: UsageBalance;
t: TFunction;
locale?: string;
}): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
const format = (value: number) => formatAmount(value, unit, locale);
if (limit != null && limit > 0) {
@@ -20,7 +30,10 @@ function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
}
if (remaining != null) {
return { amountText: `${format(remaining)} left`, usedPct: null };
return {
amountText: t("providerUsage.values.remaining", { amount: format(remaining) }),
usedPct: null,
};
}
if (used != null) {
return { amountText: format(used), usedPct: null };
@@ -48,10 +61,11 @@ export function UsageBalanceBar({
balance: UsageBalance;
retained?: boolean;
}) {
const { i18n } = useTranslation();
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
const { t, i18n } = useTranslation();
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<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
@@ -62,12 +76,18 @@ export function UsageBalanceBar({
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{balance.label}
</Text>
<Text style={styles.value}>
{amountText}
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
{formatProviderUsageLabel(balance.id, balance.label, t)}
</Text>
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
{amountText}
</Text>
{resetLabel ? (
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
{resetLabel}
</Text>
) : null}
</View>
</View>
{usedPct != null ? (
<View style={styles.track}>
@@ -94,13 +114,19 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
alignItems: "flex-end",
},
amount: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
textAlign: "right",
},
track: {
height: 4,
@@ -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(
<>
<UsageWindowBar
window={window}
displayAs="used"
pinnable={false}
pinned={false}
onTogglePin={ignorePin}
pinLabel=""
pinTestID=""
/>
<UsageBalanceBar balance={balance} />
</>,
),
);
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("クレジット");
});
+26 -12
View File
@@ -1,5 +1,6 @@
import { RotateCw } from "lucide-react-native";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
@@ -12,19 +13,22 @@ 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 { useCompactTimeAgo } from "@/hooks/use-time-ago";
import type { TFunction } from "i18next";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
import { formatAgo, formatProviderUsageLabel } from "./format";
import { resolveUsageReport, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
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" ? "Error" : "Unavailable";
return report.status === "error"
? t("providerUsage.status.error")
: t("providerUsage.status.unavailable");
}
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -49,15 +53,16 @@ export function UsageCard({
refreshable: boolean;
compact?: boolean;
}) {
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],
@@ -156,7 +161,7 @@ export function UsageCard({
{refreshState === "failed" ? (
<Text style={styles.error} testID="usage-refresh-error">
{usageCopy.refreshFailed}
{t("providerUsage.refreshFailed")}
</Text>
) : null}
</View>
@@ -176,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],
@@ -190,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}`}
/>
);
@@ -212,6 +218,7 @@ function UsageRefreshButton({
onRefresh: () => void;
compact: boolean;
}) {
const { t } = useTranslation();
const isPending = refreshState === "pending";
const iconSize = paneContentToolbarIconSize(compact);
const freshness = useMemo(
@@ -227,7 +234,7 @@ function UsageRefreshButton({
);
return (
<ToolbarButton
label={`${usageCopy.refresh} ${sourceLabel}`}
label={`${t("providerUsage.refresh")} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
@@ -257,10 +264,17 @@ function UsageFreshness({
style: StyleProp<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
const { t } = useTranslation();
const now = useRelativeTimeTick({ enabled: successfulAt != null });
const relativeTime = formatAgo(successfulAt, now, t);
const freshness = relativeTime
? t(retained ? "providerUsage.timing.lastUpdated" : "providerUsage.timing.updated", {
relativeTime,
})
: t("providerUsage.timing.noSuccessfulUpdate");
return (
<Text style={style} numberOfLines={1} testID={testID}>
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
{freshness}
</Text>
);
}
+16 -33
View File
@@ -1,40 +1,23 @@
import type { TFunction } from "i18next";
import type { UsageProblem } from "@getpaseo/protocol/messages";
import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time";
import { i18n } from "@/i18n/i18next";
import { formatAgo } from "./format";
// User-facing copy for the usage surfaces, kept in one file so localization is a
// single-file change.
// Messages for usage failures outside the UI. Rendered copy uses useTranslation.
export const usageCopy = {
problem: (problem: UsageProblem, now: Date = new Date()): string => {
problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): string => {
if (problem.kind === "no_quota") return problem.detail;
const remedy = problem.refreshedBy
? `Run ${problem.refreshedBy} to refresh it.`
: "Sign in again.";
if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`;
const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now));
return `Login expired ${ago}. ${remedy}`;
? t("providerUsage.runToRefresh", { command: problem.refreshedBy })
: t("providerUsage.signInAgain");
if (problem.kind === "rejected")
return t("providerUsage.loginRejected", { status: problem.status, remedy });
return t("providerUsage.loginExpired", {
relativeTime: formatAgo(problem.expiresAt, now.getTime(), t),
remedy,
});
},
get clientUnavailable() {
return i18n.t("providerUsage.clientUnavailable");
},
title: "Usage",
planUsage: "Plan usage",
options: "Settings",
refresh: "Refresh",
refreshAll: "Refresh all",
refreshing: "Refreshing...",
refreshFailed: "Unable to refresh usage",
updated: "Updated",
loading: "Loading usage...",
empty: "No usage data",
noHosts: "No connected hosts",
errorTitle: "Unable to load usage",
agentError: (reason: string) => `Unable to load usage: ${reason}`,
hostUnavailable: (host: string) => `Connect to ${host} to see usage`,
hostUpgradeRequired: (host: string) => `Update ${host} to see usage`,
clientUnavailable: "Host connection is not ready",
retry: "Try again",
pin: "Pin",
unpin: "Unpin",
displayAs: "Percentages",
displayUsed: "Used",
displayRemaining: "Remaining",
showInSidebar: "Summary in sidebar",
showInSidebarHint: "Pinned windows show in the sidebar footer",
} as const;
+112 -2
View File
@@ -1,7 +1,25 @@
import { describe, expect, it } from "vitest";
import { formatAmount, formatResetLabel } from "./format";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
formatDisplayPct,
formatAgo,
formatAmount,
formatPct,
formatProviderUsageLabel,
formatResetLabel,
formatRunsOutLabel,
} from "./format";
const NOW = Date.parse("2026-07-19T00:00:00.000Z");
describe("formatAmount", () => {
beforeEach(async () => {
await i18n.changeLanguage("en");
});
afterEach(async () => {
await i18n.changeLanguage("en");
});
it("groups thousands in the app's language", () => {
expect(formatAmount(12345, "credits", "en")).toBe("12,345");
expect(formatAmount(12345, "requests", "en")).toBe("12,345");
@@ -12,6 +30,68 @@ describe("formatAmount", () => {
expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50");
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
});
it("localizes usage timing without patching the global clock", async () => {
const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
expect(formatPct(7, "en")).toBe("7%");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
await i18n.changeLanguage("ja");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達");
expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前");
});
it("formats percentages with locale-specific spacing", () => {
expect(formatPct(42, "fr")).toBe("42\u00a0%");
});
it("formats relative-time counts with the active locale", async () => {
await i18n.changeLanguage("fr");
const days = 1_234;
const daysFromNow = new Date(NOW + days * 24 * 60 * 60 * 1000).toISOString();
const daysAgo = new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString();
expect(formatResetLabel(daysFromNow, false, NOW)).toBe("se réinitialise dans 1\u202f234 j");
expect(formatRunsOutLabel(daysFromNow, NOW)).toBe("s’épuise dans 1\u202f234 j");
expect(formatAgo(daysAgo, NOW)).toBe("il y a 1\u202f234 j");
});
it("selects Arabic plural forms for relative durations", async () => {
await i18n.changeLanguage("ar");
const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
const threeHoursFromNow = new Date(NOW + 3 * 60 * 60 * 1000).toISOString();
const twoDaysAgo = new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString();
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("تتم إعادة التعيين خلال ساعتين");
expect(formatRunsOutLabel(threeHoursFromNow, NOW)).toBe("ينفد خلال 3 ساعات");
expect(formatAgo(twoDaysAgo, NOW)).toBe("قبل يومين");
});
it("uses locale-aware compact notation for token balances", () => {
expect(formatAmount(1_234, "tokens", "en")).toBe("1.2K");
expect(formatAmount(1_234, "tokens", "ar")).toBe("1.2\u00a0ألف");
});
it("localizes known provider-usage labels and preserves provider-specific labels", async () => {
await i18n.changeLanguage("ko");
expect(formatProviderUsageLabel("session", "Session")).toBe("세션");
expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션");
expect(formatProviderUsageLabel("five_hour", "5-hour limit")).toBe("5-hour limit");
expect(formatProviderUsageLabel("monthly", "Monthly")).toBe("월간");
expect(formatProviderUsageLabel("code_review:weekly", "Code review · Weekly")).toBe(
"코드 검토 · 주간",
);
expect(formatProviderUsageLabel("code_review:five_hour", "Code review · 5-hour")).toBe(
"코드 검토 · 5-hour",
);
expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧");
expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit");
});
});
it("retained reset times that have passed do not claim the quota is resetting", () => {
@@ -20,3 +100,33 @@ it("retained reset times that have passed do not claim the quota is resetting",
expect(formatResetLabel(past)).toBe("resetting now");
expect(formatResetLabel(null, true)).toBeNull();
});
it("says that a usage window percentage is consumed", () => {
expect(formatDisplayPct(70, "used")).toBe("70% used");
expect(formatDisplayPct(30, "remaining")).toBe("30% left");
});
it("translates the usage refresh control into Japanese", async () => {
try {
await i18n.changeLanguage("ja");
expect(i18n.t("providerUsage.refresh")).toBe("更新");
} finally {
await i18n.changeLanguage("en");
}
});
it("translates the neighboring usage percentage settings", async () => {
try {
await i18n.changeLanguage("ja");
expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み");
expect(i18n.t("providerUsage.displayRemaining")).toBe("残り");
} finally {
await i18n.changeLanguage("en");
}
});
it("preserves provider-defined labels even when their IDs match a standard period", () => {
expect(formatProviderUsageLabel("monthly", "Extra allotment")).toBe("Extra allotment");
expect(formatProviderUsageLabel("code_review:weekly", "Custom review allowance")).toBe(
"Custom review allowance",
);
});
+134 -18
View File
@@ -1,47 +1,163 @@
import { formatTokenCount } from "@/components/context-window-meter.utils";
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
const providerUsageLabels = {
session: { label: "Session", key: "providerUsage.labels.session" },
weekly: { label: "Weekly", key: "providerUsage.labels.weekly" },
monthly: { label: "Monthly", key: "providerUsage.labels.monthly" },
code_review: { label: "Code review", key: "providerUsage.labels.codeReview" },
credits: { label: "Credits", key: "providerUsage.labels.credits" },
monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" },
} as const;
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 `${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 ? t(standard.key) : label;
}
export function clampPct(value: number): number {
return Math.max(0, Math.min(100, value));
}
export function formatPct(value: number): string {
return `${Math.round(clampPct(value))}%`;
export function formatPct(value: number, locale?: string): string {
const resolvedLocale = locale ?? i18n.resolvedLanguage;
return new Intl.NumberFormat(resolvedLocale, {
style: "percent",
maximumFractionDigits: 0,
}).format(clampPct(value) / 100);
}
/** "31%" of the window used, or "69% left" of it. */
export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string {
return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`;
/** A localized percentage that says whether it is used or remaining. */
export function formatDisplayPct(
value: number,
displayAs: UsageDisplayAs,
locale?: string,
t: TFunction = i18n.t,
): string {
const percentage = formatPct(value, locale);
return displayAs === "used"
? t("providerUsage.values.used", { percentage })
: t("providerUsage.values.remaining", { amount: percentage });
}
function relativeDuration(iso: string): string | null {
const diffMs = new Date(iso).getTime() - Date.now();
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
function formatCount(value: number): string {
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
}
function relativeDuration(iso: string, now: number): RelativeDuration | null {
const diffMs = new Date(iso).getTime() - now;
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return "now";
if (diffMs <= 0) return { unit: "now" };
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d`;
if (diffHours > 0) return `${diffHours}h`;
return `${diffMinutes}m`;
if (diffDays > 0) return { unit: "days", count: diffDays };
if (diffHours > 0) return { unit: "hours", count: diffHours };
return { unit: "minutes", count: diffMinutes };
}
export function formatResetLabel(iso: string | null | undefined, retained = false): string | null {
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
switch (duration.unit) {
case "now":
return null;
case "days":
return t("providerUsage.duration.days", {
count: duration.count,
value: formatCount(duration.count),
});
case "hours":
return t("providerUsage.duration.hours", {
count: duration.count,
value: formatCount(duration.count),
});
case "minutes":
return t("providerUsage.duration.minutes", {
count: duration.count,
value: formatCount(duration.count),
});
}
}
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);
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel === "now" && retained) return "Reset time passed";
return rel === "now" ? "resetting now" : `resets ${rel}`;
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 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(),
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 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 t("providerUsage.timing.daysAgo", {
count: diffDays,
value: formatCount(diffDays),
});
}
if (diffHours > 0) {
return t("providerUsage.timing.hoursAgo", {
count: diffHours,
value: formatCount(diffHours),
});
}
return t("providerUsage.timing.minutesAgo", {
count: diffMinutes,
value: formatCount(diffMinutes),
});
}
/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */
export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string {
export function formatAmount(value: number, unit: UsageBalanceUnit, locale?: string): string {
switch (unit) {
case "usd":
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value);
case "tokens":
return formatTokenCount(value);
return new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
default:
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
}
@@ -1,16 +1,17 @@
import { usageCopy } from "./copy";
import { useTranslation } from "react-i18next";
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 }) {
const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsagePreferences(serverId);
return (
<UsageSection
serverId={serverId}
title={usageCopy.planUsage}
title={t("providerUsage.title")}
view={view}
display={display}
onRefresh={refresh}
+25 -15
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
resolveUsageReport,
displayPercent,
formatUsageFreshness,
resolveAgentUsageView,
replaceReport,
resolveUsageRefresh,
@@ -65,6 +65,18 @@ describe("resolveUsageView", () => {
).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" });
});
it("localizes unavailable host messages while retaining the host name", async () => {
await i18n.changeLanguage("ko");
expect(
resolveUsageView({
hostLabel: "Laptop",
isConnected: false,
supportsUsage: false,
query: undefined,
}),
).toEqual({ kind: "unavailable", message: "Laptop에 연결하여 제공업체 사용량을 확인하세요" });
});
it("moves from loading to ready to error", () => {
const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true };
expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
@@ -80,6 +92,10 @@ describe("resolveUsageView", () => {
});
});
afterEach(async () => {
await i18n.changeLanguage("en");
});
const hosts: UsageHost[] = [
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
@@ -185,14 +201,6 @@ describe("displayPercent", () => {
});
});
describe("formatUsageFreshness", () => {
it("says when the report was fetched", () => {
expect(formatUsageFreshness("now")).toBe("Updated just now");
expect(formatUsageFreshness("3m")).toBe("Updated 3m ago");
expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
});
});
describe("resolveUsageRefresh", () => {
it("is pending while a refresh runs, even after an earlier failure", () => {
expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending");
@@ -338,7 +346,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
report: success.report as Extract<UsageReportEntry["report"], { status: "available" }>,
},
};
expect(resolveUsageReport(failure)).toEqual({
expect(resolveUsageReport(failure, i18n.t)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -347,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: [],
@@ -356,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: [],
@@ -384,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,
@@ -392,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]);
});
+35 -24
View File
@@ -1,4 +1,5 @@
import { formatCompactTimeAgoAsProse } from "@/utils/time";
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";
@@ -31,11 +32,6 @@ export function usageWindowRowLabel(input: {
return `${input.pinLabel}, ${summary}`;
}
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
export function formatUsageFreshness(compactTimeAgo: string, retained = false): string {
return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
}
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
export type UsageRefresh = "idle" | "pending" | "failed";
@@ -108,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 };
@@ -138,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 };
@@ -194,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.
@@ -204,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 ?? [],
@@ -224,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 "";
}
+16 -12
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Settings } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
@@ -8,20 +9,23 @@ 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";
const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
];
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
const { t } = useTranslation();
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
{ 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();
const { inSidebar, setInSidebar } = useUsageInSidebar();
@@ -29,7 +33,7 @@ export function UsageOptions() {
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
label={t("providerUsage.options")}
compact={compact}
testID="usage-options-menu"
>
@@ -41,21 +45,21 @@ export function UsageOptions() {
<DropdownMenuContent
align="end"
width={340}
sheetTitle={usageCopy.options}
sheetTitle={t("providerUsage.options")}
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={usageCopy.showInSidebar}
hint={usageCopy.showInSidebarHint}
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={usageCopy.displayAs}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
options={displayAsOptions}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
+26
View File
@@ -275,3 +275,29 @@ it("keeps unavailable and error reports out of the sidebar summary even with ret
};
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
});
it("localizes pinned labels and meanings while preserving custom window names", async () => {
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");
}
});
+6 -4
View File
@@ -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),
+13 -7
View File
@@ -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. */
+3 -2
View File
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { RotateCw } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
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);
@@ -14,11 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
* header's island, so its highlight is round.
*/
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
<ToolbarButton
label={usageCopy.refreshAll}
label={t("providerUsage.refreshAll")}
compact={compact}
disabled={busy}
onPress={onRefresh}
+2 -1
View File
@@ -34,9 +34,10 @@ import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
+2 -2
View File
@@ -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,11 +21,12 @@ interface UsageModalProps {
* title row.
*/
export function UsageModal(props: UsageModalProps) {
const { t } = useTranslation();
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
<UsageModalFrame {...props}>
<UsageMessage text={usageCopy.noHosts} />
<UsageMessage text={t("providerUsage.noHosts")} />
</UsageModalFrame>
);
}
+11 -5
View File
@@ -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 <UsageMessage text={view.message} />;
}
if (view.kind === "loading") {
return <UsageMessage text={usageCopy.loading} />;
return <UsageMessage text={t("providerUsage.loading")} />;
}
if (view.kind === "error") {
return (
<Alert size="sm" variant="error" title={usageCopy.errorTitle} description={view.message}>
<Alert
size="sm"
variant="error"
title={t("providerUsage.errorTitle")}
description={view.message}
>
<Button variant="outline" size="sm" onPress={onRefresh}>
{usageCopy.retry}
{t("providerUsage.retry")}
</Button>
</Alert>
);
}
if (view.reports.length === 0) {
return <UsageMessage text={usageCopy.empty} />;
return <UsageMessage text={t("providerUsage.empty")} />;
}
return <UsageList serverId={serverId} reports={view.reports} display={display} />;
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker";
/** 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", () => setNow(Date.now()));
}, [enabled]);
return now;
}
+22 -9
View File
@@ -1,15 +1,21 @@
import { Pin } from "lucide-react-native";
import { useMemo } from "react";
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, formatResetLabel } from "./format";
import {
formatDisplayPct,
formatProviderUsageLabel,
formatResetLabel,
formatRunsOutLabel,
} from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import type { UsageDisplayAs } from "./preferences";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
@@ -39,21 +45,24 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
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());
(!retained || Date.parse(window.runsOutAt) > now);
const trailing = isAtRisk
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
: formatResetLabel(window.resetsAt, retained);
? formatRunsOutLabel(window.runsOutAt, now, t)
: formatResetLabel(window.resetsAt, retained, now, t);
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
const value =
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
const content = {
label: window.label,
label: formatProviderUsageLabel(window.id, window.label, t),
value,
trailing,
isAtRisk,
@@ -124,7 +133,7 @@ function WindowRowContent({
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value}>
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
@@ -142,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,
@@ -160,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
</View>
</TooltipTrigger>
<TooltipContent side="top">
<Text style={styles.tooltipText}>{pinned ? usageCopy.unpin : usageCopy.pin}</Text>
<Text style={styles.tooltipText}>
{pinned ? t("providerUsage.unpin") : t("providerUsage.pin")}
</Text>
</TooltipContent>
</Tooltip>
);
@@ -209,6 +221,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
+21
View File
@@ -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",
],
@@ -240,6 +240,8 @@ async function openTwoDraftTabs(page: Page, workspaceId: string) {
async function expectTabNumberNavigation(page: Page) {
const tabs = page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
await expect(page.getByTestId("settings-sidebar")).not.toBeVisible();
await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Control+1");
await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Control+2");