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.
290 lines
8.7 KiB
TypeScript
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,
|
|
},
|
|
}));
|