diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 5d024de761..79a572a24b 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -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 { diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index f51c0ad510..b680754da2 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -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); +} diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 1b509f5640..15dd2fdb7a 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -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 }) => { diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 11931bdd41..f9cfabcf8d 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -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 ? ( - {`${context.percentage}%`} + + {formatContextPercentage(context.percentage, i18n.resolvedLanguage)} + ) : null; const accessibilityLabel = context ? t("contextWindow.accessibility", { percentage: context.percentage }) diff --git a/packages/app/src/components/context-window-meter.utils.test.ts b/packages/app/src/components/context-window-meter.utils.test.ts new file mode 100644 index 0000000000..394c933176 --- /dev/null +++ b/packages/app/src/components/context-window-meter.utils.test.ts @@ -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%"); + }); +}); diff --git a/packages/app/src/components/context-window-meter.utils.ts b/packages/app/src/components/context-window-meter.utils.ts index a0fa45ccbf..055c01386c 100644 --- a/packages/app/src/components/context-window-meter.utils.ts +++ b/packages/app/src/components/context-window-meter.utils.ts @@ -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`; diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index bf42a3b61a..c837815a7c 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -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 { 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", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b7b81c66ee..27be1cd3ea 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "جارٍ تحميل الإعدادات...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d8a4081dda..c81039affa 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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 = { [K in keyof T]: T[K] extends string ? string : WidenStringLeaves; }; -export type TranslationResources = WidenStringLeaves; +type TranslationResourcesBase = WidenStringLeaves; +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>; + timing: TranslationResourcesBase["providerUsage"]["timing"] & + Partial>; + }; +}; diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7fba1c22b9..03511d299c 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 54b6de6098..e581139dd0 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 31f1d4241f..07993a6b2c 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "設定を読み込み中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 1214aa2435..00e18ad1e3 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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: "설정 불러오는 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1a24fd1f83..6303dd2c46 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3b444895e5..fb58a4db29 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Загрузка настроек...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7b517fafb1..7e7e2382c6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "正在加载设置...", diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx index 0199c08ff2..bf10054db6 100644 --- a/packages/app/src/usage/agent-usage.tsx +++ b/packages/app/src/usage/agent-usage.tsx @@ -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({ /> ) : ( - {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 38b2f0804a..def723c554 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -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>( () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], @@ -62,12 +76,18 @@ export function UsageBalanceBar({ - {balance.label} - - - {amountText} - {resetLabel ? {` · ${resetLabel}`} : null} + {formatProviderUsageLabel(balance.id, balance.label, t)} + + + {amountText} + + {resetLabel ? ( + + {resetLabel} + + ) : null} + {usedPct != null ? ( @@ -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, 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 562f3805a1..7d0f9903da 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -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" ? ( - {usageCopy.refreshFailed} + {t("providerUsage.refreshFailed")} ) : null} @@ -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 ( ; 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 ( - {successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"} + {freshness} ); } diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 639272283b..14bcfe7d95 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -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; diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 22529c8d71..b7b81f0e5a 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -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", + ); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index e13a550e7b..e7a6ba6f7b 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -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); } diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index 927d32e7d8..e80e839680 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -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 ( { ).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, }, }; - 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]); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 1f1c82cce3..48143ad23f 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -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 ""; } diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index 886c21e190..b735b612aa 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -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[] = [ - { 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[] = [ + { 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() { @@ -41,21 +45,21 @@ 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 48754c4e19..473e4442cc 100644 --- a/packages/app/src/usage/refresh-button.tsx +++ b/packages/app/src/usage/refresh-button.tsx @@ -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 ( 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 8305c3f878..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,11 +21,12 @@ interface UsageModalProps { * title row. */ export function UsageModal(props: UsageModalProps) { + 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 new file mode 100644 index 0000000000..c21c1398cb --- /dev/null +++ b/packages/app/src/usage/use-relative-time-tick.ts @@ -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; +} diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 79ea6b9f2d..9082f2dbac 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -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({ {label} - + {value} {trailing ? ( {` · ${trailing}`} @@ -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 }) { - {pinned ? usageCopy.unpin : usageCopy.pin} + + {pinned ? t("providerUsage.unpin") : t("providerUsage.pin")} + ); @@ -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, 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", ], diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts index 86aef1b880..d745f983be 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -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");