refactor: remove AI add-on badge, seat column, and add-on wording leftovers (#28004)

Removes all "AI add-on" labeling from the UI, plus two leftover "AI
Governance add-on" wording items from the licensing repackaging (AI
Governance is now included with Premium).

- Remove the **AI add-on** badge from the group settings AI budget
section and the AI Governance add-on card on the Licenses page.
- Remove the **AI add-on** column (check icon showing AI seat
consumption) from the Users and Organization Members tables, along with
its help popover, the `AISeatCell` component, and the
`shouldShowAISeatColumn` entitlement helper.
- Update related Storybook stories.
- Drop "add-on" from the AI cost control route comments in
`enterprise/coderd/coderd.go` and the `coder exp ai-model-prices`
prerequisite in `docs/ai-coder/ai-gateway/cost-controls.md` (leftovers
not covered by #28075/#28077/#28268).

The `has_ai_seat` API field is left intact.

<details>
<summary>Removal scope</summary>

Badge usages removed:

- `site/src/pages/GroupsPage/GroupSettingsPageView.tsx`
-
`site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.tsx`

AI seat column plumbing removed:

- `site/src/pages/UsersPage/UsersTable.tsx` and `UsersPage.tsx`
- `site/src/pages/OrganizationSettingsPage/OrganizationMembersTable.tsx`
and `OrganizationMembersPage.tsx`
- `site/src/modules/users/AISeatCell.tsx` (deleted)
- `AiAddonHelpPopover` in `site/src/modules/users/UserHelpPopovers.tsx`
- `shouldShowAISeatColumn` in
`site/src/modules/dashboard/entitlements.ts`

Add-on wording leftovers:

- `enterprise/coderd/coderd.go`: 7 route comments (comments only, no
behavior change)
- `docs/ai-coder/ai-gateway/cost-controls.md`: model prices CLI
prerequisite

</details>

---

🤖 This pull request was generated by Coder Agents on behalf of
@tracyjohnsonux.
This commit is contained in:
TJ
2026-08-19 11:43:27 -07:00
committed by GitHub
parent f2bc9ab1f5
commit dd43574990
18 changed files with 45 additions and 259 deletions
+3 -3
View File
@@ -217,9 +217,9 @@ price for it yourself.
### Set model prices
Use the experimental `coder exp ai-model-prices` command to set prices for
models the price book does not cover. It requires the AI Governance add-on and
the `ai_model_price:update` permission. Run
`coder exp ai-model-prices --help` for the full reference.
models the price book does not cover. It requires AI Governance, which is
included with a Premium license, and the `ai_model_price:update` permission.
Run `coder exp ai-model-prices --help` for the full reference.
List the prices this deployment holds, optionally narrowed to one provider or
model:
+7 -7
View File
@@ -517,7 +517,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Post("/", api.postGroupByOrganization)
r.Get("/", api.groupsByOrganization)
r.Route("/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
// AI cost controls are a paid feature (AI Governance).
r.Use(
api.RequireFeatureMW(codersdk.FeatureAIBridge),
)
@@ -531,7 +531,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Get("/", api.groupByOrganization)
r.Get("/members", api.groupMembersByOrganization)
r.Route("/members/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
// AI cost controls are a paid feature (AI Governance).
r.Use(
api.RequireFeatureMW(codersdk.FeatureAIBridge),
)
@@ -548,7 +548,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Get("/", api.paginatedGroups)
})
r.Route("/organizations/{organization}/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
// AI cost controls are a paid feature (AI Governance).
r.Use(
apiKeyMiddleware,
httpmw.ExtractOrganizationParam(api.Database),
@@ -640,21 +640,21 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Delete("/", api.deleteGroup)
r.Get("/members", api.groupMembers)
r.Route("/members/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
// AI cost controls are a paid feature (AI Governance).
r.Use(
api.RequireFeatureMW(codersdk.FeatureAIBridge),
)
r.Get("/", api.groupMembersAISpend)
})
r.Route("/ai/spend", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
// AI cost controls are a paid feature (AI Governance).
r.Use(
api.RequireFeatureMW(codersdk.FeatureAIBridge),
)
r.Get("/", api.groupAISpend)
})
r.Route("/ai/budget", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
// AI cost controls are a paid feature (AI Governance).
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))
r.Get("/", api.groupAIBudget)
r.Put("/", api.upsertGroupAIBudget)
@@ -701,7 +701,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Put("/", api.putUserQuietHoursSchedule)
})
r.Route("/users/{user}/ai", func(r chi.Router) {
// AI cost controls are a paid feature (AI Governance add-on).
// AI cost controls are a paid feature (AI Governance).
r.Use(
api.RequireFeatureMW(codersdk.FeatureAIBridge),
apiKeyMiddleware,
@@ -28,17 +28,3 @@ export const selectFeatureVisibility = (
): Record<FeatureName, boolean> => {
return getFeatureVisibility(entitlements.has_license, entitlements.features);
};
/**
* Keep the AI seats column visible while in grace period so admins can
* identify who is consuming seats while remediating overages.
*/
export const shouldShowAISeatColumn = (entitlements: Entitlements): boolean => {
const aiGovernanceUserLimit = entitlements.features.ai_governance_user_limit;
return (
entitlements.has_license &&
aiGovernanceUserLimit.enabled &&
(aiGovernanceUserLimit.entitlement === "entitled" ||
aiGovernanceUserLimit.entitlement === "grace_period")
);
};
-28
View File
@@ -1,28 +0,0 @@
import { CheckIcon } from "lucide-react";
import type { FC } from "react";
import { TableCell } from "#/components/Table/Table";
interface AISeatCellProps {
hasAISeat: boolean;
}
export const AISeatCell: FC<AISeatCellProps> = ({ hasAISeat }) => {
return (
<TableCell>
{hasAISeat ? (
<CheckIcon
className="size-5 text-content-success"
aria-label="Consuming AI seat"
/>
) : (
<span
role="img"
aria-label="Not consuming AI seat"
className="text-content-disabled"
>
&mdash;
</span>
)}
</TableCell>
);
};
@@ -49,18 +49,3 @@ export const GroupsHelpPopover: FC = () => {
</HelpPopover>
);
};
export const AiAddonHelpPopover: FC = () => {
return (
<HelpPopover>
<HelpPopoverIconTrigger size="small" />
<HelpPopoverContent>
<HelpPopoverTitle>What is the AI add-on?</HelpPopoverTitle>
<HelpPopoverText>
Users with access to AI features like AI Gateway or Tasks who are
actively consuming a seat.
</HelpPopoverText>
</HelpPopoverContent>
</HelpPopover>
);
};
@@ -1,6 +1,5 @@
import { InfoIcon } from "lucide-react";
import type { FC } from "react";
import { Badge } from "#/components/Badge/Badge";
import {
Tooltip,
TooltipContent,
@@ -32,29 +31,24 @@ export const AIGovernanceAddOnCard: FC<AIGovernanceAddOnCardProps> = ({
>
<div className="flex items-center gap-1 px-6 py-1.5">
<div className="flex flex-1 items-center gap-1">
<div className="flex items-center gap-6">
<div className="flex items-center gap-1">
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-content-primary">
{title}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="AI Governance seat information"
className="m-0 inline-flex appearance-none border-0 bg-transparent p-0 text-content-secondary"
>
<InfoIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
Seats consumed by users using AI Governance features.
</TooltipContent>
</Tooltip>
</div>
<Badge variant="magenta" size="sm">
AI add-on
</Badge>
<div className="flex items-center gap-1">
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-content-primary">
{title}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="AI Governance seat information"
className="m-0 inline-flex appearance-none border-0 bg-transparent p-0 text-content-secondary"
>
<InfoIcon className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
Seats consumed by users using AI Governance features.
</TooltipContent>
</Tooltip>
</div>
<div className="min-w-[100px] flex-1 pl-8 pr-3">
@@ -808,6 +808,5 @@ export const EnterpriseDoesNotShowAIGovernanceAddOn: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument();
await expect(canvas.queryByText("AI add-on")).not.toBeInTheDocument();
},
};
@@ -24,7 +24,7 @@ type Story = StoryObj<typeof GroupSettingsPageView>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Without the AI add-on, the AI budget section is hidden.
// Without the aibridge feature, the AI budget section is hidden.
await expect(canvas.queryByText("AI budget")).not.toBeInTheDocument();
},
};
@@ -2,7 +2,6 @@ import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import * as Yup from "yup";
import type { Group } from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { IconField } from "#/components/IconField/IconField";
import { Input } from "#/components/Input/Input";
@@ -135,7 +134,7 @@ const AIBudgetFeedback: FC<AIBudgetFeedbackProps> = ({
interface UpdateGroupFormProps {
group: Group;
/** Whether the AI add-on settings are shown (gated by the aibridge feature). */
/** Whether the AI budget settings are shown (gated by the aibridge feature). */
showAISettings: boolean;
/** Per-member AI budget in dollars, or null for unlimited spend. */
initialBudgetDollars: number | null;
@@ -251,14 +250,9 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
{showAISettings && (
<section className="flex flex-col gap-8 max-w-md">
<div className="flex items-center gap-2">
<h2 className="m-0 text-xl font-semibold text-content-primary">
AI budget
</h2>
<Badge variant="purple" size="sm">
AI add-on
</Badge>
</div>
<h2 className="m-0 text-xl font-semibold text-content-primary">
AI budget
</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col items-start gap-2">
<Label htmlFor={budgetField.id}>Monthly limit per member</Label>
@@ -278,7 +272,9 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({
placeholder="no budget"
aria-invalid={budgetField.error}
/>
<InputGroupAddon align="inline-end">USD</InputGroupAddon>
<InputGroupAddon align="inline-end" className="pr-3">
USD
</InputGroupAddon>
</InputGroup>
<AIBudgetFeedback
error={budgetField.error}
@@ -388,7 +388,9 @@ const OverrideForm: FC<OverrideFormProps> = ({
budgetInvalid ? `${budgetId}-error` : undefined
}
/>
<InputGroupAddon align="inline-end">USD</InputGroupAddon>
<InputGroupAddon align="inline-end" className="pr-3">
USD
</InputGroupAddon>
</InputGroup>
{budgetInvalid && (
<p
@@ -21,7 +21,6 @@ import { EmptyState } from "#/components/EmptyState/EmptyState";
import { useFilter } from "#/components/Filter/Filter";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { shouldShowAISeatColumn } from "#/modules/dashboard/entitlements";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { useOrganizationSettings } from "#/modules/management/OrganizationSettingsLayout";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
@@ -38,7 +37,6 @@ const OrganizationMembersPage: FC = () => {
const { organization, organizationPermissions } = useOrganizationSettings();
const { entitlements } = useDashboard();
const searchParamsResult = useSearchParams();
const showAISeatColumn = shouldShowAISeatColumn(entitlements);
const organizationRolesQuery = useQuery(organizationRoles(organizationName));
const groupsByUserIdQuery = useQuery(
@@ -123,7 +121,6 @@ const OrganizationMembersPage: FC = () => {
organizationName={organizationName}
membersQuery={membersQuery}
members={members}
showAISeatColumn={showAISeatColumn}
addMembers={async (users: User[]) => {
// TODO: Replace with a batch endpoint (POST /organizations/{org}/members)
// to add all users in a single request instead of N individual calls.
@@ -54,12 +54,6 @@ type Story = StoryObj<typeof OrganizationMembersPageView>;
export const Default: Story = {};
export const WithAIAddonColumn: Story = {
args: {
showAISeatColumn: true,
},
};
export const Loading: Story = {
args: {
members: undefined,
@@ -25,10 +25,8 @@ import {
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import { AISeatCell } from "#/modules/users/AISeatCell";
import { UserGroupsCell } from "#/modules/users/UserGroupsCell";
import {
AiAddonHelpPopover,
GroupsHelpPopover,
RolesHelpPopover,
} from "#/modules/users/UserHelpPopovers";
@@ -38,7 +36,6 @@ export type OrganizationMembersTableProps = {
// State
organizationName: string;
members: Array<OrganizationMemberTableEntry> | undefined;
showAISeatColumn?: boolean;
// Actions
onEditMemberRoles: (member: OrganizationMemberWithUserData) => void;
@@ -62,8 +59,6 @@ type OrganizationMemberTableEntry = OrganizationMemberWithUserData & {
export const OrganizationMembersTable: React.FC<
OrganizationMembersTableProps
> = (props) => {
const { showAISeatColumn } = props;
return (
<Table>
<TableHeader>
@@ -81,14 +76,6 @@ export const OrganizationMembersTable: React.FC<
<GroupsHelpPopover />
</div>
</TableHead>
{showAISeatColumn && (
<TableHead className="w-1/6">
<div className="flex flex-row items-center gap-2">
<span>AI add-on</span>
<AiAddonHelpPopover />
</div>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
@@ -101,7 +88,6 @@ export const OrganizationMembersTable: React.FC<
const OrganizationMembersTableBody: React.FC<OrganizationMembersTableProps> = ({
organizationName,
members,
showAISeatColumn,
isUpdatingMemberRoles,
removeMember,
@@ -141,7 +127,6 @@ const OrganizationMembersTableBody: React.FC<OrganizationMembersTableProps> = ({
roles={member.roles}
/>
<UserGroupsCell userGroups={member.groups} />
{showAISeatColumn && <AISeatCell hasAISeat={member.has_ai_seat} />}
<TableCell className="w-px whitespace-nowrap text-right">
<div className="flex justify-end">
{member.user_id !== me && canEditMembers && (
+1 -29
View File
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, spyOn, userEvent, within } from "storybook/test";
import { screen, spyOn, userEvent, within } from "storybook/test";
import { API } from "#/api/api";
import { deploymentConfigQueryKey } from "#/api/queries/deployment";
import { groupsQueryKey } from "#/api/queries/groups";
@@ -79,34 +79,6 @@ type Story = StoryObj<typeof UsersPage>;
export const Loaded: Story = {};
export const WithAIAddonColumn: Story = {
parameters: {
features: ["ai_governance_user_limit"],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const header = await canvas.findByRole("columnheader", {
name: /AI add-on/i,
});
await expect(header).toBeVisible();
},
};
export const WithoutAIAddonColumn: Story = {
parameters: {
features: ["audit_log"],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole("columnheader", { name: "User" });
await expect(
canvas.queryByRole("columnheader", { name: /AI add-on/i }),
).not.toBeInTheDocument();
},
};
export const SuspendUserSuccess: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
-3
View File
@@ -21,7 +21,6 @@ import { useFilter } from "#/components/Filter/Filter";
import { useStatusFilterMenu } from "#/components/Filter/UsersFilter";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { shouldShowAISeatColumn } from "#/modules/dashboard/entitlements";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { RoleSelectorDialog } from "#/modules/roles/RoleSelectorDialog";
import { pageTitle } from "#/utils/page";
@@ -33,7 +32,6 @@ const UsersPage: React.FC = () => {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const { entitlements } = useDashboard();
const showAISeatColumn = shouldShowAISeatColumn(entitlements);
const groupsByUserIdQuery = useQuery(groupsByUserId());
@@ -113,7 +111,6 @@ const UsersPage: React.FC = () => {
}}
usersQuery={usersQuery}
groupsByUserId={groupsByUserIdQuery.data}
showAISeatColumn={showAISeatColumn}
onEditUserRoles={setUserToEditRoles}
isUpdatingUserRoles={updateUserRolesMutation.isPending}
onResetUserPassword={(user) => {
@@ -35,10 +35,7 @@ const meta: Meta<typeof UsersPageView> = {
totalRecords: 2,
data: {
count: 2,
users: [
{ ...MockUserOwner, has_ai_seat: false },
{ ...MockUserMember, has_ai_seat: false },
],
users: [MockUserOwner, MockUserMember],
},
},
},
@@ -26,32 +26,17 @@ type Story = StoryObj<typeof UsersTable>;
export const Example: Story = {
args: {
users: [
{ ...MockUserOwner, has_ai_seat: false },
{ ...MockUserMember, has_ai_seat: false },
],
users: [MockUserOwner, MockUserMember],
canEditUsers: false,
groupsByUserId: mockGroupsByUserId,
},
};
export const ExampleWithAISeatColumn: Story = {
args: {
users: [
{ ...MockUserOwner, has_ai_seat: true },
{ ...MockUserMember, has_ai_seat: false },
],
canEditUsers: false,
groupsByUserId: mockGroupsByUserId,
showAISeatColumn: true,
},
};
export const Editable: Story = {
args: {
users: [
{ ...MockUserOwner, has_ai_seat: false },
{ ...MockUserMember, has_ai_seat: false },
MockUserOwner,
MockUserMember,
{
...MockUserOwner,
username: "John Doe",
@@ -63,7 +48,6 @@ export const Editable: Story = {
MockAuditorRole,
],
status: "dormant",
has_ai_seat: false,
},
{
...MockUserOwner,
@@ -71,7 +55,6 @@ export const Editable: Story = {
email: "roger.moore@coder.com",
roles: [],
status: "suspended",
has_ai_seat: false,
},
{
...MockUserOwner,
@@ -80,7 +63,6 @@ export const Editable: Story = {
roles: [],
status: "active",
login_type: "oidc",
has_ai_seat: false,
},
],
canEditUsers: true,
@@ -89,49 +71,6 @@ export const Editable: Story = {
},
};
export const EditableWithAISeatColumn: Story = {
args: {
users: [
{ ...MockUserOwner, has_ai_seat: true },
{ ...MockUserMember, has_ai_seat: false },
{
...MockUserOwner,
username: "John Doe",
email: "john.doe@coder.com",
roles: [
MockUserAdminRole,
MockTemplateAdminRole,
MockMemberRole,
MockAuditorRole,
],
status: "dormant",
has_ai_seat: false,
},
{
...MockUserOwner,
username: "Roger Moore",
email: "roger.moore@coder.com",
roles: [],
status: "suspended",
has_ai_seat: false,
},
{
...MockUserOwner,
username: "OIDC User",
email: "oidc.user@coder.com",
roles: [],
status: "active",
login_type: "oidc",
has_ai_seat: false,
},
],
canEditUsers: true,
canViewActivity: true,
groupsByUserId: mockGroupsByUserId,
showAISeatColumn: true,
},
};
export const Empty: Story = {
args: {
users: [],
+1 -30
View File
@@ -30,10 +30,8 @@ import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "#/components/TableLoader/TableLoader";
import { AISeatCell } from "#/modules/users/AISeatCell";
import { UserGroupsCell } from "#/modules/users/UserGroupsCell";
import {
AiAddonHelpPopover,
GroupsHelpPopover,
RolesHelpPopover,
} from "#/modules/users/UserHelpPopovers";
@@ -47,7 +45,6 @@ export type UsersTableProps = {
isLoading: boolean;
users: readonly TypesGen.User[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
showAISeatColumn?: boolean;
// Actions
onEditUserRoles: (user: TypesGen.User) => void;
@@ -70,8 +67,6 @@ export type UsersTableProps = {
};
export const UsersTable: React.FC<UsersTableProps> = (props) => {
const { showAISeatColumn } = props;
return (
<Table data-testid="users-table">
<TableHeader>
@@ -89,14 +84,6 @@ export const UsersTable: React.FC<UsersTableProps> = (props) => {
<GroupsHelpPopover />
</div>
</TableHead>
{showAISeatColumn && (
<TableHead className="w-1/6">
<div className="flex flex-row gap-2 items-center">
<span>AI add-on</span>
<AiAddonHelpPopover />
</div>
</TableHead>
)}
<TableHead className="w-1/6">Status</TableHead>
</TableRow>
</TableHeader>
@@ -112,7 +99,6 @@ const UsersTableBody: React.FC<UsersTableProps> = ({
isLoading,
users,
groupsByUserId,
showAISeatColumn,
onEditUserRoles,
isUpdatingUserRoles,
@@ -127,12 +113,7 @@ const UsersTableBody: React.FC<UsersTableProps> = ({
oidcRoleSyncEnabled,
}) => {
if (isLoading) {
return (
<UsersTableSkeleton
showAISeatColumn={showAISeatColumn}
canEditUsers={canEditUsers}
/>
);
return <UsersTableSkeleton canEditUsers={canEditUsers} />;
}
if (!users || users.length === 0) {
@@ -157,8 +138,6 @@ const UsersTableBody: React.FC<UsersTableProps> = ({
<UserGroupsCell userGroups={groupsByUserId?.get(user.id)} />
{showAISeatColumn && <AISeatCell hasAISeat={user.has_ai_seat} />}
<TableCell
className={cn(
"capitalize",
@@ -261,12 +240,10 @@ const UsersTableBody: React.FC<UsersTableProps> = ({
};
type UsersTableSkeletonProps = {
showAISeatColumn?: boolean;
canEditUsers: boolean;
};
const UsersTableSkeleton: React.FC<UsersTableSkeletonProps> = ({
showAISeatColumn,
canEditUsers,
}) => {
return (
@@ -284,12 +261,6 @@ const UsersTableSkeleton: React.FC<UsersTableSkeletonProps> = ({
<Skeleton variant="text" width="25%" />
</TableCell>
{showAISeatColumn && (
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
)}
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>