From e569e166828bfc8a6bea933c2358281ed5eb33e1 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 24 Jun 2026 10:00:37 +0100 Subject: [PATCH] feat(site): move template allowlist to ai settings (#26615) --- .../management/AISettingsSidebarView.tsx | 5 + .../TemplatesPage/TemplatesPage.tsx} | 16 +- .../TemplatesPageView.stories.tsx | 280 ++++++++++++ .../TemplatesPage/TemplatesPageView.tsx | 410 ++++++++++++++++++ ...AgentSettingsTemplatesPageView.stories.tsx | 163 ------- .../AgentSettingsTemplatesPageView.tsx | 156 ------- .../ChatsSidebar/settings/SettingsPanel.tsx | 6 +- site/src/router.tsx | 12 +- 8 files changed, 716 insertions(+), 332 deletions(-) rename site/src/pages/{AgentsPage/AgentSettingsTemplatesPage.tsx => AISettingsPage/TemplatesPage/TemplatesPage.tsx} (75%) create mode 100644 site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx delete mode 100644 site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.stories.tsx delete mode 100644 site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index f3290bc4f4..872eeaac34 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -35,6 +35,11 @@ const AISettingsSidebarView: FC = ({ {permissions.editDeploymentConfig && ( Models )} + {permissions.editDeploymentConfig && ( + + Templates + + )} {permissions.editDeploymentConfig && (
diff --git a/site/src/pages/AgentsPage/AgentSettingsTemplatesPage.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx similarity index 75% rename from site/src/pages/AgentsPage/AgentSettingsTemplatesPage.tsx rename to site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx index ded9a5217d..893c2344c9 100644 --- a/site/src/pages/AgentsPage/AgentSettingsTemplatesPage.tsx +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPage.tsx @@ -7,9 +7,10 @@ import { import { templates } from "#/api/queries/templates"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; -import { AgentSettingsTemplatesPageView } from "./AgentSettingsTemplatesPageView"; +import { pageTitle } from "#/utils/page"; +import { TemplatesPageView } from "./TemplatesPageView"; -const AgentSettingsTemplatesPage: FC = () => { +const TemplatesPage: FC = () => { const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); @@ -23,21 +24,24 @@ const AgentSettingsTemplatesPage: FC = () => { return ( - {pageTitle("Templates", "AI Settings")} + + { void templatesQuery.refetch(); void allowlistQuery.refetch(); }} onSaveAllowlist={saveAllowlistMutation.mutate} isSaving={saveAllowlistMutation.isPending} - isSaveError={saveAllowlistMutation.isError} + saveError={saveAllowlistMutation.error} /> ); }; -export default AgentSettingsTemplatesPage; +export default TemplatesPage; diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx new file mode 100644 index 0000000000..1cbd1bab0f --- /dev/null +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.stories.tsx @@ -0,0 +1,280 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, 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[] = [ + { + id: templateIDs[0], + 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, + }, + { + id: templateIDs[1], + 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, + }, + { + id: templateIDs[2], + name: "ai-webinar", + display_name: "AI webinar", + description: "Workspace for webinar demos.", + updated_at: "2026-06-04T12:00:00.000Z", + active_user_count: 3, + }, + { + id: templateIDs[3], + 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, + }, + { + 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 })); + +const meta = { + title: "pages/AISettingsPage/TemplatesPage/TemplatesPageView", + component: TemplatesPageView, + args: { + templatesData: templates, + allowlistData: { template_ids: [templateIDs[0], templateIDs[1]] }, + isLoading: false, + templatesError: undefined, + allowlistError: undefined, + isSaving: false, + saveError: undefined, + onRetry: fn(), + onSaveAllowlist: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NoRestrictions: Story = { + args: { + allowlistData: { template_ids: [] }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText("No restrictions set.")).toBeVisible(); + expect( + canvas.getByText( + "All templates are available. Add a template to create an allowlist.", + ), + ).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(); + }); + }, +}; + +export const TemplateAllowlist: Story = { + play: async ({ canvasElement, step, 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]], + }); + }); + }); + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + templatesData: undefined, + allowlistData: undefined, + }, +}; + +export const TemplatesLoadError: Story = { + args: { + templatesError: 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 = { + args: { + allowlistError: new Error("Allowlist request failed"), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + expect( + await canvas.findByText( + "Failed to load template allowlist configuration.", + ), + ).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Retry" })); + expect(args.onRetry).toHaveBeenCalled(); + }, +}; + +export const PhantomTemplateIDs: Story = { + args: { + allowlistData: { template_ids: ["deleted-template", templateIDs[0]] }, + }, + play: async ({ canvasElement, step, args }) => { + 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: [], + }); + }); + }); + }, +}; + +export const Saving: Story = { + args: { + isSaving: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + await canvas.findByRole("button", { name: /add template/i }), + ).toBeDisabled(); + expect( + canvas.getByRole("button", { name: "Actions for Docker containers" }), + ).toBeDisabled(); + }, +}; + +export const SaveError: Story = { + args: { + saveError: "Template allowlist is locked.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(await canvas.findByText("Docker containers")).toBeVisible(); + expect( + await canvas.findByText("Template allowlist is locked."), + ).toBeVisible(); + }, +}; diff --git a/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx new file mode 100644 index 0000000000..a67b7e4cb4 --- /dev/null +++ b/site/src/pages/AISettingsPage/TemplatesPage/TemplatesPageView.tsx @@ -0,0 +1,410 @@ +import { + ChevronDownIcon, + EllipsisVerticalIcon, + PlusIcon, + TrashIcon, +} from "lucide-react"; +import { type FC, useMemo, useState } 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; +import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; +import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { createDayString } from "#/utils/createDayString"; +import { formatTemplateActiveDevelopers } from "#/utils/templates"; + +interface TemplatesPageViewProps { + templatesData: TypesGen.Template[] | undefined; + allowlistData: TypesGen.ChatTemplateAllowlist | undefined; + isLoading: boolean; + templatesError: unknown; + allowlistError: unknown; + onRetry: () => void; + onSaveAllowlist: (req: TypesGen.ChatTemplateAllowlist) => void; + isSaving: boolean; + saveError: unknown; +} + +interface AddTemplatePickerProps { + availableTemplates: TypesGen.Template[]; + isSaving: boolean; + onAddTemplate: (templateID: string) => void; +} + +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; +} + +const TemplateRow: FC = ({ + template, + isSaving, + onRemoveTemplate, +}) => { + const label = template.display_name || template.name; + + return ( + + +
+ +
+ + {label} + + {template.description && ( + + {template.description} + + )} +
+
+
+ + {createDayString(template.updated_at)} + + + {`${formatTemplateActiveDevelopers(template.active_user_count)} developer${template.active_user_count === 1 ? "" : "s"}`} + + + + + + + + onRemoveTemplate(template.id)} + > + + Remove + + + + +
+ ); +}; + +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, + isLoading, + templatesError, + allowlistError, + onRetry, + onSaveAllowlist, + isSaving, + saveError, +}) => { + 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. + + + + {hasError ? ( +
+ {hasTemplatesError && ( + + )} + {hasAllowlistError && ( + + )} + +
+ ) : ( + <> + + {saveError && ( +

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

+ )} + + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.stories.tsx deleted file mode 100644 index 025468e3dc..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.stories.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import { MockTemplate } from "#/testHelpers/entities"; -import { AgentSettingsTemplatesPageView } from "./AgentSettingsTemplatesPageView"; - -const manyTemplates = [ - { id: "t-01", name: "docker-dev", display_name: "Docker Development" }, - { - id: "t-02", - name: "kubernetes-prod", - display_name: "Kubernetes Production", - }, - { id: "t-03", name: "aws-windows", display_name: "AWS Windows Desktop" }, - { id: "t-04", name: "gcp-linux", display_name: "GCP Linux Workspace" }, - { - id: "t-05", - name: "azure-dotnet", - display_name: "Azure .NET Environment", - }, - { id: "t-06", name: "ml-jupyter", display_name: "ML Jupyter Notebook" }, - { - id: "t-07", - name: "data-eng-spark", - display_name: "Data Engineering (Spark)", - }, - { - id: "t-08", - name: "frontend-vite", - display_name: "Frontend (Vite + React)", - }, -].map((t) => ({ ...MockTemplate, ...t })); - -const meta = { - title: "pages/AgentsPage/AgentSettingsTemplatesPageView", - component: AgentSettingsTemplatesPageView, - args: { - templatesData: manyTemplates, - allowlistData: { template_ids: [] }, - isLoading: false, - hasError: false, - isSaving: false, - isSaveError: false, - onRetry: fn(), - onSaveAllowlist: fn(), - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const TemplateAllowlist: Story = { - play: async ({ canvasElement, step, args }) => { - const canvas = within(canvasElement); - - await step("starts empty", async () => { - await canvas.findByText(/no templates selected/i); - const saveBtn = await canvas.findByRole("button", { - name: "Save", - }); - expect(saveBtn).toBeDisabled(); - }); - - await step("search filters by display name", async () => { - const input = canvas.getByPlaceholderText("Select templates..."); - await userEvent.click(input); - - // Type a partial display name. - await userEvent.type(input, "Docker"); - - // The matching template should be visible. - await waitFor(() => { - expect( - canvas.getByRole("option", { name: "Docker Development" }), - ).toBeVisible(); - }); - - // A non-matching template should not be visible. - await waitFor(() => { - expect( - canvas.queryByRole("option", { name: "Kubernetes Production" }), - ).not.toBeInTheDocument(); - }); - - // Clear the search and verify the full list returns. - await userEvent.clear(input); - await waitFor(() => { - expect( - canvas.getByRole("option", { name: "Kubernetes Production" }), - ).toBeVisible(); - }); - - // Close dropdown by pressing Escape so the next step starts clean. - await userEvent.keyboard("{Escape}"); - }); - - await step("select one template and save", async () => { - const input = canvas.getByPlaceholderText("Select templates..."); - await userEvent.click(input); - await userEvent.click( - await canvas.findByRole("option", { - name: "Docker Development", - }), - ); - - await waitFor(() => { - expect(canvas.getByText("1 template selected")).toBeInTheDocument(); - }); - - const saveBtn = canvas.getByRole("button", { name: "Save" }); - expect(saveBtn).toBeEnabled(); - await userEvent.click(saveBtn); - - await waitFor(() => { - expect(args.onSaveAllowlist).toHaveBeenCalledWith( - { template_ids: ["t-01"] }, - expect.anything(), - ); - }); - }); - - await step("add the remaining seven and save", async () => { - const input = canvas.getByLabelText("Select allowed templates"); - await userEvent.click(input); - - for (const name of [ - "Kubernetes Production", - "AWS Windows Desktop", - "GCP Linux Workspace", - "Azure .NET Environment", - "ML Jupyter Notebook", - "Data Engineering (Spark)", - "Frontend (Vite + React)", - ]) { - await userEvent.click(await canvas.findByRole("option", { name })); - } - - await waitFor(() => { - expect(canvas.getByText("8 templates selected")).toBeInTheDocument(); - }); - - const saveBtn = canvas.getByRole("button", { name: "Save" }); - await userEvent.click(saveBtn); - - await waitFor(() => { - expect(args.onSaveAllowlist).toHaveBeenLastCalledWith( - { - template_ids: expect.arrayContaining([ - "t-01", - "t-02", - "t-03", - "t-04", - "t-05", - "t-06", - "t-07", - "t-08", - ]), - }, - expect.anything(), - ); - }); - }); - }, -}; diff --git a/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx deleted file mode 100644 index 61f7ff8a32..0000000000 --- a/site/src/pages/AgentsPage/AgentSettingsTemplatesPageView.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { type FC, type FormEvent, useState } from "react"; -import type * as TypesGen from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { - MultiSelectCombobox, - type Option, -} from "#/components/MultiSelectCombobox/MultiSelectCombobox"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { SectionHeader } from "./components/SectionHeader"; - -interface MutationCallbacks { - onSuccess?: () => void; - onError?: () => void; -} - -interface AgentSettingsTemplatesPageViewProps { - // Raw query data - templatesData: TypesGen.Template[] | undefined; - allowlistData: TypesGen.ChatTemplateAllowlist | undefined; - isLoading: boolean; - hasError: boolean; - onRetry: () => void; - - // Mutation - onSaveAllowlist: ( - req: TypesGen.ChatTemplateAllowlist, - options?: MutationCallbacks, - ) => void; - isSaving: boolean; - isSaveError: boolean; -} - -export const AgentSettingsTemplatesPageView: FC< - AgentSettingsTemplatesPageViewProps -> = ({ - templatesData, - allowlistData, - isLoading, - hasError, - onRetry, - onSaveAllowlist, - isSaving, - isSaveError, -}) => { - // ── Local form state ── - const [localSelection, setLocalSelection] = useState(null); - - // ── Derived state ── - const allOptions: Option[] = (templatesData ?? []).map((t) => ({ - value: t.id, - label: t.display_name || t.name, - icon: t.icon, - })); - - const optionsByID = new Map(allOptions.map((o) => [o.value, o])); - - const serverSelection: Option[] = (allowlistData?.template_ids ?? []) - .map((id) => optionsByID.get(id)) - .filter((o) => o !== undefined); - - const currentSelection = localSelection ?? serverSelection; - - const serverSet = new Set(serverSelection.map((o) => o.value)); - const isDirty = - localSelection !== null && - (localSelection.length !== serverSet.size || - localSelection.some((o) => !serverSet.has(o.value))); - - const serverSelectionKey = serverSelection.map((o) => o.value).join(","); - - // ── Event handlers ── - const handleSave = (event: FormEvent) => { - event.preventDefault(); - if (!isDirty) return; - onSaveAllowlist( - { template_ids: currentSelection.map((o) => o.value) }, - { onSuccess: () => setLocalSelection(null) }, - ); - }; - - return ( -
- - - {isLoading && ( -
- -
- )} - - {!isLoading && hasError && ( -
-

- Failed to load template data. -

- -
- )} - - {!isLoading && !hasError && ( -
void handleSave(event)} - > - - No templates found. -

- } - disabled={isSaving} - hidePlaceholderWhenSelected - data-testid="template-allowlist-select" - /> -

- {currentSelection.length > 0 - ? `${currentSelection.length} template${currentSelection.length !== 1 ? "s" : ""} selected` - : "No templates selected \u2014 all templates are available"} -

- -
- -
- - {isSaveError && ( -

- Failed to save template allowlist. -

- )} - - )} -
- ); -}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx index fc1535e5be..bf395defd8 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsPanel.tsx @@ -187,9 +187,9 @@ export const SettingsPanel: FC = ({ import("./pages/AgentsPage/AgentSettingsSpendPage"), ); -const AgentSettingsTemplatesPage = lazy( - () => import("./pages/AgentsPage/AgentSettingsTemplatesPage"), -); const AgentAnalyticsPage = lazy( () => import("./pages/AgentsPage/AgentAnalyticsPage"), ); @@ -451,6 +448,9 @@ const AISettingsGatewayKeysPage = lazy( const AISettingsModelsPage = lazy( () => import("./pages/AISettingsPage/ModelsPage/ModelsPage"), ); +const AISettingsTemplatesPage = lazy( + () => import("./pages/AISettingsPage/TemplatesPage/TemplatesPage"), +); const AISettingsAddModelPage = lazy( () => import("./pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage"), ); @@ -755,6 +755,7 @@ export const router = createBrowserRouter( /> } /> } /> + } /> } /> } /> } /> } /> - } /> + } + /> } />