Files
paseo/packages/app/src/usage/card.tsx
T
paseo-bot[bot]andMichael Wu eda3e4318f 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>
2026-10-19 18:01:45 +02:00

385 lines
11 KiB
TypeScript

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 {
extraMutedIconColorMapping,
iconButtonChromeGlyphSize,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import type { TFunction } from "i18next";
import { UsageBalanceBar } from "./balance-bar";
import type { UsageDisplay } from "./display";
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, t: TFunction): string | null {
if (report.status === "available") return null;
return report.status === "error"
? t("providerUsage.status.error")
: t("providerUsage.status.unavailable");
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
export function UsageCard({
serverId,
agentId,
entry,
display,
pinnable,
refreshable,
compact = false,
}: {
serverId: string;
agentId?: string;
entry: UsageReportEntry;
display: UsageDisplay;
/** Whether each window row pins the window to the sidebar. */
pinnable: boolean;
/** Whether the header has a Refresh button. Without one the freshness shows on the card. */
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, t);
const footer = entry.account.label ?? null;
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
resolveUsageReport(entry, t);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
[compact],
);
const dotStyle = useMemo(
() => [
styles.statusDot,
usage.status === "available" && styles.statusDotAvailable,
usage.status === "error" && styles.statusDotError,
],
[usage.status],
);
return (
<View style={containerStyle} testID={`usage-report-${entry.id}`}>
<View style={styles.header}>
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{planLabel ? <StatusBadge label={planLabel} variant="muted" size="xs" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
{refreshable ? (
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
successfulAt={successfulAt}
retained={retained}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
/>
) : null}
</View>
{messages.length > 0 ? (
<Text style={styles.error} testID="usage-login-error">
{messages.join("\n")}
</Text>
) : null}
{windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{windows.map((window) => (
<CardWindowBar
key={window.id}
entry={entry}
window={window}
retained={retained}
display={display}
pinnable={pinnable}
/>
))}
{balances.map((balance) => (
<UsageBalanceBar key={balance.id} balance={balance} retained={retained} />
))}
</View>
) : null}
{details.length > 0 ? (
<View style={styles.details}>
{details.map((detail) => (
<View key={detail.id} style={styles.detailRow}>
<Text style={styles.detailLabel} numberOfLines={1}>
{detail.label}
</Text>
<Text style={styles.detailValue} numberOfLines={1}>
{detail.value}
</Text>
</View>
))}
</View>
) : null}
{footer || showsFreshnessInline ? (
<View style={styles.footerRow}>
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
{showsFreshnessInline ? (
<UsageFreshness
successfulAt={successfulAt}
retained={retained}
style={styles.freshness}
testID="usage-freshness"
/>
) : null}
</View>
) : null}
{refreshState === "failed" ? (
<Text style={styles.error} testID="usage-refresh-error">
{t("providerUsage.refreshFailed")}
</Text>
) : null}
</View>
);
}
function CardWindowBar({
entry,
window,
display,
pinnable,
retained,
}: {
retained: boolean;
entry: UsageReportEntry;
window: UsageWindow;
display: UsageDisplay;
pinnable: boolean;
}) {
const { t } = useTranslation();
const pin = useMemo(
() => ({ sourceId: entry.sourceId, windowId: window.id }),
[entry.sourceId, window.id],
);
const { togglePin } = display;
const toggle = useCallback(() => togglePin(pin), [pin, togglePin]);
return (
<UsageWindowBar
window={window}
retained={retained}
displayAs={display.displayAs}
pinnable={pinnable}
pinned={display.isPinned(pin)}
onTogglePin={toggle}
pinLabel={`${t("providerUsage.pin")} ${entry.sourceLabel} ${formatProviderUsageLabel(window.id, window.label, t)}`}
pinTestID={`usage-pin-${entry.sourceId}-${window.id}`}
/>
);
}
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
function UsageRefreshButton({
sourceLabel,
successfulAt,
retained,
refreshState,
onRefresh,
compact,
}: {
sourceLabel: string;
successfulAt: string | null;
retained: boolean;
refreshState: UsageRefresh;
onRefresh: () => void;
compact: boolean;
}) {
const { t } = useTranslation();
const isPending = refreshState === "pending";
const iconSize = paneContentToolbarIconSize(compact);
const freshness = useMemo(
() => (
<UsageFreshness
successfulAt={successfulAt}
retained={retained}
style={styles.tooltipText}
testID="usage-freshness-tooltip"
/>
),
[successfulAt, retained],
);
return (
<ToolbarButton
label={`${t("providerUsage.refresh")} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
disabled={isPending}
onPress={onRefresh}
style={compact ? styles.refreshButtonCompact : styles.refreshButton}
testID="usage-refresh"
>
{isPending ? (
<ThemedLoadingSpinner size={iconSize} uniProps={extraMutedIconColorMapping} />
) : (
<ThemedRotateCw size={iconSize} uniProps={extraMutedIconColorMapping} />
)}
</ToolbarButton>
);
}
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
function UsageFreshness({
successfulAt,
retained,
style,
testID,
}: {
successfulAt: string | null;
retained: boolean;
style: StyleProp<TextStyle>;
testID: string;
}) {
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}>
{freshness}
</Text>
);
}
// The Refresh glyph lands on the card's right rail and the header keeps its text height;
// the button's larger hitbox overhangs both instead of pushing them.
function iconHitboxOverhang(compact: boolean) {
const overhang =
(smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2;
return { marginRight: -overhang, marginVertical: -overhang };
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
// The gap is one step under the card padding: window rows add their own vertical padding.
containerPadded: {
gap: theme.spacing[3],
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[3],
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.foregroundMuted,
},
statusDotAvailable: {
backgroundColor: theme.colors.statusSuccess,
},
statusDotError: {
backgroundColor: theme.colors.statusDanger,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
// Window rows carry their own vertical padding, which already separates them.
bars: {
gap: theme.spacing[1],
},
details: {
gap: theme.spacing[1],
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing[2],
},
detailLabel: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
detailValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
footerRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
footer: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
freshness: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipText: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
},
refreshButton: iconHitboxOverhang(false),
refreshButtonCompact: iconHitboxOverhang(true),
}));