Files
paseo/packages/app/src/components/context-window-meter.tsx
T
Slava GoltserandGitHub c7406a415d fix(app): handle CancelledError from cancelled provider usage queries (#1885)
Move canFetch guard before first await so disabled refresh bails out synchronously instead of entering async path. Add .catch() swallow for fire-and-forget call to prevent unhandled promise rejections when react-query cancels pending queries during rapid tooltip open/close cycles.
2026-07-09 05:00:57 +08:00

290 lines
8.7 KiB
TypeScript

import { useCallback, useState } from "react";
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
maxTokens: number | null;
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 6;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
function isValidUsedTokens(value: number): boolean {
return Number.isFinite(value) && value >= 0;
}
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
return null;
}
return (usedTokens / maxTokens) * 100;
}
function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function formatSessionCost(value: number): string | null {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
if (value < 0.01) {
return `$${value.toFixed(4)}`;
}
return `$${value.toFixed(2)}`;
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
): { progress: string; track: string } {
const track = theme.colors.surface3;
if (percentage > 90) {
return { progress: theme.colors.destructive, track };
}
if (percentage >= 70) {
return { progress: theme.colors.palette.amber[500], track };
}
return { progress: theme.colors.foregroundMuted, track };
}
function getMeterGeometry(showPercentage: boolean) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
center: COMPACT_CENTER,
radius: COMPACT_RADIUS,
strokeWidth: COMPACT_STROKE_WIDTH,
circumference: COMPACT_CIRCUMFERENCE,
containerStyle: styles.containerWithLabel,
};
}
return {
svgSize: SVG_SIZE,
center: CENTER,
radius: RADIUS,
strokeWidth: STROKE_WIDTH,
circumference: CIRCUMFERENCE,
containerStyle: styles.container,
};
}
export function ContextWindowMeter({
maxTokens,
usedTokens,
totalCostUsd,
showPercentage = false,
serverId,
provider,
pending = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
if (nextOpen) {
void refreshProviderUsage().catch(() => {});
}
},
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage);
// No usage yet: reserve the footprint with a track-only ring while a session is
// active so the real ring fades in without shifting siblings. Render nothing when
// no usage is expected.
if (percentage === null || maxTokens === null || usedTokens === null) {
if (!pending) {
return null;
}
return (
<View style={geometry.containerStyle}>
<Svg
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={geometry.center}
cy={geometry.center}
r={geometry.radius}
fill="none"
stroke={theme.colors.surface3}
strokeWidth={geometry.strokeWidth}
/>
</Svg>
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
</View>
);
}
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return (
<Tooltip
open={isTooltipOpen}
onOpenChange={handleTooltipOpenChange}
delayDuration={0}
enabledOnDesktop
enabledOnMobile
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
})}
>
<Svg
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.track}
strokeWidth={strokeWidth}
/>
<Circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.progress}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</Svg>
{showPercentage ? (
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
) : null}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
<Text style={styles.tooltipText}>
{t("contextWindow.used", { percentage: roundedPercentage })}
</Text>
<Text style={styles.tooltipDetail}>
{t("contextWindow.tokens", {
used: formatTokenCount(usedTokens),
max: formatTokenCount(maxTokens),
})}
</Text>
{formattedSessionCost ? (
<Text style={styles.tooltipDetail}>
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
</View>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
svg: {
transform: [{ rotate: "-90deg" }],
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
skeletonLabel: {
width: 22,
height: theme.fontSize.sm,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
tooltipContent: {
gap: theme.spacing[1.5],
minWidth: 200,
},
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
tooltipDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
}));