From 916094c71cdbc93ef7bedf2c9b3c3dac22cc411e Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 26 May 2026 22:01:31 -0700 Subject: [PATCH] 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 --- .../components/ContextUsageIndicator.tsx | 40 ++---- .../AgentsPage/components/SvgRingProgress.tsx | 66 +++++++++ .../components/UsageIndicator.stories.tsx | 29 +--- .../AgentsPage/components/UsageIndicator.tsx | 127 +++++++++++------- 4 files changed, 161 insertions(+), 101 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/SvgRingProgress.tsx diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 5085cd883a..ec965f856a 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -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" > - - - - + ); diff --git a/site/src/pages/AgentsPage/components/SvgRingProgress.tsx b/site/src/pages/AgentsPage/components/SvgRingProgress.tsx new file mode 100644 index 0000000000..a43f28be91 --- /dev/null +++ b/site/src/pages/AgentsPage/components/SvgRingProgress.tsx @@ -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 ( + + ); +}; diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index dbdb6c4bf4..1d177bfbb2 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx @@ -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); diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 00516e6260..d90fc5972e 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -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: , - 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: , - 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 ( -
- {sections.map((section) => ( -
- - - - {section.summaryValue} - -
- ))} + +
+ {sections.map((section) => ( + + +
+ +
+
+ + {section.hoverLabel} + +
+ ))} +
+
+ ); +}; + +const UsageRingProgress: FC<{ + ariaLabel: string; + percent: number; + severity?: UsageSeverity; + icon: ReactNode; +}> = ({ ariaLabel, percent, severity = "normal", icon }) => { + const clampedPercent = clampPercent(percent); + + return ( +
+ +
); }; @@ -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":