mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user