From c782cbce77b08c0d031a7131a05f5babd762519f Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 29 Jun 2026 00:52:47 +0300 Subject: [PATCH] feat(site/src/pages/GroupsPage): update group AI budget UI (#26659) Update the group AI budget settings UI to match the unlimited, no-budget, and finite-budget states. The field now uses a USD input suffix, the label reads "Monthly limit per member", and unlimited or $0 budgets show explanatory helper text with an info alert. --- .../GroupSettingsPageView.stories.tsx | 21 ++- .../GroupsPage/GroupSettingsPageView.tsx | 143 ++++++++++++------ 2 files changed, 113 insertions(+), 51 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx index 54db6dd909..98cb79f48f 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx @@ -37,6 +37,9 @@ export const WithAIBudget: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("AI budget")).toBeInTheDocument(); + await expect(canvas.getByLabelText("Monthly limit per member")).toHaveValue( + 1000, + ); const helper = canvas.getByText(/month maximum/i); await expect(helper).toHaveTextContent( "$7,000/month maximum, based on 7 members.", @@ -52,7 +55,11 @@ export const AIBudgetUncapped: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( - canvas.getByText("Leave empty for uncapped spend."), + canvas.getByLabelText("Monthly limit per member"), + ).toHaveAttribute("placeholder", "unlimited"); + await expect(canvas.getByText("unlimited budget")).toBeInTheDocument(); + await expect( + canvas.getByText("Members in this group have no spending cap."), ).toBeInTheDocument(); }, }; @@ -65,11 +72,13 @@ export const AIBudgetDisabled: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // A budget of 0 is valid and reads as disabled spend. - const helper = canvas.getByText(/month maximum/i); - await expect(helper).toHaveTextContent( - "$0/month maximum, based on 7 members.", + await expect(canvas.getByLabelText("Monthly limit per member")).toHaveValue( + 0, ); + await expect(canvas.getByText("no budget")).toBeInTheDocument(); + await expect( + canvas.getByText("A $0 limit disables AI access for this group."), + ).toBeInTheDocument(); }, }; @@ -96,7 +105,7 @@ export const SaveWithBudget: Story = { }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - const input = canvas.getByLabelText("Monthly budget per member (USD)"); + const input = canvas.getByLabelText("Monthly limit per member"); await userEvent.type(input, "25"); await userEvent.click(canvas.getByRole("button", { name: "Save" })); // onSubmit fires asynchronously with (values, formikHelpers). diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx index 46982ddca8..a2eee3fc73 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx @@ -1,11 +1,17 @@ import { useFormik } from "formik"; -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import * as Yup from "yup"; import type { Group } from "#/api/typesGenerated"; +import { Alert } from "#/components/Alert/Alert"; import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; import { Label } from "#/components/Label/Label"; import { Spinner } from "#/components/Spinner/Spinner"; import { isEveryoneGroup } from "#/modules/groups"; @@ -21,24 +27,88 @@ type FormData = { display_name: string; avatar_url: string; quota_allowance: number; - // Per-member AI budget, in dollars. "" is no budget (uncapped); 0 disables. + // Per-member AI budget, in dollars. "" is unlimited; 0 disables. monthly_budget_per_member: string; }; const validationSchema = Yup.object({ name: nameValidator("Name"), quota_allowance: Yup.number().required().min(0).integer(), - // Optional: empty is uncapped. A value must be zero or more (0 disables). + // Optional: empty is unlimited. A value must be zero or more; 0 disables. monthly_budget_per_member: Yup.number() .transform((value, original) => (original === "" ? undefined : value)) .min(0, "Enter an amount of zero or more."), }); +interface AIBudgetFeedbackProps { + error: boolean; + helperText?: ReactNode; + monthlyBudgetPerMember: string; + memberCount: number; +} + +const AIBudgetFeedback: FC = ({ + error, + helperText, + monthlyBudgetPerMember, + memberCount, +}) => { + if (error) { + return ( + + {helperText} + + ); + } + + const budgetValue = monthlyBudgetPerMember.trim(); + const budgetAmount = Number(budgetValue); + + // Empty means unlimited spend; $0 disables AI access. Both states show an + // explanatory alert alongside the summary line. + if (budgetValue === "" || budgetAmount === 0) { + const { label, message } = + budgetValue === "" + ? { + label: "unlimited budget", + message: "Members in this group have no spending cap.", + } + : { + label: "no budget", + message: "A $0 limit disables AI access for this group.", + }; + return ( + <> + + This group has{" "} + {label}. + + {message} + + ); + } + + if (Number.isFinite(budgetAmount) && budgetAmount > 0) { + return ( + + + {usdBudgetFormatter.format(budgetAmount * memberCount)} + + /month maximum, based on{" "} + {memberCount}{" "} + {memberCount === 1 ? "member" : "members"}. + + ); + } + + return null; +}; + interface UpdateGroupFormProps { group: Group; /** Whether the AI add-on settings are shown (gated by the aibridge feature). */ showAISettings: boolean; - /** Per-member AI budget in dollars, or null when none is set. */ + /** Per-member AI budget in dollars, or null for unlimited spend. */ initialBudgetDollars: number | null; errors: unknown; onSubmit: (data: FormData) => void; @@ -77,11 +147,6 @@ const UpdateGroupForm: FC = ({ of its members.`, }); const budgetField = getFieldHelpers("monthly_budget_per_member"); - const budgetDollars = form.values.monthly_budget_per_member; - const memberCount = group.total_member_count; - const monthlyMaximum = usdBudgetFormatter.format( - Number(budgetDollars) * memberCount, - ); return (
@@ -158,7 +223,7 @@ const UpdateGroupForm: FC = ({ {showAISettings && (
-

+

AI budget

@@ -167,42 +232,30 @@ const UpdateGroupForm: FC = ({
- - - form.setFieldValue(budgetField.name, event.target.value) - } - onBlur={budgetField.onBlur} - type="number" - min="0" - step="1" - aria-invalid={budgetField.error} + + + + form.setFieldValue(budgetField.name, event.target.value) + } + onBlur={budgetField.onBlur} + type="number" + min="0" + step="1" + placeholder="unlimited" + aria-invalid={budgetField.error} + /> + USD + + - {budgetField.error ? ( - - {budgetField.helperText} - - ) : budgetDollars.trim() !== "" ? ( - - - {monthlyMaximum} - - /month maximum, based on{" "} - - {memberCount} - {" "} - {memberCount === 1 ? "member" : "members"}. - - ) : ( - - Leave empty for uncapped spend. - - )}