mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
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.
This commit is contained in:
@@ -600,6 +600,7 @@ for (const compact of [false, true]) {
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
@@ -617,6 +618,7 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
@@ -642,6 +644,7 @@ for (const compact of [false, true]) {
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
@@ -675,13 +678,14 @@ test("usage percentages state their meaning and follow the selected language", a
|
||||
test.setTimeout(120_000);
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 70 }],
|
||||
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
|
||||
await switchToJapanese(page);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
|
||||
await openJapaneseUsage(page);
|
||||
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
|
||||
@@ -693,3 +697,42 @@ test("usage percentages state their meaning and follow the selected language", a
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const now = new Date();
|
||||
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
|
||||
],
|
||||
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
|
||||
});
|
||||
try {
|
||||
await openCompactUsageWithClock(page, now);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
||||
await advanceUsageClock(page, 120_000);
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated 2m ago");
|
||||
await expect(card.getByText("resets 28m", { exact: false }).first()).toBeVisible();
|
||||
await expect(card.getByText("runs out 28m", { exact: false })).toBeVisible();
|
||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText("resets 28m");
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openCompactUsageWithClock(page: Page, now: Date) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.clock.install({ time: now });
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
}
|
||||
|
||||
async function advanceUsageClock(page: Page, milliseconds: number) {
|
||||
await page.clock.fastForward(milliseconds);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { UsageCard } from "./card";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useAgentUsage } from "./queries";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
@@ -22,7 +21,7 @@ export function AgentUsage({
|
||||
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
|
||||
refreshable: boolean;
|
||||
}) {
|
||||
useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const view = useAgentUsage(serverId, agentId);
|
||||
if (view.kind === "none") return null;
|
||||
return (
|
||||
@@ -37,7 +36,7 @@ export function AgentUsage({
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.message}>
|
||||
{view.kind === "loading" ? usageCopy.loading : view.message}
|
||||
{view.kind === "loading" ? t("providerUsage.loading") : view.message}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -62,10 +62,10 @@ export function UsageBalanceBar({
|
||||
retained?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
useRelativeTimeTick({ enabled: balance.resetsAt != null });
|
||||
const now = useRelativeTimeTick({ enabled: balance.resetsAt != null });
|
||||
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt, retained);
|
||||
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
|
||||
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
||||
@@ -76,7 +76,7 @@ export function UsageBalanceBar({
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{formatProviderUsageLabel(balance.id, balance.label)}
|
||||
{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">
|
||||
|
||||
@@ -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("クレジット");
|
||||
});
|
||||
@@ -13,11 +13,10 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { UsageBalanceBar } from "./balance-bar";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { formatAgo } from "./format";
|
||||
import { formatAgo, formatProviderUsageLabel } from "./format";
|
||||
import { resolveUsageReport, type UsageRefresh } from "./model";
|
||||
import { useReportRefresh } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
@@ -25,11 +24,11 @@ import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
|
||||
import { UsageWindowBar } from "./window-bar";
|
||||
import { useRelativeTimeTick } from "./use-relative-time-tick";
|
||||
|
||||
function statusText(report: UsageReport): string | null {
|
||||
function statusText(report: UsageReport, t: TFunction): string | null {
|
||||
if (report.status === "available") return null;
|
||||
return report.status === "error"
|
||||
? i18n.t("providerUsage.status.error")
|
||||
: i18n.t("providerUsage.status.unavailable");
|
||||
? t("providerUsage.status.error")
|
||||
: t("providerUsage.status.unavailable");
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
@@ -54,16 +53,16 @@ export function UsageCard({
|
||||
refreshable: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
|
||||
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
||||
const showsFreshnessInline = isNative || isCompact || !refreshable;
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const status = statusText(usage, t);
|
||||
const footer = entry.account.label ?? null;
|
||||
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
|
||||
resolveUsageReport(entry);
|
||||
resolveUsageReport(entry, t);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -162,7 +161,7 @@ export function UsageCard({
|
||||
|
||||
{refreshState === "failed" ? (
|
||||
<Text style={styles.error} testID="usage-refresh-error">
|
||||
{usageCopy.refreshFailed}
|
||||
{t("providerUsage.refreshFailed")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -182,6 +181,7 @@ function CardWindowBar({
|
||||
display: UsageDisplay;
|
||||
pinnable: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pin = useMemo(
|
||||
() => ({ sourceId: entry.sourceId, windowId: window.id }),
|
||||
[entry.sourceId, window.id],
|
||||
@@ -196,7 +196,7 @@ function CardWindowBar({
|
||||
pinnable={pinnable}
|
||||
pinned={display.isPinned(pin)}
|
||||
onTogglePin={toggle}
|
||||
pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
|
||||
pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`}
|
||||
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
|
||||
/>
|
||||
);
|
||||
@@ -218,6 +218,7 @@ function UsageRefreshButton({
|
||||
onRefresh: () => void;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isPending = refreshState === "pending";
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
const freshness = useMemo(
|
||||
@@ -233,7 +234,7 @@ function UsageRefreshButton({
|
||||
);
|
||||
return (
|
||||
<ToolbarButton
|
||||
label={`${usageCopy.refresh} ${sourceLabel}`}
|
||||
label={`${t("providerUsage.refresh")} ${sourceLabel}`}
|
||||
tooltip={freshness}
|
||||
tooltipSide="top"
|
||||
compact={compact}
|
||||
@@ -264,8 +265,8 @@ function UsageFreshness({
|
||||
testID: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useRelativeTimeTick({ enabled: successfulAt != null });
|
||||
const relativeTime = formatAgo(successfulAt);
|
||||
const now = useRelativeTimeTick({ enabled: successfulAt != null });
|
||||
const relativeTime = formatAgo(successfulAt, now, t);
|
||||
const freshness = relativeTime
|
||||
? t(retained ? "providerUsage.timing.lastUpdated" : "providerUsage.timing.updated", {
|
||||
relativeTime,
|
||||
|
||||
@@ -1,83 +1,23 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { UsageProblem } from "@getpaseo/protocol/messages";
|
||||
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
|
||||
? i18n.t("providerUsage.runToRefresh", { command: problem.refreshedBy })
|
||||
: i18n.t("providerUsage.signInAgain");
|
||||
? t("providerUsage.runToRefresh", { command: problem.refreshedBy })
|
||||
: t("providerUsage.signInAgain");
|
||||
if (problem.kind === "rejected")
|
||||
return i18n.t("providerUsage.loginRejected", { status: problem.status, remedy });
|
||||
return i18n.t("providerUsage.loginExpired", {
|
||||
relativeTime: formatAgo(problem.expiresAt, now.getTime()),
|
||||
return t("providerUsage.loginRejected", { status: problem.status, remedy });
|
||||
return t("providerUsage.loginExpired", {
|
||||
relativeTime: formatAgo(problem.expiresAt, now.getTime(), t),
|
||||
remedy,
|
||||
});
|
||||
},
|
||||
get title() {
|
||||
return i18n.t("providerUsage.title");
|
||||
},
|
||||
get planUsage() {
|
||||
return i18n.t("providerUsage.title");
|
||||
},
|
||||
get options() {
|
||||
return i18n.t("providerUsage.options");
|
||||
},
|
||||
get refresh() {
|
||||
return i18n.t("providerUsage.refresh");
|
||||
},
|
||||
get refreshAll() {
|
||||
return i18n.t("providerUsage.refreshAll");
|
||||
},
|
||||
get refreshing() {
|
||||
return i18n.t("providerUsage.refreshing");
|
||||
},
|
||||
get refreshFailed() {
|
||||
return i18n.t("providerUsage.refreshFailed");
|
||||
},
|
||||
get loading() {
|
||||
return i18n.t("providerUsage.loading");
|
||||
},
|
||||
get empty() {
|
||||
return i18n.t("providerUsage.empty");
|
||||
},
|
||||
get noHosts() {
|
||||
return i18n.t("providerUsage.noHosts");
|
||||
},
|
||||
get errorTitle() {
|
||||
return i18n.t("providerUsage.errorTitle");
|
||||
},
|
||||
agentError: (reason: string) => i18n.t("providerUsage.agentError", { reason }),
|
||||
hostUnavailable: (host: string) => i18n.t("providerUsage.hostUnavailable", { host }),
|
||||
hostUpgradeRequired: (host: string) => i18n.t("providerUsage.hostUpgradeRequired", { host }),
|
||||
get clientUnavailable() {
|
||||
return i18n.t("providerUsage.clientUnavailable");
|
||||
},
|
||||
get retry() {
|
||||
return i18n.t("providerUsage.retry");
|
||||
},
|
||||
get pin() {
|
||||
return i18n.t("providerUsage.pin");
|
||||
},
|
||||
get unpin() {
|
||||
return i18n.t("providerUsage.unpin");
|
||||
},
|
||||
get displayAs() {
|
||||
return i18n.t("providerUsage.displayAs");
|
||||
},
|
||||
get displayUsed() {
|
||||
return i18n.t("providerUsage.displayUsed");
|
||||
},
|
||||
get displayRemaining() {
|
||||
return i18n.t("providerUsage.displayRemaining");
|
||||
},
|
||||
get showInSidebar() {
|
||||
return i18n.t("providerUsage.showInSidebar");
|
||||
},
|
||||
get showInSidebarHint() {
|
||||
return i18n.t("providerUsage.showInSidebarHint");
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { usageCopy } from "./copy";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
@@ -109,7 +108,7 @@ it("says that a usage window percentage is consumed", () => {
|
||||
it("translates the usage refresh control into Japanese", async () => {
|
||||
try {
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(usageCopy.refresh).toBe("更新");
|
||||
expect(i18n.t("providerUsage.refresh")).toBe("更新");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
@@ -118,8 +117,8 @@ it("translates the usage refresh control into Japanese", async () => {
|
||||
it("translates the neighboring usage percentage settings", async () => {
|
||||
try {
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(usageCopy.displayUsed).toBe("使用済み");
|
||||
expect(usageCopy.displayRemaining).toBe("残り");
|
||||
expect(i18n.t("providerUsage.displayUsed")).toBe("使用済み");
|
||||
expect(i18n.t("providerUsage.displayRemaining")).toBe("残り");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageBalanceUnit } from "./types";
|
||||
@@ -11,17 +12,17 @@ const providerUsageLabels = {
|
||||
monthly_credits: { label: "Monthly credits", key: "providerUsage.labels.monthlyCredits" },
|
||||
} as const;
|
||||
|
||||
export function formatProviderUsageLabel(id: string, label: string): string {
|
||||
export function formatProviderUsageLabel(id: string, label: string, t: TFunction = i18n.t): string {
|
||||
const reviewPrefix = "Code review · ";
|
||||
if (id.startsWith("code_review:") && label.startsWith(reviewPrefix)) {
|
||||
const periodId = id.slice("code_review:".length);
|
||||
const periodLabel = label.slice(reviewPrefix.length);
|
||||
return `${i18n.t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel)}`;
|
||||
return `${t(providerUsageLabels.code_review.key)} · ${formatProviderUsageLabel(periodId, periodLabel, t)}`;
|
||||
}
|
||||
const standard =
|
||||
providerUsageLabels[id as keyof typeof providerUsageLabels] ??
|
||||
(id === "five_hour" ? providerUsageLabels.session : undefined);
|
||||
return standard?.label === label ? i18n.t(standard.key) : label;
|
||||
return standard?.label === label ? t(standard.key) : label;
|
||||
}
|
||||
|
||||
export function clampPct(value: number): number {
|
||||
@@ -41,11 +42,12 @@ export function formatDisplayPct(
|
||||
value: number,
|
||||
displayAs: UsageDisplayAs,
|
||||
locale?: string,
|
||||
t: TFunction = i18n.t,
|
||||
): string {
|
||||
const percentage = formatPct(value, locale);
|
||||
return displayAs === "used"
|
||||
? i18n.t("providerUsage.values.used", { percentage })
|
||||
: i18n.t("providerUsage.values.remaining", { amount: percentage });
|
||||
? t("providerUsage.values.used", { percentage })
|
||||
: t("providerUsage.values.remaining", { amount: percentage });
|
||||
}
|
||||
|
||||
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
|
||||
@@ -66,22 +68,22 @@ function relativeDuration(iso: string, now: number): RelativeDuration | null {
|
||||
return { unit: "minutes", count: diffMinutes };
|
||||
}
|
||||
|
||||
function formatRelativeDuration(duration: RelativeDuration): string | null {
|
||||
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
|
||||
switch (duration.unit) {
|
||||
case "now":
|
||||
return null;
|
||||
case "days":
|
||||
return i18n.t("providerUsage.duration.days", {
|
||||
return t("providerUsage.duration.days", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
case "hours":
|
||||
return i18n.t("providerUsage.duration.hours", {
|
||||
return t("providerUsage.duration.hours", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
case "minutes":
|
||||
return i18n.t("providerUsage.duration.minutes", {
|
||||
return t("providerUsage.duration.minutes", {
|
||||
count: duration.count,
|
||||
value: formatCount(duration.count),
|
||||
});
|
||||
@@ -92,49 +94,55 @@ export function formatResetLabel(
|
||||
iso: string | null | undefined,
|
||||
retained = false,
|
||||
now = Date.now(),
|
||||
t: TFunction = i18n.t,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso, now);
|
||||
if (!rel) return null;
|
||||
if (rel.unit === "now" && retained) return i18n.t("providerUsage.timing.resetTimePassed");
|
||||
if (rel.unit === "now") return i18n.t("providerUsage.timing.resettingNow");
|
||||
const duration = formatRelativeDuration(rel);
|
||||
return duration ? i18n.t("providerUsage.timing.resetsIn", { duration }) : null;
|
||||
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
|
||||
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
|
||||
const duration = formatRelativeDuration(rel, t);
|
||||
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
|
||||
}
|
||||
|
||||
export function formatRunsOutLabel(
|
||||
iso: string | null | undefined,
|
||||
now = Date.now(),
|
||||
t: TFunction = i18n.t,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso, now);
|
||||
if (!rel) return null;
|
||||
if (rel.unit === "now") return i18n.t("providerUsage.timing.runsOutNow");
|
||||
const duration = formatRelativeDuration(rel);
|
||||
return duration ? i18n.t("providerUsage.timing.runsOutIn", { duration }) : null;
|
||||
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
|
||||
const duration = formatRelativeDuration(rel, t);
|
||||
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
|
||||
}
|
||||
|
||||
export function formatAgo(iso: string | null | undefined, now = Date.now()): string | null {
|
||||
export function formatAgo(
|
||||
iso: string | null | undefined,
|
||||
now = Date.now(),
|
||||
t: TFunction = i18n.t,
|
||||
): string | null {
|
||||
if (!iso) return null;
|
||||
const diffMs = now - new Date(iso).getTime();
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs < 60_000) return i18n.t("providerUsage.timing.justNow");
|
||||
if (diffMs < 60_000) return t("providerUsage.timing.justNow");
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) {
|
||||
return i18n.t("providerUsage.timing.daysAgo", {
|
||||
return t("providerUsage.timing.daysAgo", {
|
||||
count: diffDays,
|
||||
value: formatCount(diffDays),
|
||||
});
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
return i18n.t("providerUsage.timing.hoursAgo", {
|
||||
return t("providerUsage.timing.hoursAgo", {
|
||||
count: diffHours,
|
||||
value: formatCount(diffHours),
|
||||
});
|
||||
}
|
||||
return i18n.t("providerUsage.timing.minutesAgo", {
|
||||
return t("providerUsage.timing.minutesAgo", {
|
||||
count: diffMinutes,
|
||||
value: formatCount(diffMinutes),
|
||||
});
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useHostUsage } from "./queries";
|
||||
import { UsageSection } from "./usage-section";
|
||||
|
||||
/** A host's usage reports, for its settings page. */
|
||||
export function HostUsageSection({ serverId }: { serverId: string }) {
|
||||
useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { view, refresh } = useHostUsage(serverId);
|
||||
const { display } = useUsagePreferences(serverId);
|
||||
return (
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
title={usageCopy.planUsage}
|
||||
title={t("providerUsage.title")}
|
||||
view={view}
|
||||
display={display}
|
||||
onRefresh={refresh}
|
||||
|
||||
@@ -346,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: [],
|
||||
@@ -355,7 +355,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
|
||||
retained: true,
|
||||
messages: ["Rate limited"],
|
||||
});
|
||||
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
|
||||
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined }, i18n.t)).toEqual({
|
||||
windows: [],
|
||||
balances: [],
|
||||
details: [],
|
||||
@@ -364,7 +364,7 @@ it("resolves retained usage and successful time without inventing old-daemon his
|
||||
retained: false,
|
||||
messages: ["Rate limited"],
|
||||
});
|
||||
expect(resolveUsageReport(success)).toEqual({
|
||||
expect(resolveUsageReport(success, i18n.t)).toEqual({
|
||||
windows: success.report.status === "available" ? success.report.windows : [],
|
||||
balances: [],
|
||||
details: [],
|
||||
@@ -392,7 +392,7 @@ it("orders failed polls by attempt time even when successful data is older", ()
|
||||
it("available and retained usage show the same successful completion time", () => {
|
||||
const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" };
|
||||
if (success.report.status !== "available") throw new Error("fixture must be available");
|
||||
expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
|
||||
expect(resolveUsageReport(success, i18n.t).successfulAt).toBe("2026-01-01T00:00:15.000Z");
|
||||
const failure: UsageReportEntry = {
|
||||
...success,
|
||||
successfulAt: undefined,
|
||||
@@ -400,6 +400,8 @@ it("available and retained usage show the same successful completion time", () =
|
||||
report: { status: "error", error: "Rate limited" },
|
||||
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
|
||||
};
|
||||
expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
|
||||
expect(resolveUsageReport(failure, i18n.t).successfulAt).toBe(
|
||||
resolveUsageReport(success, i18n.t).successfulAt,
|
||||
);
|
||||
expect(upsertReport([failure], success)).toEqual([failure]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
@@ -102,16 +104,26 @@ export interface UsageQueryState {
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export function resolveUsageView(input: {
|
||||
hostLabel: string;
|
||||
isConnected: boolean;
|
||||
supportsUsage: boolean;
|
||||
query: UsageQueryState | undefined;
|
||||
}): UsageView {
|
||||
export function resolveUsageView(
|
||||
input: {
|
||||
hostLabel: string;
|
||||
isConnected: boolean;
|
||||
supportsUsage: boolean;
|
||||
query: UsageQueryState | undefined;
|
||||
},
|
||||
t: TFunction = i18n.t,
|
||||
): UsageView {
|
||||
const { hostLabel, isConnected, supportsUsage, query } = input;
|
||||
if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) };
|
||||
if (!isConnected)
|
||||
return {
|
||||
kind: "unavailable",
|
||||
message: t("providerUsage.hostUnavailable", { host: hostLabel }),
|
||||
};
|
||||
if (!supportsUsage) {
|
||||
return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) };
|
||||
return {
|
||||
kind: "unavailable",
|
||||
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
|
||||
};
|
||||
}
|
||||
if (query?.data) {
|
||||
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
|
||||
@@ -132,17 +144,20 @@ export type AgentUsageView =
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; reports: UsageReportEntry[] };
|
||||
|
||||
export function resolveAgentUsageView(input: {
|
||||
canReport: boolean;
|
||||
query: UsageQueryState;
|
||||
}): AgentUsageView {
|
||||
export function resolveAgentUsageView(
|
||||
input: {
|
||||
canReport: boolean;
|
||||
query: UsageQueryState;
|
||||
},
|
||||
t: TFunction = i18n.t,
|
||||
): AgentUsageView {
|
||||
const { canReport, query } = input;
|
||||
if (!canReport) return { kind: "none" };
|
||||
// A failed request keeps the reports from before it, or those that streamed in before it failed;
|
||||
// shown alone they would pass for the agent's complete, current usage.
|
||||
if (query.error) {
|
||||
const reason = query.error instanceof Error ? query.error.message : String(query.error);
|
||||
return { kind: "error", message: usageCopy.agentError(reason) };
|
||||
return { kind: "error", message: t("providerUsage.agentError", { reason }) };
|
||||
}
|
||||
if (query.data) {
|
||||
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
|
||||
@@ -188,7 +203,7 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
|
||||
}
|
||||
|
||||
/** Current outcome owns messages; only a successful poll owns values and freshness. */
|
||||
export function resolveUsageReport(entry: UsageReportEntry) {
|
||||
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
|
||||
const available = entry.report.status === "available";
|
||||
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
||||
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
||||
@@ -198,10 +213,12 @@ export function resolveUsageReport(entry: UsageReportEntry) {
|
||||
if (!available) {
|
||||
if (entry.loginErrors)
|
||||
messages.push(
|
||||
...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
|
||||
...entry.loginErrors.map(
|
||||
(login) => `${login.harness}: ${usageProblemText(login.report, t)}`,
|
||||
),
|
||||
);
|
||||
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
|
||||
else messages.push(usageProblemText(entry.report));
|
||||
else messages.push(usageProblemText(entry.report, t));
|
||||
}
|
||||
return {
|
||||
windows: content?.windows ?? [],
|
||||
@@ -218,8 +235,8 @@ export function resolveUsageReport(entry: UsageReportEntry) {
|
||||
};
|
||||
}
|
||||
|
||||
function usageProblemText(report: UsageReport): string {
|
||||
function usageProblemText(report: UsageReport, t: TFunction): string {
|
||||
if (report.status === "error") return report.error;
|
||||
if (report.status === "unavailable") return usageCopy.problem(report.problem);
|
||||
if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SettingsRow, SettingsSwitch } from "@/components/settings";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
@@ -18,10 +17,14 @@ const ThemedSettings = withUnistyles(Settings);
|
||||
|
||||
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
|
||||
export function UsageOptions() {
|
||||
useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" },
|
||||
{ value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" },
|
||||
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
|
||||
{
|
||||
value: "remaining",
|
||||
label: t("providerUsage.displayRemaining"),
|
||||
testID: "usage-display-remaining",
|
||||
},
|
||||
];
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { display } = useUsagePreferences();
|
||||
@@ -30,7 +33,7 @@ export function UsageOptions() {
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<ToolbarButton
|
||||
kind="menu"
|
||||
label={usageCopy.options}
|
||||
label={t("providerUsage.options")}
|
||||
compact={compact}
|
||||
testID="usage-options-menu"
|
||||
>
|
||||
@@ -42,19 +45,19 @@ 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={displayAsOptions}
|
||||
value={display.displayAs}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -5,7 +5,6 @@ import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usageCopy } from "./copy";
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
@@ -15,12 +14,12 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
* header's island, so its highlight is round.
|
||||
*/
|
||||
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
|
||||
useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
return (
|
||||
<ToolbarButton
|
||||
label={usageCopy.refreshAll}
|
||||
label={t("providerUsage.refreshAll")}
|
||||
compact={compact}
|
||||
disabled={busy}
|
||||
onPress={onRefresh}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -4,7 +4,6 @@ import { View } from "react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostSelection } from "./hosts";
|
||||
import type { UsageHost } from "./model";
|
||||
@@ -22,12 +21,12 @@ interface UsageModalProps {
|
||||
* title row.
|
||||
*/
|
||||
export function UsageModal(props: UsageModalProps) {
|
||||
useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { serverId, connectedHosts, select } = useUsageHostSelection();
|
||||
if (!serverId) {
|
||||
return (
|
||||
<UsageModalFrame {...props}>
|
||||
<UsageMessage text={usageCopy.noHosts} />
|
||||
<UsageMessage text={t("providerUsage.noHosts")} />
|
||||
</UsageModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker";
|
||||
|
||||
/** Re-renders a mounted usage label when its relative time can change. */
|
||||
export function useRelativeTimeTick({ enabled }: { enabled: boolean }): void {
|
||||
const [, tick] = useReducer((value: number) => value + 1, 0);
|
||||
/** The current time as a render input, updated while a relative-time label is mounted. */
|
||||
export function useRelativeTimeTick({ enabled }: { enabled: boolean }): number {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
return subscribeToRelativeTimeTick("minute", tick);
|
||||
return subscribeToRelativeTimeTick("minute", () => setNow(Date.now()));
|
||||
}, [enabled]);
|
||||
return now;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { usageCopy } from "./copy";
|
||||
import {
|
||||
formatDisplayPct,
|
||||
formatProviderUsageLabel,
|
||||
@@ -46,24 +45,24 @@ export function UsageWindowBar({
|
||||
pinLabel: string;
|
||||
pinTestID: string;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const shownPct = displayPercent(window, displayAs);
|
||||
const tone = windowTone(window);
|
||||
|
||||
const now = useRelativeTimeTick({ enabled: window.runsOutAt != null || window.resetsAt != null });
|
||||
const isAtRisk =
|
||||
window.runsOutAt != null &&
|
||||
window.shortfallPct != null &&
|
||||
(!retained || Date.parse(window.runsOutAt) > Date.now());
|
||||
useRelativeTimeTick({ enabled: (isAtRisk ? window.runsOutAt : window.resetsAt) != null });
|
||||
(!retained || Date.parse(window.runsOutAt) > now);
|
||||
const trailing = isAtRisk
|
||||
? formatRunsOutLabel(window.runsOutAt)
|
||||
: formatResetLabel(window.resetsAt, retained);
|
||||
? formatRunsOutLabel(window.runsOutAt, now, t)
|
||||
: formatResetLabel(window.resetsAt, retained, now, t);
|
||||
|
||||
const value =
|
||||
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage) : "—";
|
||||
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
|
||||
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
|
||||
const content = {
|
||||
label: formatProviderUsageLabel(window.id, window.label),
|
||||
label: formatProviderUsageLabel(window.id, window.label, t),
|
||||
value,
|
||||
trailing,
|
||||
isAtRisk,
|
||||
@@ -152,6 +151,7 @@ function WindowRowContent({
|
||||
const ThemedPin = withUnistyles(Pin);
|
||||
|
||||
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const iconMapping = useMemo(
|
||||
() => (theme: { colors: { foregroundMuted: string } }) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -170,7 +170,9 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig, configDefaults } from "vitest/config";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { transformSync } from "@babel/core";
|
||||
|
||||
// Set this before Vite resolves dependencies and Vitest starts its workers.
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
@@ -16,6 +17,25 @@ const resolvePackageEntry = (packageName: string) => {
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "compile-usage-components",
|
||||
enforce: "pre",
|
||||
transform(source, id) {
|
||||
// These labels must react to time and language in the compiled app too.
|
||||
if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return;
|
||||
const result = transformSync(source, {
|
||||
filename: id,
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
parserOpts: { plugins: ["typescript", "jsx"] },
|
||||
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
|
||||
sourceMaps: true,
|
||||
});
|
||||
if (result?.code) return { code: result.code, map: result.map };
|
||||
},
|
||||
},
|
||||
],
|
||||
test: {
|
||||
environment: "node",
|
||||
exclude: [...configDefaults.exclude, "e2e/**"],
|
||||
@@ -82,6 +102,7 @@ export default defineConfig({
|
||||
// Bundle the CJS dependencies of the excluded gesture-handler package for the browser.
|
||||
include: [
|
||||
"react/jsx-runtime",
|
||||
"react/compiler-runtime",
|
||||
"react-native-gesture-handler > hoist-non-react-statics",
|
||||
"react-native-gesture-handler > invariant",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user