* fix(app): refine compact composer controls and native scrolling Consolidate responsive agent controls and model browsing inside the compact sheet. Preserve manual native scrolling by suspending sticky bottom maintenance while the user owns the viewport. * fix(app): suppress recoverable resume refresh errors Resume revalidation can race host reconnection and reject while the host is offline. Treat the refresh as deferred so development LogBox does not surface a recoverable disconnect. * fix(app): stabilize compact model sheet loading Let the model list consume the sheet space above persistent controls as the sheet expands. Keep unresolved defaults in a loading state so Select model only represents a genuinely empty selection. * fix(app): preserve route anchors during native scroll * fix(app): preserve native scroll intent and sheet dismissal * fix(app): keep compact sheet styles composable
292 lines
8.9 KiB
TypeScript
292 lines
8.9 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;
|
|
/** 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,
|
|
serverId,
|
|
provider,
|
|
pending = false,
|
|
glyphSize,
|
|
}: 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, 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}`}
|
|
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,
|
|
},
|
|
}));
|