Clarify and localize provider usage (#7376)

* Localize provider usage UI

* fix: restore compact provider usage meter

* fix: localize compact context percentage

* fix(app): deduplicate context meter refresh

* fix(app): localize provider usage time counts

* fix(app): stabilize compact provider usage row

* fix(app): localize provider usage plurals

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

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

* fix(app): preserve context overflow percentage

* fix(app): truncate provider balance reset values

* fix(app): add Korean provider usage translations

* fix(app): refresh provider usage timing

* fix(app): localize provider usage labels

* fix(app): clarify compact usage policies

* fix(app): localize monthly provider usage

* test(app): require Arabic provider usage plurals

* docs(i18n): keep migration guidance focused

* fix(app): hide empty compact usage slot

* fix(app): keep usage in context tooltip

* fix(app): align usage with sidebar reports

* test(app): pass usage timing clock explicitly

* fix(app): restore context usage details

* fix(app): make context usage tooltip interactive

* fix(app): support keyboard tooltip controls

* fix(app): keep focused tooltip controls open

* fix(app): address usage localization review

* fix(app): localize bulk usage refresh

* fix(app): localize Claude session window

* fix(app): complete provider usage localization

* fix(app): keep mounted usage times and translations current

Pass time and translation values into usage rendering so React Compiler invalidates cached labels when they change. Use the same localized window names and percentage meanings in sidebar summaries.

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

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

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

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

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-19 18:01:45 +02:00
committed by GitHub
co-authored by Michael Wu
parent 91154ba4e8
commit eda3e4318f
38 changed files with 1375 additions and 192 deletions
+3 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { UsageCard } from "./card";
import { usageCopy } from "./copy";
import { useUsageDisplay } from "./display";
import { useAgentUsage } from "./queries";
import type { UsageReportEntry } from "./types";
@@ -21,6 +21,7 @@ export function AgentUsage({
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
refreshable: boolean;
}) {
const { t } = useTranslation();
const view = useAgentUsage(serverId, agentId);
if (view.kind === "none") return null;
return (
@@ -35,7 +36,7 @@ export function AgentUsage({
/>
) : (
<Text style={styles.message}>
{view.kind === "loading" ? usageCopy.loading : view.message}
{view.kind === "loading" ? t("providerUsage.loading") : view.message}
</Text>
)}
</>
+37 -11
View File
@@ -1,16 +1,26 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
import type { UsageBalance, UsageTone } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
interface ResolvedBalance {
amountText: string;
usedPct: number | null;
}
function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance {
function resolveBalance({
balance,
t,
locale,
}: {
balance: UsageBalance;
t: TFunction;
locale?: string;
}): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
const format = (value: number) => formatAmount(value, unit, locale);
if (limit != null && limit > 0) {
@@ -20,7 +30,10 @@ function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance
return { amountText: `${usedText} / ${format(limit)}`, usedPct };
}
if (remaining != null) {
return { amountText: `${format(remaining)} left`, usedPct: null };
return {
amountText: t("providerUsage.values.remaining", { amount: format(remaining) }),
usedPct: null,
};
}
if (used != null) {
return { amountText: format(used), usedPct: null };
@@ -48,10 +61,11 @@ export function UsageBalanceBar({
balance: UsageBalance;
retained?: boolean;
}) {
const { i18n } = useTranslation();
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
const { t, i18n } = useTranslation();
const now = useRelativeTimeTick({ enabled: balance.resetsAt != null });
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt, retained);
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
@@ -62,12 +76,18 @@ export function UsageBalanceBar({
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{balance.label}
</Text>
<Text style={styles.value}>
{amountText}
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
{formatProviderUsageLabel(balance.id, balance.label, t)}
</Text>
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
{amountText}
</Text>
{resetLabel ? (
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
{resetLabel}
</Text>
) : null}
</View>
</View>
{usedPct != null ? (
<View style={styles.track}>
@@ -94,13 +114,19 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
alignItems: "flex-end",
},
amount: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
textAlign: "right",
},
track: {
height: 4,
@@ -0,0 +1,53 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@/i18n/i18next";
import { UsageWindowBar } from "./window-bar";
import type { UsageBalance, UsageWindow } from "./types";
import { UsageBalanceBar } from "./balance-bar";
const window: UsageWindow = { id: "session", label: "Session", usedPct: 70 };
const balance: UsageBalance = { id: "credits", label: "Credits", unit: "usd", remaining: 12 };
const ignorePin = () => {};
let root: Root;
let container: HTMLDivElement;
beforeEach(async () => {
vi.stubGlobal("React", React);
await i18n.changeLanguage("en");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
act(() => root.unmount());
container.remove();
await i18n.changeLanguage("en");
vi.unstubAllGlobals();
});
it("translates mounted usage rows when the language changes", async () => {
act(() =>
root.render(
<>
<UsageWindowBar
window={window}
displayAs="used"
pinnable={false}
pinned={false}
onTogglePin={ignorePin}
pinLabel=""
pinTestID=""
/>
<UsageBalanceBar balance={balance} />
</>,
),
);
expect(container.textContent).toContain("Session70% used");
expect(container.textContent).toContain("Credits");
await act(async () => {
await i18n.changeLanguage("ja");
});
expect(container.textContent).toContain("セッション70% 使用済み");
expect(container.textContent).toContain("クレジット");
});
+26 -12
View File
@@ -1,5 +1,6 @@
import { RotateCw } from "lucide-react-native";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
@@ -12,19 +13,22 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import type { TFunction } from "i18next";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
import { formatAgo, formatProviderUsageLabel } from "./format";
import { resolveUsageReport, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
import { UsageWindowBar } from "./window-bar";
import { useRelativeTimeTick } from "./use-relative-time-tick";
function statusText(report: UsageReport): string | null {
function statusText(report: UsageReport, t: TFunction): string | null {
if (report.status === "available") return null;
return report.status === "error" ? "Error" : "Unavailable";
return report.status === "error"
? t("providerUsage.status.error")
: t("providerUsage.status.unavailable");
}
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -49,15 +53,16 @@ export function UsageCard({
refreshable: boolean;
compact?: boolean;
}) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact || !refreshable;
const usage = entry.report;
const status = statusText(usage);
const status = statusText(usage, t);
const footer = entry.account.label ?? null;
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
resolveUsageReport(entry);
resolveUsageReport(entry, t);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -156,7 +161,7 @@ export function UsageCard({
{refreshState === "failed" ? (
<Text style={styles.error} testID="usage-refresh-error">
{usageCopy.refreshFailed}
{t("providerUsage.refreshFailed")}
</Text>
) : null}
</View>
@@ -176,6 +181,7 @@ function CardWindowBar({
display: UsageDisplay;
pinnable: boolean;
}) {
const { t } = useTranslation();
const pin = useMemo(
() => ({ sourceId: entry.sourceId, windowId: window.id }),
[entry.sourceId, window.id],
@@ -190,7 +196,7 @@ function CardWindowBar({
pinnable={pinnable}
pinned={display.isPinned(pin)}
onTogglePin={toggle}
pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`}
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
/>
);
@@ -212,6 +218,7 @@ function UsageRefreshButton({
onRefresh: () => void;
compact: boolean;
}) {
const { t } = useTranslation();
const isPending = refreshState === "pending";
const iconSize = paneContentToolbarIconSize(compact);
const freshness = useMemo(
@@ -227,7 +234,7 @@ function UsageRefreshButton({
);
return (
<ToolbarButton
label={`${usageCopy.refresh} ${sourceLabel}`}
label={`${t("providerUsage.refresh")} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
@@ -257,10 +264,17 @@ function UsageFreshness({
style: StyleProp<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
const { t } = useTranslation();
const now = useRelativeTimeTick({ enabled: successfulAt != null });
const relativeTime = formatAgo(successfulAt, now, t);
const freshness = relativeTime
? t(retained ? "providerUsage.timing.lastUpdated" : "providerUsage.timing.updated", {
relativeTime,
})
: t("providerUsage.timing.noSuccessfulUpdate");
return (
<Text style={style} numberOfLines={1} testID={testID}>
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
{freshness}
</Text>
);
}
+16 -33
View File
@@ -1,40 +1,23 @@
import type { TFunction } from "i18next";
import type { UsageProblem } from "@getpaseo/protocol/messages";
import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time";
import { i18n } from "@/i18n/i18next";
import { formatAgo } from "./format";
// User-facing copy for the usage surfaces, kept in one file so localization is a
// single-file change.
// Messages for usage failures outside the UI. Rendered copy uses useTranslation.
export const usageCopy = {
problem: (problem: UsageProblem, now: Date = new Date()): string => {
problem: (problem: UsageProblem, now: Date = new Date(), t: TFunction = i18n.t): string => {
if (problem.kind === "no_quota") return problem.detail;
const remedy = problem.refreshedBy
? `Run ${problem.refreshedBy} to refresh it.`
: "Sign in again.";
if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`;
const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now));
return `Login expired ${ago}. ${remedy}`;
? t("providerUsage.runToRefresh", { command: problem.refreshedBy })
: t("providerUsage.signInAgain");
if (problem.kind === "rejected")
return t("providerUsage.loginRejected", { status: problem.status, remedy });
return t("providerUsage.loginExpired", {
relativeTime: formatAgo(problem.expiresAt, now.getTime(), t),
remedy,
});
},
get clientUnavailable() {
return i18n.t("providerUsage.clientUnavailable");
},
title: "Usage",
planUsage: "Plan usage",
options: "Settings",
refresh: "Refresh",
refreshAll: "Refresh all",
refreshing: "Refreshing...",
refreshFailed: "Unable to refresh usage",
updated: "Updated",
loading: "Loading usage...",
empty: "No usage data",
noHosts: "No connected hosts",
errorTitle: "Unable to load usage",
agentError: (reason: string) => `Unable to load usage: ${reason}`,
hostUnavailable: (host: string) => `Connect to ${host} to see usage`,
hostUpgradeRequired: (host: string) => `Update ${host} to see usage`,
clientUnavailable: "Host connection is not ready",
retry: "Try again",
pin: "Pin",
unpin: "Unpin",
displayAs: "Percentages",
displayUsed: "Used",
displayRemaining: "Remaining",
showInSidebar: "Summary in sidebar",
showInSidebarHint: "Pinned windows show in the sidebar footer",
} as const;
+112 -2
View File
@@ -1,7 +1,25 @@
import { describe, expect, it } from "vitest";
import { formatAmount, formatResetLabel } from "./format";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
formatDisplayPct,
formatAgo,
formatAmount,
formatPct,
formatProviderUsageLabel,
formatResetLabel,
formatRunsOutLabel,
} from "./format";
const NOW = Date.parse("2026-07-19T00:00:00.000Z");
describe("formatAmount", () => {
beforeEach(async () => {
await i18n.changeLanguage("en");
});
afterEach(async () => {
await i18n.changeLanguage("en");
});
it("groups thousands in the app's language", () => {
expect(formatAmount(12345, "credits", "en")).toBe("12,345");
expect(formatAmount(12345, "requests", "en")).toBe("12,345");
@@ -12,6 +30,68 @@ describe("formatAmount", () => {
expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50");
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
});
it("localizes usage timing without patching the global clock", async () => {
const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
expect(formatPct(7, "en")).toBe("7%");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
await i18n.changeLanguage("ja");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達");
expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前");
});
it("formats percentages with locale-specific spacing", () => {
expect(formatPct(42, "fr")).toBe("42\u00a0%");
});
it("formats relative-time counts with the active locale", async () => {
await i18n.changeLanguage("fr");
const days = 1_234;
const daysFromNow = new Date(NOW + days * 24 * 60 * 60 * 1000).toISOString();
const daysAgo = new Date(NOW - days * 24 * 60 * 60 * 1000).toISOString();
expect(formatResetLabel(daysFromNow, false, NOW)).toBe("se réinitialise dans 1\u202f234 j");
expect(formatRunsOutLabel(daysFromNow, NOW)).toBe("s’épuise dans 1\u202f234 j");
expect(formatAgo(daysAgo, NOW)).toBe("il y a 1\u202f234 j");
});
it("selects Arabic plural forms for relative durations", async () => {
await i18n.changeLanguage("ar");
const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString();
const threeHoursFromNow = new Date(NOW + 3 * 60 * 60 * 1000).toISOString();
const twoDaysAgo = new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString();
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("تتم إعادة التعيين خلال ساعتين");
expect(formatRunsOutLabel(threeHoursFromNow, NOW)).toBe("ينفد خلال 3 ساعات");
expect(formatAgo(twoDaysAgo, NOW)).toBe("قبل يومين");
});
it("uses locale-aware compact notation for token balances", () => {
expect(formatAmount(1_234, "tokens", "en")).toBe("1.2K");
expect(formatAmount(1_234, "tokens", "ar")).toBe("1.2\u00a0ألف");
});
it("localizes known provider-usage labels and preserves provider-specific labels", async () => {
await i18n.changeLanguage("ko");
expect(formatProviderUsageLabel("session", "Session")).toBe("세션");
expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션");
expect(formatProviderUsageLabel("five_hour", "5-hour limit")).toBe("5-hour limit");
expect(formatProviderUsageLabel("monthly", "Monthly")).toBe("월간");
expect(formatProviderUsageLabel("code_review:weekly", "Code review · Weekly")).toBe(
"코드 검토 · 주간",
);
expect(formatProviderUsageLabel("code_review:five_hour", "Code review · 5-hour")).toBe(
"코드 검토 · 5-hour",
);
expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧");
expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit");
});
});
it("retained reset times that have passed do not claim the quota is resetting", () => {
@@ -20,3 +100,33 @@ it("retained reset times that have passed do not claim the quota is resetting",
expect(formatResetLabel(past)).toBe("resetting now");
expect(formatResetLabel(null, true)).toBeNull();
});
it("says that a usage window percentage is consumed", () => {
expect(formatDisplayPct(70, "used")).toBe("70% used");
expect(formatDisplayPct(30, "remaining")).toBe("30% left");
});
it("translates the usage refresh control into Japanese", async () => {
try {
await i18n.changeLanguage("ja");
expect(i18n.t("providerUsage.refresh")).toBe("更新");
} finally {
await i18n.changeLanguage("en");
}
});
it("translates the neighboring usage percentage settings", async () => {
try {
await i18n.changeLanguage("ja");
expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み");
expect(i18n.t("providerUsage.displayRemaining")).toBe("残り");
} finally {
await i18n.changeLanguage("en");
}
});
it("preserves provider-defined labels even when their IDs match a standard period", () => {
expect(formatProviderUsageLabel("monthly", "Extra allotment")).toBe("Extra allotment");
expect(formatProviderUsageLabel("code_review:weekly", "Custom review allowance")).toBe(
"Custom review allowance",
);
});
+134 -18
View File
@@ -1,47 +1,163 @@
import { formatTokenCount } from "@/components/context-window-meter.utils";
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
const providerUsageLabels = {
session: { label: "Session", key: "providerUsage.labels.session" },
weekly: { label: "Weekly", key: "providerUsage.labels.weekly" },
monthly: { label: "Monthly", key: "providerUsage.labels.monthly" },
code_review: { label: "Code review", key: "providerUsage.labels.codeReview" },
credits: { label: "Credits", key: "providerUsage.labels.credits" },
monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" },
} as const;
export function formatProviderUsageLabel(id: string, label: string, t: TFunction = i18n.t): string {
const reviewPrefix = "Code review · ";
if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) {
const periodId = id.slice("code_review:".length);
const periodLabel = label.slice(reviewPrefix.length);
return `${t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel, t)}`;
}
const standard =
providerUsageLabels[id as keyof typeof providerUsageLabels] ??
(id === "five_hour" ? providerUsageLabels.session : undefined);
return standard?.label === label ? t(standard.key) : label;
}
export function clampPct(value: number): number {
return Math.max(0, Math.min(100, value));
}
export function formatPct(value: number): string {
return `${Math.round(clampPct(value))}%`;
export function formatPct(value: number, locale?: string): string {
const resolvedLocale = locale ?? i18n.resolvedLanguage;
return new Intl.NumberFormat(resolvedLocale, {
style: "percent",
maximumFractionDigits: 0,
}).format(clampPct(value) / 100);
}
/** "31%" of the window used, or "69% left" of it. */
export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string {
return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`;
/** A localized percentage that says whether it is used or remaining. */
export function formatDisplayPct(
value: number,
displayAs: UsageDisplayAs,
locale?: string,
t: TFunction = i18n.t,
): string {
const percentage = formatPct(value, locale);
return displayAs === "used"
? t("providerUsage.values.used", { percentage })
: t("providerUsage.values.remaining", { amount: percentage });
}
function relativeDuration(iso: string): string | null {
const diffMs = new Date(iso).getTime() - Date.now();
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
function formatCount(value: number): string {
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
}
function relativeDuration(iso: string, now: number): RelativeDuration | null {
const diffMs = new Date(iso).getTime() - now;
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return "now";
if (diffMs <= 0) return { unit: "now" };
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d`;
if (diffHours > 0) return `${diffHours}h`;
return `${diffMinutes}m`;
if (diffDays > 0) return { unit: "days", count: diffDays };
if (diffHours > 0) return { unit: "hours", count: diffHours };
return { unit: "minutes", count: diffMinutes };
}
export function formatResetLabel(iso: string | null | undefined, retained = false): string | null {
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
switch (duration.unit) {
case "now":
return null;
case "days":
return t("providerUsage.duration.days", {
count: duration.count,
value: formatCount(duration.count),
});
case "hours":
return t("providerUsage.duration.hours", {
count: duration.count,
value: formatCount(duration.count),
});
case "minutes":
return t("providerUsage.duration.minutes", {
count: duration.count,
value: formatCount(duration.count),
});
}
}
export function formatResetLabel(
iso: string | null | undefined,
retained = false,
now = Date.now(),
t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const rel = relativeDuration(iso);
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel === "now" && retained) return "Reset time passed";
return rel === "now" ? "resetting now" : `resets ${rel}`;
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
}
export function formatRunsOutLabel(
iso: string | null | undefined,
now = Date.now(),
t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
}
export function formatAgo(
iso: string | null | undefined,
now = Date.now(),
t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const diffMs = now - new Date(iso).getTime();
if (!Number.isFinite(diffMs)) return null;
if (diffMs < 60_000) return t("providerUsage.timing.justNow");
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return t("providerUsage.timing.daysAgo", {
count: diffDays,
value: formatCount(diffDays),
});
}
if (diffHours > 0) {
return t("providerUsage.timing.hoursAgo", {
count: diffHours,
value: formatCount(diffHours),
});
}
return t("providerUsage.timing.minutesAgo", {
count: diffMinutes,
value: formatCount(diffMinutes),
});
}
/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */
export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string {
export function formatAmount(value: number, unit: UsageBalanceUnit, locale?: string): string {
switch (unit) {
case "usd":
return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value);
case "tokens":
return formatTokenCount(value);
return new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
default:
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
}
@@ -1,16 +1,17 @@
import { usageCopy } from "./copy";
import { useTranslation } from "react-i18next";
import { useUsagePreferences } from "./display";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
/** A host's usage reports, for its settings page. */
export function HostUsageSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsagePreferences(serverId);
return (
<UsageSection
serverId={serverId}
title={usageCopy.planUsage}
title={t("providerUsage.title")}
view={view}
display={display}
onRefresh={refresh}
+25 -15
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
resolveUsageReport,
displayPercent,
formatUsageFreshness,
resolveAgentUsageView,
replaceReport,
resolveUsageRefresh,
@@ -65,6 +65,18 @@ describe("resolveUsageView", () => {
).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" });
});
it("localizes unavailable host messages while retaining the host name", async () => {
await i18n.changeLanguage("ko");
expect(
resolveUsageView({
hostLabel: "Laptop",
isConnected: false,
supportsUsage: false,
query: undefined,
}),
).toEqual({ kind: "unavailable", message: "Laptop에 연결하여 제공업체 사용량을 확인하세요" });
});
it("moves from loading to ready to error", () => {
const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true };
expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
@@ -80,6 +92,10 @@ describe("resolveUsageView", () => {
});
});
afterEach(async () => {
await i18n.changeLanguage("en");
});
const hosts: UsageHost[] = [
{ serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
{ serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
@@ -185,14 +201,6 @@ describe("displayPercent", () => {
});
});
describe("formatUsageFreshness", () => {
it("says when the report was fetched", () => {
expect(formatUsageFreshness("now")).toBe("Updated just now");
expect(formatUsageFreshness("3m")).toBe("Updated 3m ago");
expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
});
});
describe("resolveUsageRefresh", () => {
it("is pending while a refresh runs, even after an earlier failure", () => {
expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending");
@@ -338,7 +346,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
report: success.report as Extract<UsageReportEntry["report"], { status: "available" }>,
},
};
expect(resolveUsageReport(failure)).toEqual({
expect(resolveUsageReport(failure, i18n.t)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -347,7 +355,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: true,
messages: ["Rate limited"],
});
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({
windows: [],
balances: [],
details: [],
@@ -356,7 +364,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
retained: false,
messages: ["Rate limited"],
});
expect(resolveUsageReport(success)).toEqual({
expect(resolveUsageReport(success, i18n.t)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
@@ -384,7 +392,7 @@ it("orders failed polls by attempt time even when successful data is older", ()
it("available and retained usage show the same successful completion time", () => {
const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" };
if (success.report.status !== "available") throw new Error("fixture must be available");
expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
expect(resolveUsageReport(success, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z");
const failure: UsageReportEntry = {
...success,
successfulAt: undefined,
@@ -392,6 +400,8 @@ it("available and retained usage show the same successful completion time", () =
report: { status: "error", error: "Rate limited" },
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
};
expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
expect(resolveUsageReport(failure, i18n.t).successfulAt).toBe(
resolveUsageReport(success, i18n.t).successfulAt,
);
expect(upsertReport([failure], success)).toEqual([failure]);
});
+35 -24
View File
@@ -1,4 +1,5 @@
import { formatCompactTimeAgoAsProse } from "@/utils/time";
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
@@ -31,11 +32,6 @@ export function usageWindowRowLabel(input: {
return `${input.pinLabel}, ${summary}`;
}
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
export function formatUsageFreshness(compactTimeAgo: string, retained = false): string {
return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
}
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
export type UsageRefresh = "idle" | "pending" | "failed";
@@ -108,16 +104,26 @@ export interface UsageQueryState {
isFetching: boolean;
}
export function resolveUsageView(input: {
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
query: UsageQueryState | undefined;
}): UsageView {
export function resolveUsageView(
input: {
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
query: UsageQueryState | undefined;
},
t: TFunction = i18n.t,
): UsageView {
const { hostLabel, isConnected, supportsUsage, query } = input;
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) };
if (!isConnected)
return {
kind: "unavailable",
message: t("providerUsage.hostUnavailable", { host: hostLabel }),
};
if (!supportsUsage) {
return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) };
return {
kind: "unavailable",
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
};
}
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
@@ -138,17 +144,20 @@ export type AgentUsageView =
| { kind: "error"; message: string }
| { kind: "ready"; reports: UsageReportEntry[] };
export function resolveAgentUsageView(input: {
canReport: boolean;
query: UsageQueryState;
}): AgentUsageView {
export function resolveAgentUsageView(
input: {
canReport: boolean;
query: UsageQueryState;
},
t: TFunction = i18n.t,
): AgentUsageView {
const { canReport, query } = input;
if (!canReport) return { kind: "none" };
// A failed request keeps the reports from before it, or those that streamed in before it failed;
// shown alone they would pass for the agent's complete, current usage.
if (query.error) {
const reason = query.error instanceof Error ? query.error.message : String(query.error);
return { kind: "error", message: usageCopy.agentError(reason) };
return { kind: "error", message: t("providerUsage.agentError", { reason }) };
}
if (query.data) {
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
@@ -194,7 +203,7 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
}
/** Current outcome owns messages; only a successful poll owns values and freshness. */
export function resolveUsageReport(entry: UsageReportEntry) {
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
const available = entry.report.status === "available";
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
@@ -204,10 +213,12 @@ export function resolveUsageReport(entry: UsageReportEntry) {
if (!available) {
if (entry.loginErrors)
messages.push(
...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
...entry.loginErrors.map(
(login) => `${login.harness}: ${usageProblemText(login.report, t)}`,
),
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
else messages.push(usageProblemText(entry.report));
else messages.push(usageProblemText(entry.report, t));
}
return {
windows: content?.windows ?? [],
@@ -224,8 +235,8 @@ export function resolveUsageReport(entry: UsageReportEntry) {
};
}
function usageProblemText(report: UsageReport): string {
function usageProblemText(report: UsageReport, t: TFunction): string {
if (report.status === "error") return report.error;
if (report.status === "unavailable") return usageCopy.problem(report.problem);
if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t);
return "";
}
+16 -12
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Settings } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
@@ -8,20 +9,23 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { settingsStyles } from "@/styles/settings";
import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
];
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
const { t } = useTranslation();
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
{
value: "remaining",
label: t("providerUsage.displayRemaining"),
testID: "usage-display-remaining",
},
];
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
const { inSidebar, setInSidebar } = useUsageInSidebar();
@@ -29,7 +33,7 @@ export function UsageOptions() {
<DropdownMenu compactMode="sheet">
<ToolbarButton
kind="menu"
label={usageCopy.options}
label={t("providerUsage.options")}
compact={compact}
testID="usage-options-menu"
>
@@ -41,21 +45,21 @@ export function UsageOptions() {
<DropdownMenuContent
align="end"
width={340}
sheetTitle={usageCopy.options}
sheetTitle={t("providerUsage.options")}
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={usageCopy.showInSidebar}
hint={usageCopy.showInSidebarHint}
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={usageCopy.displayAs}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SegmentedControl
options={DISPLAY_AS_OPTIONS}
options={displayAsOptions}
value={display.displayAs}
onValueChange={display.setDisplayAs}
size="sm"
+26
View File
@@ -275,3 +275,29 @@ it("keeps unavailable and error reports out of the sidebar summary even with ret
};
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
});
it("localizes pinned labels and meanings while preserving custom window names", async () => {
const { i18n } = await import("@/i18n/i18next");
await i18n.changeLanguage("ja");
try {
const entries = [
report({
sourceId: "test",
sourceLabel: "Test",
windows: [
{ id: "session", label: "Session", summary: true, usedPct: 70 },
{ id: "weekly", label: "My allowance", summary: true, usedPct: 20 },
],
}),
];
expect(windows(entries, preferences(null)).map((w) => [w.label, w.shortLabel])).toEqual([
["Test セッション 70% 使用済み", "セッション"],
["Test My allowance 20% 使用済み", "My allowance"],
]);
expect(windows(entries, preferences(null, "remaining"))[0].label).toBe(
"Test セッション 残り 30%",
);
} finally {
await i18n.changeLanguage("en");
}
});
+6 -4
View File
@@ -1,4 +1,6 @@
import { formatPct } from "./format";
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatPct, formatDisplayPct, formatProviderUsageLabel } from "./format";
import { displayPercent } from "./model";
import { effectiveUsagePins, type UsagePreferences } from "./preferences";
import { windowTone } from "./tone";
@@ -68,9 +70,9 @@ function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[]
export function resolvePinnedUsage(
reports: readonly UsageReportEntry[],
preferences: UsagePreferences,
t: TFunction = i18n.t,
): PinnedUsageSource[] {
const pins = effectiveUsagePins(preferences, reports);
const meaning = preferences.displayAs === "remaining" ? "left" : "used";
return groupBySource(reports).flatMap((entry) => {
if (entry.report.status !== "available") return [];
const windows = entry.report.windows
@@ -84,8 +86,8 @@ export function resolvePinnedUsage(
const percentText = formatPct(percent);
return {
key: `${entry.id}/${window.id}`,
label: `${describe(entry, window.label)} ${percentText} ${meaning}`,
shortLabel: window.shortLabel ?? window.label,
label: `${describe(entry, formatProviderUsageLabel(window.id, window.label, t))} ${formatDisplayPct(percent, preferences.displayAs, undefined, t)}`,
shortLabel: window.shortLabel ?? formatProviderUsageLabel(window.id, window.label, t),
percent,
percentText,
tone: windowTone(window),
+13 -7
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
import { useCallback, useMemo } from "react";
import {
@@ -144,13 +145,17 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const refresh = useCallback(() => {
void refreshReports(queryClient, serverId).catch(() => undefined);
}, [queryClient, serverId]);
const { t } = useTranslation();
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
const view = resolveUsageView({
hostLabel,
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
});
const view = resolveUsageView(
{
hostLabel,
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
},
t,
);
return { view, refresh };
}
@@ -196,7 +201,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
retry: false,
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return resolveAgentUsageView({ canReport, query: toQueryState(query) });
const { t } = useTranslation();
return resolveAgentUsageView({ canReport, query: toQueryState(query) }, t);
}
/** Whether a host is connected and reports usage, read without fetching anything. */
+3 -2
View File
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { RotateCw } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -14,11 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
* header's island, so its highlight is round.
*/
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const { t } = useTranslation();
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
return (
<ToolbarButton
label={usageCopy.refreshAll}
label={t("providerUsage.refreshAll")}
compact={compact}
disabled={busy}
onPress={onRefresh}
+2 -1
View File
@@ -34,9 +34,10 @@ import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
+2 -2
View File
@@ -4,7 +4,6 @@ import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
@@ -22,11 +21,12 @@ interface UsageModalProps {
* title row.
*/
export function UsageModal(props: UsageModalProps) {
const { t } = useTranslation();
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
<UsageModalFrame {...props}>
<UsageMessage text={usageCopy.noHosts} />
<UsageMessage text={t("providerUsage.noHosts")} />
</UsageModalFrame>
);
}
+11 -5
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -5,7 +6,6 @@ import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { UsageControls } from "./controls";
import { UsageList } from "./list";
@@ -50,26 +50,32 @@ export function UsageBody({
display: UsageDisplay;
onRefresh: () => void;
}) {
const { t } = useTranslation();
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
if (view.kind === "loading") {
return <UsageMessage text={usageCopy.loading} />;
return <UsageMessage text={t("providerUsage.loading")} />;
}
if (view.kind === "error") {
return (
<Alert size="sm" variant="error" title={usageCopy.errorTitle} description={view.message}>
<Alert
size="sm"
variant="error"
title={t("providerUsage.errorTitle")}
description={view.message}
>
<Button variant="outline" size="sm" onPress={onRefresh}>
{usageCopy.retry}
{t("providerUsage.retry")}
</Button>
</Alert>
);
}
if (view.reports.length === 0) {
return <UsageMessage text={usageCopy.empty} />;
return <UsageMessage text={t("providerUsage.empty")} />;
}
return <UsageList serverId={serverId} reports={view.reports} display={display} />;
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker";
/** The current time as a render input, updated while a relative-time label is mounted. */
export function useRelativeTimeTick({ enabled }: { enabled: boolean }): number {
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (!enabled) return undefined;
return subscribeToRelativeTimeTick("minute", () => setNow(Date.now()));
}, [enabled]);
return now;
}
+22 -9
View File
@@ -1,15 +1,21 @@
import { Pin } from "lucide-react-native";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { usageCopy } from "./copy";
import { formatDisplayPct, formatResetLabel } from "./format";
import {
formatDisplayPct,
formatProviderUsageLabel,
formatResetLabel,
formatRunsOutLabel,
} from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import type { UsageDisplayAs } from "./preferences";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
@@ -39,21 +45,24 @@ export function UsageWindowBar({
pinLabel: string;
pinTestID: string;
}) {
const { t, i18n } = useTranslation();
const shownPct = displayPercent(window, displayAs);
const tone = windowTone(window);
const now = useRelativeTimeTick({ enabled: window.runsOutAt != null || window.resetsAt != null });
const isAtRisk =
window.runsOutAt != null &&
window.shortfallPct != null &&
(!retained || Date.parse(window.runsOutAt) > Date.now());
(!retained || Date.parse(window.runsOutAt) > now);
const trailing = isAtRisk
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
: formatResetLabel(window.resetsAt, retained);
? formatRunsOutLabel(window.runsOutAt, now, t)
: formatResetLabel(window.resetsAt, retained, now, t);
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
const value =
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
const content = {
label: window.label,
label: formatProviderUsageLabel(window.id, window.label, t),
value,
trailing,
isAtRisk,
@@ -124,7 +133,7 @@ function WindowRowContent({
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value}>
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
@@ -142,6 +151,7 @@ function WindowRowContent({
const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
const { t } = useTranslation();
const iconMapping = useMemo(
() => (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
@@ -160,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
</View>
</TooltipTrigger>
<TooltipContent side="top">
<Text style={styles.tooltipText}>{pinned ? usageCopy.unpin : usageCopy.pin}</Text>
<Text style={styles.tooltipText}>
{pinned ? t("providerUsage.unpin") : t("providerUsage.pin")}
</Text>
</TooltipContent>
</Tooltip>
);
@@ -209,6 +221,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,