Files
paseo/packages/app/src/components/context-window-meter.tsx
T
paseo-bot[bot] 3b41183606 fix(app): start the context meter ring at twelve o'clock on web (#5598)
The ring's quarter-turn lived in a Unistyles style on the react-native-svg
root. On web, Unistyles only turns styles into classes for the React Native
components its Babel plugin rewrites, so the Svg received an empty style and
the arc started at three o'clock. The rotation now lives on the progress
circle as an SVG transform, which renders the same on web and native.

Closes #5596

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 04:57:36 +02:00

259 lines
8.0 KiB
TypeScript

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 { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
maxTokens: number | null;
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
}
const SVG_SIZE = 14;
const COMPACT_SVG_SIZE = 12;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const COMPACT_RADIUS = 5;
const STROKE_WIDTH = 2;
const COMPACT_STROKE_WIDTH = 1.75;
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, glyphSize?: number) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
center: COMPACT_CENTER,
radius: COMPACT_RADIUS,
strokeWidth: COMPACT_STROKE_WIDTH,
circumference: COMPACT_CIRCUMFERENCE,
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
return {
svgSize: resolvedSize,
center: resolvedSize / 2,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
strokeWidth: resolvedStrokeWidth,
circumference: Math.PI * (resolvedSize - resolvedStrokeWidth),
containerStyle: styles.container,
};
}
export function ContextWindowMeter({
maxTokens,
usedTokens,
totalCostUsd,
showPercentage = false,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const geometry = getMeterGeometry(showPercentage, glyphSize);
// 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}`}
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 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}`}
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 strokes start at three o'clock; the ring reads clockwise from twelve.
transform={`rotate(-90 ${center} ${center})`}
/>
</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}
</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,
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
skeletonLabel: {
width: 22,
height: theme.fontSize.base,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
tooltipContent: {
gap: theme.spacing[1.5],
minWidth: 200,
},
tooltipTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: theme.fontSize.base * 1.4,
},
tooltipDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
}));