From eb2eabe91e6b1b96bef21f6809a7c6ab3a146c2d Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 19 Jul 2026 17:59:00 +0900 Subject: [PATCH 01/30] Localize provider usage UI --- docs/i18n.md | 1 + packages/app/src/i18n/resources/ar.ts | 37 +++++++ packages/app/src/i18n/resources/en.ts | 37 +++++++ packages/app/src/i18n/resources/es.ts | 37 +++++++ packages/app/src/i18n/resources/fr.ts | 37 +++++++ packages/app/src/i18n/resources/ja.ts | 37 +++++++ packages/app/src/i18n/resources/pt-BR.ts | 37 +++++++ packages/app/src/i18n/resources/ru.ts | 37 +++++++ packages/app/src/i18n/resources/zh-CN.ts | 37 +++++++ packages/app/src/usage/balance-bar.tsx | 19 +++- packages/app/src/usage/format.test.ts | 28 ++++- packages/app/src/usage/format.ts | 125 ++++++++++++++++++++--- packages/app/src/usage/window-bar.tsx | 19 +++- 13 files changed, 460 insertions(+), 28 deletions(-) diff --git a/docs/i18n.md b/docs/i18n.md index b3653a8b1d..0e132c89a8 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -91,3 +91,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t - Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch. - Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added. - Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales. +- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Provider names, plan labels, usage-window labels, balance/detail labels, source labels, and raw provider errors remain runtime values. diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index d29de2b33d..869a26a88c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1950,6 +1950,43 @@ export const ar: TranslationResources = { saveAccessibility: "حفظ تعليق المراجعة", }, }, + providerUsage: { + title: "استخدام الخطة", + refresh: "تحديث", + refreshing: "جارٍ التحديث...", + loading: "جارٍ تحميل بيانات الاستخدام...", + empty: "لا توجد بيانات استخدام", + errorTitle: "تعذّر تحميل بيانات الاستخدام", + hostUnavailable: "اتصل بهذا المضيف لعرض استخدام المزوّد", + hostUpgradeRequired: "حدّث المضيف لعرض استخدام المزوّد", + clientUnavailable: "اتصال المضيف غير جاهز", + retry: "حاول مرة أخرى", + tooltipLoading: "جارٍ تحميل استخدام الخطة...", + status: { + error: "خطأ", + unavailable: "غير متاح", + }, + values: { + used: "تم استخدام {{percentage}}", + remaining: "متبقٍ {{amount}}", + }, + duration: { + days: "{{value}} يوم", + hours: "{{value}} ساعة", + minutes: "{{value}} دقيقة", + }, + timing: { + 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/en.ts b/packages/app/src/i18n/resources/en.ts index 1e2d4c3f10..13544c4d09 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1975,6 +1975,43 @@ export const en = { saveAccessibility: "Save review comment", }, }, + providerUsage: { + title: "Plan usage", + refresh: "Refresh", + refreshing: "Refreshing...", + loading: "Loading usage...", + empty: "No usage data", + errorTitle: "Unable to load usage", + hostUnavailable: "Connect to this host to see provider usage", + hostUpgradeRequired: "Update the host to see provider 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", + }, + duration: { + days: "{{value}}d", + hours: "{{value}}h", + minutes: "{{value}}m", + }, + timing: { + 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...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index cc37432b5b..718499f853 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1998,6 +1998,43 @@ export const es: TranslationResources = { saveAccessibility: "Guardar comentario de revisión", }, }, + providerUsage: { + title: "Uso del plan", + refresh: "Actualizar", + refreshing: "Actualizando...", + loading: "Cargando uso...", + empty: "No hay datos de uso", + errorTitle: "No se pudo cargar el uso", + hostUnavailable: "Conéctate a este host para ver el uso del proveedor", + hostUpgradeRequired: "Actualiza el 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}}", + }, + duration: { + days: "{{value}} d", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + 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 aaf2608f9e..442c5c940c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2003,6 +2003,43 @@ export const fr: TranslationResources = { saveAccessibility: "Enregistrer le commentaire de l'avis", }, }, + providerUsage: { + title: "Utilisation du forfait", + refresh: "Actualiser", + refreshing: "Actualisation...", + loading: "Chargement de l’utilisation...", + empty: "Aucune donnée d’utilisation", + errorTitle: "Impossible de charger l’utilisation", + hostUnavailable: "Connectez-vous à cet hôte pour voir l’utilisation du fournisseur", + hostUpgradeRequired: "Mettez à jour l’hôte 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}}", + }, + duration: { + days: "{{value}} j", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + 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 3491b507a2..6e449075d9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1970,6 +1970,43 @@ export const ja: TranslationResources = { saveAccessibility: "レビューコメントを保存", }, }, + providerUsage: { + title: "プラン使用量", + refresh: "更新", + refreshing: "更新中...", + loading: "使用状況を読み込み中...", + empty: "使用状況データがありません", + errorTitle: "使用状況を読み込めません", + hostUnavailable: "このホストに接続してプロバイダーの使用状況を確認してください", + hostUpgradeRequired: "ホストを更新してプロバイダーの使用状況を確認してください", + clientUnavailable: "ホスト接続の準備ができていません", + retry: "再試行", + tooltipLoading: "プラン使用状況を読み込み中...", + status: { + error: "エラー", + unavailable: "利用不可", + }, + values: { + used: "{{percentage}} 使用済み", + remaining: "残り {{amount}}", + }, + duration: { + days: "{{value}}日", + hours: "{{value}}時間", + minutes: "{{value}}分", + }, + timing: { + 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 a08088b2e4..bae71a564a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1983,6 +1983,43 @@ export const ptBR: TranslationResources = { saveAccessibility: "Salvar comentário de revisão", }, }, + providerUsage: { + title: "Uso do plano", + refresh: "Atualizar", + refreshing: "Atualizando...", + loading: "Carregando uso...", + empty: "Sem dados de uso", + errorTitle: "Não foi possível carregar o uso", + hostUnavailable: "Conecte-se a este host para ver o uso do provedor", + hostUpgradeRequired: "Atualize o 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", + }, + duration: { + days: "{{value}} d", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + 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 4526784896..5eac9f746f 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1983,6 +1983,43 @@ export const ru: TranslationResources = { saveAccessibility: "Сохранить комментарий к ревью", }, }, + providerUsage: { + title: "Использование плана", + refresh: "Обновить", + refreshing: "Обновление...", + loading: "Загрузка данных об использовании...", + empty: "Нет данных об использовании", + errorTitle: "Не удалось загрузить данные об использовании", + hostUnavailable: "Подключитесь к этому хосту, чтобы увидеть использование провайдера", + hostUpgradeRequired: "Обновите хост, чтобы увидеть использование провайдера", + clientUnavailable: "Соединение с хостом не готово", + retry: "Повторить", + tooltipLoading: "Загрузка использования плана...", + status: { + error: "Ошибка", + unavailable: "Недоступно", + }, + values: { + used: "Использовано: {{percentage}}", + remaining: "Осталось: {{amount}}", + }, + duration: { + days: "{{value}} дн.", + hours: "{{value}} ч", + minutes: "{{value}} мин", + }, + timing: { + 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 d13007dbea..3f12ac2005 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1928,6 +1928,43 @@ export const zhCN: TranslationResources = { saveAccessibility: "保存 review 评论", }, }, + providerUsage: { + title: "套餐用量", + refresh: "刷新", + refreshing: "刷新中...", + loading: "正在加载用量...", + empty: "暂无用量数据", + errorTitle: "无法加载用量", + hostUnavailable: "连接到此主机以查看提供商用量", + hostUpgradeRequired: "更新主机以查看提供商用量", + clientUnavailable: "主机连接尚未就绪", + retry: "重试", + tooltipLoading: "正在加载套餐用量...", + status: { + error: "错误", + unavailable: "不可用", + }, + values: { + used: "已使用 {{percentage}}", + remaining: "剩余 {{amount}}", + }, + duration: { + days: "{{value}}天", + hours: "{{value}}小时", + minutes: "{{value}}分钟", + }, + timing: { + 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/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 751193eec0..e25448f0d3 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,4 +1,5 @@ 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"; @@ -10,7 +11,11 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { +function resolveBalance( + 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 +25,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 }; @@ -42,8 +50,8 @@ function fillToneStyle(tone: UsageTone) { } export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { - const { i18n } = useTranslation(); - const { amountText, usedPct } = resolveBalance(balance, i18n.language); + const { t, i18n } = useTranslation(); + const { amountText, usedPct } = resolveBalance(balance, t, i18n.resolvedLanguage); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); @@ -58,7 +66,7 @@ export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { {balance.label} - + {amountText} {resetLabel ? {` · ${resetLabel}`} : null} @@ -88,6 +96,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/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 88fe227ae6..cda2b9d9d4 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from "vitest"; -import { formatAmount } from "./format"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { formatAgo, formatAmount, formatPct, 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,4 +22,18 @@ 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, NOW)).toBe("resets in 2h"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h"); + + await i18n.changeLanguage("ja"); + expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("2時間後にリセット"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達"); + expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前"); + }); }); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 5adef2154d..c99a7a07a8 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,46 +1,139 @@ import { formatTokenCount } from "@/components/context-window-meter.utils"; +import { i18n } from "@/i18n/i18next"; import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; +const providerUsageLabelKeys = { + session: "providerUsage.labels.session", + five_hour: "providerUsage.labels.session", + weekly: "providerUsage.labels.weekly", + monthly: "providerUsage.labels.monthly", + code_review: "providerUsage.labels.codeReview", + credits: "providerUsage.labels.credits", + monthly_credits: "providerUsage.labels.monthlyCredits", +} as const; + +export function formatProviderUsageLabel(id: string, fallback: string): string { + const key = providerUsageLabelKeys[id as keyof typeof providerUsageLabelKeys]; + return key ? i18n.t(key) : fallback; +} + 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 { + return new Intl.NumberFormat(locale, { + 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): string { + const percentage = formatPct(value, locale); + return displayAs === "used" + ? i18n.t("providerUsage.values.used", { percentage }) + : i18n.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): string | null { +function formatRelativeDuration(duration: RelativeDuration): string | null { + switch (duration.unit) { + case "now": + return null; + case "days": + return i18n.t("providerUsage.duration.days", { + count: duration.count, + value: formatCount(duration.count), + }); + case "hours": + return i18n.t("providerUsage.duration.hours", { + count: duration.count, + value: formatCount(duration.count), + }); + case "minutes": + return i18n.t("providerUsage.duration.minutes", { + count: duration.count, + value: formatCount(duration.count), + }); + } +} + +export function formatResetLabel(iso: string | null | undefined, now = Date.now()): string | null { if (!iso) return null; - const rel = relativeDuration(iso); + const rel = relativeDuration(iso, now); if (!rel) return null; - return rel === "now" ? "resetting now" : `resets ${rel}`; + if (rel.unit === "now") return i18n.t("providerUsage.timing.resettingNow"); + const duration = formatRelativeDuration(rel); + return duration ? i18n.t("providerUsage.timing.resetsIn", { duration }) : null; +} + +export function formatRunsOutLabel( + iso: string | null | undefined, + now = Date.now(), +): string | null { + if (!iso) return null; + const rel = relativeDuration(iso, now); + if (!rel) return null; + if (rel.unit === "now") return i18n.t("providerUsage.timing.runsOutNow"); + const duration = formatRelativeDuration(rel); + return duration ? i18n.t("providerUsage.timing.runsOutIn", { duration }) : null; +} + +export function formatAgo(iso: string | null | undefined, now = Date.now()): string | null { + if (!iso) return null; + const diffMs = now - new Date(iso).getTime(); + if (!Number.isFinite(diffMs)) return null; + if (diffMs < 60_000) return i18n.t("providerUsage.timing.justNow"); + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffDays > 0) { + return i18n.t("providerUsage.timing.daysAgo", { + count: diffDays, + value: formatCount(diffDays), + }); + } + if (diffHours > 0) { + return i18n.t("providerUsage.timing.hoursAgo", { + count: diffHours, + value: formatCount(diffHours), + }); + } + return i18n.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/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 8b668eab00..35525e1c36 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,10 +1,16 @@ 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"; @@ -34,15 +40,17 @@ export function UsageWindowBar({ pinLabel: string; pinTestID: string; }) { + const { i18n } = useTranslation(); const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const trailing = isAtRisk - ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() + ? formatRunsOutLabel(window.runsOutAt) : formatResetLabel(window.resetsAt); - const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; + const value = + shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same @@ -60,7 +68,7 @@ export function UsageWindowBar({ {({ hovered }: { hovered?: boolean }) => ( {label} - + {value} {trailing ? ( {` · ${trailing}`} @@ -186,6 +194,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, From 862250ea36ae748ceebfe4a08862397c537e41bd Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 4 Aug 2026 17:38:50 +0900 Subject: [PATCH 02/30] fix: restore compact provider usage meter --- packages/app/src/composer/index.tsx | 55 ++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c46e2ac15a..864211e375 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -283,11 +283,13 @@ function renderContextWindowMeter( contextWindowUsedTokens: number | null, totalCostUsd: number | null, showPercentage: boolean, + serverId: string, + agentId: string, pending: boolean, glyphSize: number, ): ReactElement | null { const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null; - if (!hasData && !pending) { + if (!hasData && !pending && !showPercentage) { return null; } return ( @@ -296,6 +298,8 @@ function renderContextWindowMeter( usedTokens={contextWindowUsedTokens} totalCostUsd={totalCostUsd} showPercentage={showPercentage} + serverId={serverId} + agentId={agentId} pending={pending} glyphSize={glyphSize} /> @@ -305,8 +309,18 @@ function renderContextWindowMeter( function resolveContextWindowPlacement( meter: ReactElement | null, reserveSlot: boolean, -): ReactNode { - return reserveSlot ? {meter} : null; + isCompactLayout: boolean, +): { beforeVoiceContent: ReactNode; compactContextWindowContent: ReactNode } { + if (!reserveSlot) { + return { beforeVoiceContent: null, compactContextWindowContent: null }; + } + if (isCompactLayout) { + return { beforeVoiceContent: null, compactContextWindowContent: meter }; + } + return { + beforeVoiceContent: {meter}, + compactContextWindowContent: null, + }; } interface RenderLeftContentArgs { @@ -2079,7 +2093,9 @@ function ComposerContentImpl({ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - false, + isCompactLayout, + serverId, + agentId, contextWindowPending, contextWindowMeterGlyphSize, ), @@ -2087,14 +2103,29 @@ function ComposerContentImpl({ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, + isCompactLayout, + serverId, + agentId, contextWindowPending, contextWindowMeterGlyphSize, ], ); - const beforeVoiceContent = useMemo( - () => <>{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}, - [contextWindowMeter, hasAgent], - ); + const { beforeVoiceContent, compactContextWindowContent } = useMemo( + () => { + const placement = resolveContextWindowPlacement(contextWindowMeter, hasAgent, isCompactLayout); + return { + ...placement, + beforeVoiceContent: ( + <> + {placement.beforeVoiceContent} + {hasAgent && !isCompactLayout ? ( + + ) : null} + + ), + }; + }, + [agentId, contextWindowMeter, hasAgent, isCompactLayout, serverId], const hasGithubAttachment = useMemo( () => @@ -2476,6 +2507,11 @@ function ComposerContentImpl({ submitLabel={submitLabel} /> + {compactContextWindowContent ? ( + + {compactContextWindowContent} + + ) : null} ({ alignItems: "center", justifyContent: "center", }, + contextWindowMeterCompactSlot: { + alignSelf: "flex-start", + }, realtimeVoiceButton: { width: 28, height: 28, From 8d84e9fad9d10b0f64ad249f7e898864cb1215da Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 4 Aug 2026 18:13:45 +0900 Subject: [PATCH 03/30] fix: localize compact context percentage --- packages/app/src/components/context-window-meter.tsx | 8 +++++--- packages/app/src/components/context-window-meter.utils.ts | 7 +++++++ packages/app/src/usage/format.test.ts | 4 ++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 2696f7c1ba..4ea10a936b 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -3,7 +3,7 @@ import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { formatTokenCount } from "./context-window-meter.utils"; +import { formatContextPercentage, formatTokenCount } from "./context-window-meter.utils"; interface ContextWindowMeterProps { maxTokens: number | null; @@ -99,7 +99,7 @@ export function ContextWindowMeter({ glyphSize, }: ContextWindowMeterProps) { const { theme } = useUnistyles(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; const geometry = getMeterGeometry(showPercentage, glyphSize); @@ -183,7 +183,9 @@ export function ContextWindowMeter({ /> {showPercentage ? ( - {`${roundedPercentage}%`} + + {formatContextPercentage(roundedPercentage, i18n.resolvedLanguage)} + ) : null} diff --git a/packages/app/src/components/context-window-meter.utils.ts b/packages/app/src/components/context-window-meter.utils.ts index a0fa45ccbf..1f7035e44e 100644 --- a/packages/app/src/components/context-window-meter.utils.ts +++ b/packages/app/src/components/context-window-meter.utils.ts @@ -7,3 +7,10 @@ export function formatTokenCount(value: number): string { } return Math.round(value).toString(); } + +export function formatContextPercentage(value: number, locale?: string): string { + return new Intl.NumberFormat(locale, { + style: "percent", + maximumFractionDigits: 0, + }).format(Math.max(0, Math.min(100, value)) / 100); +} diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index cda2b9d9d4..f4e1ac4f0c 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -36,4 +36,8 @@ describe("formatAmount", () => { 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%"); + }); }); From b8ec20452edd2ad34e838a733306ff94304a4b83 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 5 Aug 2026 09:17:19 +0900 Subject: [PATCH 04/30] fix(app): deduplicate context meter refresh --- packages/app/src/components/context-window-meter.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 4ea10a936b..8df68aa5ff 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,3 +1,7 @@ +<<<<<<< HEAD +======= +import { useCallback, useRef, useState } from "react"; +>>>>>>> ca504c7e6 (fix(app): deduplicate context meter refresh) import { Pressable, Text, View } from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; From 414d8b64c3158c61a1fbdda87aa194be3db26464 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 6 Aug 2026 10:05:13 +0900 Subject: [PATCH 05/30] fix(app): localize provider usage time counts --- packages/app/src/usage/format.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index f4e1ac4f0c..939628e26d 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -40,4 +40,15 @@ describe("formatAmount", () => { 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, 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"); + }); }); From 1b65afe2b9463bfa4f4e69c8958435ae3a6e9e12 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 6 Aug 2026 11:37:22 +0900 Subject: [PATCH 06/30] fix(app): stabilize compact provider usage row --- packages/app/src/composer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 864211e375..ea58442429 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2603,7 +2603,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ justifyContent: "center", }, contextWindowMeterCompactSlot: { - alignSelf: "flex-start", + alignSelf: "stretch", }, realtimeVoiceButton: { width: 28, From c211d7af97f2e2f473e294cab20078a5d08063a0 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 6 Aug 2026 12:04:27 +0900 Subject: [PATCH 07/30] fix(app): localize provider usage plurals --- packages/app/src/i18n/resources/ar.ts | 36 +++++++++++++++++++++++++++ packages/app/src/i18n/resources/en.ts | 16 +++++++++++- packages/app/src/usage/format.test.ts | 16 ++++++++++++ packages/app/src/usage/format.ts | 1 - 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 869a26a88c..8f3fa1a417 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1972,8 +1972,26 @@ export const ar: TranslationResources = { }, 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: { resettingNow: "تتم إعادة التعيين الآن", @@ -1982,8 +2000,26 @@ export const ar: TranslationResources = { 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}}", }, }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 13544c4d09..f2f16e36e4 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2913,4 +2913,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/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 939628e26d..27ea022148 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -51,4 +51,20 @@ describe("formatAmount", () => { 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)).toBe("تتم إعادة التعيين خلال ساعتين"); + expect(formatRunsOutLabel(threeHoursFromNow)).toBe("ينفد خلال 3 ساعات"); + expect(formatAgo(twoDaysAgo)).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ألف"); + }); }); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index c99a7a07a8..4b0a1c67e9 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,4 +1,3 @@ -import { formatTokenCount } from "@/components/context-window-meter.utils"; import { i18n } from "@/i18n/i18next"; import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; From e25049fb20af079016d86e0bf60cc49b6e561ced Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 6 Aug 2026 12:10:26 +0900 Subject: [PATCH 08/30] test(app): allow locale-specific plural keys --- packages/app/src/i18n/resources.test.ts | 33 ++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 46b3428026..055383077b 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -20,6 +20,21 @@ 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)$/; + +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 }; @@ -105,15 +120,15 @@ 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("keeps non-English supported languages translated beyond fallback labels", () => { From fc4dceee603b25bed9950c9215cae472acff38b4 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 6 Aug 2026 13:05:39 +0900 Subject: [PATCH 09/30] fix(app): show compact quota without context usage --- packages/app/src/components/context-window-meter.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 8df68aa5ff..4ea10a936b 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,7 +1,3 @@ -<<<<<<< HEAD -======= -import { useCallback, useRef, useState } from "react"; ->>>>>>> ca504c7e6 (fix(app): deduplicate context meter refresh) import { Pressable, Text, View } from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; From 62df878b4239fd645ccf45850e89076c2f9a79f2 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 6 Aug 2026 13:27:47 +0900 Subject: [PATCH 10/30] fix(app): preserve context overflow percentage --- .../components/context-window-meter.utils.test.ts | 9 +++++++++ .../src/components/context-window-meter.utils.ts | 14 +++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/components/context-window-meter.utils.test.ts 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 1f7035e44e..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`; @@ -7,10 +14,3 @@ export function formatTokenCount(value: number): string { } return Math.round(value).toString(); } - -export function formatContextPercentage(value: number, locale?: string): string { - return new Intl.NumberFormat(locale, { - style: "percent", - maximumFractionDigits: 0, - }).format(Math.max(0, Math.min(100, value)) / 100); -} From d5be235b6fedd2c2f8e3d1d1e2b9a5ea0de33ed8 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 16 Aug 2026 09:13:33 +0900 Subject: [PATCH 11/30] fix(app): truncate provider balance reset values --- .../app/e2e/browser/usage-settings.spec.ts | 41 +++++++++++++++++++ packages/app/src/usage/balance-bar.tsx | 7 +++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 04bfe649b1..bd9d7ffc29 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -133,6 +133,47 @@ test.describe("usage settings", () => { await expect(card.getByText("64%")).toBeVisible(); }); + test("keeps a localized balance reset within its usage card", async ({ page }) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 320, height: 844 }); + const serverId = getServerId(); + await installUsageReportsFixture(page, { + lists: [ + [ + report({ + sourceId: "fixture", + sourceLabel: "Fixture plan", + report: { + balances: [ + { + id: "credits", + label: "Credits available for additional usage", + remaining: 999_999_999_999_999, + unit: "credits", + resetsAt: "2026-12-31T23:59:00.000Z", + }, + ], + }, + }), + ], + ], + }); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHostSection(page, serverId, "usage"); + + const card = page.getByTestId("usage-card"); + const value = page.getByTestId("provider-usage-balance-credits-value"); + await expect(value).toBeVisible({ timeout: 10_000 }); + const [cardBox, valueBox] = await Promise.all([card.boundingBox(), value.boundingBox()]); + expect(cardBox).not.toBeNull(); + expect(valueBox).not.toBeNull(); + expect((valueBox?.x ?? 0) + (valueBox?.width ?? 0)).toBeLessThanOrEqual( + (cardBox?.x ?? 0) + (cardBox?.width ?? 0), + ); + }); + test("asks to update a host without usage support and never calls it", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index e25448f0d3..7bfcbbd902 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -66,7 +66,12 @@ export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { {balance.label} - + {amountText} {resetLabel ? {` · ${resetLabel}`} : null} From 6ee1d560b4fe7a61d40aafbba33ca9ffe8ef1f25 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 24 Aug 2026 09:15:51 +0900 Subject: [PATCH 12/30] fix(app): add Korean provider usage translations --- packages/app/src/i18n/resources/ko.ts | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7afe54b158..b03cbf0fc3 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1960,6 +1960,43 @@ export const ko: TranslationResources = { saveAccessibility: "리뷰 댓글 저장", }, }, + providerUsage: { + title: "플랜 사용량", + refresh: "새로 고침", + refreshing: "새로 고치는 중...", + loading: "사용량 불러오는 중...", + empty: "사용량 데이터가 없습니다", + errorTitle: "사용량을 불러올 수 없습니다", + hostUnavailable: "이 호스트에 연결하여 제공업체 사용량을 확인하세요", + hostUpgradeRequired: "호스트를 업데이트하여 제공업체 사용량을 확인하세요", + clientUnavailable: "호스트 연결이 준비되지 않았습니다", + retry: "다시 시도", + tooltipLoading: "플랜 사용량 불러오는 중...", + status: { + error: "오류", + unavailable: "사용할 수 없음", + }, + values: { + used: "{{percentage}} 사용됨", + remaining: "{{amount}} 남음", + }, + duration: { + days: "{{value}}일", + hours: "{{value}}시간", + minutes: "{{value}}분", + }, + timing: { + resettingNow: "지금 재설정 중", + resetsIn: "{{duration}} 후 재설정", + runsOutNow: "지금 한도에 도달", + runsOutIn: "{{duration}} 후 한도에 도달", + justNow: "방금", + daysAgo: "{{value}}일 전", + hoursAgo: "{{value}}시간 전", + minutesAgo: "{{value}}분 전", + updated: "{{relativeTime}}에 업데이트됨", + }, + }, settings: { title: "설정", loading: "설정 불러오는 중...", From b57a03c0051c700ad4db573a07565f899a0c01d4 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 10 Sep 2026 05:20:03 +0900 Subject: [PATCH 13/30] fix(app): refresh provider usage timing --- packages/app/src/usage/balance-bar.tsx | 2 ++ packages/app/src/usage/use-relative-time-tick.ts | 12 ++++++++++++ packages/app/src/usage/window-bar.tsx | 5 +++++ 3 files changed, 19 insertions(+) create mode 100644 packages/app/src/usage/use-relative-time-tick.ts diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 7bfcbbd902..b8339c1038 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -5,6 +5,7 @@ import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { clampPct, formatAmount, formatResetLabel } from "./format"; import type { UsageBalance, UsageTone } from "./types"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; interface ResolvedBalance { amountText: string; @@ -51,6 +52,7 @@ function fillToneStyle(tone: UsageTone) { export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { const { t, i18n } = useTranslation(); + useRelativeTimeTick(balance.resetsAt != null); const { amountText, usedPct } = resolveBalance(balance, t, i18n.resolvedLanguage); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); 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..c075612f40 --- /dev/null +++ b/packages/app/src/usage/use-relative-time-tick.ts @@ -0,0 +1,12 @@ +import { useEffect, useReducer } from "react"; +import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker"; + +/** Re-renders a mounted usage label when its relative time can change. */ +export function useRelativeTimeTick(enabled: boolean): void { + const [, tick] = useReducer((value: number) => value + 1, 0); + + useEffect(() => { + if (!enabled) return undefined; + return subscribeToRelativeTimeTick("minute", tick); + }, [enabled]); +} diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 35525e1c36..1f7fc3db28 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -16,6 +16,7 @@ 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. @@ -45,6 +46,10 @@ export function UsageWindowBar({ const tone = windowTone(window); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; + const relativeTime = isAtRisk ? window.runsOutAt : window.resetsAt; + useRelativeTimeTick(relativeTime != null); + const relativeTime = isAtRisk ? window.runsOutAt : window.resetsAt; + useRelativeTimeTick(relativeTime != null); const trailing = isAtRisk ? formatRunsOutLabel(window.runsOutAt) : formatResetLabel(window.resetsAt); From 78f7267f8b0045b17ebc9afc3f1b42adcc3a6c0a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 10 Sep 2026 07:20:44 +0900 Subject: [PATCH 14/30] fix(app): localize provider usage labels --- docs/i18n.md | 2 +- packages/app/src/i18n/resources/ar.ts | 6 ++++++ packages/app/src/i18n/resources/en.ts | 6 ++++++ packages/app/src/i18n/resources/es.ts | 6 ++++++ packages/app/src/i18n/resources/fr.ts | 6 ++++++ packages/app/src/i18n/resources/ja.ts | 6 ++++++ packages/app/src/i18n/resources/ko.ts | 6 ++++++ packages/app/src/i18n/resources/pt-BR.ts | 6 ++++++ packages/app/src/i18n/resources/ru.ts | 6 ++++++ packages/app/src/i18n/resources/zh-CN.ts | 6 ++++++ packages/app/src/usage/balance-bar.tsx | 4 ++-- packages/app/src/usage/format.test.ts | 17 ++++++++++++++++- 12 files changed, 73 insertions(+), 4 deletions(-) diff --git a/docs/i18n.md b/docs/i18n.md index 0e132c89a8..e132ece6e7 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -91,4 +91,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t - Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch. - Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added. - Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales. -- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Provider names, plan labels, usage-window labels, balance/detail labels, source labels, and raw provider errors remain runtime values. +- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Known generic usage labels (`session`, `weekly`, `code_review`, and `credits`) are localized by ID; provider names, plan labels, provider-specific labels, detail labels, source labels, and raw provider errors remain runtime values. diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8f3fa1a417..17bead2308 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1970,6 +1970,12 @@ export const ar: TranslationResources = { used: "تم استخدام {{percentage}}", remaining: "متبقٍ {{amount}}", }, + labels: { + session: "الجلسة", + weekly: "أسبوعي", + codeReview: "مراجعة التعليمات البرمجية", + credits: "الأرصدة", + }, duration: { days: "{{value}} يوم", days_zero: "{{value}} يوم", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f2f16e36e4..f4e1bb54ff 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1995,6 +1995,12 @@ export const en = { used: "{{percentage}} used", remaining: "{{amount}} left", }, + labels: { + session: "Session", + weekly: "Weekly", + codeReview: "Code review", + credits: "Credits", + }, duration: { days: "{{value}}d", hours: "{{value}}h", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 718499f853..455abc8dc8 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2018,6 +2018,12 @@ export const es: TranslationResources = { used: "Usado: {{percentage}}", remaining: "Quedan {{amount}}", }, + labels: { + session: "Sesión", + weekly: "Semanal", + codeReview: "Revisión de código", + credits: "Créditos", + }, duration: { days: "{{value}} d", hours: "{{value}} h", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 442c5c940c..d4bb5cd94c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2023,6 +2023,12 @@ export const fr: TranslationResources = { used: "Utilisé : {{percentage}}", remaining: "Il reste {{amount}}", }, + labels: { + session: "Session", + weekly: "Hebdomadaire", + codeReview: "Révision de code", + credits: "Crédits", + }, duration: { days: "{{value}} j", hours: "{{value}} h", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6e449075d9..6a5046b3a4 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1990,6 +1990,12 @@ export const ja: TranslationResources = { used: "{{percentage}} 使用済み", remaining: "残り {{amount}}", }, + labels: { + session: "セッション", + weekly: "週間", + codeReview: "コードレビュー", + credits: "クレジット", + }, duration: { days: "{{value}}日", hours: "{{value}}時間", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index b03cbf0fc3..8e2d3725b2 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1980,6 +1980,12 @@ export const ko: TranslationResources = { used: "{{percentage}} 사용됨", remaining: "{{amount}} 남음", }, + labels: { + session: "세션", + weekly: "주간", + codeReview: "코드 검토", + credits: "크레딧", + }, duration: { days: "{{value}}일", hours: "{{value}}시간", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index bae71a564a..778529b402 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2003,6 +2003,12 @@ export const ptBR: TranslationResources = { used: "{{percentage}} usado", remaining: "{{amount}} restantes", }, + labels: { + session: "Sessão", + weekly: "Semanal", + codeReview: "Revisão de código", + credits: "Créditos", + }, duration: { days: "{{value}} d", hours: "{{value}} h", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5eac9f746f..1c41ab3106 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2003,6 +2003,12 @@ export const ru: TranslationResources = { used: "Использовано: {{percentage}}", remaining: "Осталось: {{amount}}", }, + labels: { + session: "Сессия", + weekly: "Еженедельно", + codeReview: "Проверка кода", + credits: "Кредиты", + }, duration: { days: "{{value}} дн.", hours: "{{value}} ч", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3f12ac2005..97200ec74b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1948,6 +1948,12 @@ export const zhCN: TranslationResources = { used: "已使用 {{percentage}}", remaining: "剩余 {{amount}}", }, + labels: { + session: "会话", + weekly: "每周", + codeReview: "代码审查", + credits: "积分", + }, duration: { days: "{{value}}天", hours: "{{value}}小时", diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index b8339c1038..2e5302c938 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -3,7 +3,7 @@ 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"; @@ -66,7 +66,7 @@ export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { - {balance.label} + {formatProviderUsageLabel(balance.id, balance.label)} { 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("credits", "Credits")).toBe("크레딧"); + expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit"); + }); }); From 84e8bb002f234672bc607dd2e5d5bf7ff4403f87 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 10 Sep 2026 07:41:36 +0900 Subject: [PATCH 15/30] fix(app): clarify compact usage policies --- packages/app/src/usage/format.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 0c4a5d6fc3..25b479ddf4 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -79,6 +79,7 @@ describe("formatAmount", () => { await i18n.changeLanguage("ko"); expect(formatProviderUsageLabel("session", "Session")).toBe("세션"); + expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션"); expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧"); expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit"); }); From 7a06b7d9ce7256d54f9731d3add934236d25467f Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 20 Sep 2026 05:11:54 +0900 Subject: [PATCH 16/30] fix(app): localize monthly provider usage --- docs/i18n.md | 2 +- packages/app/src/i18n/resources/ar.ts | 2 ++ packages/app/src/i18n/resources/en.ts | 2 ++ packages/app/src/i18n/resources/es.ts | 2 ++ packages/app/src/i18n/resources/fr.ts | 2 ++ packages/app/src/i18n/resources/ja.ts | 2 ++ packages/app/src/i18n/resources/ko.ts | 2 ++ packages/app/src/i18n/resources/pt-BR.ts | 2 ++ packages/app/src/i18n/resources/ru.ts | 2 ++ packages/app/src/i18n/resources/zh-CN.ts | 2 ++ 10 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/i18n.md b/docs/i18n.md index e132ece6e7..8bdd6945e1 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -91,4 +91,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t - Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch. - Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added. - Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales. -- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Known generic usage labels (`session`, `weekly`, `code_review`, and `credits`) are localized by ID; provider names, plan labels, provider-specific labels, detail labels, source labels, and raw provider errors remain runtime values. +- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Known generic usage labels (`session`, `five_hour`, `weekly`, `monthly`, `code_review`, `credits`, and `monthly_credits`) are localized by ID; provider names, plan labels, provider-specific labels, detail labels, source labels, and raw provider errors remain runtime values. diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 17bead2308..4a73e215b6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1973,8 +1973,10 @@ export const ar: TranslationResources = { labels: { session: "الجلسة", weekly: "أسبوعي", + monthly: "شهري", codeReview: "مراجعة التعليمات البرمجية", credits: "الأرصدة", + monthlyCredits: "أرصدة شهرية", }, duration: { days: "{{value}} يوم", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f4e1bb54ff..e6b2a6d2c1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1998,8 +1998,10 @@ export const en = { labels: { session: "Session", weekly: "Weekly", + monthly: "Monthly", codeReview: "Code review", credits: "Credits", + monthlyCredits: "Monthly credits", }, duration: { days: "{{value}}d", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 455abc8dc8..a2f692380d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2021,8 +2021,10 @@ export const es: TranslationResources = { 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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d4bb5cd94c..f37e2ef4af 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2026,8 +2026,10 @@ export const fr: TranslationResources = { labels: { session: "Session", weekly: "Hebdomadaire", + monthly: "Mensuel", codeReview: "Révision de code", credits: "Crédits", + monthlyCredits: "Crédits mensuels", }, duration: { days: "{{value}} j", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6a5046b3a4..b8a6d30f23 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1993,8 +1993,10 @@ export const ja: TranslationResources = { labels: { session: "セッション", weekly: "週間", + monthly: "月間", codeReview: "コードレビュー", credits: "クレジット", + monthlyCredits: "月間クレジット", }, duration: { days: "{{value}}日", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 8e2d3725b2..35db5ff857 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1983,8 +1983,10 @@ export const ko: TranslationResources = { labels: { session: "세션", weekly: "주간", + monthly: "월간", codeReview: "코드 검토", credits: "크레딧", + monthlyCredits: "월간 크레딧", }, duration: { days: "{{value}}일", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 778529b402..0c1c082ec6 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2006,8 +2006,10 @@ export const ptBR: TranslationResources = { 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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 1c41ab3106..baa5b255f9 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2006,8 +2006,10 @@ export const ru: TranslationResources = { labels: { session: "Сессия", weekly: "Еженедельно", + monthly: "Ежемесячно", codeReview: "Проверка кода", credits: "Кредиты", + monthlyCredits: "Ежемесячные кредиты", }, duration: { days: "{{value}} дн.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 97200ec74b..a7f41aa1cd 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1951,8 +1951,10 @@ export const zhCN: TranslationResources = { labels: { session: "会话", weekly: "每周", + monthly: "每月", codeReview: "代码审查", credits: "积分", + monthlyCredits: "每月积分", }, duration: { days: "{{value}}天", From 529bfb434116d10794a98dc9cb545fbb09a88775 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 20 Sep 2026 05:28:27 +0900 Subject: [PATCH 17/30] test(app): require Arabic provider usage plurals --- packages/app/src/i18n/resources.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 055383077b..be680095c6 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -21,6 +21,15 @@ function flattenKeys(value: unknown, prefix = ""): string[] { } 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); @@ -131,6 +140,15 @@ describe("translation resources", () => { 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", () => { const totalStrings = Object.keys(flattenStrings(en)).length; const maxFallbackStrings = Math.floor(totalStrings * 0.25); From 10d6f41d5e0ce2c8dbff5ca256add460339e0fdb Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 20 Sep 2026 05:31:58 +0900 Subject: [PATCH 18/30] docs(i18n): keep migration guidance focused --- docs/i18n.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/i18n.md b/docs/i18n.md index 8bdd6945e1..b3653a8b1d 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -91,4 +91,3 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t - Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch. - Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added. - Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales. -- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Known generic usage labels (`session`, `five_hour`, `weekly`, `monthly`, `code_review`, `credits`, and `monthly_credits`) are localized by ID; provider names, plan labels, provider-specific labels, detail labels, source labels, and raw provider errors remain runtime values. From 3d686cbba3b540de995cf17004810d82e4a01a8a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 21 Sep 2026 05:16:43 +0900 Subject: [PATCH 19/30] fix(app): hide empty compact usage slot --- packages/app/src/composer/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ea58442429..6cf9136c20 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2126,6 +2126,7 @@ function ComposerContentImpl({ }; }, [agentId, contextWindowMeter, hasAgent, isCompactLayout, serverId], + ); const hasGithubAttachment = useMemo( () => From 90da59966276a15201ce9f925baea5cf8cc09cc3 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 29 Sep 2026 05:40:30 +0900 Subject: [PATCH 20/30] fix(app): keep usage in context tooltip --- .../src/components/context-window-meter.tsx | 16 +++++---- packages/app/src/composer/index.tsx | 2 +- packages/app/src/usage/balance-bar.tsx | 6 +--- .../app/src/usage/context-window-section.tsx | 34 +++++++++++++++++++ 4 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 packages/app/src/usage/context-window-section.tsx diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 4ea10a936b..d41ca87528 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -3,6 +3,7 @@ import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ContextWindowUsageTooltip } from "@/usage/context-window-section"; import { formatContextPercentage, formatTokenCount } from "./context-window-meter.utils"; interface ContextWindowMeterProps { @@ -10,6 +11,8 @@ interface ContextWindowMeterProps { usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; + serverId?: string; + agentId?: string; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; /** Optional glyph envelope for icon-toolbar alignment. */ @@ -95,6 +98,8 @@ export function ContextWindowMeter({ usedTokens, totalCostUsd, showPercentage = false, + serverId, + agentId, pending = false, glyphSize, }: ContextWindowMeterProps) { @@ -104,13 +109,9 @@ export function ContextWindowMeter({ maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; const geometry = getMeterGeometry(showPercentage, glyphSize); - // No usage yet: reserve the footprint with a track-only ring while a session is - // active so the real ring fades in without shifting siblings. Render nothing when - // no usage is expected. + // No context telemetry yet: reserve the meter footprint while it is pending. if (percentage === null || maxTokens === null || usedTokens === null) { - if (!pending) { - return null; - } + if (!pending) return null; return ( ) : null} + {serverId && agentId ? ( + + ) : null} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 6cf9136c20..65ca01b0bb 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -289,7 +289,7 @@ function renderContextWindowMeter( glyphSize: number, ): ReactElement | null { const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null; - if (!hasData && !pending && !showPercentage) { + if (!hasData && !pending) { return null; } return ( diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 2e5302c938..cf6f5929fa 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -12,11 +12,7 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance( - balance: UsageBalance, - t: TFunction, - locale?: string, -): ResolvedBalance { +function resolveBalance(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) { diff --git a/packages/app/src/usage/context-window-section.tsx b/packages/app/src/usage/context-window-section.tsx new file mode 100644 index 0000000000..81f1e29746 --- /dev/null +++ b/packages/app/src/usage/context-window-section.tsx @@ -0,0 +1,34 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { UsageCard } from "./card"; +import { useUsagePreferences } from "./display"; +import { useAgentUsage } from "./queries"; + +/** The active agent's usage inside its context-window tooltip. */ +export function ContextWindowUsageTooltip({ + serverId, + agentId, +}: { + serverId: string; + agentId: string; +}) { + const { entry } = useAgentUsage(serverId, agentId); + const { display } = useUsagePreferences(); + if (!entry) return null; + + return ( + <> + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { + height: 1, + backgroundColor: theme.colors.borderAccent, + marginVertical: theme.spacing[2], + marginHorizontal: -theme.spacing[2], + }, +})); From 96e99b27339ecf48fec1822d48febb347223153c Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 1 Oct 2026 05:26:21 +0900 Subject: [PATCH 21/30] fix(app): align usage with sidebar reports --- .../src/components/context-window-meter.tsx | 8 ----- packages/app/src/composer/index.tsx | 25 ++------------ .../app/src/usage/context-window-section.tsx | 34 ------------------- packages/app/src/usage/format.ts | 6 +++- packages/app/src/usage/window-bar.tsx | 2 -- 5 files changed, 7 insertions(+), 68 deletions(-) delete mode 100644 packages/app/src/usage/context-window-section.tsx diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index d41ca87528..f4c4c1edf4 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -3,7 +3,6 @@ import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { ContextWindowUsageTooltip } from "@/usage/context-window-section"; import { formatContextPercentage, formatTokenCount } from "./context-window-meter.utils"; interface ContextWindowMeterProps { @@ -11,8 +10,6 @@ interface ContextWindowMeterProps { usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; - serverId?: string; - agentId?: string; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; /** Optional glyph envelope for icon-toolbar alignment. */ @@ -98,8 +95,6 @@ export function ContextWindowMeter({ usedTokens, totalCostUsd, showPercentage = false, - serverId, - agentId, pending = false, glyphSize, }: ContextWindowMeterProps) { @@ -207,9 +202,6 @@ export function ContextWindowMeter({ {t("contextWindow.sessionCost", { cost: formattedSessionCost })} ) : null} - {serverId && agentId ? ( - - ) : null} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 65ca01b0bb..1286f561d3 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -283,8 +283,6 @@ function renderContextWindowMeter( contextWindowUsedTokens: number | null, totalCostUsd: number | null, showPercentage: boolean, - serverId: string, - agentId: string, pending: boolean, glyphSize: number, ): ReactElement | null { @@ -298,8 +296,6 @@ function renderContextWindowMeter( usedTokens={contextWindowUsedTokens} totalCostUsd={totalCostUsd} showPercentage={showPercentage} - serverId={serverId} - agentId={agentId} pending={pending} glyphSize={glyphSize} /> @@ -2094,8 +2090,6 @@ function ComposerContentImpl({ contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout, - serverId, - agentId, contextWindowPending, contextWindowMeterGlyphSize, ), @@ -2104,28 +2098,13 @@ function ComposerContentImpl({ contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout, - serverId, - agentId, contextWindowPending, contextWindowMeterGlyphSize, ], ); const { beforeVoiceContent, compactContextWindowContent } = useMemo( - () => { - const placement = resolveContextWindowPlacement(contextWindowMeter, hasAgent, isCompactLayout); - return { - ...placement, - beforeVoiceContent: ( - <> - {placement.beforeVoiceContent} - {hasAgent && !isCompactLayout ? ( - - ) : null} - - ), - }; - }, - [agentId, contextWindowMeter, hasAgent, isCompactLayout, serverId], + () => resolveContextWindowPlacement(contextWindowMeter, hasAgent, isCompactLayout), + [contextWindowMeter, hasAgent, isCompactLayout], ); const hasGithubAttachment = useMemo( diff --git a/packages/app/src/usage/context-window-section.tsx b/packages/app/src/usage/context-window-section.tsx deleted file mode 100644 index 81f1e29746..0000000000 --- a/packages/app/src/usage/context-window-section.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { UsageCard } from "./card"; -import { useUsagePreferences } from "./display"; -import { useAgentUsage } from "./queries"; - -/** The active agent's usage inside its context-window tooltip. */ -export function ContextWindowUsageTooltip({ - serverId, - agentId, -}: { - serverId: string; - agentId: string; -}) { - const { entry } = useAgentUsage(serverId, agentId); - const { display } = useUsagePreferences(); - if (!entry) return null; - - return ( - <> - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - divider: { - height: 1, - backgroundColor: theme.colors.borderAccent, - marginVertical: theme.spacing[2], - marginHorizontal: -theme.spacing[2], - }, -})); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 4b0a1c67e9..5cfa0b0f36 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -29,7 +29,11 @@ export function formatPct(value: number, locale?: string): string { } /** A localized percentage that says whether it is used or remaining. */ -export function formatDisplayPct(value: number, displayAs: UsageDisplayAs, locale?: string): string { +export function formatDisplayPct( + value: number, + displayAs: UsageDisplayAs, + locale?: string, +): string { const percentage = formatPct(value, locale); return displayAs === "used" ? i18n.t("providerUsage.values.used", { percentage }) diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 1f7fc3db28..0ec3976102 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -48,8 +48,6 @@ export function UsageWindowBar({ const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const relativeTime = isAtRisk ? window.runsOutAt : window.resetsAt; useRelativeTimeTick(relativeTime != null); - const relativeTime = isAtRisk ? window.runsOutAt : window.resetsAt; - useRelativeTimeTick(relativeTime != null); const trailing = isAtRisk ? formatRunsOutLabel(window.runsOutAt) : formatResetLabel(window.resetsAt); From c2335a6b00ba88a7a46ee453da8ab6dcbac35bd1 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Fri, 2 Oct 2026 05:15:45 +0900 Subject: [PATCH 22/30] test(app): pass usage timing clock explicitly --- packages/app/src/usage/format.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 25b479ddf4..80a63c7c72 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -35,8 +35,8 @@ describe("formatAmount", () => { const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); expect(formatPct(7, "en")).toBe("7%"); - expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("resets in 2h"); - expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h"); + expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("resets 2h"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h"); await i18n.changeLanguage("ja"); expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("2時間後にリセット"); @@ -65,9 +65,9 @@ describe("formatAmount", () => { const threeHoursFromNow = new Date(NOW + 3 * 60 * 60 * 1000).toISOString(); const twoDaysAgo = new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString(); - expect(formatResetLabel(twoHoursFromNow)).toBe("تتم إعادة التعيين خلال ساعتين"); - expect(formatRunsOutLabel(threeHoursFromNow)).toBe("ينفد خلال 3 ساعات"); - expect(formatAgo(twoDaysAgo)).toBe("قبل يومين"); + expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("تتم إعادة التعيين خلال ساعتين"); + expect(formatRunsOutLabel(threeHoursFromNow, NOW)).toBe("ينفد خلال 3 ساعات"); + expect(formatAgo(twoDaysAgo, NOW)).toBe("قبل يومين"); }); it("uses locale-aware compact notation for token balances", () => { From 0ee0551c4cc63e51b22042c51bdb1ebe2612ef58 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 3 Oct 2026 05:10:40 +0900 Subject: [PATCH 23/30] fix(app): restore context usage details --- .../e2e/browser/context-window-meter.spec.ts | 44 +++++++++++++++++++ .../app/e2e/browser/usage-settings.spec.ts | 3 ++ .../src/components/context-window-meter.tsx | 5 +++ packages/app/src/composer/index.tsx | 4 ++ packages/app/src/usage/balance-bar.tsx | 24 ++++++---- .../app/src/usage/context-window-section.tsx | 36 +++++++++++++++ 6 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/usage/context-window-section.tsx diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index c7868ac94e..833d599374 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -1,7 +1,27 @@ import type { Locator } from "@playwright/test"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, test } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; + +const USAGE_ICON = + ''; + +function contextUsageReport(): UsageReportEntry { + return { + id: "mock:account", + account: { label: "dev@example.com" }, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: "mock", + sourceLabel: "Mock plan", + icon: USAGE_ICON, + report: { + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 42 }], + }, + }; +} // Where the progress arc is painted, as its centroid relative to the ring's centre in pixels. // Reads the rendered pixels, so any rotation that does not reach the screen counts as none. @@ -68,4 +88,28 @@ test.describe("context window meter", () => { await session.cleanup(); } }); + + test("shows the host's plan limits in the context tooltip", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { lists: [[contextUsageReport()]] }); + const session = await seedMockAgentWorkspace({ + repoPrefix: "context-window-usage-", + title: "Context window usage tooltip", + initialPrompt: "emit 32000 byte file agent stream payload", + }); + try { + await openAgentRoute(page, session); + await expectComposerVisible(page); + const meter = page.getByTestId("context-window-meter"); + await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 }); + await meter.hover(); + await usage.waitForListRequests(1); + + const tooltip = page.getByTestId("context-window-meter-tooltip"); + await expect(tooltip.getByText("Mock plan", { exact: true })).toBeVisible(); + await expect(tooltip.getByText("42% used", { exact: true })).toBeVisible(); + } finally { + await session.cleanup(); + } + }); }); diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index bd9d7ffc29..bda4751073 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -165,7 +165,10 @@ test.describe("usage settings", () => { const card = page.getByTestId("usage-card"); const value = page.getByTestId("provider-usage-balance-credits-value"); + const reset = page.getByTestId("provider-usage-balance-credits-reset"); await expect(value).toBeVisible({ timeout: 10_000 }); + await expect(reset).toHaveText(/resets \d+d/); + await expect(reset).toBeVisible(); const [cardBox, valueBox] = await Promise.all([card.boundingBox(), value.boundingBox()]); expect(cardBox).not.toBeNull(); expect(valueBox).not.toBeNull(); diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index f4c4c1edf4..615b37dc66 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -3,6 +3,7 @@ import Svg, { Circle } from "react-native-svg"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ContextWindowUsageTooltip } from "@/usage/context-window-section"; import { formatContextPercentage, formatTokenCount } from "./context-window-meter.utils"; interface ContextWindowMeterProps { @@ -10,6 +11,7 @@ interface ContextWindowMeterProps { usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; + serverId?: string; /** Reserve the meter footprint and show a loading ring while usage is pending. */ pending?: boolean; /** Optional glyph envelope for icon-toolbar alignment. */ @@ -95,6 +97,7 @@ export function ContextWindowMeter({ usedTokens, totalCostUsd, showPercentage = false, + serverId, pending = false, glyphSize, }: ContextWindowMeterProps) { @@ -202,6 +205,7 @@ export function ContextWindowMeter({ {t("contextWindow.sessionCost", { cost: formattedSessionCost })} ) : null} + {serverId ? : null} @@ -238,6 +242,7 @@ const styles = StyleSheet.create((theme) => ({ tooltipContent: { gap: theme.spacing[1.5], minWidth: 200, + maxWidth: 280, }, tooltipTitle: { color: theme.colors.foreground, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 1286f561d3..63098a50ec 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -283,6 +283,7 @@ function renderContextWindowMeter( contextWindowUsedTokens: number | null, totalCostUsd: number | null, showPercentage: boolean, + serverId: string, pending: boolean, glyphSize: number, ): ReactElement | null { @@ -296,6 +297,7 @@ function renderContextWindowMeter( usedTokens={contextWindowUsedTokens} totalCostUsd={totalCostUsd} showPercentage={showPercentage} + serverId={serverId} pending={pending} glyphSize={glyphSize} /> @@ -2090,6 +2092,7 @@ function ComposerContentImpl({ contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout, + serverId, contextWindowPending, contextWindowMeterGlyphSize, ), @@ -2098,6 +2101,7 @@ function ComposerContentImpl({ contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout, + serverId, contextWindowPending, contextWindowMeterGlyphSize, ], diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index cf6f5929fa..c0f1acb160 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -64,15 +64,16 @@ export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { {formatProviderUsageLabel(balance.id, balance.label)} - - {amountText} - {resetLabel ? {` · ${resetLabel}`} : null} - + + + {amountText} + + {resetLabel ? ( + + {resetLabel} + + ) : null} + {usedPct != null ? ( @@ -100,13 +101,18 @@ const styles = StyleSheet.create((theme) => ({ }, 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/context-window-section.tsx b/packages/app/src/usage/context-window-section.tsx new file mode 100644 index 0000000000..2770f38bf7 --- /dev/null +++ b/packages/app/src/usage/context-window-section.tsx @@ -0,0 +1,36 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { UsageCard } from "./card"; +import { useUsagePreferences } from "./display"; +import { useHostUsage } from "./queries"; + +/** The current host's available plan limits inside the context-window tooltip. */ +export function ContextWindowUsageTooltip({ serverId }: { serverId: string }) { + const { view } = useHostUsage(serverId); + const { display } = useUsagePreferences(serverId); + if (view.kind !== "ready" || view.reports.length === 0) return null; + + return ( + <> + + + {view.reports.map((entry) => ( + + ))} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { + height: 1, + backgroundColor: theme.colors.borderAccent, + marginVertical: theme.spacing[2], + marginHorizontal: -theme.spacing[2], + }, + reports: { + alignSelf: "stretch", + gap: theme.spacing[3], + }, +})); From 71faa28997deb5612795dd4f5797e17aaecc172c Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 4 Oct 2026 05:22:35 +0900 Subject: [PATCH 24/30] fix(app): make context usage tooltip interactive --- .../e2e/browser/context-window-meter.spec.ts | 10 ++ .../app/e2e/browser/usage-settings.spec.ts | 2 +- .../src/components/context-window-meter.tsx | 4 +- packages/app/src/components/ui/tooltip.tsx | 106 +++++++++++++++--- .../app/src/usage/context-window-section.tsx | 15 ++- 5 files changed, 114 insertions(+), 23 deletions(-) diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 833d599374..d54bf977fc 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -108,6 +108,16 @@ test.describe("context window meter", () => { const tooltip = page.getByTestId("context-window-meter-tooltip"); await expect(tooltip.getByText("Mock plan", { exact: true })).toBeVisible(); await expect(tooltip.getByText("42% used", { exact: true })).toBeVisible(); + const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); + await pin.click(); + await expect(pin).toBeChecked(); + + await tooltip.getByRole("button", { name: "Refresh Mock plan" }).click(); + await usage.waitForListRequests(2); + expect(usage.listRequests()[1]).toMatchObject({ + forceRefresh: true, + reportIds: ["mock:account"], + }); } finally { await session.cleanup(); } diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index bda4751073..d6ceb2412a 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -150,7 +150,7 @@ test.describe("usage settings", () => { label: "Credits available for additional usage", remaining: 999_999_999_999_999, unit: "credits", - resetsAt: "2026-12-31T23:59:00.000Z", + resetsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }, ], }, diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 615b37dc66..50016ae668 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -142,12 +142,12 @@ export function ContextWindowMeter({ typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; return ( - + void; + cancelClose: () => void; + scheduleClose: () => void; triggerRef: React.RefObject; enabled: boolean; openOnPress: boolean; delayDuration: number; + interactive: boolean; } const TooltipContext = createContext(null); @@ -231,6 +234,7 @@ export function Tooltip({ delayDuration = 0, enabledOnDesktop = true, enabledOnMobile = false, + interactive = false, children, }: PropsWithChildren<{ open?: boolean; @@ -239,8 +243,11 @@ export function Tooltip({ delayDuration?: number; enabledOnDesktop?: boolean; enabledOnMobile?: boolean; + /** Keep the surface open while people use controls inside it. */ + interactive?: boolean; }>): ReactElement { const triggerRef = useRef(null); + const closeTimerRef = useRef | null>(null); const [isOpen, setIsOpen] = useControllableOpenState({ open, defaultOpen, @@ -250,17 +257,48 @@ export function Tooltip({ const isCompact = useIsCompactFormFactor(); const opensOnPress = isNative || isCompact; const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop; + const cancelClose = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + const scheduleClose = useCallback(() => { + cancelClose(); + if (!interactive) { + setIsOpen(false); + return; + } + closeTimerRef.current = setTimeout(() => { + setIsOpen(false); + closeTimerRef.current = null; + }, 100); + }, [cancelClose, interactive, setIsOpen]); + + useEffect(() => cancelClose, [cancelClose]); const value = useMemo( () => ({ open: isOpen, setOpen: setIsOpen, + cancelClose, + scheduleClose, triggerRef, enabled, openOnPress: opensOnPress, delayDuration, + interactive, }), - [isOpen, setIsOpen, enabled, opensOnPress, delayDuration], + [ + isOpen, + setIsOpen, + cancelClose, + scheduleClose, + enabled, + opensOnPress, + delayDuration, + interactive, + ], ); return {children}; @@ -306,7 +344,7 @@ export function TooltipTrigger({ const close = useCallback(() => { clearOpenTimer(); - ctx.setOpen(false); + ctx.scheduleClose(); }, [clearOpenTimer, ctx]); useEffect(() => { @@ -358,6 +396,7 @@ export function TooltipTrigger({ } if (ctx.openOnPress) { clearOpenTimer(); + ctx.cancelClose(); ctx.setOpen(true); return; } @@ -509,6 +548,8 @@ export function TooltipContent({ const contentStyle = useMemo(() => [styles.content, style], [style]); const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]); + const handleContentEnter = useCallback(() => ctx.cancelClose(), [ctx]); + const handleContentLeave = useCallback(() => ctx.scheduleClose(), [ctx]); if (!ctx.open || !ctx.enabled) return null; @@ -517,14 +558,18 @@ export function TooltipContent({ // exact same positioning math as DropdownMenu, without hover feedback loops. if (isWeb) { return createPortal( - + @@ -543,26 +588,51 @@ export function TooltipContent({ statusBarTranslucent={Platform.OS === "android"} onRequestClose={handleDismiss} > - - - {children} - - + {ctx.interactive ? ( + + + + {children} + + + ) : ( + + + {children} + + + )} ); } const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1 }, + dismissOverlay: { ...StyleSheet.absoluteFillObject }, portalOverlay: { position: "absolute", top: 0, diff --git a/packages/app/src/usage/context-window-section.tsx b/packages/app/src/usage/context-window-section.tsx index 2770f38bf7..7aa20f67aa 100644 --- a/packages/app/src/usage/context-window-section.tsx +++ b/packages/app/src/usage/context-window-section.tsx @@ -1,5 +1,6 @@ import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { FloatingScrollView } from "@/components/ui/floating"; import { UsageCard } from "./card"; import { useUsagePreferences } from "./display"; import { useHostUsage } from "./queries"; @@ -13,11 +14,17 @@ export function ContextWindowUsageTooltip({ serverId }: { serverId: string }) { return ( <> - + {view.reports.map((entry) => ( ))} - + ); } @@ -33,4 +40,8 @@ const styles = StyleSheet.create((theme) => ({ alignSelf: "stretch", gap: theme.spacing[3], }, + reportScroll: { + alignSelf: "stretch", + maxHeight: 240, + }, })); From 8ee1f640d3cac684a02de56e0ce4db59776c8a08 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 4 Oct 2026 05:45:13 +0900 Subject: [PATCH 25/30] fix(app): support keyboard tooltip controls --- .../e2e/browser/context-window-meter.spec.ts | 26 ++++++++---- packages/app/src/components/ui/tooltip.tsx | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index d54bf977fc..6d37157d84 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -89,7 +89,9 @@ test.describe("context window meter", () => { } }); - test("shows the host's plan limits in the context tooltip", async ({ page }) => { + test("shows host plan limits and lets keyboard users enter the context tooltip", async ({ + page, + }) => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { lists: [[contextUsageReport()]] }); const session = await seedMockAgentWorkspace({ @@ -102,22 +104,32 @@ test.describe("context window meter", () => { await expectComposerVisible(page); const meter = page.getByTestId("context-window-meter"); await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 }); - await meter.hover(); await usage.waitForListRequests(1); + // The preceding Tab marks this as keyboard focus; tooltips deliberately ignore restored focus. + await page.keyboard.press("Tab"); + await meter.focus(); + const tooltip = page.getByTestId("context-window-meter-tooltip"); await expect(tooltip.getByText("Mock plan", { exact: true })).toBeVisible(); await expect(tooltip.getByText("42% used", { exact: true })).toBeVisible(); - const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); - await pin.click(); - await expect(pin).toBeChecked(); - - await tooltip.getByRole("button", { name: "Refresh Mock plan" }).click(); + const refresh = tooltip.getByRole("button", { name: "Refresh Mock plan" }); + await page.keyboard.press("Tab"); + await expect(refresh).toBeFocused(); + await page.keyboard.press("Enter"); await usage.waitForListRequests(2); expect(usage.listRequests()[1]).toMatchObject({ forceRefresh: true, reportIds: ["mock:account"], }); + + const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); + await page.keyboard.press("Tab"); + await expect(pin).toBeFocused(); + await page.keyboard.press("Space"); + await expect(pin).toBeChecked(); + await pin.click(); + await expect(pin).not.toBeChecked(); } finally { await session.cleanup(); } diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 84b780cbda..d2a3332fd1 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -47,6 +47,8 @@ interface TooltipContextValue { cancelClose: () => void; scheduleClose: () => void; triggerRef: React.RefObject; + contentRef: React.RefObject; + focusContent: () => boolean; enabled: boolean; openOnPress: boolean; delayDuration: number; @@ -101,6 +103,17 @@ function composeEventHandlers( }; } +function focusFirstContentControl(content: View | null): boolean { + if (!isWeb || typeof document === "undefined" || !content) return false; + const root = content as unknown as HTMLElement; + const control = root.querySelector( + '[role="button"][tabindex]:not([aria-disabled="true"]), [role="checkbox"][tabindex]:not([aria-disabled="true"]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])', + ); + if (!control) return false; + control.focus(); + return document.activeElement === control; +} + function useControllableOpenState({ open, defaultOpen, @@ -247,6 +260,7 @@ export function Tooltip({ interactive?: boolean; }>): ReactElement { const triggerRef = useRef(null); + const contentRef = useRef(null); const closeTimerRef = useRef | null>(null); const [isOpen, setIsOpen] = useControllableOpenState({ open, @@ -274,6 +288,7 @@ export function Tooltip({ closeTimerRef.current = null; }, 100); }, [cancelClose, interactive, setIsOpen]); + const focusContent = useCallback(() => focusFirstContentControl(contentRef.current), []); useEffect(() => cancelClose, [cancelClose]); @@ -284,6 +299,8 @@ export function Tooltip({ cancelClose, scheduleClose, triggerRef, + contentRef, + focusContent, enabled, openOnPress: opensOnPress, delayDuration, @@ -294,6 +311,7 @@ export function Tooltip({ setIsOpen, cancelClose, scheduleClose, + focusContent, enabled, opensOnPress, delayDuration, @@ -311,6 +329,7 @@ export function TooltipTrigger({ onHoverOut, onFocus, onBlur, + onKeyDown, onPress, asChild = false, triggerRefProp = "ref", @@ -318,6 +337,7 @@ export function TooltipTrigger({ }: PressableProps & { asChild?: boolean; triggerRefProp?: string; + onKeyDown?: (event: unknown) => void; }): ReactElement { const ctx = useTooltipContext("TooltipTrigger"); const openTimerRef = useRef | null>(null); @@ -388,6 +408,21 @@ export function TooltipTrigger({ [close, onBlur], ); + const handleKeyDown = useCallback( + (event: unknown) => { + if (isCallable(onKeyDown)) onKeyDown(event); + if (!ctx.interactive || !ctx.open) return; + const keyboardEvent = event as { + key?: unknown; + shiftKey?: unknown; + preventDefault?: () => void; + }; + if (keyboardEvent.key !== "Tab" || keyboardEvent.shiftKey) return; + if (ctx.focusContent()) keyboardEvent.preventDefault?.(); + }, + [ctx, onKeyDown], + ); + const handlePress = useCallback( (e: unknown) => { if (isCallable(onPress)) onPress(e); @@ -412,6 +447,7 @@ export function TooltipTrigger({ onHoverOut: handleHoverOut, onFocus: handleFocus, onBlur: handleBlur, + onKeyDown: handleKeyDown, onPress: handlePress, ...(isWeb ? ({ @@ -442,6 +478,7 @@ export function TooltipTrigger({ onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut), onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus), onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur), + onKeyDown: composeEventHandlers(Reflect.get(rawProps, "onKeyDown"), handleKeyDown), onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress), onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn), onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut), @@ -561,6 +598,7 @@ export function TooltipContent({ Date: Sun, 4 Oct 2026 05:54:18 +0900 Subject: [PATCH 26/30] fix(app): keep focused tooltip controls open --- .../e2e/browser/context-window-meter.spec.ts | 21 +++++++++++++------ packages/app/src/components/ui/tooltip.tsx | 16 ++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 6d37157d84..f8e728f3b2 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -3,7 +3,10 @@ import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, test } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + installUsageReportsFixture, + type UsageReportsFixture, +} from "../support/helpers/usage-reports"; const USAGE_ICON = ''; @@ -23,6 +26,10 @@ function contextUsageReport(): UsageReportEntry { }; } +function forcedRefreshRequests(usage: UsageReportsFixture) { + return usage.listRequests().filter((request) => request.forceRefresh); +} + // Where the progress arc is painted, as its centroid relative to the ring's centre in pixels. // Reads the rendered pixels, so any rotation that does not reach the screen counts as none. async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> { @@ -116,12 +123,14 @@ test.describe("context window meter", () => { const refresh = tooltip.getByRole("button", { name: "Refresh Mock plan" }); await page.keyboard.press("Tab"); await expect(refresh).toBeFocused(); + await page.mouse.move(0, 0); + await page.waitForTimeout(150); // Longer than the trigger's close grace period. + await expect(refresh).toBeFocused(); + await expect(tooltip).toBeVisible(); await page.keyboard.press("Enter"); - await usage.waitForListRequests(2); - expect(usage.listRequests()[1]).toMatchObject({ - forceRefresh: true, - reportIds: ["mock:account"], - }); + await expect + .poll(() => forcedRefreshRequests(usage)) + .toEqual([{ forceRefresh: true, reportIds: ["mock:account"] }]); const pin = tooltip.getByRole("checkbox", { name: /Pin Mock plan Session/ }); await page.keyboard.press("Tab"); diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index d2a3332fd1..84512df23e 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -284,11 +284,23 @@ export function Tooltip({ return; } closeTimerRef.current = setTimeout(() => { - setIsOpen(false); closeTimerRef.current = null; + if ( + isWeb && + typeof document !== "undefined" && + contentRef.current && + (contentRef.current as unknown as HTMLElement).contains(document.activeElement) + ) { + return; + } + setIsOpen(false); }, 100); }, [cancelClose, interactive, setIsOpen]); - const focusContent = useCallback(() => focusFirstContentControl(contentRef.current), []); + const focusContent = useCallback(() => { + const focused = focusFirstContentControl(contentRef.current); + if (focused) cancelClose(); + return focused; + }, [cancelClose]); useEffect(() => cancelClose, [cancelClose]); From 6fcd93bb5a72aed6f0136f99506e1f51e7764d6d Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Tue, 6 Oct 2026 05:34:49 +0900 Subject: [PATCH 27/30] fix(app): address usage localization review --- .../app/e2e/browser/usage-settings.spec.ts | 14 +------ .../app/e2e/support/helpers/usage-reports.ts | 17 ++++++++- packages/app/src/usage/card.tsx | 5 ++- packages/app/src/usage/copy.ts | 37 ++++++++++++++----- packages/app/src/usage/format.test.ts | 2 +- packages/app/src/usage/format.ts | 4 +- 6 files changed, 53 insertions(+), 26 deletions(-) diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index ea98df9d0c..a6d48daf80 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -4,6 +4,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; import { + expectUsageBalanceInsideCard, installUsageReportsFixture, type UsageReportsFixture, } from "../support/helpers/usage-reports"; @@ -163,18 +164,7 @@ test.describe("usage settings", () => { await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); - const card = page.getByTestId("usage-card"); - const value = page.getByTestId("provider-usage-balance-credits-value"); - const reset = page.getByTestId("provider-usage-balance-credits-reset"); - await expect(value).toBeVisible({ timeout: 10_000 }); - await expect(reset).toHaveText(/resets \d+d/); - await expect(reset).toBeVisible(); - const [cardBox, valueBox] = await Promise.all([card.boundingBox(), value.boundingBox()]); - expect(cardBox).not.toBeNull(); - expect(valueBox).not.toBeNull(); - expect((valueBox?.x ?? 0) + (valueBox?.width ?? 0)).toBeLessThanOrEqual( - (cardBox?.x ?? 0) + (cardBox?.width ?? 0), - ); + await expectUsageBalanceInsideCard(page, "credits"); }); test("asks to update a host without usage support and never calls it", async ({ page }) => { diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 86b74c6ea3..eaa3aa7352 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port"; @@ -13,6 +13,21 @@ export interface UsageReportsFixture { waitForListRequests(count: number): Promise; } +export async function expectUsageBalanceInsideCard(page: Page, balanceId: string): Promise { + const card = page.getByTestId("usage-card"); + const value = page.getByTestId(`provider-usage-balance-${balanceId}-value`); + const reset = page.getByTestId(`provider-usage-balance-${balanceId}-reset`); + await expect(value).toBeVisible({ timeout: 10_000 }); + await expect(reset).toHaveText(/resets \d+d/); + await expect(reset).toBeVisible(); + const [cardBox, valueBox] = await Promise.all([card.boundingBox(), value.boundingBox()]); + expect(cardBox).not.toBeNull(); + expect(valueBox).not.toBeNull(); + expect((valueBox?.x ?? 0) + (valueBox?.width ?? 0)).toBeLessThanOrEqual( + (cardBox?.x ?? 0) + (cardBox?.width ?? 0), + ); +} + interface UsageReportsFixtureOptions { /** * Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index a1936e0f97..e6f9199394 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -13,6 +13,7 @@ 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 { i18n } from "@/i18n/i18next"; import { UsageBalanceBar } from "./balance-bar"; import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; @@ -24,7 +25,9 @@ import { UsageWindowBar } from "./window-bar"; function statusText(report: UsageReport): string | null { if (report.status === "available") return null; - return report.status === "error" ? "Error" : "Unavailable"; + return report.status === "error" + ? i18n.t("providerUsage.status.error") + : i18n.t("providerUsage.status.unavailable"); } function reportContent(report: UsageReport) { diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 639272283b..3fddde64fb 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -1,4 +1,5 @@ import type { UsageProblem } from "@getpaseo/protocol/messages"; +import { i18n } from "@/i18n/i18next"; import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time"; // User-facing copy for the usage surfaces, kept in one file so localization is a @@ -13,23 +14,41 @@ export const usageCopy = { const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now)); return `Login expired ${ago}. ${remedy}`; }, - title: "Usage", - planUsage: "Plan usage", + get title() { + return i18n.t("providerUsage.title"); + }, + get planUsage() { + return i18n.t("providerUsage.title"); + }, options: "Settings", - refresh: "Refresh", - refreshAll: "Refresh all", - refreshing: "Refreshing...", + get refresh() { + return i18n.t("providerUsage.refresh"); + }, + get refreshAll() { + return i18n.t("providerUsage.refresh"); + }, + get refreshing() { + return i18n.t("providerUsage.refreshing"); + }, refreshFailed: "Unable to refresh usage", updated: "Updated", - loading: "Loading usage...", - empty: "No usage data", + get loading() { + return i18n.t("providerUsage.loading"); + }, + get empty() { + return i18n.t("providerUsage.empty"); + }, noHosts: "No connected hosts", - errorTitle: "Unable to load usage", + get errorTitle() { + return i18n.t("providerUsage.errorTitle"); + }, 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", + get retry() { + return i18n.t("providerUsage.retry"); + }, pin: "Pin", unpin: "Unpin", displayAs: "Percentages", diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 80a63c7c72..f92d13df1f 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -79,7 +79,7 @@ describe("formatAmount", () => { 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("credits", "Credits")).toBe("크레딧"); expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit"); }); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 5cfa0b0f36..af5c10f7d8 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -4,7 +4,6 @@ import type { UsageBalanceUnit } from "./types"; const providerUsageLabelKeys = { session: "providerUsage.labels.session", - five_hour: "providerUsage.labels.session", weekly: "providerUsage.labels.weekly", monthly: "providerUsage.labels.monthly", code_review: "providerUsage.labels.codeReview", @@ -22,7 +21,8 @@ export function clampPct(value: number): number { } export function formatPct(value: number, locale?: string): string { - return new Intl.NumberFormat(locale, { + const resolvedLocale = locale ?? i18n.resolvedLanguage; + return new Intl.NumberFormat(resolvedLocale, { style: "percent", maximumFractionDigits: 0, }).format(clampPct(value) / 100); From e3f51bc1530d746ee6eff339c168a29f94ee3b28 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Wed, 7 Oct 2026 05:18:37 +0900 Subject: [PATCH 28/30] fix(app): localize bulk usage refresh --- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/usage/copy.ts | 2 +- 10 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 3b69361d6e..6b1e3ea807 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1961,6 +1961,7 @@ export const ar: TranslationResources = { providerUsage: { title: "استخدام الخطة", refresh: "تحديث", + refreshAll: "تحديث الكل", refreshing: "جارٍ التحديث...", loading: "جارٍ تحميل بيانات الاستخدام...", empty: "لا توجد بيانات استخدام", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 70a56a5498..069ba267f5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1986,6 +1986,7 @@ export const en = { providerUsage: { title: "Plan usage", refresh: "Refresh", + refreshAll: "Refresh all", refreshing: "Refreshing...", loading: "Loading usage...", empty: "No usage data", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index d6fb25783d..d64fe39f1f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2009,6 +2009,7 @@ export const es: TranslationResources = { providerUsage: { title: "Uso del plan", refresh: "Actualizar", + refreshAll: "Actualizar todo", refreshing: "Actualizando...", loading: "Cargando uso...", empty: "No hay datos de uso", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index eccf392c37..e6b4614853 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2014,6 +2014,7 @@ export const fr: TranslationResources = { providerUsage: { title: "Utilisation du forfait", refresh: "Actualiser", + refreshAll: "Tout actualiser", refreshing: "Actualisation...", loading: "Chargement de l’utilisation...", empty: "Aucune donnée d’utilisation", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4c5c24e7b8..b5e9828fac 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1981,6 +1981,7 @@ export const ja: TranslationResources = { providerUsage: { title: "プラン使用量", refresh: "更新", + refreshAll: "すべて更新", refreshing: "更新中...", loading: "使用状況を読み込み中...", empty: "使用状況データがありません", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d99c5ffeaf..a3880f4d41 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1971,6 +1971,7 @@ export const ko: TranslationResources = { providerUsage: { title: "플랜 사용량", refresh: "새로 고침", + refreshAll: "모두 새로 고침", refreshing: "새로 고치는 중...", loading: "사용량 불러오는 중...", empty: "사용량 데이터가 없습니다", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b5a75b58a3..cfaea81ed0 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1994,6 +1994,7 @@ export const ptBR: TranslationResources = { providerUsage: { title: "Uso do plano", refresh: "Atualizar", + refreshAll: "Atualizar tudo", refreshing: "Atualizando...", loading: "Carregando uso...", empty: "Sem dados de uso", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 17abe8cfee..2de98060ba 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1994,6 +1994,7 @@ export const ru: TranslationResources = { providerUsage: { title: "Использование плана", refresh: "Обновить", + refreshAll: "Обновить всё", refreshing: "Обновление...", loading: "Загрузка данных об использовании...", empty: "Нет данных об использовании", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 9cd9a1b55e..cf6feda7f9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1939,6 +1939,7 @@ export const zhCN: TranslationResources = { providerUsage: { title: "套餐用量", refresh: "刷新", + refreshAll: "全部刷新", refreshing: "刷新中...", loading: "正在加载用量...", empty: "暂无用量数据", diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index 3fddde64fb..e50bfb8172 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -25,7 +25,7 @@ export const usageCopy = { return i18n.t("providerUsage.refresh"); }, get refreshAll() { - return i18n.t("providerUsage.refresh"); + return i18n.t("providerUsage.refreshAll"); }, get refreshing() { return i18n.t("providerUsage.refreshing"); From dacccf13749780f84dd266cc2be3bab2b033560c Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 18 Oct 2026 05:19:33 +0900 Subject: [PATCH 29/30] fix(app): localize Claude session window --- packages/app/src/usage/format.test.ts | 2 ++ packages/app/src/usage/format.ts | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index f92d13df1f..cdb0e7dd26 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -79,7 +79,9 @@ describe("formatAmount", () => { 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("credits", "Credits")).toBe("크레딧"); expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit"); }); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index af5c10f7d8..b025e3b117 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -12,7 +12,11 @@ const providerUsageLabelKeys = { } as const; export function formatProviderUsageLabel(id: string, fallback: string): string { - const key = providerUsageLabelKeys[id as keyof typeof providerUsageLabelKeys]; + const key = + providerUsageLabelKeys[id as keyof typeof providerUsageLabelKeys] ?? + // Claude exposes its session window with a five-hour ID. Other sources use that same ID for + // literal five-hour quotas, so retain their provider label unless this is Claude's Session. + (id === "five_hour" && fallback === "Session" ? providerUsageLabelKeys.session : undefined); return key ? i18n.t(key) : fallback; } From d60c35bff2acfcdf4deb741757bb4494f4d13935 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 18 Oct 2026 05:24:40 +0900 Subject: [PATCH 30/30] fix(app): complete provider usage localization --- packages/app/src/i18n/resources/ar.ts | 4 +-- packages/app/src/i18n/resources/en.ts | 4 +-- packages/app/src/i18n/resources/es.ts | 4 +-- packages/app/src/i18n/resources/fr.ts | 4 +-- packages/app/src/i18n/resources/ja.ts | 4 +-- packages/app/src/i18n/resources/ko.ts | 4 +-- packages/app/src/i18n/resources/pt-BR.ts | 4 +-- packages/app/src/i18n/resources/ru.ts | 4 +-- packages/app/src/i18n/resources/zh-CN.ts | 4 +-- packages/app/src/usage/balance-bar.tsx | 14 ++++++-- packages/app/src/usage/card.tsx | 13 +++++--- packages/app/src/usage/copy.ts | 9 +++--- packages/app/src/usage/format.test.ts | 6 ++++ packages/app/src/usage/format.ts | 11 +++++++ packages/app/src/usage/model.test.ts | 32 ++++++++++++------- packages/app/src/usage/model.ts | 6 ---- .../app/src/usage/use-relative-time-tick.ts | 2 +- packages/app/src/usage/window-bar.tsx | 2 +- 18 files changed, 82 insertions(+), 49 deletions(-) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2134f527da..b416d82c9a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2050,8 +2050,8 @@ export const ar: TranslationResources = { loading: "جارٍ تحميل بيانات الاستخدام...", empty: "لا توجد بيانات استخدام", errorTitle: "تعذّر تحميل بيانات الاستخدام", - hostUnavailable: "اتصل بهذا المضيف لعرض استخدام المزوّد", - hostUpgradeRequired: "حدّث المضيف لعرض استخدام المزوّد", + hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد", + hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد", clientUnavailable: "اتصال المضيف غير جاهز", retry: "حاول مرة أخرى", tooltipLoading: "جارٍ تحميل استخدام الخطة...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1ef0fb3c27..3126d3e334 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2075,8 +2075,8 @@ export const en = { loading: "Loading usage...", empty: "No usage data", errorTitle: "Unable to load usage", - hostUnavailable: "Connect to this host to see provider usage", - hostUpgradeRequired: "Update the host to see provider usage", + hostUnavailable: "Connect to {{host}} to see provider usage", + hostUpgradeRequired: "Update {{host}} to see provider usage", clientUnavailable: "Host connection is not ready", retry: "Try again", tooltipLoading: "Loading plan usage...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 76d0e68b4a..86c9a8c587 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2098,8 +2098,8 @@ export const es: TranslationResources = { loading: "Cargando uso...", empty: "No hay datos de uso", errorTitle: "No se pudo cargar el uso", - hostUnavailable: "Conéctate a este host para ver el uso del proveedor", - hostUpgradeRequired: "Actualiza el host para ver el uso del proveedor", + 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...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index dbd03bf29b..6ef88c10ab 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2097,8 +2097,8 @@ export const fr: TranslationResources = { loading: "Chargement de l’utilisation...", empty: "Aucune donnée d’utilisation", errorTitle: "Impossible de charger l’utilisation", - hostUnavailable: "Connectez-vous à cet hôte pour voir l’utilisation du fournisseur", - hostUpgradeRequired: "Mettez à jour l’hôte pour voir l’utilisation du fournisseur", + 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...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index b6abe4329b..62a5fb928c 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2071,8 +2071,8 @@ export const ja: TranslationResources = { loading: "使用状況を読み込み中...", empty: "使用状況データがありません", errorTitle: "使用状況を読み込めません", - hostUnavailable: "このホストに接続してプロバイダーの使用状況を確認してください", - hostUpgradeRequired: "ホストを更新してプロバイダーの使用状況を確認してください", + hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください", + hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください", clientUnavailable: "ホスト接続の準備ができていません", retry: "再試行", tooltipLoading: "プラン使用状況を読み込み中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f5f6515131..7e82cf8611 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2060,8 +2060,8 @@ export const ko: TranslationResources = { loading: "사용량 불러오는 중...", empty: "사용량 데이터가 없습니다", errorTitle: "사용량을 불러올 수 없습니다", - hostUnavailable: "이 호스트에 연결하여 제공업체 사용량을 확인하세요", - hostUpgradeRequired: "호스트를 업데이트하여 제공업체 사용량을 확인하세요", + hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요", + hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요", clientUnavailable: "호스트 연결이 준비되지 않았습니다", retry: "다시 시도", tooltipLoading: "플랜 사용량 불러오는 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index d5dbcde0d5..1413c8f0f7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2083,8 +2083,8 @@ export const ptBR: TranslationResources = { loading: "Carregando uso...", empty: "Sem dados de uso", errorTitle: "Não foi possível carregar o uso", - hostUnavailable: "Conecte-se a este host para ver o uso do provedor", - hostUpgradeRequired: "Atualize o host para ver o uso do provedor", + 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...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4281f61ddd..dd4cbcecd2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2083,8 +2083,8 @@ export const ru: TranslationResources = { loading: "Загрузка данных об использовании...", empty: "Нет данных об использовании", errorTitle: "Не удалось загрузить данные об использовании", - hostUnavailable: "Подключитесь к этому хосту, чтобы увидеть использование провайдера", - hostUpgradeRequired: "Обновите хост, чтобы увидеть использование провайдера", + hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера", + hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера", clientUnavailable: "Соединение с хостом не готово", retry: "Повторить", tooltipLoading: "Загрузка использования плана...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 75600fd8ad..c4b7d7d89a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2028,8 +2028,8 @@ export const zhCN: TranslationResources = { loading: "正在加载用量...", empty: "暂无用量数据", errorTitle: "无法加载用量", - hostUnavailable: "连接到此主机以查看提供商用量", - hostUpgradeRequired: "更新主机以查看提供商用量", + hostUnavailable: "连接到 {{host}} 以查看提供商用量", + hostUpgradeRequired: "更新 {{host}} 以查看提供商用量", clientUnavailable: "主机连接尚未就绪", retry: "重试", tooltipLoading: "正在加载套餐用量...", diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index c0f1acb160..3ed672aae0 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -12,7 +12,15 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance(balance: UsageBalance, t: TFunction, 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) { @@ -48,8 +56,8 @@ function fillToneStyle(tone: UsageTone) { export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { const { t, i18n } = useTranslation(); - useRelativeTimeTick(balance.resetsAt != null); - const { amountText, usedPct } = resolveBalance(balance, t, i18n.resolvedLanguage); + useRelativeTimeTick({ enabled: balance.resetsAt != null }); + const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage }); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index e6f9199394..abb22edf88 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,16 +13,17 @@ 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 { i18n } from "@/i18n/i18next"; import { UsageBalanceBar } from "./balance-bar"; import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { formatUsageFreshness, type UsageRefresh } from "./model"; +import { formatAgo } from "./format"; +import 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 { if (report.status === "available") return null; @@ -276,10 +278,13 @@ function UsageFreshness({ style: StyleProp; testID: string; }) { - const elapsed = useCompactTimeAgo(new Date(fetchedAt)); + const { t } = useTranslation(); + useRelativeTimeTick({ enabled: true }); + const relativeTime = formatAgo(fetchedAt); + if (!relativeTime) return null; return ( - {formatUsageFreshness(elapsed)} + {t("providerUsage.timing.updated", { relativeTime })} ); } diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index e50bfb8172..5d167d2724 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -31,7 +31,6 @@ export const usageCopy = { return i18n.t("providerUsage.refreshing"); }, refreshFailed: "Unable to refresh usage", - updated: "Updated", get loading() { return i18n.t("providerUsage.loading"); }, @@ -43,9 +42,11 @@ export const usageCopy = { return i18n.t("providerUsage.errorTitle"); }, 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", + hostUnavailable: (host: string) => i18n.t("providerUsage.hostUnavailable", { host }), + hostUpgradeRequired: (host: string) => i18n.t("providerUsage.hostUpgradeRequired", { host }), + get clientUnavailable() { + return i18n.t("providerUsage.clientUnavailable"); + }, get retry() { return i18n.t("providerUsage.retry"); }, diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index cdb0e7dd26..6a5ece79cc 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -82,6 +82,12 @@ describe("formatAmount", () => { 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"); }); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index b025e3b117..97b8aecc43 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -12,6 +12,17 @@ const providerUsageLabelKeys = { } as const; export function formatProviderUsageLabel(id: string, fallback: string): string { + if (id.startsWith("code_review:")) { + const periodId = id.slice("code_review:".length); + const separator = " · "; + const periodFallback = fallback.includes(separator) + ? fallback.slice(fallback.lastIndexOf(separator) + separator.length) + : fallback; + return `${i18n.t(providerUsageLabelKeys.code_review)} · ${formatProviderUsageLabel( + periodId, + periodFallback, + )}`; + } const key = providerUsageLabelKeys[id as keyof typeof providerUsageLabelKeys] ?? // Claude exposes its session window with a five-hour ID. Other sources use that same ID for diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index a2c493619d..61fbec6843 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import { displayPercent, - formatUsageFreshness, resolveAgentUsageView, replaceReport, resolveUsageRefresh, @@ -50,7 +50,7 @@ describe("resolveUsageView", () => { supportsUsage: false, query: ready([entry({})]), }), - ).toEqual({ kind: "unavailable", message: "Update Laptop to see usage" }); + ).toEqual({ kind: "unavailable", message: "Update Laptop to see provider usage" }); }); it("asks for a connection before anything else", () => { @@ -61,7 +61,19 @@ describe("resolveUsageView", () => { supportsUsage: false, query: undefined, }), - ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" }); + ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see provider 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", () => { @@ -79,6 +91,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 }, @@ -184,14 +200,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"); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index ed8a39163a..3b904061bb 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,4 +1,3 @@ -import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { usageCopy } from "./copy"; import type { UsageDisplayAs } from "./preferences"; import type { UsageReportEntry, UsageView, UsageWindow } from "./types"; @@ -31,11 +30,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): string { - return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; -} - /** A user-requested refresh of one report. The previous report stays on screen throughout. */ export type UsageRefresh = "idle" | "pending" | "failed"; diff --git a/packages/app/src/usage/use-relative-time-tick.ts b/packages/app/src/usage/use-relative-time-tick.ts index c075612f40..2e59bd9ddc 100644 --- a/packages/app/src/usage/use-relative-time-tick.ts +++ b/packages/app/src/usage/use-relative-time-tick.ts @@ -2,7 +2,7 @@ import { useEffect, useReducer } from "react"; import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker"; /** Re-renders a mounted usage label when its relative time can change. */ -export function useRelativeTimeTick(enabled: boolean): void { +export function useRelativeTimeTick({ enabled }: { enabled: boolean }): void { const [, tick] = useReducer((value: number) => value + 1, 0); useEffect(() => { diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 4971f6c2ee..a3693b06f3 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -50,7 +50,7 @@ export function UsageWindowBar({ const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const relativeTime = isAtRisk ? window.runsOutAt : window.resetsAt; - useRelativeTimeTick(relativeTime != null); + useRelativeTimeTick({ enabled: relativeTime != null }); const trailing = isAtRisk ? formatRunsOutLabel(window.runsOutAt) : formatResetLabel(window.resetsAt);