feat(site): replace usage bars with ring indicators (#25708)

Replaces the linear progress bars and text labels in the sidebar footer
usage trigger with SVG donut ring charts that show the section icon
centered inside each ring.

## Changes

- **`SvgRingProgress`**: shared SVG component used by both
`UsageIndicator` and `ContextUsageIndicator`
- Ring colors follow the existing severity system
(normal/warning/exceeded)
- Hover tooltips show "Spend $12.50" and "Workspaces 30/100"
- Dropdown menu content unchanged; full usage details still appear on
click
- Removed dead `summaryValue` field and `size="compact"` variant
- Updated stories to cover ring trigger rendering and dropdown usage
details

> Generated by Coder Agents on behalf of @tracyjohnsonux
This commit is contained in:
TJ
2026-05-26 22:01:31 -07:00
committed by GitHub
parent 2afb33ac5e
commit 916094c71c
4 changed files with 161 additions and 101 deletions
@@ -14,6 +14,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { isMobileViewport } from "#/utils/mobile";
import { SvgRingProgress } from "./SvgRingProgress";
export interface AgentContextUsage {
readonly usedTokens?: number;
@@ -71,8 +72,6 @@ const basename = (path: string): string => {
const RING_SIZE = 18;
const RING_STROKE = 2.5;
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
// Delay before the popover closes after the mouse leaves, giving
// the user time to move into the popover content.
@@ -122,8 +121,6 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
const clampedPercent = hasPercent
? Math.min(Math.max(percentUsed, 0), 100)
: 100;
const dashOffset =
RING_CIRCUMFERENCE - (clampedPercent / 100) * RING_CIRCUMFERENCE;
const toneClassName = getIndicatorToneClassName(percentUsed);
const ariaLabel = hasPercent
? `Context usage ${percentLabel}. ${formatTokenCount(usedTokens)} of ${formatTokenCount(contextLimitTokens)} tokens used.`
@@ -225,33 +222,14 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
aria-label={ariaLabel}
className="relative inline-flex size-7 shrink-0 items-center justify-center rounded-full border-none bg-transparent p-0 outline-none transition-colors hover:bg-surface-secondary/60 focus-visible:ring-2 focus-visible:ring-content-link/40"
>
<svg
className={cn("size-icon-sm -rotate-90", toneClassName)}
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
aria-hidden
>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
className="stroke-content-secondary/25"
/>
<circle
cx={RING_SIZE / 2}
cy={RING_SIZE / 2}
r={RING_RADIUS}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="stroke-current transition-all duration-300 ease-out"
style={{
strokeDasharray: `${RING_CIRCUMFERENCE} ${RING_CIRCUMFERENCE}`,
strokeDashoffset: dashOffset,
}}
/>
</svg>
<SvgRingProgress
size={RING_SIZE}
strokeWidth={RING_STROKE}
percent={clampedPercent}
trackClassName="stroke-content-secondary/25"
progressClassName="stroke-current"
className={cn("size-icon-sm", toneClassName)}
/>
</button>
);
@@ -0,0 +1,66 @@
import type { FC } from "react";
import { cn } from "#/utils/cn";
/**
* SVG ring (donut) progress indicator.
*
* The rendered SVG is aria-hidden; callers must provide an accessible
* wrapper (e.g. a progressbar role or labeled button).
*
* @param percent - Fill percentage, clamped to [0, 100].
*/
export const SvgRingProgress: FC<{
size: number;
strokeWidth: number;
percent: number;
trackClassName?: string;
progressClassName?: string;
className?: string;
}> = ({
size,
strokeWidth,
percent,
trackClassName = "stroke-surface-tertiary",
progressClassName = "stroke-current",
className,
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const clamped = Math.min(Math.max(percent, 0), 100);
const offset = circumference * (1 - clamped / 100);
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={cn("-rotate-90", className)}
aria-hidden="true"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
className={trackClassName}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={strokeWidth}
strokeLinecap="round"
className={cn(
"transition-[stroke-dashoffset] duration-300 ease-out",
progressClassName,
)}
style={{
strokeDasharray: circumference,
strokeDashoffset: offset,
}}
/>
</svg>
);
};
@@ -180,7 +180,9 @@ export const WorkspaceQuotaOnly: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("30/100")).toBeVisible();
expect(
canvas.getByRole("progressbar", { name: "Workspace quota usage" }),
).toBeVisible();
await openUsageMenu(canvasElement);
},
};
@@ -196,33 +198,15 @@ export const UsageAndWorkspaceQuota: Story = {
const progressBars = canvas.getAllByRole("progressbar");
expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible();
expect(canvas.getByText("$12.50")).toBeVisible();
expect(canvas.getByText("30/100")).toBeVisible();
expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([
"Monthly spend usage",
"Workspace quota usage",
]);
await openUsageMenu(canvasElement);
},
};
// The tiny story covers the responsive edge case where the trigger keeps
// the bars visible and drops the numeric details.
const expectTriggerContentFits = (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
const frame = canvas.getByTestId("usage-indicator-frame");
expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible();
expect(frame.scrollWidth).toBeLessThanOrEqual(frame.clientWidth);
};
const expectTriggerDetailsHidden = (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
expect(canvas.getByText("$12.50")).not.toBeVisible();
expect(canvas.getByText("30/100")).not.toBeVisible();
};
export const TriggerTiny: Story = {
decorators: [
withUsageIndicatorFrame("w-[240px]", "usage-indicator-frame"),
@@ -230,10 +214,6 @@ export const TriggerTiny: Story = {
withWorkspaceQuota(defaultWorkspaceQuota),
withWorkspaceCount(3),
],
play: ({ canvasElement }) => {
expectTriggerContentFits(canvasElement);
expectTriggerDetailsHidden(canvasElement);
},
};
export const WorkspaceQuotaUnused: Story = {
@@ -266,7 +246,6 @@ export const WorkspaceQuotaWithoutBudget: Story = {
name: "Workspace quota usage",
});
expect(canvas.getByText("20")).toBeInTheDocument();
expect(progressbar).toHaveAttribute("aria-valuenow", "100");
await openUsageMenu(canvasElement);
@@ -27,6 +27,7 @@ import {
import { cn } from "#/utils/cn";
import { formatCostMicros } from "#/utils/currency";
import { getUsageLimitPeriodLabel } from "./ChatCostSummaryView";
import { SvgRingProgress } from "./SvgRingProgress";
type UsageSeverity = "normal" | "warning" | "exceeded";
@@ -37,7 +38,7 @@ type UsageSectionData = {
percent: number;
detail: ReactNode;
icon: ReactNode;
summaryValue: string;
hoverLabel: string;
secondaryDetail?: ReactNode;
tooltip?: ReactNode;
severity?: UsageSeverity;
@@ -82,7 +83,7 @@ export const UsageIndicator: FC = () => {
percent: getPercent(currentSpend, spendLimit),
severity: getSeverity(currentSpend, spendLimit),
icon: <CoinsIcon className="size-3.5" />,
summaryValue: formatCostMicros(currentSpend),
hoverLabel: `Spend ${formatCostMicros(currentSpend)}`,
detail: (
<>
{formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "}
@@ -110,6 +111,11 @@ export const UsageIndicator: FC = () => {
? `${formatNumber(creditsConsumed)} of ${formatNumber(quota.budget)} credits used`
: `${formatNumber(workspaceCount)} ${workspaceCount === 1 ? "workspace" : "workspaces"} using ${formatNumber(creditsConsumed)} of ${formatNumber(quota.budget)} credits`;
const workspaceHoverLabel =
quota.budget > 0
? `Workspaces ${formatNumber(creditsConsumed)}/${formatNumber(quota.budget)}`
: `Workspaces ${formatNumber(creditsConsumed)}`;
sections.push({
id: "workspace-quota",
title: "Workspace quota",
@@ -117,10 +123,7 @@ export const UsageIndicator: FC = () => {
percent: getPercent(creditsConsumed, quota.budget),
severity: getSeverity(creditsConsumed, quota.budget),
icon: <ServerIcon className="size-3.5" />,
summaryValue:
quota.budget > 0
? `${formatNumber(creditsConsumed)}/${formatNumber(quota.budget)}`
: formatNumber(creditsConsumed),
hoverLabel: workspaceHoverLabel,
detail: quotaDetail,
tooltip:
"Workspaces, stopped or running, may consume credits. Stop or delete unused ones to free quota.",
@@ -170,39 +173,70 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
);
};
const RING_SIZE = 28;
const RING_STROKE = 1;
const UsageTriggerProgress: FC<{ sections: readonly UsageSectionData[] }> = ({
sections,
}) => {
return (
<div className="flex shrink-0 flex-col gap-1">
{sections.map((section) => (
<div key={section.id} className="flex items-center gap-2">
<span
aria-hidden="true"
className={cn(
"flex shrink-0 items-center justify-center",
getTextClassName(section.severity),
)}
>
{section.icon}
</span>
<UsageProgress
ariaLabel={section.progressLabel}
percent={section.percent}
severity={section.severity}
size="compact"
className="w-20 shrink-0 [@container_(min-width:300px)]:w-24 [@container_(min-width:420px)]:w-32 [@container_(min-width:560px)]:w-40"
/>
<span
className={cn(
"hidden shrink-0 whitespace-nowrap text-xs tabular-nums [@container_(min-width:300px)]:inline",
getTextClassName(section.severity),
)}
>
{section.summaryValue}
</span>
</div>
))}
<TooltipProvider delayDuration={150}>
<div className="flex shrink-0 items-center gap-2">
{sections.map((section) => (
<Tooltip key={section.id}>
<TooltipTrigger asChild>
<div>
<UsageRingProgress
ariaLabel={section.progressLabel}
percent={section.percent}
severity={section.severity}
icon={section.icon}
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{section.hoverLabel}
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
);
};
const UsageRingProgress: FC<{
ariaLabel: string;
percent: number;
severity?: UsageSeverity;
icon: ReactNode;
}> = ({ ariaLabel, percent, severity = "normal", icon }) => {
const clampedPercent = clampPercent(percent);
return (
<div
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(clampedPercent)}
className="relative flex shrink-0 items-center justify-center"
style={{ width: RING_SIZE, height: RING_SIZE }}
>
<SvgRingProgress
size={RING_SIZE}
strokeWidth={RING_STROKE}
percent={clampedPercent}
progressClassName={getRingStrokeClassName(severity)}
/>
<span
aria-hidden="true"
className={cn(
"absolute inset-0 flex items-center justify-center",
getTextClassName(severity),
)}
>
{icon}
</span>
</div>
);
};
@@ -277,15 +311,8 @@ const UsageProgress: FC<{
ariaLabel: string;
percent: number;
severity?: UsageSeverity;
size?: "default" | "compact";
className?: string;
}> = ({
ariaLabel,
percent,
severity = "normal",
size = "default",
className,
}) => {
}> = ({ ariaLabel, percent, severity = "normal", className }) => {
const clampedPercent = clampPercent(percent);
return (
@@ -296,8 +323,7 @@ const UsageProgress: FC<{
aria-valuemax={100}
aria-valuenow={Math.round(clampedPercent)}
className={cn(
size === "compact" ? "h-1" : "h-1.5",
"overflow-hidden rounded-full bg-surface-tertiary",
"h-1.5 overflow-hidden rounded-full bg-surface-tertiary",
className,
)}
>
@@ -353,6 +379,17 @@ function getProgressClassName(severity: UsageSeverity): string {
}
}
function getRingStrokeClassName(severity: UsageSeverity): string {
switch (severity) {
case "exceeded":
return "stroke-content-destructive";
case "warning":
return "stroke-content-warning";
case "normal":
return "stroke-content-secondary";
}
}
function getTextClassName(severity: UsageSeverity = "normal"): string {
switch (severity) {
case "exceeded":