feat(site): polish AI budget members table (#26805)

Finishing touches for the AI cost control group members table.

- Spend stays primary-colored until near the budget limit, via a new
  AIBudgetAmount component
- "AI budget period" label shows the current spend window in local
  time, next to the members tab
- Budget tooltip notes the reset date and the group's default limit
- "Budget type" renamed to "Budget group", with a badge for the
  governing group or override
- Distinguishes $0 budget ("None") from no budget ("Unlimited")
- Unattributed spend from another group shows a note instead of a dash
- "Manage AI budget" disabled only when another named group governs
- Replaces UserAISpend with generated UserAISpendStatus, fixing
  limit_source

Closes #26401
This commit is contained in:
Ehab Younes
2026-07-13 17:33:40 +03:00
committed by GitHub
parent 8fee087952
commit d5e5b10ff1
21 changed files with 1060 additions and 506 deletions
+3 -12
View File
@@ -213,7 +213,7 @@ export type GroupMemberAICostControl = Readonly<{
current_spend_micros: number;
spend_limit_micros: number | null;
effective_group_id: string | null;
limit_source: "group" | "override" | null;
limit_source: TypesGen.AIBudgetLimitSource | null;
}>;
export type GroupMemberWithAICostControl = TypesGen.ReducedUser &
Readonly<{ ai_cost_control?: GroupMemberAICostControl }>;
@@ -223,15 +223,6 @@ export type GroupMembersResponseWithAICostControl = Omit<
> &
Readonly<{ users: readonly GroupMemberWithAICostControl[] }>;
// TODO(AIGOV-473): drop once generated from codersdk.
export type UserAISpend = Readonly<{
user_id: string;
spend_limit_micros: number | null;
effective_group_id: string | null;
limit_source: "group" | "override" | null;
current_spend_micros: number;
}>;
export function watchInboxNotifications(
params?: WatchInboxNotificationsParams,
): OneWayWebSocket<TypesGen.GetInboxNotificationResponse> {
@@ -584,8 +575,8 @@ class ApiMethods {
return response.data;
};
getUserAISpend = async (): Promise<UserAISpend> => {
const response = await this.axios.get<UserAISpend>(
getUserAISpend = async (): Promise<TypesGen.UserAISpendStatus> => {
const response = await this.axios.get<TypesGen.UserAISpendStatus>(
"/api/v2/users/me/ai/spend",
);
return response.data;
+3 -2
View File
@@ -4,7 +4,7 @@ import type {
UseMutationOptions,
UseQueryOptions,
} from "react-query";
import { API, type UserAISpend } from "#/api/api";
import { API } from "#/api/api";
import { isApiError } from "#/api/errors";
import type {
AuthorizationRequest,
@@ -19,6 +19,7 @@ import type {
UpsertUserAIBudgetOverrideRequest,
User,
UserAIBudgetOverride,
UserAISpendStatus,
UserAppearanceSettings,
UserPreferenceSettings,
UsersRequest,
@@ -159,7 +160,7 @@ export const me = (metadata: MetadataState<User>) => {
export const meAISpendKey = [...meKey, "aiSpend"] as const;
export const meAISpend = (): UseQueryOptions<UserAISpend> => {
export const meAISpend = (): UseQueryOptions<UserAISpendStatus> => {
return {
queryKey: meAISpendKey,
queryFn: () => API.getUserAISpend(),
@@ -0,0 +1,19 @@
import type { FC } from "react";
import { getSeverity, type UsageSeverity } from "#/utils/budget";
import { formatBudgetUSD } from "#/utils/currency";
const severityTextClasses = {
normal: "text-content-primary",
warning: "text-content-warning",
exceeded: "text-content-destructive",
} as const satisfies Record<UsageSeverity, string>;
/** A spend amount in USD that takes the warning/exceeded color as it nears the limit; values in micros. */
export const AIBudgetAmount: FC<{ spend: number; limit: number }> = ({
spend,
limit,
}) => (
<span className={severityTextClasses[getSeverity(spend, limit)]}>
{formatBudgetUSD(spend)}
</span>
);
@@ -19,7 +19,7 @@ export const Unlimited: Story = {
},
};
// Well under budget: spend rendered in the normal (secondary) color.
// Well under budget: spend emphasized in the primary color, like the limit.
export const UnderBudget: Story = {
args: { currentSpend: 10_000_000, spendLimit: 50_000_000 },
play: async ({ canvasElement }) => {
@@ -1,5 +1,5 @@
import type { FC } from "react";
import { getSeverity, severityTextClassName } from "#/utils/budget";
import { AIBudgetAmount } from "#/components/AIBudgetAmount/AIBudgetAmount";
import { formatBudgetUSD } from "#/utils/currency";
/** Spend against budget. Highlights spend once it nears or exceeds the limit; values in micros. */
@@ -16,12 +16,9 @@ export const AIBudgetUsage: FC<{
);
}
const severity = getSeverity(currentSpend, spendLimit);
return (
<span className="whitespace-nowrap">
<span className={severityTextClassName(severity)}>
{formatBudgetUSD(currentSpend)}
</span>{" "}
<AIBudgetAmount spend={currentSpend} limit={spendLimit} />{" "}
<span className="text-content-primary">
/ {formatBudgetUSD(spendLimit)}
</span>{" "}
+8 -6
View File
@@ -1,11 +1,13 @@
import type { FC } from "react";
import {
clampPercentage,
severityProgressClassName,
type UsageSeverity,
} from "#/utils/budget";
import { clampPercentage, type UsageSeverity } from "#/utils/budget";
import { cn } from "#/utils/cn";
const severityProgressClasses = {
normal: "bg-content-secondary",
warning: "bg-content-warning",
exceeded: "bg-content-destructive",
} as const satisfies Record<UsageSeverity, string>;
interface UsageBarProps {
/** Fraction used, 0-100. Clamped for safety. */
percent: number;
@@ -38,7 +40,7 @@ export const UsageBar: FC<UsageBarProps> = ({
<div
className={cn(
"h-full rounded-full transition-all duration-300 ease-out",
severityProgressClassName(severity),
severityProgressClasses[severity],
)}
style={{ width: `${clampedPercent}%` }}
/>
@@ -1,29 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
import type { UserAISpend } from "#/api/api";
import { meAISpendKey } from "#/api/queries/users";
import type { Experiment, FeatureName } from "#/api/typesGenerated";
import type {
Experiment,
FeatureName,
UserAISpendStatus,
} from "#/api/typesGenerated";
import { MockBuildInfo, MockUserOwner } from "#/testHelpers/entities";
import { withDashboardProvider } from "#/testHelpers/storybook";
import { UserDropdown } from "./UserDropdown";
function mockAISpend(overrides: Partial<UserAISpend> = {}): UserAISpend {
return {
user_id: MockUserOwner.id,
spend_limit_micros: 1_200_000_000,
effective_group_id: "grp-789",
limit_source: "group",
current_spend_micros: 819_000_000,
...overrides,
};
}
const mockAISpend: UserAISpendStatus = {
user_id: MockUserOwner.id,
spend_limit_micros: 1_200_000_000,
effective_group_id: "grp-789",
limit_source: "group",
current_spend_micros: 819_000_000,
period_start: "2026-06-01T00:00:00Z",
period_end: "2026-07-01T00:00:00Z",
};
const aiSpendQuery = (overrides?: Partial<UserAISpend>) => ({
key: meAISpendKey,
data: mockAISpend(overrides),
});
// Gates the AI spend section, matching the group budget UI.
const aiCostControl: { features: FeatureName[]; experiments: Experiment[] } = {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
@@ -59,7 +55,7 @@ const openDropdown = async (canvasElement: HTMLElement) => {
const Example: Story = {
parameters: {
queries: [aiSpendQuery()],
queries: [{ key: meAISpendKey, data: mockAISpend }],
},
play: async ({ canvasElement, step }) => {
await step("hides AI spend without cost control", async () => {
@@ -72,7 +68,7 @@ const Example: Story = {
export const WithAISpend: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery()],
queries: [{ key: meAISpendKey, data: mockAISpend }],
},
play: async ({ canvasElement, step }) => {
await step("shows AI spend", async () => {
@@ -88,11 +84,15 @@ export const WithAISpend: Story = {
},
};
// 90% of the limit lands in the warning band (>=85%, <100%).
export const AISpendWarning: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: 1_080_000_000 })],
queries: [
{
key: meAISpendKey,
data: { ...mockAISpend, current_spend_micros: 1_080_000_000 },
},
],
},
play: async ({ canvasElement, step }) => {
await step("shows the warning marker near the limit", async () => {
@@ -108,11 +108,15 @@ export const AISpendWarning: Story = {
},
};
// Spend exactly at the limit is exceeded (used >= budget).
export const AISpendAtLimit: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: 1_200_000_000 })],
queries: [
{
key: meAISpendKey,
data: { ...mockAISpend, current_spend_micros: 1_200_000_000 },
},
],
},
play: async ({ canvasElement, step }) => {
await step("marks spend at the limit as exceeded", async () => {
@@ -127,11 +131,15 @@ export const AISpendAtLimit: Story = {
},
};
// Spend past the limit clamps the bar to 100% and marks it exceeded.
export const AISpendExceeded: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: 1_500_000_000 })],
queries: [
{
key: meAISpendKey,
data: { ...mockAISpend, current_spend_micros: 1_500_000_000 },
},
],
},
play: async ({ canvasElement, step }) => {
await step("shows the exceeded marker at the limit", async () => {
@@ -147,11 +155,12 @@ export const AISpendExceeded: Story = {
},
};
// A null limit means unlimited: spend is shown without a progress bar.
export const AISpendUnlimited: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ spend_limit_micros: null })],
queries: [
{ key: meAISpendKey, data: { ...mockAISpend, spend_limit_micros: null } },
],
},
play: async ({ canvasElement, step }) => {
await step("shows unlimited spend without a bar", async () => {
@@ -167,11 +176,12 @@ export const AISpendUnlimited: Story = {
},
};
// $0 spend against a limit shows an empty bar.
export const AISpendZeroSpend: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: 0 })],
queries: [
{ key: meAISpendKey, data: { ...mockAISpend, current_spend_micros: 0 } },
],
},
play: async ({ canvasElement, step }) => {
await step("shows zero spend with an empty bar", async () => {
@@ -186,11 +196,19 @@ export const AISpendZeroSpend: Story = {
},
};
// $0 limit with $0 spend stays normal, not exceeded.
export const AISpendZeroLimit: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: 0, spend_limit_micros: 0 })],
queries: [
{
key: meAISpendKey,
data: {
...mockAISpend,
current_spend_micros: 0,
spend_limit_micros: 0,
},
},
],
},
play: async ({ canvasElement, step }) => {
await step("shows a zero limit without exceeding", async () => {
@@ -207,42 +225,49 @@ export const AISpendZeroLimit: Story = {
// Dropdown closed to isolate the avatar border, which reflects spend severity.
// No cost control: default border.
export const AvatarBorderDisabled: Story = {
parameters: {
queries: [aiSpendQuery()],
queries: [{ key: meAISpendKey, data: mockAISpend }],
},
};
// 68% of the limit.
export const AvatarBorderNormal: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery()],
queries: [{ key: meAISpendKey, data: mockAISpend }],
},
};
// 90% of the limit.
export const AvatarBorderWarning: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: 1_080_000_000 })],
queries: [
{
key: meAISpendKey,
data: { ...mockAISpend, current_spend_micros: 1_080_000_000 },
},
],
},
};
// Over the limit.
export const AvatarBorderExceeded: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: 1_500_000_000 })],
queries: [
{
key: meAISpendKey,
data: { ...mockAISpend, current_spend_micros: 1_500_000_000 },
},
],
},
};
// Invalid (negative) spend hides the section.
export const AISpendHiddenOnInvalidData: Story = {
parameters: {
...aiCostControl,
queries: [aiSpendQuery({ current_spend_micros: -1 })],
queries: [
{ key: meAISpendKey, data: { ...mockAISpend, current_spend_micros: -1 } },
],
},
play: async ({ canvasElement, step }) => {
await step("hides AI spend on invalid data", async () => {
@@ -252,4 +277,19 @@ export const AISpendHiddenOnInvalidData: Story = {
},
};
export const AISpendHiddenOnNegativeLimit: Story = {
parameters: {
...aiCostControl,
queries: [
{ key: meAISpendKey, data: { ...mockAISpend, spend_limit_micros: -1 } },
],
},
play: async ({ canvasElement, step }) => {
await step("hides AI spend on a negative limit", async () => {
await openDropdown(canvasElement);
expect(screen.queryByText("(AI spend/month)")).not.toBeInTheDocument();
});
},
};
export { Example as UserDropdown };
@@ -1,4 +1,6 @@
import type { FC } from "react";
import { useQuery } from "react-query";
import { meAISpend } from "#/api/queries/users";
import type * as TypesGen from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import {
@@ -7,10 +9,17 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { severityBorderClassName } from "#/utils/budget";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { getSeverity, type UsageSeverity } from "#/utils/budget";
import { UserDropdownAISpend } from "./UserDropdownAISpend";
import { UserDropdownContent } from "./UserDropdownContent";
import { useAISpend } from "./useAISpend";
const severityBorderClasses = {
normal: "border-content-secondary",
warning: "border-content-warning",
exceeded: "border-content-destructive",
} as const satisfies Record<UsageSeverity, string>;
interface UserDropdownProps {
user: TypesGen.User;
@@ -25,7 +34,32 @@ export const UserDropdown: FC<UserDropdownProps> = ({
supportLinks,
onSignOut,
}) => {
const spend = useAISpend();
const { experiments } = useDashboard();
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
const aibridgeVisible =
Boolean(useFeatureVisibility().aibridge) &&
experiments.includes("ai-gateway-cost-control");
const { data, isError } = useQuery({
...meAISpend(),
enabled: aibridgeVisible,
});
// A null limit is unlimited and still shown.
const hasValidSpend =
data !== undefined &&
data.current_spend_micros >= 0 &&
(data.spend_limit_micros === null || data.spend_limit_micros >= 0);
const spend =
aibridgeVisible && !isError && hasValidSpend
? {
currentSpend: data.current_spend_micros,
spendLimit: data.spend_limit_micros,
}
: null;
const severity =
spend && spend.spendLimit !== null
? getSeverity(spend.currentSpend, spend.spendLimit)
: "normal";
return (
<DropdownMenu>
@@ -38,9 +72,7 @@ export const UserDropdown: FC<UserDropdownProps> = ({
fallback={user.username}
src={user.avatar_url}
size="lg"
className={
spend ? severityBorderClassName(spend.severity) : undefined
}
className={spend ? severityBorderClasses[severity] : undefined}
/>
</button>
</DropdownMenuTrigger>
@@ -50,10 +82,12 @@ export const UserDropdown: FC<UserDropdownProps> = ({
user={user}
buildInfo={buildInfo}
profileExtra={
<UserDropdownAISpend
spend={spend}
header={<DropdownMenuSeparator />}
/>
spend && (
<>
<DropdownMenuSeparator />
<UserDropdownAISpend {...spend} />
</>
)
}
supportLinks={supportLinks}
onSignOut={onSignOut}
@@ -1,47 +1,38 @@
import type { FC, ReactNode } from "react";
import type { FC } from "react";
import { UsageBar } from "#/components/UsageBar/UsageBar";
import { getSeverity, usageProgressPercentage } from "#/utils/budget";
import { formatBudgetUSD } from "#/utils/currency";
import type { AISpend } from "./useAISpend";
interface UserDropdownAISpendProps {
spend: AISpend | null;
/** Rendered above the section, only when the section is shown. */
header?: ReactNode;
currentSpend: number;
/** A null limit means unlimited. */
spendLimit: number | null;
}
export const UserDropdownAISpend: FC<UserDropdownAISpendProps> = ({
spend,
header,
currentSpend,
spendLimit,
}) => {
if (!spend) {
return null;
}
const { currentSpend, spendLimit, percent, severity } = spend;
return (
<>
{header}
<div className="px-2 py-2">
<div className="whitespace-nowrap text-sm text-content-primary">
{formatBudgetUSD(currentSpend)}{" "}
<span className="text-content-secondary">
/ {spendLimit === null ? "Unlimited" : formatBudgetUSD(spendLimit)}{" "}
USD
</span>
</div>
{spendLimit !== null && (
<UsageBar
ariaLabel="AI spend usage"
percent={percent}
severity={severity}
className="mt-2 h-2.5"
/>
)}
<div className="mt-1 text-xs text-content-secondary">
(AI spend/month)
</div>
<div className="px-2 py-2">
<div className="whitespace-nowrap text-sm text-content-primary">
{formatBudgetUSD(currentSpend)}{" "}
<span className="text-content-secondary">
/ {spendLimit === null ? "Unlimited" : formatBudgetUSD(spendLimit)}{" "}
USD
</span>
</div>
</>
{spendLimit !== null && (
<UsageBar
ariaLabel="AI spend usage"
percent={usageProgressPercentage(currentSpend, spendLimit)}
severity={getSeverity(currentSpend, spendLimit)}
className="mt-2 h-2.5"
/>
)}
<div className="mt-1 text-xs text-content-secondary">
(AI spend/month)
</div>
</div>
);
};
@@ -1,54 +0,0 @@
import { useQuery } from "react-query";
import { meAISpend } from "#/api/queries/users";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import {
getSeverity,
type UsageSeverity,
usageProgressPercentage,
} from "#/utils/budget";
export interface AISpend {
currentSpend: number;
/** A null limit means unlimited. */
spendLimit: number | null;
percent: number;
severity: UsageSeverity;
}
/** Resolves AI spend for the avatar border and dropdown section, or null when
* it should be hidden. */
export function useAISpend(): AISpend | null {
const { experiments } = useDashboard();
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
const aibridgeVisible =
useFeatureVisibility().aibridge &&
experiments.includes("ai-gateway-cost-control");
const { data, isError } = useQuery({
...meAISpend(),
enabled: aibridgeVisible,
});
if (!aibridgeVisible || isError || !data) {
return null;
}
const currentSpend = data.current_spend_micros;
const spendLimit = data.spend_limit_micros;
// Hide on invalid spend data. A null limit means unlimited, which is shown.
if (currentSpend < 0 || (spendLimit !== null && spendLimit < 0)) {
return null;
}
return {
currentSpend,
spendLimit,
percent:
spendLimit === null
? 0
: usageProgressPercentage(currentSpend, spendLimit),
severity:
spendLimit === null ? "normal" : getSeverity(currentSpend, spendLimit),
};
}
@@ -29,8 +29,6 @@ import { getUsageLimitPeriodLabel } from "#/pages/AISettingsPage/SpendPage/compo
import {
clampPercentage,
getSeverity,
severityRingClassName,
severityTextClassName,
type UsageSeverity,
usageProgressPercentage,
} from "#/utils/budget";
@@ -48,7 +46,7 @@ type UsageSectionData = {
hoverLabel: string;
secondaryDetail?: ReactNode;
tooltip?: ReactNode;
severity?: UsageSeverity;
severity: UsageSeverity;
};
const numberFormatter = new Intl.NumberFormat("en-US");
@@ -183,6 +181,18 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({
const RING_SIZE = 28;
const RING_STROKE = 1;
const severityTextClasses = {
normal: "text-content-secondary",
warning: "text-content-warning",
exceeded: "text-content-destructive",
} as const satisfies Record<UsageSeverity, string>;
const severityRingClasses = {
normal: "stroke-content-secondary",
warning: "stroke-content-warning",
exceeded: "stroke-content-destructive",
} as const satisfies Record<UsageSeverity, string>;
const UsageTriggerProgress: FC<{ sections: readonly UsageSectionData[] }> = ({
sections,
}) => {
@@ -233,13 +243,13 @@ const UsageRingProgress: FC<{
size={RING_SIZE}
strokeWidth={RING_STROKE}
percent={clampedPercent}
progressClassName={severityRingClassName(severity)}
progressClassName={severityRingClasses[severity]}
/>
<span
aria-hidden="true"
className={cn(
"absolute inset-0 flex items-center justify-center",
severityTextClassName(severity),
severityTextClasses[severity],
)}
>
{icon}
@@ -260,7 +270,7 @@ const UsageSection: FC<{ section: UsageSectionData }> = ({ section }) => {
<span
className={cn(
"shrink-0 text-xs",
severityTextClassName(section.severity),
severityTextClasses[section.severity],
)}
>
{roundedPercent}%
@@ -0,0 +1,29 @@
import dayjs from "dayjs";
import type { FC } from "react";
import { useQuery } from "react-query";
import { meAISpend } from "#/api/queries/users";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
/** The current AI budget window, e.g. "June 1 - July 1, 2026". */
export const AIBudgetPeriod: FC = () => {
const { experiments } = useDashboard();
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
const visible =
Boolean(useFeatureVisibility().aibridge) &&
experiments.includes("ai-gateway-cost-control");
const { data: aiSpend } = useQuery({ ...meAISpend(), enabled: visible });
if (!visible || !aiSpend) {
return null;
}
// Local time and raw exclusive period_end, matching the spend page.
const start = dayjs(aiSpend.period_start).format("MMMM D");
const end = dayjs(aiSpend.period_end).format("MMMM D, YYYY");
return (
<span className="text-sm text-content-secondary">
AI budget period: {start} - {end}
</span>
);
};
@@ -0,0 +1,258 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { API, type GroupMemberAICostControl } from "#/api/api";
import { getGroupByIdQueryKey } from "#/api/queries/groups";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { MockGroup2, MockGroupWithoutMembers } from "#/testHelpers/entities";
import { GroupMemberBudgetCells } from "./GroupMemberBudgetCells";
const group = MockGroupWithoutMembers;
const testId = "member-ai-budget-member-1";
const mockCostControl: GroupMemberAICostControl = {
current_spend_micros: 0,
spend_limit_micros: 7_000_000_000,
effective_group_id: group.id,
limit_source: "group",
};
const openInfo = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
const cell = await canvas.findByTestId(testId);
await userEvent.click(
within(cell).getByRole("button", { name: "More info" }),
);
return within(document.body);
};
const meta: Meta<typeof GroupMemberBudgetCells> = {
title: "pages/OrganizationGroupsPage/GroupMemberBudgetCells",
component: GroupMemberBudgetCells,
args: { group, userID: "member-1" },
decorators: [
(Story) => (
<Table aria-label="Member budget">
<TableHeader>
<TableRow>
<TableHead>AI budget</TableHead>
<TableHead>Budget group</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<Story />
</TableRow>
</TableBody>
</Table>
),
],
};
export default meta;
type Story = StoryObj<typeof GroupMemberBudgetCells>;
export const NoCostControl: Story = {
args: { costControl: undefined },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cells = canvas.getAllByRole("cell");
expect(cells).toHaveLength(2);
for (const cell of cells) {
await expect(cell).toHaveTextContent("\u2014");
}
},
};
export const Unlimited: Story = {
args: {
costControl: {
...mockCostControl,
spend_limit_micros: null,
effective_group_id: group.organization_id,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByTestId(testId)).toHaveTextContent(
"Unlimited",
);
await expect(
canvas.getByText("Everyone (not allocated)"),
).toBeInTheDocument();
const body = await openInfo(canvasElement);
await expect(await body.findByText(/isn't restricted/)).toBeInTheDocument();
},
};
/**
* A null effective group means no budget applies: unlimited, no badge.
* TODO(AIGOV-509): null will instead mean a group in another org.
*/
export const NoGoverningGroup: Story = {
args: {
costControl: {
...mockCostControl,
spend_limit_micros: null,
effective_group_id: null,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByTestId(testId)).toHaveTextContent(
"Unlimited",
);
await expect(canvas.getAllByRole("cell")[1]).toHaveTextContent("\u2014");
},
};
export const None: Story = {
args: {
costControl: {
...mockCostControl,
spend_limit_micros: 0,
effective_group_id: group.organization_id,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByTestId(testId)).toHaveTextContent("None");
const body = await openInfo(canvasElement);
await expect(
await body.findByText(/no AI spending allowance/),
).toBeInTheDocument();
},
};
export const Regular: Story = {
args: {
costControl: { ...mockCostControl, current_spend_micros: 3_235_000_000 },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cell = await canvas.findByTestId(testId);
await expect(cell).toHaveTextContent("$3,235 USD");
await expect(cell).toHaveTextContent("Group limit $7,000");
await expect(canvas.getByText("Front-End")).toBeInTheDocument();
},
};
export const Custom: Story = {
args: {
costControl: {
...mockCostControl,
current_spend_micros: 7_175_000_000,
spend_limit_micros: 9_000_000_000,
limit_source: "user_override",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cell = await canvas.findByTestId(testId);
await expect(cell).toHaveTextContent("$7,175 USD");
await expect(cell).toHaveTextContent("Custom limit $9,000");
await expect(
canvas.getByText("Front-End (individual)"),
).toBeInTheDocument();
},
};
// Visual variants of Regular: the amount takes the warning/exceeded color.
export const NearLimit: Story = {
args: {
costControl: { ...mockCostControl, current_spend_micros: 6_735_000_000 },
},
};
export const OverLimit: Story = {
args: {
costControl: { ...mockCostControl, current_spend_micros: 7_200_000_000 },
},
};
export const NotAttributed: Story = {
args: {
costControl: {
...mockCostControl,
current_spend_micros: 456_000_000,
effective_group_id: MockGroup2.id,
},
},
parameters: {
queries: [
{
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
data: MockGroup2,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cell = await canvas.findByTestId(testId);
await expect(cell).toHaveTextContent("$456 USD");
await expect(cell).toHaveTextContent("Not attributed to this group");
await expect(await canvas.findByText("developer")).toBeInTheDocument();
const body = await openInfo(canvasElement);
await expect(
await body.findByText(/None of this user's spend counts against/),
).toHaveTextContent(
"None of this user's spend counts against the Front-End group. It is managed by the developer group.",
);
},
};
/** Spinners while the group name resolves, not a flash of the fallback. */
export const ResolvingGroupName: Story = {
args: {
costControl: {
...mockCostControl,
current_spend_micros: 456_000_000,
effective_group_id: MockGroup2.id,
},
},
beforeEach: () => {
// Never settles; the cells stay resolving.
spyOn(API, "getGroupById").mockImplementation(() => new Promise(() => {}));
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(testId);
// Both the amount and the badge cell wait for the group name.
await expect(canvas.getAllByTitle("Loading spinner")).toHaveLength(2);
},
};
export const NotAttributedUnknownGroup: Story = {
args: {
costControl: {
...mockCostControl,
current_spend_micros: 456_000_000,
effective_group_id: "external-group",
},
},
parameters: {
queries: [
{
key: getGroupByIdQueryKey("external-group", { exclude_members: true }),
data: null,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const cell = await canvas.findByTestId(testId);
await expect(cell).toHaveTextContent("\u2014");
await expect(cell).not.toHaveTextContent("$456");
await expect(canvas.getByText("Another org")).toBeInTheDocument();
const body = await openInfo(canvasElement);
await expect(
await body.findByText(/managed by another org and isn't visible here/),
).toBeInTheDocument();
},
};
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { GroupMemberAICostControl } from "#/api/api";
import { effectiveBudgetGroup } from "./GroupMemberBudgetCells";
const group = { id: "group-1", organization_id: "org-1" };
const mockCostControl: GroupMemberAICostControl = {
current_spend_micros: 0,
spend_limit_micros: null,
effective_group_id: null,
limit_source: "group",
};
describe("effectiveBudgetGroup", () => {
it("is none without cost control data", () => {
expect(effectiveBudgetGroup(undefined, group)).toEqual({ kind: "none" });
});
it("is none without a governing group", () => {
expect(effectiveBudgetGroup(mockCostControl, group)).toEqual({
kind: "none",
});
});
it("is everyone for the org-wide Everyone group", () => {
expect(
effectiveBudgetGroup(
{ ...mockCostControl, effective_group_id: "org-1" },
group,
),
).toEqual({ kind: "everyone" });
});
it("is everyone when the viewed group is Everyone itself", () => {
expect(
effectiveBudgetGroup(
{ ...mockCostControl, effective_group_id: "org-1" },
{ id: "org-1", organization_id: "org-1" },
),
).toEqual({ kind: "everyone" });
});
it("is this for the given group", () => {
expect(
effectiveBudgetGroup(
{ ...mockCostControl, effective_group_id: "group-1" },
group,
),
).toEqual({ kind: "this" });
});
it("is other for any other group", () => {
expect(
effectiveBudgetGroup(
{ ...mockCostControl, effective_group_id: "group-2" },
group,
),
).toEqual({ kind: "other", groupId: "group-2" });
});
});
@@ -0,0 +1,204 @@
import type { FC, ReactNode } from "react";
import { useQuery } from "react-query";
import type { GroupMemberAICostControl } from "#/api/api";
import { groupById } from "#/api/queries/groups";
import type { Group } from "#/api/typesGenerated";
import { AIBudgetAmount } from "#/components/AIBudgetAmount/AIBudgetAmount";
import { Badge } from "#/components/Badge/Badge";
import { Spinner } from "#/components/Spinner/Spinner";
import { TableCell } from "#/components/Table/Table";
import { formatBudgetUSD } from "#/utils/currency";
import { InfoIconTooltip } from "./InfoIconTooltip";
const EM_DASH = "\u2014";
/**
* The AI budget and Budget group cells for a group member. Spend only counts
* against the viewed group; another group's budget shows as unattributed.
*/
export const GroupMemberBudgetCells: FC<{
group: Group;
userID: string;
costControl: GroupMemberAICostControl | undefined;
}> = ({ group, userID, costControl }) => {
const effective = effectiveBudgetGroup(costControl, group);
const fromOtherGroup = effective.kind === "other";
const { data: effectiveGroup, isLoading: isResolvingGroupName } = useQuery({
...groupById(fromOtherGroup ? effective.groupId : "", {
exclude_members: true,
}),
enabled: fromOtherGroup,
});
const effectiveGroupName =
effectiveGroup?.display_name || effectiveGroup?.name;
const groupName = group.display_name || group.name;
// A user override shows as "(individual)" on the governing group's badge.
const badgeName = (name: string) =>
costControl?.limit_source === "user_override"
? `${name} (individual)`
: name;
let budgetGroup: ReactNode;
switch (effective.kind) {
case "none":
budgetGroup = EM_DASH;
break;
case "everyone":
budgetGroup = <Badge size="sm">Everyone (not allocated)</Badge>;
break;
case "this":
budgetGroup = <Badge size="sm">{badgeName(groupName)}</Badge>;
break;
case "other": {
// "Another org" when the governing group can't be resolved.
const label = effectiveGroupName
? badgeName(effectiveGroupName)
: "Another org";
// Wait for the name to resolve rather than flashing the fallback.
budgetGroup = isResolvingGroupName ? (
<Spinner loading size="sm" />
) : (
<Badge size="sm">{label}</Badge>
);
break;
}
}
let budget: ReactNode = EM_DASH;
if (costControl && fromOtherGroup) {
if (isResolvingGroupName) {
budget = <Spinner loading size="sm" />;
} else if (!effectiveGroupName) {
// The spend hides entirely when the governing group can't be resolved.
budget = (
<LabelWithInfo
label={EM_DASH}
message="This user's AI budget is managed by another org and isn't visible here."
/>
);
} else {
budget = (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1">
<span>
<span className="text-content-secondary">
{formatBudgetUSD(costControl.current_spend_micros)}
</span>{" "}
<span className="text-content-disabled">USD</span>
</span>
<InfoIconTooltip
message={
<>
None of this user's spend counts against the{" "}
<span className="font-medium text-content-primary">
{groupName}
</span>{" "}
group. It is managed by the{" "}
<span className="font-medium text-content-primary">
{effectiveGroupName}
</span>{" "}
group.
</>
}
/>
</span>
<span className="text-xs text-content-secondary">
Not attributed to this group
</span>
</div>
);
}
} else if (costControl) {
const limit = costControl.spend_limit_micros;
if (limit === null) {
// Also covers a missing governing group: no budget applies.
budget = (
<LabelWithInfo
label="Unlimited"
message="None of this user's groups have an AI budget configured, so their AI usage isn't restricted."
/>
);
} else if (limit === 0) {
// A $0 budget disables spending, distinct from no budget configured.
budget = (
<LabelWithInfo
label="None"
message="This user's group(s) have an AI budget of $0, so they have no AI spending allowance."
/>
);
} else {
const limitLabel =
costControl.limit_source === "user_override" ? "Custom" : "Group";
budget = (
<div className="flex flex-col gap-0.5">
<span>
<AIBudgetAmount
spend={costControl.current_spend_micros}
limit={limit}
/>{" "}
<span className="text-content-disabled">USD</span>
</span>
<span className="text-xs text-content-secondary">
{`${limitLabel} limit ${formatBudgetUSD(limit)}`}
</span>
</div>
);
}
}
return (
<>
<TableCell
data-testid={`member-ai-budget-${userID}`}
className="whitespace-nowrap tabular-nums"
>
{budget}
</TableCell>
<TableCell>{budgetGroup}</TableCell>
</>
);
};
/** Which group governs a member's AI budget, relative to the given group. */
type EffectiveBudgetGroup =
| { kind: "none" }
| { kind: "everyone" }
| { kind: "this" }
| { kind: "other"; groupId: string };
/**
* Resolves which group governs a member's AI budget. "none" means no budget
* applies; "everyone" is the org-wide fallback when no named group sets a
* budget.
*
* TODO(AIGOV-509): null will instead mean a group in another org.
*/
export function effectiveBudgetGroup(
costControl: GroupMemberAICostControl | undefined,
group: Pick<Group, "id" | "organization_id">,
): EffectiveBudgetGroup {
const groupId = costControl?.effective_group_id ?? null;
if (groupId === null) {
return { kind: "none" };
}
// Everyone shares the org's id; checked first so it wins when the viewed
// group is Everyone itself.
if (groupId === group.organization_id) {
return { kind: "everyone" };
}
if (groupId === group.id) {
return { kind: "this" };
}
return { kind: "other", groupId };
}
const LabelWithInfo: FC<{ label: ReactNode; message: ReactNode }> = ({
label,
message,
}) => (
<span className="inline-flex items-center gap-1">
{label}
<InfoIconTooltip message={message} />
</span>
);
+43 -86
View File
@@ -1,19 +1,17 @@
import dayjs from "dayjs";
import { EllipsisVerticalIcon, UserPlusIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useOutletContext } from "react-router";
import { toast } from "sonner";
import type {
GroupMemberAICostControl,
GroupMemberWithAICostControl,
} from "#/api/api";
import type { GroupMemberWithAICostControl } from "#/api/api";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { addMembers, groupById, removeMember } from "#/api/queries/groups";
import { addMembers, groupAIBudget, removeMember } from "#/api/queries/groups";
import { meAISpend } from "#/api/queries/users";
import type {
Group,
OrganizationMemberWithUserData,
} from "#/api/typesGenerated";
import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage";
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
@@ -48,6 +46,10 @@ import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
import { isEveryoneGroup } from "#/modules/groups";
import { cn } from "#/utils/cn";
import { formatBudgetUSD } from "#/utils/currency";
import {
effectiveBudgetGroup,
GroupMemberBudgetCells,
} from "./GroupMemberBudgetCells";
import type { GroupPageOutletContext } from "./GroupPage";
import { InfoIconTooltip } from "./InfoIconTooltip";
import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog";
@@ -71,11 +73,29 @@ const GroupMembersPage: FC = () => {
useState<GroupMemberWithAICostControl | null>(null);
const { experiments } = useDashboard();
// TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once
// the cost-control feature is stable.
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
const aibridgeVisible =
Boolean(useFeatureVisibility().aibridge) &&
experiments.includes("ai-gateway-cost-control");
const { data: aiSpend } = useQuery({
...meAISpend(),
enabled: aibridgeVisible,
});
const { data: groupBudget } = useQuery({
...groupAIBudget(groupData.id),
enabled: aibridgeVisible,
});
const aiBudgetNote = [
"Monthly AI spend for this user.",
// Spend resets at period_end, rendered in the viewer's local time.
aiSpend &&
`Resets ${dayjs(aiSpend.period_end).format("MMM D, YYYY h:mm A")}.`,
// A $0 default still shows: it means no spending allowance.
groupBudget &&
`The group's default limit is ${formatBudgetUSD(groupBudget.spend_limit_micros)} per member.`,
]
.filter(Boolean)
.join(" ");
return (
<div className="flex flex-col w-full gap-1 pb-8">
@@ -110,13 +130,13 @@ const GroupMembersPage: FC = () => {
<TableHead>
<div className="flex items-center gap-1">
AI budget
<InfoIconTooltip message="A member's AI spend against their budget for the current period." />
<InfoIconTooltip message={aiBudgetNote} />
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-1">
Budget type
<InfoIconTooltip message="Whether a member's budget comes from their group or an individual override." />
Budget group
<InfoIconTooltip message="The group or individual budget currently responsible for this user's AI spend. Admins can reassign this at any time, so spend history may span multiple sources." />
</div>
</TableHead>
</>
@@ -288,6 +308,10 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
onManageAIBudget,
onRemove,
}) => {
const costControl = member.ai_cost_control;
const budgetFromOtherGroup =
effectiveBudgetGroup(costControl, group).kind === "other";
return (
<TableRow key={member.id}>
<TableCell width={showAIBudget ? undefined : "59%"}>
@@ -316,10 +340,10 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
<LastSeen at={member.last_seen_at} className="text-xs" />
</TableCell>
{showAIBudget && (
<GroupMemberAIBudgetCells
<GroupMemberBudgetCells
group={group}
userID={member.id}
costControl={member.ai_cost_control}
costControl={costControl}
/>
)}
<TableCell className="w-1 whitespace-nowrap">
@@ -333,8 +357,11 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{showAIBudget && (
<DropdownMenuItem onClick={onManageAIBudget}>
AI Budget
<DropdownMenuItem
onClick={onManageAIBudget}
disabled={budgetFromOtherGroup}
>
Manage AI budget
</DropdownMenuItem>
)}
<DropdownMenuItem
@@ -352,74 +379,4 @@ const GroupMemberRow: FC<GroupMemberRowProps> = ({
);
};
const GroupMemberAIBudgetCells: FC<{
group: Group;
userID: string;
costControl: GroupMemberAICostControl | undefined;
}> = ({ group, userID, costControl }) => {
// Limit and type apply only when this group is the member's effective source.
const onEffectiveGroup = costControl?.effective_group_id === group.id;
let budget: ReactNode = "-";
let type: ReactNode = "-";
if (costControl) {
// Another group sets this member's budget; surface their spend only.
budget = onEffectiveGroup ? (
<AIBudgetUsage
currentSpend={costControl.current_spend_micros}
spendLimit={costControl.spend_limit_micros}
/>
) : (
<span className="inline-flex items-center gap-1 text-content-disabled">
{formatBudgetUSD(costControl.current_spend_micros)}
<MemberBudgetSourceTooltip groupId={costControl.effective_group_id} />
</span>
);
if (onEffectiveGroup && costControl.limit_source) {
type = budgetTypeLabels[costControl.limit_source];
}
}
return (
<>
<TableCell
data-testid={`member-ai-budget-${userID}`}
className="whitespace-nowrap tabular-nums"
>
{budget}
</TableCell>
<TableCell>{type}</TableCell>
</>
);
};
// Names the group whose budget governs a member, resolving the id to a name.
const MemberBudgetSourceTooltip: FC<{ groupId: string | null }> = ({
groupId,
}) => {
const { data: group } = useQuery({
...groupById(groupId ?? "", { exclude_members: true }),
enabled: Boolean(groupId),
});
const name = group?.display_name || group?.name;
return (
<InfoIconTooltip
className="text-content-disabled"
message={
name
? `This member's AI budget is set by the "${name}" group.`
: "This member's AI budget is set by another group."
}
/>
);
};
const budgetTypeLabels: Record<
NonNullable<GroupMemberAICostControl["limit_source"]>,
string
> = {
group: "Group",
override: "Individual",
};
export default GroupMembersPage;
+248 -129
View File
@@ -18,8 +18,15 @@ import {
groupPermissionsKey,
} from "#/api/queries/groups";
import { organizationMembersKey } from "#/api/queries/organizations";
import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users";
import type { ReducedUser, UserAIBudgetOverride } from "#/api/typesGenerated";
import {
getUserAIBudgetOverrideQueryKey,
meAISpendKey,
} from "#/api/queries/users";
import type {
GroupAIBudget,
ReducedUser,
UserAISpendStatus,
} from "#/api/typesGenerated";
import {
MockDefaultOrganization,
MockGroup,
@@ -91,6 +98,17 @@ const membersQuery = (data: unknown) => ({
data,
});
/** period_end is exclusive. */
const mockUserAISpend: UserAISpendStatus = {
user_id: MockUserOwner.id,
spend_limit_micros: 9_000_000_000,
effective_group_id: MockGroupWithoutMembers.id,
limit_source: "group",
current_spend_micros: 1_345_000_000,
period_start: "2026-06-01T00:00:00Z",
period_end: "2026-07-01T00:00:00Z",
};
export default meta;
type Story = StoryObj<typeof GroupPage>;
@@ -239,33 +257,26 @@ export const FiltersByMembers: Story = {
},
};
const mockOwnerOverride: UserAIBudgetOverride = {
user_id: MockUserOwner.id,
group_id: MockGroup2.id,
spend_limit_micros: 12_000_000_000,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
const mockCostControl: GroupMemberAICostControl = {
current_spend_micros: 1_345_000_000,
spend_limit_micros: 9_000_000_000,
effective_group_id: MockGroupWithoutMembers.id,
limit_source: "group",
};
// Member row with inline AI cost control; defaults to the page's group.
const memberWithSpend = (
user: ReducedUser,
overrides: Partial<GroupMemberAICostControl> = {},
): GroupMemberWithAICostControl => ({
...user,
ai_cost_control: {
current_spend_micros: 1_345_000_000,
spend_limit_micros: 9_000_000_000,
effective_group_id: MockGroupWithoutMembers.id,
limit_source: "override",
...overrides,
},
ai_cost_control: { ...mockCostControl, ...overrides },
});
const memberWithoutSpend: GroupMemberWithAICostControl = {
...MockUserMember,
id: "no-spend-user",
username: "no-spend",
const mockGroupBudget: GroupAIBudget = {
group_id: MockGroupWithoutMembers.id,
spend_limit_micros: 7_000_000_000,
created_at: "2026-06-01T00:00:00Z",
updated_at: "2026-06-01T00:00:00Z",
};
export const WithMemberAIBudget: Story = {
@@ -276,42 +287,34 @@ export const WithMemberAIBudget: Story = {
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
// Override source, no limit.
memberWithSpend(MockUserOwner, { spend_limit_micros: null }),
// Group source, finite limit.
memberWithSpend(MockUserMember, {
current_spend_micros: 5_492_000_000,
current_spend_micros: 3_235_000_000,
spend_limit_micros: 7_000_000_000,
limit_source: "group",
}),
// No cost control exercises the missing-spend "-" fallback.
memberWithoutSpend,
],
count: 3,
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{ key: meAISpendKey, data: mockUserAISpend },
{
key: groupAIBudget(MockGroupWithoutMembers.id).queryKey,
data: mockGroupBudget,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("AI budget")).toBeInTheDocument();
await expect(await canvas.findByText("Budget type")).toBeInTheDocument();
// Override source, no limit.
await expect(await canvas.findByText("Budget group")).toBeInTheDocument();
// Dates depend on the runner's timezone; match loosely.
await expect(
await canvas.findByTestId(`member-ai-budget-${MockUserOwner.id}`),
).toHaveTextContent("$1,345 / Unlimited USD");
await expect(await canvas.findByText("Individual")).toBeInTheDocument();
// Group source, finite limit.
await canvas.findByText(/^AI budget period: \w+ \d+ - \w+ \d+, 2026$/),
).toBeInTheDocument();
await expect(
await canvas.findByTestId(`member-ai-budget-${MockUserMember.id}`),
).toHaveTextContent("$5,492 / $7,000 USD");
await expect(await canvas.findByText("Group")).toBeInTheDocument();
// No spend reported for this member.
await expect(
await canvas.findByTestId(`member-ai-budget-${memberWithoutSpend.id}`),
).toHaveTextContent("-");
).toHaveTextContent("$3,235 USD");
// Column header tooltips.
const body = within(document.body);
await userEvent.click(
within(canvas.getByText("AI budget")).getByRole("button", {
@@ -320,64 +323,22 @@ export const WithMemberAIBudget: Story = {
);
await expect(
await body.findByText(
"A member's AI spend against their budget for the current period.",
/^Monthly AI spend for this user\. Resets .*The group's default limit is \$7,000 per member\.$/,
),
).toBeInTheDocument();
await userEvent.click(
within(canvas.getByText("Budget type")).getByRole("button", {
within(canvas.getByText("Budget group")).getByRole("button", {
name: "More info",
}),
);
await expect(
await body.findByText(
"Whether a member's budget comes from their group or an individual override.",
/The group or individual budget currently responsible for this user's AI spend\./,
),
).toBeInTheDocument();
},
};
// Budget governed by another group (effective_group_id points elsewhere): only
// the member's spend shows, with no limit or type.
export const WithMemberAIBudgetFromAnotherGroup: Story = {
parameters: {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
memberWithSpend(MockUserOwner, {
effective_group_id: MockGroup2.id,
limit_source: "group",
}),
],
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
data: MockGroup2,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const cell = await canvas.findByTestId(
`member-ai-budget-${MockUserOwner.id}`,
);
await expect(cell).toHaveTextContent("$1,345");
await expect(cell).not.toHaveTextContent("USD");
await expect(canvas.queryByText("Group")).not.toBeInTheDocument();
// The info tooltip names the group that sets the budget.
await userEvent.click(
within(cell).getByRole("button", { name: "More info" }),
);
await expect(await body.findByText(/developer/)).toBeInTheDocument();
},
};
// AI Bridge hidden: neither the AI budget nor the budget type column renders.
export const WithoutMemberAIBudgetColumn: Story = {
parameters: {
queries: [
@@ -390,11 +351,12 @@ export const WithoutMemberAIBudgetColumn: Story = {
const canvas = within(canvasElement);
await canvas.findByRole("table", { name: "Group members" });
expect(canvas.queryByText("AI budget")).not.toBeInTheDocument();
expect(canvas.queryByText("Budget type")).not.toBeInTheDocument();
expect(canvas.queryByText("Budget group")).not.toBeInTheDocument();
expect(canvas.queryByText(/AI budget period/)).not.toBeInTheDocument();
},
};
export const OpenAIBudgetFromMemberMenu: Story = {
export const AIBudgetActionDisabledForOtherGroup: Story = {
parameters: {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
@@ -405,50 +367,44 @@ export const OpenAIBudgetFromMemberMenu: Story = {
memberWithSpend(MockUserOwner, {
effective_group_id: MockGroup2.id,
}),
MockUserMember,
],
count: 2,
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{ key: meAISpendKey, data: mockUserAISpend },
{ key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null },
{
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
data: MockGroup2,
},
{
key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id),
data: mockOwnerOverride,
},
{
key: getGroupsForUserQueryKey(
MockUserOwner.id,
MockGroupWithoutMembers.organization_id,
),
data: [MockGroup],
},
{
key: groupAIBudget(MockGroup2.id).queryKey,
data: null,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
// Without a group default budget, the header note ends at the reset date.
await userEvent.click(
within(canvas.getByText("AI budget")).getByRole("button", {
name: "More info",
}),
);
await expect(
await body.findByText(/^Monthly AI spend for this user\. Resets .*\.$/),
).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await userEvent.click(
canvas.getAllByRole("button", { name: "Open menu" })[0],
);
await userEvent.click(
await body.findByRole("menuitem", { name: "AI Budget" }),
);
await expect(
await body.findByText("Custom monthly budget"),
).toBeInTheDocument();
await expect(await body.findByText("developer")).toBeInTheDocument();
const menuItem = await body.findByRole("menuitem", {
name: "Manage AI budget",
});
await expect(menuItem).toHaveAttribute("aria-disabled", "true");
},
};
// effective_group_id null: spend greys out, dialog marks no "(default)".
/** A null effective group means no budget applies: the member is unlimited. */
export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
parameters: {
features: ["aibridge"],
@@ -459,12 +415,13 @@ export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
users: [
memberWithSpend(MockUserOwner, {
effective_group_id: null,
limit_source: "group",
spend_limit_micros: null,
}),
],
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{ key: meAISpendKey, data: mockUserAISpend },
{ key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null },
{
key: getGroupsForUserQueryKey(
@@ -483,22 +440,18 @@ export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
const cell = await canvas.findByTestId(
`member-ai-budget-${MockUserOwner.id}`,
);
await expect(cell).toHaveTextContent("$1,345");
await expect(cell).not.toHaveTextContent("USD");
// Generic fallback when no group name resolves.
await expect(cell).toHaveTextContent("Unlimited");
await userEvent.click(
within(cell).getByRole("button", { name: "More info" }),
);
await expect(
await body.findByText(/set by another group/),
).toBeInTheDocument();
await expect(await body.findByText(/isn't restricted/)).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await userEvent.click(
canvas.getAllByRole("button", { name: "Open menu" })[0],
);
await userEvent.click(
await body.findByRole("menuitem", { name: "AI Budget" }),
await body.findByRole("menuitem", { name: "Manage AI budget" }),
);
await userEvent.click(await body.findByText("Override group budget"));
await expect(
@@ -508,7 +461,6 @@ export const WithMemberAIBudgetWithoutEffectiveGroup: Story = {
},
};
// Governed by the viewed group: the dialog marks it "(default)".
export const OpenAIBudgetForCurrentGroupMember: Story = {
parameters: {
features: ["aibridge"],
@@ -516,15 +468,11 @@ export const OpenAIBudgetForCurrentGroupMember: Story = {
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
memberWithSpend(MockUserOwner, {
effective_group_id: MockGroupWithoutMembers.id,
limit_source: "group",
}),
],
users: [memberWithSpend(MockUserOwner)],
count: 1,
}),
permissionsQuery({ canUpdateGroup: true }),
{ key: meAISpendKey, data: mockUserAISpend },
{ key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null },
{
key: getGroupsForUserQueryKey(
@@ -544,7 +492,7 @@ export const OpenAIBudgetForCurrentGroupMember: Story = {
canvas.getAllByRole("button", { name: "Open menu" })[0],
);
await userEvent.click(
await body.findByRole("menuitem", { name: "AI Budget" }),
await body.findByRole("menuitem", { name: "Manage AI budget" }),
);
await userEvent.click(await body.findByText("Override group budget"));
await expect(
@@ -552,3 +500,174 @@ export const OpenAIBudgetForCurrentGroupMember: Story = {
).toBeInTheDocument();
},
};
/** Unresolvable via getGroupById, standing in for another org's group. */
const unresolvedGroupId = "external-org-group";
/** Per-state details are covered by GroupMemberBudgetCells.stories. */
export const AIBudgetShowcase: Story = {
parameters: {
features: ["aibridge"],
experiments: ["ai-gateway-cost-control"],
queries: [
groupQuery(MockGroupWithoutMembers),
groupMembersQuery({
users: [
{
...MockUserMember,
id: "member-none",
username: "alice",
name: "Alice Chen",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 0,
spend_limit_micros: 0,
effective_group_id: MockGroupWithoutMembers.organization_id,
},
},
{
...MockUserMember,
id: "member-unlimited",
username: "bob",
name: "Bob Diaz",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 0,
spend_limit_micros: null,
effective_group_id: MockGroupWithoutMembers.organization_id,
},
},
{
...MockUserMember,
id: "member-elsewhere",
username: "priya",
name: "Priya Nair",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 456_000_000,
effective_group_id: unresolvedGroupId,
},
},
{
...MockUserMember,
id: "member-regular",
username: "jordan",
name: "Jordan Lee",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 3_235_000_000,
spend_limit_micros: 7_000_000_000,
},
},
{
...MockUserMember,
id: "member-custom",
username: "sam",
name: "Sam Okafor",
status: "dormant",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 7_175_000_000,
limit_source: "user_override",
},
},
{
...MockUserMember,
id: "member-near",
username: "morgan",
name: "Morgan Ito",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 6_735_000_000,
spend_limit_micros: 7_000_000_000,
},
},
{
...MockUserMember,
id: "member-over",
username: "casey",
name: "Casey Novak",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 7_200_000_000,
spend_limit_micros: 7_000_000_000,
},
},
{
...MockUserMember,
id: "member-other-group",
username: "riley",
name: "Riley Park",
status: "suspended",
ai_cost_control: {
...mockCostControl,
current_spend_micros: 456_000_000,
effective_group_id: MockGroup2.id,
},
},
],
count: 8,
}),
permissionsQuery({ canUpdateGroup: true }),
{ key: meAISpendKey, data: mockUserAISpend },
{
key: groupAIBudget(MockGroupWithoutMembers.id).queryKey,
data: mockGroupBudget,
},
{
key: getGroupByIdQueryKey(unresolvedGroupId, { exclude_members: true }),
data: null,
},
{
key: getGroupByIdQueryKey(MockGroup2.id, { exclude_members: true }),
data: MockGroup2,
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole("table", { name: "Group members" });
await expect(
await canvas.findByTestId("member-ai-budget-member-none"),
).toHaveTextContent("None");
await expect(
await canvas.findByTestId("member-ai-budget-member-unlimited"),
).toHaveTextContent("Unlimited");
await expect(
await canvas.findByTestId("member-ai-budget-member-regular"),
).toHaveTextContent("$3,235 USD");
await expect(
await canvas.findByTestId("member-ai-budget-member-custom"),
).toHaveTextContent("$7,175 USD");
await expect(
await canvas.findByTestId("member-ai-budget-member-other-group"),
).toHaveTextContent("Not attributed to this group");
const elsewhereCell = await canvas.findByTestId(
"member-ai-budget-member-elsewhere",
);
await expect(elsewhereCell).not.toHaveTextContent("$456");
const body = within(document.body);
await userEvent.click(
within(
await canvas.findByTestId("member-ai-budget-member-none"),
).getByRole("button", { name: "More info" }),
);
await expect(
await body.findByText(/no AI spending allowance/),
).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
// Everyone (unset) must not disable the override action.
await userEvent.click(
canvas.getAllByRole("button", { name: "Open menu" })[0],
);
const manageItem = await body.findByRole("menuitem", {
name: "Manage AI budget",
});
await expect(manageItem).not.toHaveAttribute("aria-disabled", "true");
},
};
+7 -2
View File
@@ -35,6 +35,7 @@ import {
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { pageTitle } from "#/utils/page";
import { AIBudgetPeriod } from "./AIBudgetPeriod";
export type GroupPageOutletContext = {
group: Group;
@@ -149,8 +150,11 @@ const GroupPage: FC = () => {
</div>
<div className="flex flex-col gap-10 w-full">
{canUpdateGroup && (
<LinkTabs active={activeTab}>
<LinkTabsList className="w-full justify-start">
<LinkTabs
active={activeTab}
className="flex items-baseline justify-between"
>
<LinkTabsList className="justify-start">
<TabLink to="." value="members">
Group members
</TabLink>
@@ -158,6 +162,7 @@ const GroupPage: FC = () => {
Group settings
</TabLink>
</LinkTabsList>
{activeTab === "members" && <AIBudgetPeriod />}
</LinkTabs>
)}
@@ -9,7 +9,7 @@ import {
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import { getErrorDetail } from "#/api/errors";
import { groupAIBudget, groupById, groupsForUser } from "#/api/queries/groups";
import { groupAIBudget, groupsForUser } from "#/api/queries/groups";
import {
deleteUserAIBudgetOverride,
saveUserAIBudgetOverride,
@@ -71,15 +71,6 @@ export const UserAIBudgetOverrideDialog: FC<
UserAIBudgetOverrideDialogProps
> = ({ open, onOpenChange, user, currentGroup, effectiveGroupId }) => {
const queryClient = useQueryClient();
const shouldLoadEffectiveGroup =
!!effectiveGroupId && effectiveGroupId !== currentGroup.id;
const effectiveGroupQuery = useQuery({
...groupById(effectiveGroupId ?? "", { exclude_members: true }),
enabled: open && shouldLoadEffectiveGroup,
});
const budgetGroup = shouldLoadEffectiveGroup
? effectiveGroupQuery.data
: currentGroup;
const budgetOverrideQuery = useQuery({
...userAIBudgetOverride(user.id),
enabled: open,
@@ -89,8 +80,8 @@ export const UserAIBudgetOverrideDialog: FC<
enabled: open,
});
const groupBudgetQuery = useQuery({
...groupAIBudget(budgetGroup?.id ?? currentGroup.id),
enabled: open && budgetGroup !== undefined,
...groupAIBudget(currentGroup.id),
enabled: open,
});
const saveMutation = useMutation(
saveUserAIBudgetOverride(queryClient, user.id),
@@ -100,12 +91,10 @@ export const UserAIBudgetOverrideDialog: FC<
);
const loadError =
effectiveGroupQuery.error ??
budgetOverrideQuery.error ??
userGroupsQuery.error ??
groupBudgetQuery.error;
const isLoading =
effectiveGroupQuery.isLoading ||
budgetOverrideQuery.isLoading ||
userGroupsQuery.isLoading ||
groupBudgetQuery.isLoading;
@@ -115,7 +104,6 @@ export const UserAIBudgetOverrideDialog: FC<
<Dialog
open={open}
onOpenChange={(nextOpen) => {
// Don't close while a mutation is in flight.
if (!isSubmitting) {
onOpenChange(nextOpen);
}
@@ -146,10 +134,10 @@ export const UserAIBudgetOverrideDialog: FC<
<Spinner loading />
Loading AI budget...
</div>
) : budgetGroup ? (
) : (
<OverrideForm
user={user}
currentGroup={budgetGroup}
currentGroup={currentGroup}
defaultGroupId={
effectiveGroupId === undefined
? currentGroup.id
@@ -163,7 +151,7 @@ export const UserAIBudgetOverrideDialog: FC<
onRemove={deleteMutation.mutateAsync}
onClose={() => onOpenChange(false)}
/>
) : null}
)}
</DialogContent>
</Dialog>
);
@@ -201,8 +189,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
const overrideId = useId();
const [overrideEnabled, setOverrideEnabled] = useState(override !== null);
// Seed from the override, else the group budget. Neither (uncapped) seeds
// empty, so enabling the override prompts for a value.
// Uncapped (no override or group budget) seeds empty, prompting for a value.
const [budgetDollars, setBudgetDollars] = useState(() => {
const seedMicros = (override ?? groupBudget)?.spend_limit_micros;
return seedMicros === undefined ? "" : String(microsToDollars(seedMicros));
@@ -232,9 +219,7 @@ const OverrideForm: FC<OverrideFormProps> = ({
// Hold the error until the field is touched, so it doesn't flag immediately.
const budgetInvalid = overrideEnabled && budgetTouched && !budgetValid;
const budgetDisablesAI = budgetValid && budgetAmount === 0;
// Footer shows only when there's something to save or remove.
const showFooter = overrideEnabled || override !== null;
// Submittable with a valid amount to write, or an existing override to remove.
const canSubmit =
!isSubmitting && (overrideEnabled ? budgetValid : override !== null);
-46
View File
@@ -2,10 +2,6 @@ import { describe, expect, it } from "vitest";
import {
clampPercentage,
getSeverity,
severityBorderClassName,
severityProgressClassName,
severityRingClassName,
severityTextClassName,
usageProgressPercentage,
} from "./budget";
@@ -37,48 +33,6 @@ describe("getSeverity", () => {
});
});
describe("severityTextClassName", () => {
it("maps each severity to its text color, defaulting to normal", () => {
expect(severityTextClassName("exceeded")).toBe("text-content-destructive");
expect(severityTextClassName("warning")).toBe("text-content-warning");
expect(severityTextClassName("normal")).toBe("text-content-secondary");
expect(severityTextClassName()).toBe("text-content-secondary");
});
});
describe("severityProgressClassName", () => {
it("maps each severity to its progress bar color, defaulting to normal", () => {
expect(severityProgressClassName("exceeded")).toBe(
"bg-content-destructive",
);
expect(severityProgressClassName("warning")).toBe("bg-content-warning");
expect(severityProgressClassName("normal")).toBe("bg-content-secondary");
expect(severityProgressClassName()).toBe("bg-content-secondary");
});
});
describe("severityRingClassName", () => {
it("maps each severity to its ring stroke color, defaulting to normal", () => {
expect(severityRingClassName("exceeded")).toBe(
"stroke-content-destructive",
);
expect(severityRingClassName("warning")).toBe("stroke-content-warning");
expect(severityRingClassName("normal")).toBe("stroke-content-secondary");
expect(severityRingClassName()).toBe("stroke-content-secondary");
});
});
describe("severityBorderClassName", () => {
it("maps each severity to its border color, defaulting to normal", () => {
expect(severityBorderClassName("exceeded")).toBe(
"border-content-destructive",
);
expect(severityBorderClassName("warning")).toBe("border-content-warning");
expect(severityBorderClassName("normal")).toBe("border-content-secondary");
expect(severityBorderClassName()).toBe("border-content-secondary");
});
});
describe("usageProgressPercentage", () => {
it("returns the usage percentage clamped from 0 to 100", () => {
expect(usageProgressPercentage(25, 100)).toBe(25);
-48
View File
@@ -18,54 +18,6 @@ export function getSeverity(used: number, budget: number): UsageSeverity {
return used / budget >= 0.85 ? "warning" : "normal";
}
const SEVERITY_CLASSES = {
normal: {
text: "text-content-secondary",
progress: "bg-content-secondary",
ring: "stroke-content-secondary",
border: "border-content-secondary",
},
warning: {
text: "text-content-warning",
progress: "bg-content-warning",
ring: "stroke-content-warning",
border: "border-content-warning",
},
exceeded: {
text: "text-content-destructive",
progress: "bg-content-destructive",
ring: "stroke-content-destructive",
border: "border-content-destructive",
},
} as const satisfies Record<
UsageSeverity,
{ text: string; progress: string; ring: string; border: string }
>;
export function severityTextClassName(
severity: UsageSeverity = "normal",
): string {
return SEVERITY_CLASSES[severity].text;
}
export function severityProgressClassName(
severity: UsageSeverity = "normal",
): string {
return SEVERITY_CLASSES[severity].progress;
}
export function severityRingClassName(
severity: UsageSeverity = "normal",
): string {
return SEVERITY_CLASSES[severity].ring;
}
export function severityBorderClassName(
severity: UsageSeverity = "normal",
): string {
return SEVERITY_CLASSES[severity].border;
}
export function usageProgressPercentage(used: number, budget: number): number {
if (!Number.isFinite(used) || !Number.isFinite(budget) || budget < 0) {
return 0;