feat(site/src/pages/AgentsPage/components): show workspace quota in usage indicator (#25168)

This updates the Agents sidebar usage indicator to surface workspace
quota alongside AI spend limits. When both signals are active, the
compact trigger renders stacked bars in the same order as the dropdown
instead of collapsing them into a single percent.

The dropdown still shows the full labels, percentages, and details for
each usage section, and Storybook coverage now exercises the combined
sidebar state.


<img width="315" height="261" alt="image"
src="https://github.com/user-attachments/assets/e5cfc276-2cc0-4dc9-9400-6d1b829e75e2"
/>

<img width="320" height="243" alt="image"
src="https://github.com/user-attachments/assets/506ae8ad-3d93-4857-9cdb-b3cf4142772d"
/>

<img width="314" height="353" alt="image"
src="https://github.com/user-attachments/assets/5af3644f-f155-43a4-bae9-91b33a0a4333"
/>

<img width="322" height="349" alt="image"
src="https://github.com/user-attachments/assets/9ae4ae55-55aa-4a2f-856e-f462793f389e"
/>




Relates to CODAGT-197
This commit is contained in:
Ethan
2026-05-14 11:59:00 +10:00
committed by GitHub
parent a35f71cd8a
commit d147dd3bdd
2 changed files with 533 additions and 121 deletions
@@ -1,30 +1,115 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { useQueryClient } from "react-query";
import { expect, userEvent, within } from "storybook/test";
import { chatUsageLimitStatusKey } from "#/api/queries/chats";
import { getWorkspaceQuotaQueryKey } from "#/api/queries/workspaceQuota";
import { workspacesKey } from "#/api/queries/workspaces";
import type {
ChatUsageLimitStatus,
WorkspaceQuota,
WorkspacesResponse,
} from "#/api/typesGenerated";
import {
MockDefaultOrganization,
MockPermissions,
MockUserOwner,
} from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
} from "#/testHelpers/storybook";
import { UsageIndicator } from "./UsageIndicator";
const withUsageLimitStatus =
(status: {
is_limited: boolean;
period?: "day" | "week" | "month";
spend_limit_micros?: number;
current_spend: number;
period_start?: string;
period_end?: string;
}) =>
(Story: FC) => {
const queryClient = useQueryClient();
queryClient.setQueryData(chatUsageLimitStatusKey, status);
return <Story />;
};
const withUsageLimitStatus = (status: ChatUsageLimitStatus) => (Story: FC) => {
const queryClient = useQueryClient();
queryClient.setQueryData(chatUsageLimitStatusKey, status);
return <Story />;
};
const periodStart = new Date().toISOString();
const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const withWorkspaceQuota = (quota: WorkspaceQuota) => (Story: FC) => {
const queryClient = useQueryClient();
queryClient.setQueryData(
getWorkspaceQuotaQueryKey(
MockDefaultOrganization.name,
MockUserOwner.username,
),
quota,
);
return <Story />;
};
const withWorkspaceCount = (count: number) => (Story: FC) => {
const queryClient = useQueryClient();
queryClient.setQueryData(workspacesKey(userWorkspacesRequest), {
workspaces: [],
count,
} satisfies WorkspacesResponse);
return <Story />;
};
const withUnavailableWorkspaceCount = (Story: FC) => {
const queryClient = useQueryClient();
queryClient.setQueryData(workspacesKey(userWorkspacesRequest), {
workspaces: [],
count: -1,
} satisfies WorkspacesResponse);
return <Story />;
};
const withUsageIndicatorFrame = (Story: FC) => (
<div className="flex h-12 w-[260px] items-stretch justify-end rounded-md bg-surface-secondary">
<Story />
</div>
);
const openUsageMenu = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
};
const limitedUsageStatus = (
overrides: Partial<ChatUsageLimitStatus> = {},
): ChatUsageLimitStatus => ({
is_limited: true,
period: "month",
spend_limit_micros: 50_000_000,
current_spend: 12_500_000,
period_start: "2026-02-10T00:00:00Z",
period_end: "2026-03-12T00:00:00Z",
...overrides,
});
const unlimitedUsageStatus = {
is_limited: false,
current_spend: 0,
} satisfies ChatUsageLimitStatus;
const userWorkspacesRequest = {
q: `owner:me organization:${MockDefaultOrganization.name}`,
limit: 0,
};
const noWorkspaceQuota = {
credits_consumed: 0,
budget: 0,
} satisfies WorkspaceQuota;
const defaultWorkspaceQuota = {
credits_consumed: 30,
budget: 100,
} satisfies WorkspaceQuota;
const meta: Meta<typeof UsageIndicator> = {
title: "pages/AgentsPage/UsageIndicator",
component: UsageIndicator,
decorators: [
withAuthProvider,
withDashboardProvider,
withUsageIndicatorFrame,
],
parameters: {
user: MockUserOwner,
permissions: MockPermissions,
},
};
export default meta;
@@ -32,61 +117,148 @@ type Story = StoryObj<typeof UsageIndicator>;
export const LowUsage: Story = {
decorators: [
withUsageLimitStatus({
is_limited: true,
period: "month",
spend_limit_micros: 50_000_000,
current_spend: 12_500_000,
period_start: periodStart,
period_end: periodEnd,
}),
withUsageLimitStatus(limitedUsageStatus()),
withWorkspaceQuota(noWorkspaceQuota),
],
};
export const MediumUsage: Story = {
decorators: [
withUsageLimitStatus({
is_limited: true,
period: "week",
spend_limit_micros: 20_000_000,
current_spend: 16_000_000,
period_start: periodStart,
period_end: periodEnd,
}),
withUsageLimitStatus(
limitedUsageStatus({
period: "week",
spend_limit_micros: 20_000_000,
current_spend: 16_000_000,
}),
),
withWorkspaceQuota(noWorkspaceQuota),
],
};
export const HighUsage: Story = {
decorators: [
withUsageLimitStatus({
is_limited: true,
period: "day",
spend_limit_micros: 10_000_000,
current_spend: 9_500_000,
period_start: periodStart,
period_end: periodEnd,
}),
withUsageLimitStatus(
limitedUsageStatus({
period: "day",
spend_limit_micros: 10_000_000,
current_spend: 9_500_000,
}),
),
withWorkspaceQuota(noWorkspaceQuota),
],
};
export const LimitExceeded: Story = {
decorators: [
withUsageLimitStatus({
is_limited: true,
period: "month",
spend_limit_micros: 30_000_000,
current_spend: 32_000_000,
period_start: periodStart,
period_end: periodEnd,
withUsageLimitStatus(
limitedUsageStatus({
spend_limit_micros: 30_000_000,
current_spend: 32_000_000,
}),
),
withWorkspaceQuota(noWorkspaceQuota),
],
};
export const WorkspaceQuotaOnly: Story = {
decorators: [
withUsageLimitStatus(unlimitedUsageStatus),
withWorkspaceQuota(defaultWorkspaceQuota),
withWorkspaceCount(3),
],
play: async ({ canvasElement }) => {
await openUsageMenu(canvasElement);
},
};
export const UsageAndWorkspaceQuota: Story = {
decorators: [
withUsageLimitStatus(limitedUsageStatus()),
withWorkspaceQuota(defaultWorkspaceQuota),
withWorkspaceCount(3),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const progressBars = canvas.getAllByRole("progressbar");
expect(canvas.getByText("Usage")).toBeInTheDocument();
expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([
"Monthly spend usage",
"Workspace quota usage",
]);
await userEvent.click(canvas.getByRole("button"));
},
};
export const WorkspaceQuotaUnused: Story = {
decorators: [
withUsageLimitStatus(unlimitedUsageStatus),
withWorkspaceQuota({
credits_consumed: 0,
budget: 100,
}),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole("button")).not.toBeInTheDocument();
},
};
export const WorkspaceQuotaWithoutBudget: Story = {
decorators: [
withUsageLimitStatus(unlimitedUsageStatus),
withWorkspaceQuota({
credits_consumed: 20,
budget: 0,
}),
withWorkspaceCount(1),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const progressbar = canvas.getByRole("progressbar", {
name: "Workspace quota usage",
});
expect(canvas.getByText("Workspace quota")).toBeInTheDocument();
expect(progressbar).toHaveAttribute("aria-valuenow", "100");
await openUsageMenu(canvasElement);
expect(within(document.body).getByText("100%")).toBeInTheDocument();
expect(
within(document.body).getByText("1 workspace using 20 of 0 credits"),
).toBeInTheDocument();
},
};
export const WorkspaceQuotaExceeded: Story = {
decorators: [
withUsageLimitStatus(unlimitedUsageStatus),
withWorkspaceQuota({
credits_consumed: 125,
budget: 100,
}),
withWorkspaceCount(7),
],
play: async ({ canvasElement }) => {
await openUsageMenu(canvasElement);
},
};
export const WorkspaceQuotaWithoutWorkspaceCount: Story = {
decorators: [
withUsageLimitStatus(unlimitedUsageStatus),
withWorkspaceQuota(defaultWorkspaceQuota),
withUnavailableWorkspaceCount,
],
play: async ({ canvasElement }) => {
await openUsageMenu(canvasElement);
},
};
export const NotLimited: Story = {
decorators: [
withUsageLimitStatus({
is_limited: false,
current_spend: 0,
}),
withUsageLimitStatus(unlimitedUsageStatus),
withWorkspaceQuota(noWorkspaceQuota),
],
};
@@ -1,8 +1,11 @@
import dayjs from "dayjs";
import type { FC } from "react";
import { InfoIcon } from "lucide-react";
import { type FC, Fragment, type ReactNode } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router";
import { chatUsageLimitStatus } from "#/api/queries/chats";
import { workspaceQuota } from "#/api/queries/workspaceQuota";
import { workspaces } from "#/api/queries/workspaces";
import {
DropdownMenu,
DropdownMenuContent,
@@ -10,94 +13,142 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { cn } from "#/utils/cn";
import { formatCostMicros } from "#/utils/currency";
import { getUsageLimitPeriodLabel } from "./ChatCostSummaryView";
export const UsageIndicator: FC = () => {
const { data, isLoading, isError } = useQuery(chatUsageLimitStatus());
type UsageSeverity = "normal" | "warning" | "exceeded";
if (isLoading || isError || !data?.is_limited) {
type UsageSectionData = {
id: string;
title: string;
progressLabel: string;
percent: number;
detail: ReactNode;
secondaryDetail?: ReactNode;
tooltip?: ReactNode;
severity?: UsageSeverity;
};
const numberFormatter = new Intl.NumberFormat("en-US");
export const UsageIndicator: FC = () => {
const { data: chatUsage, isError: isChatUsageError } = useQuery(
chatUsageLimitStatus(),
);
const { user } = useAuthenticated();
const { organizations } = useDashboard();
const organizationName =
organizations.find((org) => org.is_default)?.name ?? "";
const username = user.username;
const { data: quota, isError: isQuotaError } = useQuery({
...workspaceQuota(organizationName, username),
enabled: organizationName !== "" && username !== "",
});
const hasWorkspaceQuotaUsage =
quota !== undefined && quota.budget >= 0 && quota.credits_consumed > 0;
const workspacesQuery = useQuery({
...workspaces({
q: `owner:me organization:${organizationName}`,
limit: 0,
}),
enabled: hasWorkspaceQuotaUsage && organizationName !== "",
});
const sections: UsageSectionData[] = [];
if (!isChatUsageError && chatUsage?.is_limited) {
const spendLimit = chatUsage.spend_limit_micros ?? 0;
const currentSpend = chatUsage.current_spend;
const periodLabel = getUsageLimitPeriodLabel(chatUsage.period);
const exceeded = spendLimit > 0 && currentSpend >= spendLimit;
sections.push({
id: "ai-usage",
title: `${periodLabel} Usage`,
progressLabel: `${periodLabel} spend usage`,
percent: getPercent(currentSpend, spendLimit),
severity: getSeverity(currentSpend, spendLimit),
detail: (
<>
{formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "}
used
{exceeded && (
<span className="ml-1 text-content-destructive">
(limit exceeded)
</span>
)}
</>
),
secondaryDetail: chatUsage.period_end
? `Resets ${dayjs(chatUsage.period_end).format("MMM D, YYYY")}`
: undefined,
});
}
if (!isQuotaError && hasWorkspaceQuotaUsage) {
const creditsConsumed = quota.credits_consumed;
const workspaceCount = workspacesQuery.isError
? undefined
: getWorkspaceCount(workspacesQuery.data?.count);
const quotaDetail =
workspaceCount === undefined
? `${formatNumber(creditsConsumed)} of ${formatNumber(quota.budget)} credits used`
: `${formatNumber(workspaceCount)} ${workspaceCount === 1 ? "workspace" : "workspaces"} using ${formatNumber(creditsConsumed)} of ${formatNumber(quota.budget)} credits`;
sections.push({
id: "workspace-quota",
title: "Workspace quota",
progressLabel: "Workspace quota usage",
percent: getPercent(creditsConsumed, quota.budget),
severity: getSeverity(creditsConsumed, quota.budget),
detail: quotaDetail,
tooltip:
"Workspaces, stopped or running, may consume credits. Stop or delete unused ones to free quota.",
});
}
if (sections.length === 0) {
return null;
}
const spendLimit = data.spend_limit_micros ?? 0;
const currentSpend = data.current_spend;
const percent =
spendLimit > 0 ? Math.min((currentSpend / spendLimit) * 100, 100) : 0;
const roundedPercent = Math.round(percent);
const exceeded = spendLimit > 0 && currentSpend >= spendLimit;
const periodLabel = getUsageLimitPeriodLabel(data.period);
return <UsageMenu sections={sections} />;
};
const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
sections,
}) => {
const triggerLabel =
sections.length > 1 ? "Usage" : (sections[0]?.title ?? "Usage");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="ml-auto flex self-stretch flex-col justify-center items-start gap-1 border-none bg-transparent px-3 cursor-pointer select-none transition-colors text-content-secondary hover:bg-surface-tertiary/50 outline-none text-[13px]"
className="ml-auto flex self-stretch flex-col items-center justify-center gap-1 border-none bg-transparent px-3 cursor-pointer select-none transition-colors text-content-secondary hover:bg-surface-tertiary/50 outline-none text-[13px]"
>
<span className="shrink-0 whitespace-nowrap">
{periodLabel} Usage
<span className="shrink-0 whitespace-nowrap text-center">
{triggerLabel}
</span>
<div
role="progressbar"
aria-label={`${periodLabel} spend usage`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={roundedPercent}
className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary shrink-0"
>
<div
className="h-full rounded-full bg-content-secondary transition-all duration-300 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
<UsageTriggerProgress sections={sections} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-auto w-[240px]">
{/* Header */}
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-sm font-medium text-content-primary">
{periodLabel} Usage
</span>
<span className="text-xs text-content-secondary">
{roundedPercent}%
</span>
</div>
{/* Progress bar */}
<div className="px-2 pb-2">
<div
role="progressbar"
aria-label={`${periodLabel} spend usage`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={roundedPercent}
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
>
<div
className="h-full rounded-full bg-content-secondary transition-all duration-300 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
</div>
{/* Spend detail */}
<div className="px-2 pb-1.5 text-xs text-content-secondary">
{formatCostMicros(currentSpend)} of {formatCostMicros(spendLimit)}{" "}
used
{exceeded && (
<span className="ml-1 text-content-destructive">
limit exceeded
</span>
)}
</div>
{data.period_end && (
<div className="px-2 pb-2 text-xs text-content-secondary">
Resets {dayjs(data.period_end).format("MMM D, YYYY")}
</div>
)}
{sections.map((section, index) => (
<Fragment key={section.id}>
{index > 0 && <DropdownMenuSeparator />}
<UsageSection section={section} />
</Fragment>
))}
<DropdownMenuSeparator />
@@ -108,3 +159,192 @@ export const UsageIndicator: FC = () => {
</DropdownMenu>
);
};
const UsageTriggerProgress: FC<{ sections: readonly UsageSectionData[] }> = ({
sections,
}) => {
const size = sections.length > 1 ? "compact" : "default";
return (
<div className="flex w-24 shrink-0 flex-col gap-0.5">
{sections.map((section) => (
<UsageProgress
key={section.id}
ariaLabel={section.progressLabel}
percent={section.percent}
severity={section.severity}
size={size}
className="w-full"
/>
))}
</div>
);
};
const UsageSection: FC<{ section: UsageSectionData }> = ({ section }) => {
const roundedPercent = Math.round(section.percent);
return (
<>
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<span className="truncate text-sm font-medium text-content-primary">
{section.title}
</span>
<span
className={cn("shrink-0 text-xs", getTextClassName(section.severity))}
>
{roundedPercent}%
</span>
</div>
<div className="px-2 pb-2">
<UsageProgress
ariaLabel={section.progressLabel}
percent={section.percent}
severity={section.severity}
/>
</div>
<div
className={cn(
"px-2 text-xs leading-5 text-content-secondary",
section.secondaryDetail ? "pb-1.5" : "pb-2",
)}
>
<div className="flex items-start gap-1.5">
<span className="min-w-0 flex-1">{section.detail}</span>
{section.tooltip && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="mt-0.5 inline-flex size-3.5 shrink-0 cursor-help items-center justify-center rounded-sm border-none bg-transparent p-0 text-content-secondary/70 outline-none transition-colors hover:text-content-primary focus-visible:ring-2 focus-visible:ring-content-link"
aria-label={`${section.title} help`}
>
<InfoIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={4}
className="max-w-48 text-xs"
>
{section.tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
{section.secondaryDetail && (
<div className="px-2 pb-2 text-xs text-content-secondary">
{section.secondaryDetail}
</div>
)}
</>
);
};
const UsageProgress: FC<{
ariaLabel: string;
percent: number;
severity?: UsageSeverity;
size?: "default" | "compact";
className?: string;
}> = ({
ariaLabel,
percent,
severity = "normal",
size = "default",
className,
}) => {
const clampedPercent = clampPercent(percent);
return (
<div
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(clampedPercent)}
className={cn(
size === "compact" ? "h-1" : "h-1.5",
"overflow-hidden rounded-full bg-surface-tertiary",
className,
)}
>
<div
className={cn(
"h-full rounded-full transition-all duration-300 ease-out",
getProgressClassName(severity),
)}
style={{ width: `${clampedPercent}%` }}
/>
</div>
);
};
function getPercent(used: number, budget: number): number {
if (!Number.isFinite(used) || !Number.isFinite(budget) || budget < 0) {
return 0;
}
if (budget === 0) {
return used > 0 ? 100 : 0;
}
return clampPercent((used / budget) * 100);
}
function clampPercent(percent: number): number {
if (!Number.isFinite(percent)) {
return 0;
}
return Math.min(Math.max(percent, 0), 100);
}
function getSeverity(used: number, budget: number): UsageSeverity {
if (!Number.isFinite(used) || !Number.isFinite(budget) || budget < 0) {
return "normal";
}
if (budget === 0) {
return used > 0 ? "exceeded" : "normal";
}
if (used >= budget) {
return "exceeded";
}
return used / budget >= 0.85 ? "warning" : "normal";
}
function getProgressClassName(severity: UsageSeverity): string {
switch (severity) {
case "exceeded":
return "bg-content-destructive";
case "warning":
return "bg-content-warning";
case "normal":
return "bg-content-secondary";
}
}
function getTextClassName(severity: UsageSeverity = "normal"): string {
switch (severity) {
case "exceeded":
return "text-content-destructive";
case "warning":
return "text-content-warning";
case "normal":
return "text-content-secondary";
}
}
function getWorkspaceCount(count: number | undefined): number | undefined {
if (count === undefined || !Number.isFinite(count) || count < 0) {
return undefined;
}
return count;
}
function formatNumber(value: number): string {
return numberFormatter.format(value);
}