feat(site): move Coder Agents settings to AI settings (#26692)

This commit is contained in:
Danielle Maywood
2026-06-29 12:28:36 +00:00
committed by GitHub
parent cf1c29b651
commit b4a7b25290
14 changed files with 468 additions and 316 deletions
@@ -1,4 +1,3 @@
import { ArrowUpRightIcon } from "lucide-react";
import type { FC } from "react";
import {
Sidebar as BaseSidebar,
@@ -43,6 +42,11 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
Instructions
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/coder-agents">
Coder Agents
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/ai/settings/lifecycle">
Lifecycle
@@ -58,13 +62,6 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
MCP servers
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<SidebarNavItem href="/agents/settings/agents">
<div className="flex flex-row items-center gap-1">
Manage Coder Agents <ArrowUpRightIcon size={16} />
</div>
</SidebarNavItem>
)}
</div>
</BaseSidebar>
);
@@ -14,7 +14,8 @@ import {
import type * as TypesGen from "#/api/typesGenerated";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView";
import { pageTitle } from "#/utils/page";
import { CoderAgentsPageView } from "./CoderAgentsPageView";
const generalOverrideContext: TypesGen.ChatModelOverrideContext = "general";
const exploreOverrideContext: TypesGen.ChatModelOverrideContext = "explore";
@@ -45,7 +46,7 @@ const updateChatModelOverrideMutation = (
},
});
const AgentSettingsAgentsPage: FC = () => {
const CoderAgentsPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const canEditDeploymentConfig = permissions.editDeploymentConfig;
@@ -85,7 +86,8 @@ const AgentSettingsAgentsPage: FC = () => {
return (
<RequirePermission isFeatureVisible={canEditDeploymentConfig}>
<AgentSettingsAgentsPageView
<title>{pageTitle("Coder Agents", "AI Settings")}</title>
<CoderAgentsPageView
adminOverridesData={personalModelOverridesAdminSettingsQuery.data}
adminOverridesError={personalModelOverridesAdminSettingsQuery.error}
onRetryAdminOverrides={() => {
@@ -135,4 +137,4 @@ const AgentSettingsAgentsPage: FC = () => {
);
};
export default AgentSettingsAgentsPage;
export default CoderAgentsPage;
@@ -3,9 +3,9 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { MockChatModelConfig } from "#/testHelpers/chatModels";
import {
AgentSettingsAgentsPageView,
type AgentSettingsAgentsPageViewProps,
} from "./AgentSettingsAgentsPageView";
CoderAgentsPageView,
type CoderAgentsPageViewProps,
} from "./CoderAgentsPageView";
const OVERRIDE_MALFORMED_WARNING =
"The saved override is malformed and is being treated as unset. Click Save to clear it.";
@@ -105,8 +105,8 @@ const allModelConfigs: TypesGen.ChatModelConfig[] = [
];
const buildArgs = (
overrides: Partial<AgentSettingsAgentsPageViewProps> = {},
): AgentSettingsAgentsPageViewProps => ({
overrides: Partial<CoderAgentsPageViewProps> = {},
): CoderAgentsPageViewProps => ({
adminOverridesData: { allow_users: false },
adminOverridesError: undefined,
onRetryAdminOverrides: fn(),
@@ -138,13 +138,11 @@ const getSection = async (
): Promise<HTMLElement> => {
const canvas = within(canvasElement);
const heading = await canvas.findByRole("heading", { name: headingName });
const section = heading.closest("section");
if (!(section instanceof HTMLElement)) {
throw new Error(
`Expected ${headingName} heading to live inside a section.`,
);
const setting = heading.closest("form");
if (!(setting instanceof HTMLElement)) {
throw new Error(`Expected ${headingName} heading to live inside a form.`);
}
return section;
return setting;
};
const selectModelInSection = async (
@@ -162,29 +160,36 @@ const selectModelInSection = async (
};
const meta = {
title: "pages/AgentsPage/AgentSettingsAgentsPageView",
component: AgentSettingsAgentsPageView,
title: "pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView",
component: CoderAgentsPageView,
args: buildArgs(),
} satisfies Meta<typeof AgentSettingsAgentsPageView>;
} satisfies Meta<typeof CoderAgentsPageView>;
export default meta;
type Story = StoryObj<typeof AgentSettingsAgentsPageView>;
type Story = StoryObj<typeof CoderAgentsPageView>;
export const AllOverridesUnset: Story = {
args: buildArgs(),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Agents");
expect(
await canvas.findByRole("heading", { name: "Coder Agents" }),
).toBeVisible();
expect(
canvas.getByText(
"Configure deployment-wide defaults for Coder Agents and agent-specific capabilities.",
),
).toBeVisible();
expect(canvas.getByText("Allow personal model overrides")).toBeVisible();
const headings = await canvas.findAllByRole("heading", { level: 3 });
expect(headings.map((heading) => heading.textContent?.trim())).toEqual([
"Enable users to define their personal overrides",
"General model",
"Title generation model",
"Explore subagent model",
]);
await canvas.findByText(
"Choose a model for generated chat titles. Leave unset to use Coder's default title algorithm, which currently tries fast title models for configured providers first, for example Claude Haiku, GPT-4o mini, and Gemini Flash, then falls back to the chat's current model. When a model is selected here, Coder uses only that model for title generation. Recommended title models are fast and low cost.",
"Leave unset to use Coder's title default, which prefers fast models from configured providers.",
);
const unsetSections = [
@@ -204,8 +209,8 @@ export const AllOverridesUnset: Story = {
within(section).getByRole("combobox", { name: placeholder }),
).toBeInTheDocument();
expect(
within(section).getByRole("button", { name: "Save" }),
).toBeDisabled();
within(section).queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
}
},
};
@@ -217,7 +222,7 @@ export const PersonalOverridesDisabled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable users to define their personal overrides",
name: "Allow personal model overrides",
});
expect(toggle).not.toBeChecked();
@@ -231,7 +236,7 @@ export const PersonalOverridesEnabled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable users to define their personal overrides",
name: "Allow personal model overrides",
});
expect(toggle).toBeChecked();
@@ -1,10 +1,14 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import {
AdminPersonalModelOverridesSettings,
type SavePersonalModelOverridesAdminSetting,
} from "./components/AdminPersonalModelOverridesSettings";
import { SectionHeader } from "./components/SectionHeader";
import {
type MutationCallbacks,
SubagentModelOverrideSettings,
@@ -15,7 +19,7 @@ type SaveModelOverride = (
options?: MutationCallbacks,
) => void;
export interface AgentSettingsAgentsPageViewProps {
export interface CoderAgentsPageViewProps {
adminOverridesData?: TypesGen.ChatPersonalModelOverridesAdminSettings;
adminOverridesError?: unknown;
onRetryAdminOverrides?: () => void;
@@ -40,9 +44,7 @@ export interface AgentSettingsAgentsPageViewProps {
isSaveExploreModelOverrideError: boolean;
}
export const AgentSettingsAgentsPageView: FC<
AgentSettingsAgentsPageViewProps
> = ({
export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
adminOverridesData,
adminOverridesError,
onRetryAdminOverrides,
@@ -76,30 +78,28 @@ export const AgentSettingsAgentsPageView: FC<
isSaveGeneralModelOverrideError;
return (
<div className="flex flex-col gap-8">
<SectionHeader
label="Agents"
description="Configure defaults for delegated agents and other agent-specific capabilities."
/>
<AdminPersonalModelOverridesSettings
adminSettings={adminOverridesData}
adminSettingsError={adminOverridesError}
onRetryAdminSettings={onRetryAdminOverrides}
isRetryingAdminSettings={isRetryingAdminOverrides}
onSaveAdminSetting={onSaveAdminOverrides}
isSavingAdminSetting={isSavingAdminOverrides}
isSaveAdminSettingError={isSaveAdminOverridesError}
/>
{showGeneralModelSection && onSaveGeneralModelOverride && (
<section aria-label="General model" className="flex flex-col gap-3">
<SectionHeader
label="General model"
description="Deployment-wide model override for delegated subagents with write capabilities, such as editing files or running commands in the workspace."
level="section"
/>
<div className="flex max-w-4xl flex-col gap-8">
<SettingsHeader>
<SettingsHeaderTitle>Coder Agents</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure deployment-wide defaults for Coder Agents and agent-specific
capabilities.
</SettingsHeaderDescription>
</SettingsHeader>
<div className="flex flex-col gap-6 rounded-lg border border-solid border-border px-6 py-7">
<AdminPersonalModelOverridesSettings
adminSettings={adminOverridesData}
adminSettingsError={adminOverridesError}
onRetryAdminSettings={onRetryAdminOverrides}
isRetryingAdminSettings={isRetryingAdminOverrides}
onSaveAdminSetting={onSaveAdminOverrides}
isSavingAdminSetting={isSavingAdminOverrides}
isSaveAdminSettingError={isSaveAdminOverridesError}
/>
{showGeneralModelSection && onSaveGeneralModelOverride && (
<SubagentModelOverrideSettings
title="General model"
description="Deployment-wide model override for delegated subagents with write capabilities, such as editing files or running commands in the workspace."
description="Used by delegated agents that can edit files or run commands."
modelOverrideData={generalModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
modelConfigsError={modelConfigsError}
@@ -108,22 +108,11 @@ export const AgentSettingsAgentsPageView: FC<
isSaving={isSavingGeneralModelOverride}
isSaveError={isSaveGeneralModelOverrideError}
saveErrorMessage="Failed to save general model override."
showHeader={false}
/>
</section>
)}
<section
aria-label="Title generation model"
className="flex flex-col gap-3"
>
<SectionHeader
label="Title generation model"
description="Choose a model for generated chat titles. Leave unset to use Coder's default title algorithm, which currently tries fast title models for configured providers first, for example Claude Haiku, GPT-4o mini, and Gemini Flash, then falls back to the chat's current model. When a model is selected here, Coder uses only that model for title generation. Recommended title models are fast and low cost."
level="section"
/>
)}
<SubagentModelOverrideSettings
title="Title generation model"
description="Choose a model for generated chat titles."
description="Leave unset to use Coder's title default, which prefers fast models from configured providers."
modelOverrideData={titleGenerationModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
modelConfigsError={modelConfigsError}
@@ -134,27 +123,10 @@ export const AgentSettingsAgentsPageView: FC<
saveErrorMessage="Failed to save title generation model."
unsetPlaceholder="Use title default"
unavailableModelWarning="The selected model is currently unavailable. Title generation will be skipped until you choose another model or clear this setting."
showHeader={false}
/>
</section>
<section
aria-label="Explore subagent model"
className="flex flex-col gap-3"
>
<SectionHeader
label="Explore subagent model"
description="Deployment-wide model override for read-only Explore subagents."
level="section"
/>
<SubagentModelOverrideSettings
title="Explore subagent model"
description={
<>
Deployment-wide model override for read-only Explore subagents
launched through the <code>spawn_agent</code> tool with a
<code>type=explore</code> argument.
</>
}
description="Used for read-only codebase exploration before work returns to the main agent."
modelOverrideData={exploreModelOverrideData}
enabledModelConfigs={enabledModelConfigs}
modelConfigsError={modelConfigsError}
@@ -163,9 +135,8 @@ export const AgentSettingsAgentsPageView: FC<
isSaving={isSavingExploreModelOverride}
isSaveError={isSaveExploreModelOverrideError}
saveErrorMessage="Failed to save Explore model override."
showHeader={false}
/>
</section>
</div>
</div>
);
};
@@ -13,7 +13,8 @@ const baseArgs = {
};
const meta = {
title: "pages/AgentsPage/components/AdminPersonalModelOverridesSettings",
title:
"pages/AISettingsPage/CoderAgentsPage/components/AdminPersonalModelOverridesSettings",
component: AdminPersonalModelOverridesSettings,
args: baseArgs,
} satisfies Meta<typeof AdminPersonalModelOverridesSettings>;
@@ -25,16 +26,16 @@ export const FeatureDisabled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable users to define their personal overrides",
name: "Allow personal model overrides",
});
expect(
await canvas.findByText(
"Enable users to define their personal overrides",
),
await canvas.findByText("Allow personal model overrides"),
).toBeInTheDocument();
expect(toggle).not.toBeChecked();
expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
canvas.queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
},
};
@@ -50,10 +51,12 @@ export const LoadingState: Story = {
).toBeInTheDocument();
expect(
canvas.getByRole("switch", {
name: "Enable users to define their personal overrides",
name: "Allow personal model overrides",
}),
).toBeDisabled();
expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
canvas.queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
},
};
@@ -71,7 +74,9 @@ export const LoadError: Story = {
expect(
canvas.queryByText("Loading personal model override settings..."),
).not.toBeInTheDocument();
expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
canvas.queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Retry" }));
expect(args.onRetryAdminSettings).toHaveBeenCalled();
},
@@ -84,11 +89,13 @@ export const FeatureEnabled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable users to define their personal overrides",
name: "Allow personal model overrides",
});
expect(toggle).toBeChecked();
expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
canvas.queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
},
};
@@ -99,11 +106,11 @@ export const Saving: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable users to define their personal overrides",
name: "Allow personal model overrides",
});
expect(toggle).toBeDisabled();
expect(canvas.getByRole("button", { name: "Save" })).toBeDisabled();
expect(canvas.getByRole("button", { name: /save/i })).toBeDisabled();
},
};
@@ -126,11 +133,10 @@ export const SavesChangedSetting: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable users to define their personal overrides",
name: "Allow personal model overrides",
});
const saveButton = canvas.getByRole("button", { name: "Save" });
await userEvent.click(toggle);
const saveButton = await canvas.findByRole("button", { name: "Save" });
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
@@ -0,0 +1,146 @@
import { useFormik } from "formik";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
TemporarySavedState,
useTemporarySavedState,
} from "#/components/TemporarySavedState/TemporarySavedState";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
export type SavePersonalModelOverridesAdminSetting = (
req: TypesGen.UpdateChatPersonalModelOverridesAdminSettingsRequest,
options?: MutationCallbacks,
) => void;
interface AdminPersonalModelOverridesSettingsProps {
adminSettings: TypesGen.ChatPersonalModelOverridesAdminSettings | undefined;
adminSettingsError?: unknown;
onRetryAdminSettings?: () => void;
isRetryingAdminSettings?: boolean;
onSaveAdminSetting: SavePersonalModelOverridesAdminSetting;
isSavingAdminSetting: boolean;
isSaveAdminSettingError: boolean;
}
export const AdminPersonalModelOverridesSettings: FC<
AdminPersonalModelOverridesSettingsProps
> = ({
adminSettings,
adminSettingsError,
onRetryAdminSettings,
isRetryingAdminSettings = false,
onSaveAdminSetting,
isSavingAdminSetting,
isSaveAdminSettingError,
}) => {
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const hasLoadedAdminSettings = adminSettings !== undefined;
const hasAdminSettingsError = adminSettingsError != null;
const form = useFormik({
enableReinitialize: true,
initialValues: {
allow_users: adminSettings?.allow_users ?? false,
},
onSubmit: (values, { resetForm }) => {
onSaveAdminSetting(
{
allow_users: values.allow_users,
},
{
onSuccess: () => {
showSavedState();
resetForm({ values });
},
},
);
},
});
const isDisabled = isSavingAdminSetting || !hasLoadedAdminSettings;
const showSave = form.dirty || isSavingAdminSetting || isSavedVisible;
const showStatusArea =
hasAdminSettingsError || !hasLoadedAdminSettings || isSaveAdminSettingError;
return (
<form
aria-label="Allow personal model overrides"
className="flex flex-col"
onSubmit={form.handleSubmit}
noValidate
>
<div className="flex min-h-8 items-start gap-2 font-sans text-sm font-normal leading-6 text-content-primary">
<Switch
checked={form.values.allow_users}
onCheckedChange={(checked) => {
void form.setFieldValue("allow_users", checked);
}}
aria-label="Allow personal model overrides"
type="button"
disabled={isDisabled}
className="mt-0.5"
/>
<div className="flex min-w-0 flex-col">
<span>Allow personal model overrides</span>
<span className="text-content-secondary">
Saved user preferences are preserved but ignored while disabled.
</span>
</div>
</div>
{showSave && (
<div className="mt-4 flex min-h-10 items-center">
{isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="lg"
type="submit"
disabled={isDisabled || !form.dirty}
className="h-10 min-w-[88px]"
>
{isSavingAdminSetting && <Spinner loading className="h-4 w-4" />}
Save
</Button>
)}
</div>
)}
{showStatusArea && (
<div className="text-xs">
{hasAdminSettingsError && (
<div className="flex flex-col gap-2 text-content-primary">
<ErrorAlert error={adminSettingsError} />
{onRetryAdminSettings && (
<Button
disabled={isRetryingAdminSettings}
onClick={onRetryAdminSettings}
size="sm"
type="button"
variant="outline"
className="w-fit"
>
Retry
</Button>
)}
</div>
)}
{!hasAdminSettingsError && !hasLoadedAdminSettings && (
<p className="m-0 text-content-secondary">
Loading personal model override settings...
</p>
)}
{isSaveAdminSettingError && (
<p className="m-0 text-content-destructive">
Failed to save personal model override settings.
</p>
)}
</div>
)}
</form>
);
};
@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { Button } from "#/components/Button/Button";
import { AgentSettingLayout } from "./AgentSettingLayout";
const meta = {
title: "pages/AISettingsPage/CoderAgentsPage/components/AgentSettingLayout",
component: AgentSettingLayout,
args: {
title: "General model",
description:
"Used by delegated agents that can edit files or run commands.",
showSave: false,
isSaving: false,
isSavedVisible: false,
saveDisabled: true,
onSubmit: fn(),
},
} satisfies Meta<typeof AgentSettingLayout>;
export default meta;
type Story = StoryObj<typeof AgentSettingLayout>;
export const Default: Story = {
args: {
children: <Button type="button">Choose model</Button>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByRole("form", { name: "General model" }),
).toBeVisible();
expect(canvas.getByText("General model")).toBeVisible();
expect(canvas.getByRole("button", { name: "Choose model" })).toBeVisible();
},
};
export const Saving: Story = {
args: {
showSave: true,
isSaving: true,
saveDisabled: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByRole("button", { name: /save/i })).toBeDisabled();
},
};
export const Saved: Story = {
args: {
showSave: false,
isSavedVisible: true,
saveDisabled: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Saved")).toBeVisible();
},
};
export const WithError: Story = {
args: {
error: <p className="m-0">Failed to save setting.</p>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Failed to save setting.")).toBeVisible();
},
};
@@ -0,0 +1,73 @@
import type { FC, FormEventHandler, ReactNode } from "react";
import { Button } from "#/components/Button/Button";
import { Spinner } from "#/components/Spinner/Spinner";
import { TemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
interface AgentSettingLayoutProps {
title: string;
description?: ReactNode;
children?: ReactNode;
error?: ReactNode;
showSave: boolean;
isSaving: boolean;
isSavedVisible: boolean;
saveDisabled: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
}
export const AgentSettingLayout: FC<AgentSettingLayoutProps> = ({
title,
description,
children,
error,
showSave,
isSaving,
isSavedVisible,
saveDisabled,
onSubmit,
}) => {
const shouldShowControls =
Boolean(children) || showSave || isSavedVisible || isSaving;
return (
<form
aria-label={title}
className="flex flex-col"
onSubmit={onSubmit}
noValidate
>
<div className="min-w-0">
<h3 className="m-0 text-sm font-normal leading-6 text-content-primary">
{title}
</h3>
{description && (
<p className="mt-1 mb-0 text-sm font-normal leading-6 text-content-secondary">
{description}
</p>
)}
</div>
{shouldShowControls && (
<div className="mt-4 flex flex-wrap items-start gap-3">
{children}
<div className="flex min-h-10 items-center">
{(showSave || isSavedVisible || isSaving) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="lg"
type="submit"
disabled={saveDisabled}
className="h-10 min-w-[88px]"
>
{isSaving && <Spinner loading className="h-4 w-4" />}
Save
</Button>
))}
</div>
</div>
)}
{error && <div className="text-xs text-content-destructive">{error}</div>}
</form>
);
};
@@ -2,9 +2,11 @@ import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
import { ModelSelector } from "./ChatElements/ModelSelector";
import { ModelOverrideAlerts } from "./ModelOverrideAlerts";
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import type { ModelSelectorOption } from "#/pages/AgentsPage/components/ChatElements/ModelSelector";
import { ModelSelector } from "#/pages/AgentsPage/components/ChatElements/ModelSelector";
import { ModelOverrideAlerts } from "#/pages/AgentsPage/components/ModelOverrideAlerts";
import { AgentSettingLayout } from "./AgentSettingLayout";
export interface MutationCallbacks {
onSuccess?: () => void;
@@ -36,7 +38,6 @@ interface SubagentModelOverrideSettingsProps {
saveErrorMessage: string;
unsetPlaceholder?: string;
unavailableModelWarning?: string;
showHeader?: boolean;
disabled?: boolean;
}
@@ -65,9 +66,9 @@ export const SubagentModelOverrideSettings: FC<
saveErrorMessage,
unsetPlaceholder = "Use chat default",
unavailableModelWarning = "The saved model is no longer enabled and will be ignored until you choose a new override.",
showHeader = true,
disabled = false,
}) => {
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const hasLoadedModelOverride = modelOverrideData !== undefined;
const isMalformedOverride = modelOverrideData?.is_malformed ?? false;
const enabledModelOptions = enabledModelConfigs.map(toModelSelectorOption);
@@ -84,6 +85,7 @@ export const SubagentModelOverrideSettings: FC<
},
{
onSuccess: () => {
showSavedState();
resetForm({ values });
},
},
@@ -102,61 +104,55 @@ export const SubagentModelOverrideSettings: FC<
);
return (
<form aria-label={title} className="space-y-2" onSubmit={form.handleSubmit}>
{showHeader && (
<>
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
{title}
</h3>
{description && (
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
{description}
</p>
)}
</>
)}
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) => form.setFieldValue("model_config_id", value)}
disabled={isFormDisabled}
placeholder={
isUnavailableSavedModel ? "Unavailable model" : unsetPlaceholder
}
emptyMessage={
isLoading ? "Loading models..." : "No enabled models found."
}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm"
contentClassName="min-w-[18rem]"
/>
<ModelOverrideAlerts
isUnavailableSavedModel={isUnavailableSavedModel}
unavailableMessage={unavailableModelWarning}
isMalformedOverride={isMalformedOverride}
malformedMessage="The saved override is malformed and is being treated as unset. Click Save to clear it."
modelConfigsError={modelConfigsError}
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => {
form.setFieldValue("model_config_id", "");
}}
<AgentSettingLayout
title={title}
description={description}
showSave={canSave}
isSaving={isSaving}
isSavedVisible={isSavedVisible}
saveDisabled={isFormDisabled || !canSave}
onSubmit={form.handleSubmit}
error={
isSaveError ? <p className="m-0">{saveErrorMessage}</p> : undefined
}
>
<div className="flex w-[22rem] max-w-full flex-col gap-2">
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) =>
void form.setFieldValue("model_config_id", value)
}
disabled={isFormDisabled}
>
Clear
</Button>
<Button size="sm" type="submit" disabled={isFormDisabled || !canSave}>
Save
</Button>
placeholder={
isUnavailableSavedModel ? "Unavailable model" : unsetPlaceholder
}
emptyMessage={
isLoading ? "Loading models..." : "No enabled models found."
}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm"
contentClassName="min-w-[18rem]"
/>
<ModelOverrideAlerts
isUnavailableSavedModel={isUnavailableSavedModel}
unavailableMessage={unavailableModelWarning}
isMalformedOverride={isMalformedOverride}
malformedMessage="The saved override is malformed and is being treated as unset. Click Save to clear it."
modelConfigsError={modelConfigsError}
/>
</div>
{isSaveError && (
<p className="m-0 text-xs text-content-destructive">
{saveErrorMessage}
</p>
)}
</form>
<Button
size="lg"
variant="outline"
type="button"
onClick={() => {
void form.setFieldValue("model_config_id", "");
}}
disabled={isFormDisabled}
className="h-10"
>
Clear
</Button>
</AgentSettingLayout>
);
};
@@ -27,9 +27,9 @@ import {
withAuthProvider,
withDashboardProvider,
} from "#/testHelpers/storybook";
import { CoderAgentsPageView } from "../AISettingsPage/CoderAgentsPage/CoderAgentsPageView";
import AgentAnalyticsPage from "./AgentAnalyticsPage";
import AgentCreatePage from "./AgentCreatePage";
import { AgentSettingsAgentsPageView } from "./AgentSettingsAgentsPageView";
import AgentSettingsCompactionPage from "./AgentSettingsCompactionPage";
import AgentSettingsExperimentsPage from "./AgentSettingsExperimentsPage";
import AgentSettingsGeneralPage from "./AgentSettingsGeneralPage";
@@ -165,7 +165,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
const fixedNow = dayjs("2026-03-12T12:00:00");
const AgentsRouteElement = () => (
<AgentSettingsAgentsPageView
<CoderAgentsPageView
adminOverridesData={{ allow_users: false }}
onSaveAdminOverrides={fn()}
isSavingAdminOverrides={false}
@@ -1143,7 +1143,7 @@ export const OpensSettingsForNonAdmins: Story = {
},
};
export const OpensAdminSubPanelOnMobile: Story = {
export const OpensExperimentsFromManageAgentsOnMobile: Story = {
args: {
isAgentsAdmin: true,
},
@@ -1160,15 +1160,12 @@ export const OpensAdminSubPanelOnMobile: Story = {
);
await expect(
await screen.findByRole("link", { name: "Providers" }),
).toBeInTheDocument();
await expect(
await screen.findByRole("link", { name: "Spend" }),
await screen.findByRole("heading", { name: "Experiments" }),
).toBeInTheDocument();
},
};
export const SettingsViewSpendCrossLink: Story = {
export const SettingsViewExperimentsLink: Story = {
args: {
isAgentsAdmin: true,
},
@@ -1186,9 +1183,10 @@ export const SettingsViewSpendCrossLink: Story = {
await screen.findByRole("link", { name: "Manage agents" }),
);
await userEvent.click(await screen.findByRole("link", { name: "Spend" }));
await waitFor(() => {
expect(screen.getByText("Spend limits and usage")).toBeInTheDocument();
expect(
screen.getByText("Opt in to experimental features."),
).toBeInTheDocument();
});
},
};
@@ -1,122 +0,0 @@
import { useFormik } from "formik";
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Switch } from "#/components/Switch/Switch";
interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
export type SavePersonalModelOverridesAdminSetting = (
req: TypesGen.UpdateChatPersonalModelOverridesAdminSettingsRequest,
options?: MutationCallbacks,
) => void;
interface AdminPersonalModelOverridesSettingsProps {
adminSettings: TypesGen.ChatPersonalModelOverridesAdminSettings | undefined;
adminSettingsError?: unknown;
onRetryAdminSettings?: () => void;
isRetryingAdminSettings?: boolean;
onSaveAdminSetting: SavePersonalModelOverridesAdminSetting;
isSavingAdminSetting: boolean;
isSaveAdminSettingError: boolean;
}
export const AdminPersonalModelOverridesSettings: FC<
AdminPersonalModelOverridesSettingsProps
> = ({
adminSettings,
adminSettingsError,
onRetryAdminSettings,
isRetryingAdminSettings = false,
onSaveAdminSetting,
isSavingAdminSetting,
isSaveAdminSettingError,
}) => {
const hasLoadedAdminSettings = adminSettings !== undefined;
const hasAdminSettingsError = adminSettingsError != null;
const form = useFormik({
enableReinitialize: true,
initialValues: {
allow_users: adminSettings?.allow_users ?? false,
},
onSubmit: (values, { resetForm }) => {
onSaveAdminSetting(
{
allow_users: values.allow_users,
},
{
onSuccess: () => {
resetForm({ values });
},
},
);
},
});
const isDisabled = isSavingAdminSetting || !hasLoadedAdminSettings;
return (
<form
aria-label="Personal model overrides"
className="space-y-2"
onSubmit={form.handleSubmit}
>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Enable users to define their personal overrides
</h3>
<p className="m-0 text-xs text-content-secondary">
Lets users choose personal models for root chats, General subagents,
and Explore subagents. When disabled, saved user settings remain
stored but are ignored at runtime.
</p>
</div>
<Switch
checked={form.values.allow_users}
onCheckedChange={(checked) => {
void form.setFieldValue("allow_users", checked);
}}
aria-label="Enable users to define their personal overrides"
type="button"
disabled={isDisabled}
/>
</div>
{hasAdminSettingsError ? (
<div className="flex flex-col gap-2">
<ErrorAlert error={adminSettingsError} />
{onRetryAdminSettings && (
<Button
disabled={isRetryingAdminSettings}
onClick={onRetryAdminSettings}
size="sm"
type="button"
variant="outline"
>
Retry
</Button>
)}
</div>
) : (
!hasLoadedAdminSettings && (
<p className="m-0 text-xs text-content-secondary">
Loading personal model override settings...
</p>
)
)}
<div className="flex justify-end gap-2">
<Button size="sm" type="submit" disabled={isDisabled || !form.dirty}>
Save
</Button>
</div>
{isSaveAdminSettingError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save personal model override settings.
</p>
)}
</form>
);
};
@@ -2238,7 +2238,7 @@ export const SettingsUserAgentsAdmin: Story = {
},
};
export const SettingsAdminAgentsEntryPreserved: Story = {
export const SettingsAdminCoderAgentsEntryPreserved: Story = {
args: {
chats: [],
isAdmin: true,
@@ -2251,8 +2251,13 @@ export const SettingsAdminAgentsEntryPreserved: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const agentsLink = canvas.getByRole("link", { name: "Agents" });
await expect(agentsLink).toHaveAttribute("aria-current", "page");
const coderAgentsLink = canvas.getByRole("link", {
name: "Coder Agents",
});
expect(coderAgentsLink).toHaveAttribute(
"href",
"/ai/settings/coder-agents",
);
expect(canvas.getByText("Manage agents")).toBeInTheDocument();
},
};
@@ -3,7 +3,6 @@ import {
ArrowUpRightIcon,
BotIcon,
BoxesIcon,
ChevronRightIcon,
CoinsIcon,
FlaskConicalIcon,
KeyIcon,
@@ -147,10 +146,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
<SettingsNavItem
icon={Settings2Icon}
label="Manage agents"
active={false}
to="/agents/settings/admin"
active={settingsSection === "experiments"}
to="/agents/settings/experiments"
state={location.state}
trailingIcon={ChevronRightIcon}
/>
)}
</nav>
@@ -158,10 +156,10 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
<nav className="flex flex-col gap-0.5 px-2 py-2">
<SettingsNavItem
icon={BotIcon}
label="Agents"
active={!settingsSection || settingsSection === "agents"}
to="/agents/settings/agents"
state={location.state}
label="Coder Agents"
active={false}
to="/ai/settings/coder-agents"
trailingIcon={ArrowUpRightIcon}
/>
<SettingsNavItem
icon={PlugIcon}
+11 -4
View File
@@ -378,8 +378,8 @@ const AgentSettingsExperimentsPage = lazy(
const AISettingsLifecyclePage = lazy(
() => import("./pages/AISettingsPage/LifecyclePage/LifecyclePage"),
);
const AgentSettingsAgentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAgentsPage"),
const CoderAgentsPage = lazy(
() => import("./pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage"),
);
const AgentSettingsUserAgentsPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsUserAgentsPage"),
@@ -775,6 +775,7 @@ export const router = createBrowserRouter(
element={<AISettingsInstructionsPage />}
/>
<Route path="lifecycle" element={<AISettingsLifecyclePage />} />
<Route path="coder-agents" element={<CoderAgentsPage />} />
<Route path="templates" element={<AISettingsTemplatesPage />} />
<Route path="models/add" element={<AISettingsAddModelPage />} />
<Route
@@ -879,8 +880,14 @@ export const router = createBrowserRouter(
path="personal-skills"
element={<AgentSettingsPersonalSkillsPage />}
/>
<Route path="admin" element={<AgentSettingsAgentsPage />} />
<Route path="agents" element={<AgentSettingsAgentsPage />} />
<Route
path="admin"
element={<Navigate to="/ai/settings/coder-agents" replace />}
/>
<Route
path="agents"
element={<Navigate to="/ai/settings/coder-agents" replace />}
/>
<Route path="api-keys" element={<AgentSettingsAPIKeysPage />} />
<Route
path="providers"