From efef52202115c3806897d3f4b3b1eb26f787a551 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 30 Jul 2026 19:01:42 +0000 Subject: [PATCH] fix(elements): clamp context usage percentage to valid range --- packages/elements/__tests__/context.test.tsx | 42 ++++++++++++++++++++ packages/elements/src/context.tsx | 20 +++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/elements/__tests__/context.test.tsx b/packages/elements/__tests__/context.test.tsx index 2c2d6fbc..da0a357c 100644 --- a/packages/elements/__tests__/context.test.tsx +++ b/packages/elements/__tests__/context.test.tsx @@ -33,6 +33,48 @@ describe("context", () => { expect(screen.getByText("50%")).toBeInTheDocument(); }); + it.each([ + { maxTokens: 0, usedTokens: 0 }, + { maxTokens: 0, usedTokens: 50 }, + { maxTokens: 100, usedTokens: -1 }, + ])( + "clamps to 0% for usedTokens=$usedTokens maxTokens=$maxTokens", + ({ maxTokens, usedTokens }) => { + const { container } = render( + + + + ); + expect(screen.getByText("0%")).toBeInTheDocument(); + const ring = container.querySelector("circle[stroke-dashoffset]"); + // Empty ring: offset equals the full circumference (2π × r=10 ≈ 62.83) + expect(Number(ring?.getAttribute("stroke-dashoffset"))).toBeCloseTo( + 62.83, + 1 + ); + } + ); + + it("clamps over-budget usage to a full 100% indicator", () => { + const { container } = render( + + + + + + + ); + expect(screen.getAllByText("100%").length).toBeGreaterThan(0); + const ring = container.querySelector("circle[stroke-dashoffset]"); + // Full ring: zero offset + expect(Number(ring?.getAttribute("stroke-dashoffset"))).toBeCloseTo(0); + const bar = document.querySelector( + '[data-slot="progress-indicator"]' + ); + // Full bar: no leftward translation (150% used to yield translateX(50%)) + expect(bar?.style.transform).toBe("translateX(0%)"); + }); + it("throws error when components used outside Context provider", () => { // Suppress console.error for this test const spy = vi.spyOn(console, "error").mockImplementation(vi.fn()); diff --git a/packages/elements/src/context.tsx b/packages/elements/src/context.tsx index 53e28851..6daf0000 100644 --- a/packages/elements/src/context.tsx +++ b/packages/elements/src/context.tsx @@ -1,5 +1,8 @@ "use client"; +import type { LanguageModelUsage } from "ai"; +import type { ComponentProps } from "react"; + import { Button } from "@repo/shadcn-ui/components/ui/button"; import { HoverCard, @@ -8,8 +11,6 @@ import { } from "@repo/shadcn-ui/components/ui/hover-card"; import { Progress } from "@repo/shadcn-ui/components/ui/progress"; import { cn } from "@repo/shadcn-ui/lib/utils"; -import type { LanguageModelUsage } from "ai"; -import type { ComponentProps } from "react"; import { createContext, useContext, useMemo } from "react"; import { getUsage } from "tokenlens"; @@ -19,6 +20,15 @@ const ICON_VIEWBOX = 24; const ICON_CENTER = 12; const ICON_STROKE_WIDTH = 2; +const clampUsedPercent = (usedTokens: number, maxTokens: number) => { + if ( + !(Number.isFinite(usedTokens) && Number.isFinite(maxTokens)) || + maxTokens <= 0 + ) { + return 0; + } + return Math.min(Math.max(usedTokens / maxTokens, 0), 1); +}; type ModelId = string; interface ContextSchema { @@ -64,7 +74,7 @@ export const Context = ({ const ContextIcon = () => { const { usedTokens, maxTokens } = useContextValue(); const circumference = 2 * Math.PI * ICON_RADIUS; - const usedPercent = usedTokens / maxTokens; + const usedPercent = clampUsedPercent(usedTokens, maxTokens); const dashOffset = circumference * (1 - usedPercent); return ( @@ -106,7 +116,7 @@ export type ContextTriggerProps = ComponentProps; export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => { const { usedTokens, maxTokens } = useContextValue(); - const usedPercent = usedTokens / maxTokens; + const usedPercent = clampUsedPercent(usedTokens, maxTokens); const renderedPercent = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, style: "percent", @@ -146,7 +156,7 @@ export const ContextContentHeader = ({ ...props }: ContextContentHeaderProps) => { const { usedTokens, maxTokens } = useContextValue(); - const usedPercent = usedTokens / maxTokens; + const usedPercent = clampUsedPercent(usedTokens, maxTokens); const displayPct = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, style: "percent",