From 21bb67205365511b2c442945eeb701e8eee2455b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 15 Jul 2026 11:47:28 +0000 Subject: [PATCH 01/13] feat(site): add per-template Coder Agents controls --- site/src/api/queries/chats.ts | 19 - site/src/api/queries/templates.ts | 28 ++ .../AISettingsSidebarView.stories.tsx | 29 ++ .../management/AISettingsSidebarView.tsx | 4 +- .../TemplatesPage/TemplatesPage.stories.tsx | 68 +++ .../TemplatesPage/TemplatesPage.tsx | 86 ++-- .../TemplatesPageView.stories.tsx | 281 +++++------- .../TemplatesPage/TemplatesPageView.tsx | 417 +++++------------- .../TemplateSettingsForm.tsx | 23 + .../TemplateSettingsPage.stories.tsx | 9 + .../TemplateSettingsPageView.stories.tsx | 9 + 11 files changed, 430 insertions(+), 543 deletions(-) create mode 100644 site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index b9b02b1f191..34d2e7bf0b5 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1799,25 +1799,6 @@ export const updateChatAutoArchiveDays = (queryClient: QueryClient) => ({ }, }); -const chatTemplateAllowlistKey = [ - ...chatConfigKey, - "template-allowlist", -] as const; - -export const chatTemplateAllowlist = () => ({ - queryKey: chatTemplateAllowlistKey, - queryFn: () => API.experimental.getChatTemplateAllowlist(), -}); - -export const updateChatTemplateAllowlist = (queryClient: QueryClient) => ({ - mutationFn: API.experimental.updateChatTemplateAllowlist, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatTemplateAllowlistKey, - }); - }, -}); - const chatUserCustomPromptKey = [...chatConfigKey, "prompt", "me"] as const; export const chatUserCustomPrompt = () => ({ diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 2a01eff1a45..86f66e201d0 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -12,6 +12,7 @@ import type { Template, TemplateRole, TemplateVersion, + UpdateTemplateMeta, UsersRequest, } from "#/api/typesGenerated"; import { delay } from "#/utils/delay"; @@ -52,6 +53,33 @@ export const templates = ( }; }; +export const updateTemplateMeta = ( + queryClient: QueryClient, +): MutationOptions< + Awaited>, + unknown, + { template: Template; data: UpdateTemplateMeta } +> => { + return { + mutationFn: ({ template, data }) => + API.updateTemplateMeta(template.id, data), + onSuccess: async (_result, { template }) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["templates"] }), + queryClient.invalidateQueries({ + queryKey: templateKey(template.id), + }), + queryClient.invalidateQueries({ + queryKey: templateByNameKey( + template.organization_name, + template.name, + ), + }), + ]); + }, + }; +}; + export const templateACL = (templateId: string) => { return { queryKey: ["templateAcl", templateId], diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index a11f1097c72..e4f25fa3037 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -50,6 +50,15 @@ export const ModelsActive: Story = { }, }; +export const TemplatesActive: Story = { + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/templates" }, + routing: [{ path: "/ai/settings/templates", useStoryElement: true }], + }), + }, +}; + export const LifecycleActive: Story = { parameters: { reactRouter: reactRouterParameters({ @@ -75,6 +84,26 @@ export const NoDeploymentConfig: Story = { editDeploymentConfig: false, }, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument(); + expect(canvas.queryByText("Templates")).not.toBeInTheDocument(); + }, +}; + +export const NoUpdateTemplates: Story = { + args: { + permissions: { + ...MockPermissions, + updateTemplates: false, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText("Coder Agents")).toBeVisible(); + expect(canvas.queryByText("Templates")).not.toBeInTheDocument(); + expect(canvas.getByText("Models")).toBeVisible(); + }, }; export const NoPermissions: Story = { diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 37ac31bc41b..3cf88f3e4f5 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -62,7 +62,9 @@ const AISettingsSidebarView: FC = ({ MCP servers - Templates + {permissions.updateTemplates && ( + Templates + )} Instructions diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx new file mode 100644 index 00000000000..7a90cc22f48 --- /dev/null +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { getTemplatesQueryKey } from "#/api/queries/templates"; +import { MockTemplate, MockUserOwner } from "#/testHelpers/entities"; +import { withAuthProvider } from "#/testHelpers/storybook"; +import TemplatesPage from "./TemplatesPage"; + +const meta = { + title: "pages/AISettingsPage/TemplatesPage/TemplatesPage", + component: TemplatesPage, + decorators: [withAuthProvider], + parameters: { + layout: "fullscreen", + user: MockUserOwner, + permissions: { + editDeploymentConfig: true, + updateTemplates: true, + }, + queries: [ + { + key: getTemplatesQueryKey(), + data: [MockTemplate], + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const HasBothPermissions: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText("Test Template")).toBeVisible(); + }, +}; + +export const NoDeploymentConfigPermission: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + updateTemplates: true, + }, + }, + play: async () => { + const body = within(document.body); + expect( + await body.findByText("You don't have permission to view this page"), + ).toBeInTheDocument(); + expect(body.queryByText("Test Template")).not.toBeInTheDocument(); + }, +}; + +export const NoUpdateTemplatesPermission: Story = { + parameters: { + permissions: { + editDeploymentConfig: true, + updateTemplates: false, + }, + }, + play: async () => { + const body = within(document.body); + expect( + await body.findByText("You don't have permission to view this page"), + ).toBeInTheDocument(); + expect(body.queryByText("Test Template")).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx index 893c2344c91..6c27ced50cb 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx @@ -1,10 +1,7 @@ -import type { FC } from "react"; +import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { - chatTemplateAllowlist, - updateChatTemplateAllowlist, -} from "#/api/queries/chats"; -import { templates } from "#/api/queries/templates"; +import { templates, updateTemplateMeta } from "#/api/queries/templates"; +import type * as TypesGen from "#/api/typesGenerated"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; @@ -13,32 +10,69 @@ import { TemplatesPageView } from "./TemplatesPageView"; const TemplatesPage: FC = () => { const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); + const canManageTemplates = + permissions.editDeploymentConfig && permissions.updateTemplates; + const templatesQuery = useQuery({ + ...templates(), + enabled: canManageTemplates, + }); + const updateTemplateMutation = useMutation(updateTemplateMeta(queryClient)); + const [pendingTemplateIDs, setPendingTemplateIDs] = useState< + ReadonlySet + >(new Set()); + // Errors are tracked per template so a failure on one row is neither + // overwritten nor cleared by a later toggle on another row. + const [updateErrors, setUpdateErrors] = useState< + ReadonlyMap + >(new Map()); - const templatesQuery = useQuery(templates()); - const allowlistQuery = useQuery(chatTemplateAllowlist()); - const saveAllowlistMutation = useMutation( - updateChatTemplateAllowlist(queryClient), - ); - - const isLoading = templatesQuery.isLoading || allowlistQuery.isLoading; + const toggleAgentsAllowed = ( + template: TypesGen.Template, + agentsAllowed: boolean, + ) => { + setPendingTemplateIDs((current) => new Set(current).add(template.id)); + setUpdateErrors((current) => { + if (!current.has(template.id)) { + return current; + } + const next = new Map(current); + next.delete(template.id); + return next; + }); + updateTemplateMutation.mutate( + { + template, + data: { agents_allowed: agentsAllowed }, + }, + { + onError: (error) => { + setUpdateErrors((current) => + new Map(current).set(template.id, error), + ); + }, + onSettled: () => { + setPendingTemplateIDs((current) => { + const next = new Set(current); + next.delete(template.id); + return next; + }); + }, + }, + ); + }; return ( - + Codestin Search App { - void templatesQuery.refetch(); - void allowlistQuery.refetch(); - }} - onSaveAllowlist={saveAllowlistMutation.mutate} - isSaving={saveAllowlistMutation.isPending} - saveError={saveAllowlistMutation.error} + templates={templatesQuery.data} + isLoading={templatesQuery.isLoading} + error={templatesQuery.error} + onRetry={() => void templatesQuery.refetch()} + onToggleAgentsAllowed={toggleAgentsAllowed} + pendingTemplateIDs={pendingTemplateIDs} + updateErrors={updateErrors} /> ); diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx index dac9e8457c5..468fb2d818f 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx @@ -1,62 +1,48 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { MockTemplate } from "#/testHelpers/entities"; import { TemplatesPageView } from "./TemplatesPageView"; -const templateIDs = ["t-01", "t-02", "t-03", "t-04", "t-05", "t-06"]; - -const templates: TypesGen.Template[] = [ +const templates = [ { - id: templateIDs[0], + id: "t-01", name: "docker-containers", display_name: "Docker containers", - description: "Develop inside Docker containers.", - icon: "/icon/docker.png", updated_at: "2026-06-23T12:00:00.000Z", active_user_count: 125, + agents_allowed: true, }, { - id: templateIDs[1], + id: "t-02", name: "product-ops-engineering", display_name: "Product ops engineering", - description: "Workspace for product operations engineering.", updated_at: "2026-06-20T12:00:00.000Z", active_user_count: 12, + agents_allowed: false, }, { - id: templateIDs[2], + id: "t-03", name: "ai-webinar", display_name: "AI webinar", - description: "Workspace for webinar demos.", updated_at: "2026-06-04T12:00:00.000Z", active_user_count: 3, + agents_allowed: true, }, { - id: templateIDs[3], + id: "t-04", name: "fast-workspace", display_name: "A fast workspace", - description: "A minimal workspace that starts quickly.", updated_at: "2026-05-23T12:00:00.000Z", active_user_count: 1, + agents_allowed: false, }, - { - id: templateIDs[4], - name: "aws-ec2", - display_name: "AWS EC2", - description: "Provision AWS EC2 instances as workspaces.", - updated_at: "2026-01-23T12:00:00.000Z", - active_user_count: 0, - }, - { - id: templateIDs[5], - name: "gke-sandbox", - display_name: "gke-sandbox", - description: "Sandbox workspace on GKE.", - updated_at: "2025-06-23T12:00:00.000Z", - active_user_count: 0, - }, -].map((template) => ({ ...MockTemplate, ...template })); +].map( + (template): TypesGen.Template => ({ + ...MockTemplate, + ...template, + }), +); const meta = { title: "pages/AISettingsPage/TemplatesPage/TemplatesPageView", @@ -64,219 +50,152 @@ const meta = { // TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude. parameters: { pixel: { exclude: true } }, args: { - templatesData: templates, - allowlistData: { template_ids: [templateIDs[0], templateIDs[1]] }, + templates, isLoading: false, - templatesError: undefined, - allowlistError: undefined, - isSaving: false, - saveError: undefined, + error: undefined, + pendingTemplateIDs: new Set(), + updateErrors: new Map(), onRetry: fn(), - onSaveAllowlist: fn(), + onToggleAgentsAllowed: fn(), }, } satisfies Meta; export default meta; type Story = StoryObj; -export const NoRestrictions: Story = { - args: { - allowlistData: { template_ids: [] }, - }, - play: async ({ canvasElement, args }) => { +export const MixedToggles: Story = { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(await canvas.findByText("No restrictions set.")).toBeVisible(); + expect(await canvas.findByText("Docker containers")).toBeVisible(); expect( - canvas.getByText( - "All templates are available. Add a template to create an allowlist.", - ), + canvas.getAllByText(MockTemplate.organization_display_name)[0], ).toBeVisible(); - - const body = within(document.body); - await userEvent.click( - canvas.getByRole("button", { name: /add template/i }), - ); - await userEvent.click( - await body.findByRole("option", { name: /AI webinar/i }), - ); - await waitFor(() => { - expect(args.onSaveAllowlist).toHaveBeenCalledWith({ - template_ids: [templateIDs[2]], - }); - }); - await waitFor(() => { - expect( - body.queryByRole("option", { name: /AI webinar/i }), - ).not.toBeInTheDocument(); - }); + expect(canvas.getByText("125 developers")).toBeVisible(); + expect( + canvas.getByRole("switch", { + name: "Allow Coder Agents to use Docker containers", + }), + ).toBeChecked(); + expect( + canvas.getByRole("switch", { + name: "Allow Coder Agents to use Product ops engineering", + }), + ).not.toBeChecked(); }, }; -export const TemplateAllowlist: Story = { - play: async ({ canvasElement, step, args }) => { +export const ToggleTemplate: Story = { + play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - - await step("renders allowlisted templates", async () => { - expect(await canvas.findByText("Docker containers")).toBeVisible(); - expect(canvas.getByText("Product ops engineering")).toBeVisible(); - expect(canvas.getByText("125 developers")).toBeVisible(); - expect(canvas.getByText("12 developers")).toBeVisible(); - }); - - await step("searches and adds an available template", async () => { - const body = within(document.body); - await userEvent.click( - canvas.getByRole("button", { name: /add template/i }), - ); - const searchInput = await body.findByLabelText("Search templates"); - await userEvent.click(searchInput); - await userEvent.keyboard("webinar"); - expect(searchInput).toHaveValue("webinar"); - - expect( - await body.findByRole("option", { name: /AI webinar/i }), - ).toBeVisible(); - expect( - body.queryByRole("option", { name: /AWS EC2/i }), - ).not.toBeInTheDocument(); - - await userEvent.click(body.getByRole("option", { name: /AI webinar/i })); - await waitFor(() => { - expect(args.onSaveAllowlist).toHaveBeenLastCalledWith({ - template_ids: [templateIDs[0], templateIDs[1], templateIDs[2]], - }); - }); - await waitFor(() => { - expect( - body.queryByRole("option", { name: /AI webinar/i }), - ).not.toBeInTheDocument(); - }); - }); - - await step("removes an allowlisted template", async () => { - const body = within(document.body); - await userEvent.click( - canvas.getByRole("button", { name: "Actions for Docker containers" }), - ); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove/i }), - ); - await waitFor(() => { - expect(args.onSaveAllowlist).toHaveBeenCalledWith({ - template_ids: [templateIDs[1]], - }); - }); - }); + await userEvent.click( + canvas.getByRole("switch", { + name: "Allow Coder Agents to use Docker containers", + }), + ); + expect(args.onToggleAgentsAllowed).toHaveBeenCalledWith( + templates[0], + false, + ); }, }; export const Loading: Story = { args: { isLoading: true, - templatesData: undefined, - allowlistData: undefined, + templates: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByRole("status")).toBeVisible(); }, }; -export const TemplatesLoadError: Story = { +export const LoadError: Story = { args: { - templatesError: new Error("Templates request failed"), + error: new Error("Templates request failed"), }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); expect(await canvas.findByText("Failed to load templates.")).toBeVisible(); - expect( - canvas.getByText("Please check the developer console for more details."), - ).toBeVisible(); await userEvent.click(canvas.getByRole("button", { name: "Retry" })); expect(args.onRetry).toHaveBeenCalled(); }, }; -export const AllowlistLoadError: Story = { +export const Empty: Story = { args: { - allowlistError: new Error("Allowlist request failed"), + templates: [], }, - play: async ({ canvasElement, args }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); + expect(await canvas.findByText("No templates found.")).toBeVisible(); expect( - await canvas.findByText( - "Failed to load template allowlist configuration.", + canvas.getByText( + "Create a template before configuring Coder Agents access.", ), ).toBeVisible(); - await userEvent.click(canvas.getByRole("button", { name: "Retry" })); - expect(args.onRetry).toHaveBeenCalled(); }, }; -export const PhantomTemplateIDs: Story = { +export const MixedOrganizations: Story = { args: { - allowlistData: { template_ids: ["deleted-template", templateIDs[0]] }, + templates: [ + templates[0], + { + ...templates[1], + organization_id: "engineering-id", + organization_name: "engineering", + organization_display_name: "Engineering", + }, + { + ...templates[2], + organization_id: "product-id", + organization_name: "product", + organization_display_name: "Product", + }, + ], }, - play: async ({ canvasElement, step, args }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - - await step("drops phantom IDs when adding a template", async () => { - const body = within(document.body); - await userEvent.click( - canvas.getByRole("button", { name: /add template/i }), - ); - await userEvent.click( - await body.findByRole("option", { name: /AI webinar/i }), - ); - await waitFor(() => { - expect(args.onSaveAllowlist).toHaveBeenLastCalledWith({ - template_ids: [templateIDs[0], templateIDs[2]], - }); - }); - await waitFor(() => { - expect( - body.queryByRole("option", { name: /AI webinar/i }), - ).not.toBeInTheDocument(); - }); - }); - - await step("drops phantom IDs when removing a template", async () => { - const body = within(document.body); - await userEvent.click( - canvas.getByRole("button", { name: "Actions for Docker containers" }), - ); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove/i }), - ); - await waitFor(() => { - expect(args.onSaveAllowlist).toHaveBeenLastCalledWith({ - template_ids: [], - }); - }); - }); + expect(await canvas.findByText("Engineering")).toBeVisible(); + expect(canvas.getByText("Product")).toBeVisible(); }, }; -export const Saving: Story = { +export const UpdatingOneTemplate: Story = { args: { - isSaving: true, + pendingTemplateIDs: new Set([templates[1].id]), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( - await canvas.findByRole("button", { name: /add template/i }), + await canvas.findByRole("switch", { + name: "Allow Coder Agents to use Product ops engineering", + }), ).toBeDisabled(); expect( - canvas.getByRole("button", { name: "Actions for Docker containers" }), - ).toBeDisabled(); + canvas.getByRole("switch", { + name: "Allow Coder Agents to use Docker containers", + }), + ).toBeEnabled(); }, }; -export const SaveError: Story = { +export const MutationError: Story = { args: { - saveError: "Template allowlist is locked.", + updateErrors: new Map([ + ["t-01", "Template access is locked."], + ["t-03", "Something went wrong."], + ]), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(await canvas.findByText("Docker containers")).toBeVisible(); - expect( - await canvas.findByText("Template allowlist is locked."), - ).toBeVisible(); + const alerts = await canvas.findAllByRole("alert"); + expect(alerts).toHaveLength(2); + expect(alerts[0]).toHaveTextContent( + "Docker containers: Template access is locked.", + ); + expect(alerts[1]).toHaveTextContent("AI webinar: Something went wrong."); + expect(canvas.getByText("Docker containers")).toBeVisible(); }, }; diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx index 0980db754e5..f2a3a970133 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx @@ -1,39 +1,15 @@ -import { - ChevronDownIcon, - EllipsisVerticalIcon, - PlusIcon, - TrashIcon, -} from "lucide-react"; -import { type FC, useMemo, useState } from "react"; +import type { FC } from "react"; import { DetailedError, getErrorDetail, getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Avatar } from "#/components/Avatar/Avatar"; import { Button } from "#/components/Button/Button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "#/components/Command/Command"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "#/components/Popover/Popover"; import { SettingsHeader, SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; +import { Switch } from "#/components/Switch/Switch"; import { Table, TableBody, @@ -48,112 +24,36 @@ import { createDayString } from "#/utils/createDayString"; import { formatTemplateActiveDevelopers } from "#/utils/templates"; interface TemplatesPageViewProps { - templatesData: TypesGen.Template[] | undefined; - allowlistData: TypesGen.ChatTemplateAllowlist | undefined; + templates: TypesGen.Template[] | undefined; isLoading: boolean; - templatesError: unknown; - allowlistError: unknown; + error: unknown; onRetry: () => void; - onSaveAllowlist: (req: TypesGen.ChatTemplateAllowlist) => void; - isSaving: boolean; - saveError: unknown; -} - -interface AddTemplatePickerProps { - availableTemplates: TypesGen.Template[]; - isSaving: boolean; - onAddTemplate: (templateID: string) => void; + onToggleAgentsAllowed: ( + template: TypesGen.Template, + agentsAllowed: boolean, + ) => void; + pendingTemplateIDs: ReadonlySet; + // Update failures keyed by template ID. + updateErrors: ReadonlyMap; } -const AddTemplatePicker: FC = ({ - availableTemplates, - isSaving, - onAddTemplate, -}) => { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const filteredTemplates = availableTemplates.filter((template) => - `${template.display_name || template.name} ${template.name}` - .toLowerCase() - .includes(search.trim().toLowerCase()), - ); - - return ( - { - setOpen(nextOpen); - if (!nextOpen) { - setSearch(""); - } - }} - > - - - - - - - - No templates found. - - {filteredTemplates.map((template) => ( - { - onAddTemplate(template.id); - setOpen(false); - }} - > - - - {template.display_name || template.name} - - - ))} - - - - - - ); -}; - interface TemplateRowProps { template: TypesGen.Template; - isSaving: boolean; - onRemoveTemplate: (templateID: string) => void; + isPending: boolean; + onToggleAgentsAllowed: ( + template: TypesGen.Template, + agentsAllowed: boolean, + ) => void; } const TemplateRow: FC = ({ template, - isSaving, - onRemoveTemplate, + isPending, + onToggleAgentsAllowed, }) => { const label = template.display_name || template.name; + const organization = + template.organization_display_name || template.organization_name; return ( @@ -172,14 +72,12 @@ const TemplateRow: FC = ({ > {label} - {template.description && ( - - {template.description} - - )} + + {organization} + @@ -192,217 +90,104 @@ const TemplateRow: FC = ({ {`${formatTemplateActiveDevelopers(template.active_user_count)} developer${template.active_user_count === 1 ? "" : "s"}`} - - - - - - - onRemoveTemplate(template.id)} - > - - Remove - - - + + + onToggleAgentsAllowed(template, agentsAllowed) + } + disabled={isPending} + aria-label={`Allow Coder Agents to use ${label}`} + /> ); }; -interface TemplatesTableProps { - isLoading: boolean; - allowlistedTemplates: TypesGen.Template[]; - availableTemplates: TypesGen.Template[]; - isSaving: boolean; - onAddTemplate: (templateID: string) => void; - onRemoveTemplate: (templateID: string) => void; -} - -const TemplatesTable: FC = ({ - isLoading, - allowlistedTemplates, - availableTemplates, - isSaving, - onAddTemplate, - onRemoveTemplate, -}) => { - return ( - - - - Name - Last updated - Used by - - Actions - - - - - {isLoading ? ( - - ) : allowlistedTemplates.length === 0 ? ( - - } - isCompact - className="min-h-52" - /> - ) : ( - allowlistedTemplates.map((template) => ( - - )) - )} - -
- ); -}; - export const TemplatesPageView: FC = ({ - templatesData, - allowlistData, + templates, isLoading, - templatesError, - allowlistError, + error, onRetry, - onSaveAllowlist, - isSaving, - saveError, + onToggleAgentsAllowed, + pendingTemplateIDs, + updateErrors, }) => { - const templateIDs = allowlistData?.template_ids ?? []; - const { allowlistedTemplates, availableTemplates, resolvedTemplateIDs } = - useMemo(() => { - const allTemplates = templatesData ?? []; - const templatesByID = new Map( - allTemplates.map((template) => [template.id, template]), - ); - const selectedIDs = new Set(templateIDs); - const allowlisted = templateIDs - .map((templateID) => templatesByID.get(templateID)) - .filter((template) => template !== undefined); - const resolvedIDs = allowlisted.map((template) => template.id); - const available = allTemplates - .filter((template) => !selectedIDs.has(template.id)) - .toSorted((left, right) => - (left.display_name || left.name).localeCompare( - right.display_name || right.name, - ), - ); - - return { - allowlistedTemplates: allowlisted, - availableTemplates: available, - resolvedTemplateIDs: resolvedIDs, - }; - }, [templatesData, templateIDs]); - - const saveTemplateIDs = (nextTemplateIDs: string[]) => { - onSaveAllowlist({ template_ids: nextTemplateIDs }); - }; - - const handleAddTemplate = (templateID: string) => { - if (resolvedTemplateIDs.includes(templateID)) { - return; - } - saveTemplateIDs([...resolvedTemplateIDs, templateID]); - }; - - const handleRemoveTemplate = (templateID: string) => { - saveTemplateIDs(resolvedTemplateIDs.filter((id) => id !== templateID)); - }; - - const hasTemplatesError = Boolean(templatesError); - const hasAllowlistError = Boolean(allowlistError); - const hasError = hasTemplatesError || hasAllowlistError; - return (
- 0 && ( - - ) - } - > + Templates - Restrict which templates agents can use to create workspaces. + Choose which templates Coder Agents can use to create workspaces. - {hasError ? ( + {error ? (
- {hasTemplatesError && ( - - )} - {hasAllowlistError && ( - - )} +
) : ( <> - - {saveError && ( -

- {getErrorMessage(saveError, "Failed to save template allowlist.")} -

- )} + + + + Template + Last updated + Used by + + Agents allowed + + + + + {isLoading ? ( + + ) : !templates || templates.length === 0 ? ( + + ) : ( + templates.map((template) => ( + + )) + )} + +
+ {templates + ?.filter((template) => updateErrors.has(template.id)) + .map((template) => ( +

+ {`${template.display_name || template.name}: ${getErrorMessage( + updateErrors.get(template.id), + "Failed to update template access.", + )}`} +

+ ))} )}
diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 7e5a4db82e2..4e39e52b97b 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -52,6 +52,7 @@ export const validationSchema = Yup.object({ MAX_DESCRIPTION_MESSAGE, ), allow_user_cancel_workspace_jobs: Yup.boolean(), + agents_allowed: Yup.boolean(), icon: iconValidator, require_active_version: Yup.boolean(), disable_module_cache: Yup.boolean(), @@ -92,6 +93,7 @@ export const TemplateSettingsForm: FC = ({ icon: template.icon, allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, + agents_allowed: template.agents_allowed, update_workspace_last_used_at: false, update_workspace_dormant_at: false, require_active_version: template.require_active_version, @@ -201,6 +203,27 @@ export const TemplateSettingsForm: FC = ({ description="Regulate actions allowed on workspaces created from this template." > +
+ { + form.setFieldValue("agents_allowed", checked === true); + }} + /> + +
+
expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1)); + expect(updateTemplateMetaSpy.mock.calls[0][1]).toEqual( + expect.objectContaining({ agents_allowed: false }), + ); }, }; @@ -159,6 +162,12 @@ async function fillAndSubmitForm( await user.clear(iconField); await user.type(iconField, "vscode.png"); + const agentsAllowedField = canvas.getByRole("checkbox", { + name: /allow coder agents to use this template/i, + }); + expect(agentsAllowedField).toBeChecked(); + await user.click(agentsAllowedField); + const allowCancelJobsField = canvas.getByRole("checkbox", { name: /allow users to cancel in-progress workspace jobs/i, }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx index f19dc7c72f5..6b07832f620 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.stories.tsx @@ -21,6 +21,15 @@ type Story = StoryObj; export const Example: Story = {}; +export const AgentsNotAllowed: Story = { + args: { + template: { + ...MockTemplate, + agents_allowed: false, + }, + }, +}; + export const SaveTemplateSettingsError: Story = { args: { submitError: mockApiError({ From e7848bb7b00d8197497f5d513aca260407a58a04 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 3 Aug 2026 06:33:08 +0000 Subject: [PATCH 02/13] review --- site/src/api/queries/templates.ts | 5 +- .../TemplatesPage/TemplatesPage.stories.tsx | 117 +++++++++++++++++- .../TemplatesPage/TemplatesPage.tsx | 32 ++--- .../TemplatesPageView.stories.tsx | 34 +++-- .../TemplatesPage/TemplatesPageView.tsx | 5 +- 5 files changed, 156 insertions(+), 37 deletions(-) diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 86f66e201d0..4f1031f864d 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -19,6 +19,7 @@ import { delay } from "#/utils/delay"; import { getTemplateVersionFiles } from "#/utils/templateVersion"; const templateKey = (templateId: string) => ["template", templateId]; +const templatesKey = ["templates"] as const; export const template = (templateId: string) => { return { @@ -42,7 +43,7 @@ export const templateByName = (organization: string, name: string) => { export const getTemplatesQueryKey = ( options?: GetTemplatesOptions | GetTemplatesQuery, -) => ["templates", options]; +) => [...templatesKey, options]; export const templates = ( options?: GetTemplatesOptions | GetTemplatesQuery, @@ -65,7 +66,7 @@ export const updateTemplateMeta = ( API.updateTemplateMeta(template.id, data), onSuccess: async (_result, { template }) => { await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["templates"] }), + queryClient.invalidateQueries({ queryKey: templatesKey }), queryClient.invalidateQueries({ queryKey: templateKey(template.id), }), diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx index 7a90cc22f48..e54d0f45ed3 100644 --- a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.stories.tsx @@ -1,10 +1,29 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; import { getTemplatesQueryKey } from "#/api/queries/templates"; +import type { Template } from "#/api/typesGenerated"; +import { createDeferred, type Deferred } from "#/testHelpers/deferred"; import { MockTemplate, MockUserOwner } from "#/testHelpers/entities"; import { withAuthProvider } from "#/testHelpers/storybook"; import TemplatesPage from "./TemplatesPage"; +const secondTemplate: Template = { + ...MockTemplate, + id: "second-template", + name: "second-template", + display_name: "Second Template", +}; + +type ToggleDeferreds = { + first: Deferred