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.icon}
-
-
-
- {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 (
+
+
+
+ {icon}
+
);
};
@@ -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":