feat(site): move template allowlist to ai settings (#26615)

This commit is contained in:
Danielle Maywood
2026-06-24 10:00:37 +01:00
committed by GitHub
parent cec1b4e886
commit e569e16682
8 changed files with 716 additions and 332 deletions
@@ -35,6 +35,11 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/models">Models</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/templates">
Templates
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/agents/settings/agents">
<div className="flex flex-row items-center gap-1">
@@ -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 (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<AgentSettingsTemplatesPageView
<title>{pageTitle("Templates", "AI Settings")}</title>
<TemplatesPageView
templatesData={templatesQuery.data}
allowlistData={allowlistQuery.data}
isLoading={isLoading}
hasError={Boolean(templatesQuery.error || allowlistQuery.error)}
templatesError={templatesQuery.error}
allowlistError={allowlistQuery.error}
onRetry={() => {
void templatesQuery.refetch();
void allowlistQuery.refetch();
}}
onSaveAllowlist={saveAllowlistMutation.mutate}
isSaving={saveAllowlistMutation.isPending}
isSaveError={saveAllowlistMutation.isError}
saveError={saveAllowlistMutation.error}
/>
</RequirePermission>
);
};
export default AgentSettingsTemplatesPage;
export default TemplatesPage;
@@ -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<typeof TemplatesPageView>;
export default meta;
type Story = StoryObj<typeof TemplatesPageView>;
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();
},
};
@@ -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<AddTemplatePickerProps> = ({
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 (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) {
setSearch("");
}
}}
>
<PopoverTrigger asChild>
<Button variant="outline" disabled={isSaving}>
<PlusIcon />
<span>Add template</span>
<ChevronDownIcon className="ml-1 size-icon-xs" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-80 overflow-hidden border-border-default p-0"
>
<Command
shouldFilter={false}
className="[&_[cmdk-input-wrapper]]:border-0 [&_[cmdk-input-wrapper]]:border-border-default [&_[cmdk-input-wrapper]]:border-b [&_[cmdk-input-wrapper]]:border-solid [&_[cmdk-input-wrapper]]:px-4 [&_[cmdk-input-wrapper]]:py-3"
>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder="Search..."
aria-label="Search templates"
className="h-auto py-0"
/>
<CommandList className="max-h-80 border-t-0">
<CommandEmpty>No templates found.</CommandEmpty>
<CommandGroup>
{filteredTemplates.map((template) => (
<CommandItem
key={template.id}
value={template.id}
className="gap-3"
onSelect={() => {
onAddTemplate(template.id);
setOpen(false);
}}
>
<Avatar
size="lg"
variant="icon"
src={template.icon}
fallback={template.display_name || template.name}
/>
<span className="min-w-0 truncate">
{template.display_name || template.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface TemplateRowProps {
template: TypesGen.Template;
isSaving: boolean;
onRemoveTemplate: (templateID: string) => void;
}
const TemplateRow: FC<TemplateRowProps> = ({
template,
isSaving,
onRemoveTemplate,
}) => {
const label = template.display_name || template.name;
return (
<TableRow className="h-[72px]">
<TableCell className="w-full max-w-0 px-4 py-3">
<div className="flex min-w-0 items-center gap-4">
<Avatar
size="lg"
variant="icon"
src={template.icon}
fallback={label}
/>
<div className="flex min-w-0 flex-col">
<span
className="truncate text-sm font-medium leading-5 text-content-primary"
title={label}
>
{label}
</span>
{template.description && (
<span
className="truncate text-sm font-medium leading-5 text-content-secondary"
title={template.description}
>
{template.description}
</span>
)}
</div>
</div>
</TableCell>
<TableCell
data-chromatic="ignore"
className="whitespace-nowrap text-sm font-medium leading-6 text-content-secondary"
>
{createDayString(template.updated_at)}
</TableCell>
<TableCell className="whitespace-nowrap text-sm font-medium leading-6 text-content-secondary">
{`${formatTemplateActiveDevelopers(template.active_user_count)} developer${template.active_user_count === 1 ? "" : "s"}`}
</TableCell>
<TableCell className="w-12 whitespace-nowrap pr-4 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
type="button"
disabled={isSaving}
aria-label={`Actions for ${label}`}
>
<EllipsisVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={() => onRemoveTemplate(template.id)}
>
<TrashIcon />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
};
interface TemplatesTableProps {
isLoading: boolean;
allowlistedTemplates: TypesGen.Template[];
availableTemplates: TypesGen.Template[];
isSaving: boolean;
onAddTemplate: (templateID: string) => void;
onRemoveTemplate: (templateID: string) => void;
}
const TemplatesTable: FC<TemplatesTableProps> = ({
isLoading,
allowlistedTemplates,
availableTemplates,
isSaving,
onAddTemplate,
onRemoveTemplate,
}) => {
return (
<Table aria-label="Allowed templates" className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-1/2">Name</TableHead>
<TableHead className="w-44">Last updated</TableHead>
<TableHead className="w-44">Used by</TableHead>
<TableHead className="w-12">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableLoader />
) : allowlistedTemplates.length === 0 ? (
<TableEmpty
message="No restrictions set."
description="All templates are available. Add a template to create an allowlist."
cta={
<AddTemplatePicker
availableTemplates={availableTemplates}
isSaving={isSaving}
onAddTemplate={onAddTemplate}
/>
}
isCompact
className="min-h-52"
/>
) : (
allowlistedTemplates.map((template) => (
<TemplateRow
key={template.id}
template={template}
isSaving={isSaving}
onRemoveTemplate={onRemoveTemplate}
/>
))
)}
</TableBody>
</Table>
);
};
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
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 (
<div>
<SettingsHeader
actions={
!isLoading &&
!hasError &&
allowlistedTemplates.length > 0 && (
<AddTemplatePicker
availableTemplates={availableTemplates}
isSaving={isSaving}
onAddTemplate={handleAddTemplate}
/>
)
}
>
<SettingsHeaderTitle>Templates</SettingsHeaderTitle>
<SettingsHeaderDescription>
Restrict which templates agents can use to create workspaces.
</SettingsHeaderDescription>
</SettingsHeader>
{hasError ? (
<div className="flex flex-col gap-4">
{hasTemplatesError && (
<ErrorAlert
error={
new DetailedError(
"Failed to load templates.",
getErrorDetail(templatesError),
)
}
/>
)}
{hasAllowlistError && (
<ErrorAlert
error={
new DetailedError(
"Failed to load template allowlist configuration.",
getErrorDetail(allowlistError),
)
}
/>
)}
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
Retry
</Button>
</div>
) : (
<>
<TemplatesTable
isLoading={isLoading}
allowlistedTemplates={allowlistedTemplates}
availableTemplates={availableTemplates}
isSaving={isSaving}
onAddTemplate={handleAddTemplate}
onRemoveTemplate={handleRemoveTemplate}
/>
{saveError && (
<p
role="alert"
className="m-0 pt-3 text-xs text-content-destructive"
>
{getErrorMessage(saveError, "Failed to save template allowlist.")}
</p>
)}
</>
)}
</div>
);
};
@@ -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<typeof AgentSettingsTemplatesPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsTemplatesPageView>;
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(),
);
});
});
},
};
@@ -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<Option[] | null>(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 (
<div className="flex flex-col gap-8">
<SectionHeader
label="Templates"
description="Restrict which templates agents can use to create workspaces. When no templates are selected, all templates are available."
/>
{isLoading && (
<div
role="status"
aria-label="Loading templates"
className="flex min-h-[120px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{!isLoading && hasError && (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
Failed to load template data.
</p>
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
Retry
</Button>
</div>
)}
{!isLoading && !hasError && (
<form
className="space-y-3"
onSubmit={(event) => void handleSave(event)}
>
<MultiSelectCombobox
key={serverSelectionKey}
inputProps={{ "aria-label": "Select allowed templates" }}
options={allOptions}
defaultOptions={currentSelection}
value={currentSelection}
onChange={setLocalSelection}
placeholder="Select templates..."
emptyIndicator={
<p className="text-center text-sm text-content-secondary">
No templates found.
</p>
}
disabled={isSaving}
hidePlaceholderWhenSelected
data-testid="template-allowlist-select"
/>
<p
aria-live="polite"
role="status"
className="m-0 text-xs text-content-secondary"
>
{currentSelection.length > 0
? `${currentSelection.length} template${currentSelection.length !== 1 ? "s" : ""} selected`
: "No templates selected \u2014 all templates are available"}
</p>
<div className="flex justify-end">
<Button size="sm" type="submit" disabled={isSaving || !isDirty}>
Save
</Button>
</div>
{isSaveError && (
<p role="alert" className="m-0 text-xs text-content-destructive">
Failed to save template allowlist.
</p>
)}
</form>
)}
</div>
);
};
@@ -187,9 +187,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
<SettingsNavItem
icon={LayoutTemplateIcon}
label="Templates"
active={settingsSection === "templates"}
to="/agents/settings/templates"
state={location.state}
active={false}
to="/ai/settings/templates"
trailingIcon={ArrowUpRightIcon}
/>
<SettingsNavItem
icon={CoinsIcon}
+8 -4
View File
@@ -398,9 +398,6 @@ const AgentSettingsMCPServersPage = lazy(
const AgentSettingsSpendPage = lazy(
() => 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(
/>
<Route index element={<AISettingsIndexPage />} />
<Route path="models" element={<AISettingsModelsPage />} />
<Route path="templates" element={<AISettingsTemplatesPage />} />
<Route path="models/add" element={<AISettingsAddModelPage />} />
<Route
path="models/:modelId"
@@ -860,7 +861,10 @@ export const router = createBrowserRouter(
<Route path="spend" element={<AgentSettingsSpendPage />} />
<Route path="limits" element={<Navigate to="spend" replace />} />
<Route path="usage" element={<NavigateWithSearch to="spend" />} />
<Route path="templates" element={<AgentSettingsTemplatesPage />} />
<Route
path="templates"
element={<Navigate to="/ai/settings/templates" replace />}
/>
</Route>
<Route path="analytics" element={<AgentAnalyticsPage />} />
<Route