From d5db082de2f299aedf4ceffc08ca2cd548f1393b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 23 Jul 2026 11:48:58 +0000 Subject: [PATCH 1/2] feat(site/src): show spend for unlimited and zero AI budgets The group members table showed a bare Unlimited label when a member's budget resolves to a group with no limit, hiding their spend. It now renders the spend against the limit as "$X / Unlimited USD", matching the user dropdown, keeping the info tooltip. A $0 budget previously rendered as a special "None" label. It now renders like any other limit: the spend amount with a "Group limit $0" sub-label, taking the exceeded color only when spend is above zero. --- .../AIBudgetUsage/AIBudgetUsage.stories.tsx | 8 +++++ .../GroupMemberBudgetCells.stories.tsx | 34 ++++++++++++++----- .../GroupsPage/GroupMemberBudgetCells.tsx | 16 ++++----- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx b/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx index e51d53c4aa4..f9d0582749d 100644 --- a/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx +++ b/site/src/components/AIBudgetUsage/AIBudgetUsage.stories.tsx @@ -50,3 +50,11 @@ export const ZeroBudget: Story = { await expect(canvasElement).toHaveTextContent("$5 / $0 USD"); }, }; + +// Zero budget with no spend: shown as-is in the normal color. +export const ZeroBudgetNoSpend: Story = { + args: { currentSpend: 0, spendLimit: 0 }, + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent("$0 / $0 USD"); + }, +}; diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx index 98ae0ceda1c..70d5b6cf17b 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx @@ -74,6 +74,7 @@ export const Unlimited: Story = { args: { spend: { ...mockSpend, + group_spend_micros: 1_250_000_000, group_budget: null, effective_group_id: group.organization_id, }, @@ -81,7 +82,7 @@ export const Unlimited: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(await canvas.findByTestId(testId)).toHaveTextContent( - "Unlimited", + "$1,250 / Unlimited USD", ); await expect( canvas.getByText("Everyone (not allocated)"), @@ -102,13 +103,14 @@ export const UnlimitedThisGroup: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(await canvas.findByTestId(testId)).toHaveTextContent( - "Unlimited", + "$0 / Unlimited USD", ); await expect(canvas.getByText("Front-End")).toBeInTheDocument(); }, }; -export const None: Story = { +// A $0 budget renders like any other limit; no spend keeps the normal color. +export const ZeroBudget: Story = { args: { spend: { ...mockSpend, @@ -118,11 +120,27 @@ export const None: Story = { }, 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(); + const cell = await canvas.findByTestId(testId); + await expect(cell).toHaveTextContent("$0 USD"); + await expect(cell).toHaveTextContent("Group limit $0"); + }, +}; + +// Visual variant of ZeroBudget: spend over a $0 budget takes the exceeded color. +export const ZeroBudgetExceeded: Story = { + args: { + spend: { + ...mockSpend, + group_spend_micros: 100_000_000, + group_budget: { spend_limit_micros: 0, limit_source: "group" }, + effective_group_id: group.organization_id, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const cell = await canvas.findByTestId(testId); + await expect(cell).toHaveTextContent("$100 USD"); + await expect(cell).toHaveTextContent("Group limit $0"); }, }; diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx index 9efe4690424..17d99badcf5 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx @@ -3,6 +3,7 @@ import { useQuery } from "react-query"; import { groupById } from "#/api/queries/groups"; import type { Group, GroupMemberAISpend } from "#/api/typesGenerated"; import { AIBudgetAmount } from "#/components/AIBudgetAmount/AIBudgetAmount"; +import { AIBudgetUsage } from "#/components/AIBudgetUsage/AIBudgetUsage"; import { Badge } from "#/components/Badge/Badge"; import { Spinner } from "#/components/Spinner/Spinner"; import { TableCell } from "#/components/Table/Table"; @@ -116,18 +117,15 @@ export const GroupMemberBudgetCells: FC<{ // The effective group has no budget, so no limit applies. budget = ( + } 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 = ( - - ); } else { const limitLabel = spend.group_budget?.limit_source === "user_override" From d91a98d28b3d5c7ab129668a210e76f1e5f88104 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 23 Jul 2026 20:27:30 +0300 Subject: [PATCH 2/2] fix(site/src): show allocated Everyone budgets and clarify spend tooltip --- .../GroupMemberBudgetCells.stories.tsx | 67 ++++++++++++++++--- .../GroupsPage/GroupMemberBudgetCells.tsx | 20 ++++-- site/src/testHelpers/entities.ts | 2 +- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx index 70d5b6cf17b..758c8b66929 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.stories.tsx @@ -10,7 +10,11 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { MockGroup2, MockGroupWithoutMembers } from "#/testHelpers/entities"; +import { + MockEveryoneGroup, + MockGroup2, + MockGroupWithoutMembers, +} from "#/testHelpers/entities"; import { GroupMemberBudgetCells } from "./GroupMemberBudgetCells"; const group = MockGroupWithoutMembers; @@ -92,12 +96,14 @@ export const Unlimited: Story = { }, }; -// Unlimited where the viewed group itself is the effective group. -export const UnlimitedThisGroup: Story = { +// Only the Everyone group can be an effective group without a budget. +export const UnlimitedEveryoneGroup: Story = { args: { + group: MockEveryoneGroup, spend: { ...mockSpend, group_budget: null, + effective_group_id: MockEveryoneGroup.id, }, }, play: async ({ canvasElement }) => { @@ -105,7 +111,51 @@ export const UnlimitedThisGroup: Story = { await expect(await canvas.findByTestId(testId)).toHaveTextContent( "$0 / Unlimited USD", ); - await expect(canvas.getByText("Front-End")).toBeInTheDocument(); + await expect( + canvas.getByText("Everyone (not allocated)"), + ).toBeInTheDocument(); + }, +}; + +// The Everyone group's own budget governs; the badge drops "(not allocated)". +export const EveryoneGroupWithBudget: Story = { + args: { + group: MockEveryoneGroup, + spend: { + ...mockSpend, + group_spend_micros: 1_250_000_000, + effective_group_id: MockEveryoneGroup.id, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const cell = await canvas.findByTestId(testId); + await expect(cell).toHaveTextContent("$1,250 USD"); + await expect(cell).toHaveTextContent("Group limit $7,000"); + await expect(canvas.getByText("Everyone")).toBeInTheDocument(); + await expect(canvas.queryByText(/not allocated/)).not.toBeInTheDocument(); + }, +}; + +// A user override resolving to the Everyone group shows as individual. +export const EveryoneGroupIndividual: Story = { + args: { + group: MockEveryoneGroup, + spend: { + ...mockSpend, + group_spend_micros: 1_250_000_000, + effective_group_id: MockEveryoneGroup.id, + group_budget: { + 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("Custom limit $9,000"); + await expect(canvas.getByText("Everyone (individual)")).toBeInTheDocument(); }, }; @@ -115,7 +165,6 @@ export const ZeroBudget: Story = { spend: { ...mockSpend, group_budget: { spend_limit_micros: 0, limit_source: "group" }, - effective_group_id: group.organization_id, }, }, play: async ({ canvasElement }) => { @@ -123,6 +172,7 @@ export const ZeroBudget: Story = { const cell = await canvas.findByTestId(testId); await expect(cell).toHaveTextContent("$0 USD"); await expect(cell).toHaveTextContent("Group limit $0"); + await expect(canvas.getByText("Front-End")).toBeInTheDocument(); }, }; @@ -133,7 +183,6 @@ export const ZeroBudgetExceeded: Story = { ...mockSpend, group_spend_micros: 100_000_000, group_budget: { spend_limit_micros: 0, limit_source: "group" }, - effective_group_id: group.organization_id, }, }, play: async ({ canvasElement }) => { @@ -206,13 +255,13 @@ export const NotAttributed: Story = { 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(cell).toHaveTextContent("Budget managed by another 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/), + await body.findByText(/this user's spend in the/), ).toHaveTextContent( - "None of this user's spend counts against the Front-End group. It is managed by the developer group.", + "The amount shown is this user's spend in the Front-End group. Their AI budget is currently managed by the developer group.", ); }, }; diff --git a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx index 17d99badcf5..eb692206162 100644 --- a/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx +++ b/site/src/pages/GroupsPage/GroupMemberBudgetCells.tsx @@ -17,8 +17,8 @@ const OTHER_ORG_MESSAGE = "This user's AI budget is managed by a group in another organization and isn't visible here."; /** - * 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. + * The AI budget and Budget group cells for a group member. Spend is scoped to + * the viewed group; the limit comes from the member's effective group. */ export const GroupMemberBudgetCells: FC<{ group: Group; @@ -51,7 +51,15 @@ export const GroupMemberBudgetCells: FC<{ budgetGroup = EM_DASH; break; case "everyone": - budgetGroup = Everyone (not allocated); + // A populated budget means the Everyone group's own budget applies, + // so it isn't the unallocated fallback. + budgetGroup = ( + + {spend?.group_budget + ? badgeName("Everyone") + : "Everyone (not allocated)"} + + ); break; case "this": budgetGroup = {badgeName(groupName)}; @@ -92,11 +100,11 @@ export const GroupMemberBudgetCells: FC<{ - None of this user's spend counts against the{" "} + The amount shown is this user's spend in the{" "} {groupName} {" "} - group. It is managed by the{" "} + group. Their AI budget is currently managed by the{" "} {effectiveGroupName} {" "} @@ -106,7 +114,7 @@ export const GroupMemberBudgetCells: FC<{ /> - Not attributed to this group + Budget managed by another group ); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index cf6674b2ed5..95158700332 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3243,7 +3243,7 @@ export const MockGroup3: TypesGen.Group = { total_member_count: 2, }; -const MockEveryoneGroup: TypesGen.Group = { +export const MockEveryoneGroup: TypesGen.Group = { // The "Everyone" group must have the same ID as a the organization it belongs // to. id: MockOrganization.id,