fix: move organization model overrides from Models to Coder Agents settings (#28473)

## Summary

Relocates the organization-scoped Coder Agents model override controls
from the **Defaults & overrides** tab under **AI Settings > Models**
into an **Organization settings** section on **AI Settings > Coder
Agents**. Models remains the home for reusable model definitions,
provider availability, default designation, and ACLs; Coder Agents now
owns all agent-specific model/reasoning override controls. The backend
organization scoping from #28442 is unchanged.

## Changes

- **Coder Agents page** is split into two sections:
- **Organization settings**: General subagent, Explore subagent, title
generation, compaction, and Advisor (behind the `chat-advisor`
experiment) model/reasoning overrides, with an organization picker when
more than one accessible organization exists.
- **Deployment settings**: personal override policy, virtual desktop
provider (behind its experiment), and Advisor usage limits, visible only
with `editDeploymentConfig`.
- **Permissions**: the page and its sidebar entry are accessible with
either `editDeploymentConfig` or organization chat model access.
Organization mutations require `editChatModelConfigs` on the selected
organization; otherwise controls render read-only. Organization
discovery, selected-organization permissions, and deployment queries
fail independently.
- **Models page** drops the Defaults & overrides tab and the
now-single-item tab UI. The old `DefaultsPage` components move to
`CoderAgentsPage/OrganizationAgentSettings*`.
- **Compatibility**: `/ai/settings/models/defaults` redirects to
`/ai/settings/coder-agents`, preserving `org` and other query
parameters.
- **Docs**: intentionally not updated here; a follow-up PR will update
`docs/ai-coder/agents/models.md` and
`docs/ai-coder/agents/platform-controls/advisor.md`.

## Testing

- Targeted unit tests (`ModelDefaultsRedirect`, `organizationModels`): 9
passing.
- Affected Storybook interaction tests (Coder Agents page/view,
organization agent settings, Models layout, AI settings sidebar): 41
passing.
- `pnpm check`, full `pnpm lint` (Biome, tsc, circular deps, React
compiler, knip), `pnpm format`.
This commit is contained in:
Ethan
2026-08-24 17:43:12 +01:00
committed by GitHub
parent d12cc2da21
commit eab5e28695
23 changed files with 644 additions and 294 deletions
@@ -180,8 +180,8 @@ export const OrganizationOnlyRoleCanAccessModels: Story = {
const canvas = within(canvasElement);
await expect(canvas.getByRole("link", { name: "Models" })).toBeVisible();
await expect(
canvas.queryByRole("link", { name: "Coder Agents" }),
).not.toBeInTheDocument();
canvas.getByRole("link", { name: "Coder Agents" }),
).toBeVisible();
},
};
@@ -80,24 +80,22 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
)}
{(canAccessAnyChatModelConfig(permissions) ||
canAccessOrganizationModels) && <ModelsSidebarNavItem />}
{(permissions.editDeploymentConfig || canAccessOrganizationModels) && (
<SidebarNavItem href="/ai/settings/coder-agents">
Coder Agents
</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<>
<SidebarNavItem href="/ai/settings/coder-agents">
Coder Agents
</SidebarNavItem>
<div className="flex flex-col gap-1 ml-3 border-0 border-solid border-l border-l-border">
<SubNavItem href="/ai/settings/mcp-servers">
MCP servers
</SubNavItem>
{permissions.updateTemplates && (
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
)}
<SubNavItem href="/ai/settings/instructions">
Instructions
</SubNavItem>
<SubNavItem href="/ai/settings/lifecycle">Lifecycle</SubNavItem>
</div>
</>
<div className="flex flex-col gap-1 ml-3 border-0 border-solid border-l border-l-border">
<SubNavItem href="/ai/settings/mcp-servers">MCP servers</SubNavItem>
{permissions.updateTemplates && (
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
)}
<SubNavItem href="/ai/settings/instructions">
Instructions
</SubNavItem>
<SubNavItem href="/ai/settings/lifecycle">Lifecycle</SubNavItem>
</div>
)}
{!permissions.editDeploymentConfig &&
(permissions.viewAnyMCPServerConfigs ||
@@ -1,5 +1,6 @@
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useSearchParams } from "react-router";
import {
chatAdvisorConfig,
chatComputerUseProvider,
@@ -8,17 +9,42 @@ import {
updateChatComputerUseProvider,
updateChatPersonalModelOverridesAdminSettings,
} from "#/api/queries/chats";
import { organizationsPermissions } from "#/api/queries/organizations";
import { Loader } from "#/components/Loader/Loader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import {
modelOrganizationSearchParam,
selectModelOrganization,
useAccessibleModelOrganizations,
} from "#/pages/AISettingsPage/ModelsPage/organizationModels";
import { pageTitle } from "#/utils/page";
import { CoderAgentsPageView } from "./CoderAgentsPageView";
import { OrganizationAgentSettings } from "./OrganizationAgentSettings";
const CoderAgentsPage: FC = () => {
const { permissions } = useAuthenticated();
const { experiments } = useDashboard();
const { experiments, organizations } = useDashboard();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const canEditDeploymentConfig = permissions.editDeploymentConfig;
const accessibleOrganizationsQuery =
useAccessibleModelOrganizations(organizations);
const organizationSelection = selectModelOrganization(
accessibleOrganizationsQuery.organizations,
searchParams.get(modelOrganizationSearchParam),
);
const activeOrganization = organizationSelection.organization;
const organizationPermissionsQuery = useQuery({
...organizationsPermissions(
activeOrganization ? [activeOrganization.id] : undefined,
),
enabled: activeOrganization !== undefined,
});
const activeOrganizationPermissions = activeOrganization
? organizationPermissionsQuery.data?.[activeOrganization.id]
: undefined;
const showAdvisorSettings = experiments.includes("chat-advisor");
const showVirtualDesktopSettings = experiments.includes(
"chat-virtual-desktop",
@@ -44,11 +70,52 @@ const CoderAgentsPage: FC = () => {
const saveComputerUseProviderMutation = useMutation(
updateChatComputerUseProvider(queryClient),
);
const canAccessOrganizationSettings =
accessibleOrganizationsQuery.organizations.length > 0;
const isFeatureVisible =
canEditDeploymentConfig ||
canAccessOrganizationSettings ||
accessibleOrganizationsQuery.isLoading ||
accessibleOrganizationsQuery.error !== null;
const organizationSettings =
activeOrganization &&
organizationPermissionsQuery.data === undefined &&
organizationPermissionsQuery.error == null ? (
<Loader />
) : activeOrganization &&
organizationPermissionsQuery.data !== undefined ? (
<OrganizationAgentSettings
organization={activeOrganization}
canEdit={
!organizationSelection.requestedOrganizationDenied &&
(activeOrganizationPermissions?.editChatModelConfigs ?? false)
}
showAdvisor={showAdvisorSettings}
/>
) : undefined;
return (
<RequirePermission isFeatureVisible={canEditDeploymentConfig}>
<RequirePermission isFeatureVisible={isFeatureVisible}>
<title>{pageTitle("Coder Agents", "AI Settings")}</title>
<CoderAgentsPageView
organization={activeOrganization}
organizations={accessibleOrganizationsQuery.organizations}
onSelectOrganization={(organization) => {
const next = new URLSearchParams(searchParams);
next.set(modelOrganizationSearchParam, organization.name);
setSearchParams(next);
}}
organizationAccessError={
accessibleOrganizationsQuery.partialError ??
accessibleOrganizationsQuery.error
}
organizationPermissionsError={organizationPermissionsQuery.error}
requestedOrganizationDenied={
organizationSelection.requestedOrganizationDenied
}
isOrganizationAccessLoading={accessibleOrganizationsQuery.isLoading}
organizationSettings={organizationSettings}
canEditDeploymentConfig={canEditDeploymentConfig}
adminOverridesData={personalOverridesQuery.data}
adminOverridesError={personalOverridesQuery.error}
onRetryAdminOverrides={() => void personalOverridesQuery.refetch()}
@@ -1,12 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import {
MockDefaultOrganization,
MockOrganization2,
} from "#/testHelpers/entities";
import {
CoderAgentsPageView,
type CoderAgentsPageViewProps,
} from "./CoderAgentsPageView";
const defaultArgs: CoderAgentsPageViewProps = {
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization, MockOrganization2],
onSelectOrganization: fn(),
requestedOrganizationDenied: false,
isOrganizationAccessLoading: false,
organizationSettings: <div>Organization override controls</div>,
canEditDeploymentConfig: true,
adminOverridesData: { allow_users: true },
onSaveAdminOverrides: fn(),
isSavingAdminOverrides: false,
@@ -51,7 +62,10 @@ export const Default: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await expect(
canvas.getAllByRole("link", { name: "Defaults & overrides" })[0],
canvas.getByRole("heading", { name: "Organization settings" }),
).toBeVisible();
await expect(
canvas.getByRole("heading", { name: "Deployment settings" }),
).toBeVisible();
await expect(canvas.getByText("Advisor")).toBeVisible();
const maxUses = canvas.getByRole("spinbutton", { name: "Uses / turn" });
@@ -76,3 +90,129 @@ export const WithoutAdvisor: Story = {
await expect(canvas.queryByText("Advisor")).not.toBeInTheDocument();
},
};
export const SelectOrganization: Story = {
args: { onSelectOrganization: fn() },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", {
name: new RegExp(MockDefaultOrganization.display_name, "i"),
}),
);
await userEvent.click(
await screen.findByRole("option", {
name: new RegExp(MockOrganization2.display_name, "i"),
}),
);
await expect(args.onSelectOrganization).toHaveBeenCalledWith(
MockOrganization2,
);
},
};
export const OrganizationOnly: Story = {
args: { canEditDeploymentConfig: false },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("heading", { name: "Organization settings" }),
).toBeVisible();
await expect(
canvas.queryByRole("heading", { name: "Deployment settings" }),
).not.toBeInTheDocument();
},
};
export const OrganizationDiscoveryLoading: Story = {
args: { isOrganizationAccessLoading: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("status", { name: "Loading organization settings" }),
).toBeVisible();
await expect(
canvas.queryByRole("heading", { name: "Organization settings" }),
).not.toBeInTheDocument();
await expect(
canvas.queryByText("Organization override controls"),
).not.toBeInTheDocument();
await expect(
canvas.getByRole("heading", { name: "Deployment settings" }),
).toBeVisible();
},
};
export const DeploymentOnly: Story = {
args: {
organization: undefined,
organizations: [],
organizationSettings: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("heading", { name: "Organization settings" }),
).not.toBeInTheDocument();
await expect(
canvas.getByRole("heading", { name: "Deployment settings" }),
).toBeVisible();
},
};
export const SingleOrganization: Story = {
args: { organizations: [MockDefaultOrganization] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("button", {
name: new RegExp(MockDefaultOrganization.display_name, "i"),
}),
).not.toBeInTheDocument();
},
};
export const InaccessibleRequestedOrganization: Story = {
args: { requestedOrganizationDenied: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("alert")).toHaveTextContent(
"requested organization is not available",
);
await expect(
canvas.getByText("Organization override controls"),
).toBeVisible();
},
};
export const IndependentErrors: Story = {
args: {
organizationAccessError: new Error("Failed to load another organization"),
adminOverridesError: new Error("Failed to load deployment setting"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("Failed to load another organization"),
).toBeVisible();
await expect(
canvas.getByText("Failed to load deployment setting"),
).toBeVisible();
await expect(
canvas.getByText("Organization override controls"),
).toBeVisible();
},
};
export const Mobile: Story = {
parameters: { viewport: { defaultViewport: "mobile1" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("heading", { name: "Organization settings" }),
).toBeVisible();
await expect(
canvas.getByRole("heading", { name: "Deployment settings" }),
).toBeVisible();
},
};
@@ -1,7 +1,13 @@
import type { FC } from "react";
import type { FC, ReactNode } from "react";
import type { UseMutateFunction } from "react-query";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import {
getOrganizationLabel,
OrganizationAutocomplete,
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import {
SettingsHeader,
SettingsHeaderDescription,
@@ -16,6 +22,15 @@ import {
import type { MutationCallbacks } from "./components/SubagentModelOverrideSettings";
export interface CoderAgentsPageViewProps {
organization?: TypesGen.Organization;
organizations: readonly TypesGen.Organization[];
onSelectOrganization: (organization: TypesGen.Organization) => void;
organizationAccessError?: unknown;
organizationPermissionsError?: unknown;
requestedOrganizationDenied: boolean;
isOrganizationAccessLoading: boolean;
organizationSettings?: ReactNode;
canEditDeploymentConfig: boolean;
adminOverridesData?: TypesGen.ChatPersonalModelOverridesAdminSettings;
adminOverridesError?: unknown;
onRetryAdminOverrides?: () => void;
@@ -49,6 +64,15 @@ export interface CoderAgentsPageViewProps {
}
export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
organization,
organizations,
onSelectOrganization,
organizationAccessError,
organizationPermissionsError,
requestedOrganizationDenied,
isOrganizationAccessLoading,
organizationSettings,
canEditDeploymentConfig,
adminOverridesData,
adminOverridesError,
onRetryAdminOverrides,
@@ -71,47 +95,124 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
onSaveComputerUseProvider,
isSavingComputerUseProvider,
computerUseProviderSaveError,
}) => (
<div className="flex max-w-4xl flex-col gap-8">
<SettingsHeader>
<SettingsHeaderTitle>Coder Agents</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure deployment-wide Coder Agents capabilities. Model defaults are
configured per organization in{" "}
<Link to="/ai/settings/models/defaults">Defaults & overrides</Link>.
</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}
/>
{showVirtualDesktopSettings && (
<VirtualDesktopSettings
computerUseProviderData={computerUseProviderData}
isLoadingComputerUseProvider={isLoadingComputerUseProvider}
onSaveComputerUseProvider={onSaveComputerUseProvider}
isSavingComputerUseProvider={isSavingComputerUseProvider}
computerUseProviderSaveError={computerUseProviderSaveError}
/>
)}
{showAdvisorSettings && (
<AdvisorSettings
advisorConfigData={advisorConfigData}
isAdvisorConfigLoading={isAdvisorConfigLoading}
isAdvisorConfigFetching={isAdvisorConfigFetching}
isAdvisorConfigLoadError={isAdvisorConfigLoadError}
onSaveAdvisorConfig={onSaveAdvisorConfig}
isSavingAdvisorConfig={isSavingAdvisorConfig}
isSaveAdvisorConfigError={isSaveAdvisorConfigError}
saveAdvisorConfigError={saveAdvisorConfigError}
/>
}) => {
return (
<div className="flex max-w-4xl flex-col gap-10">
<SettingsHeader>
<SettingsHeaderTitle>Coder Agents</SettingsHeaderTitle>
<SettingsHeaderDescription>
Configure organization model choices and deployment-wide Coder Agents
capabilities.
</SettingsHeaderDescription>
</SettingsHeader>
{isOrganizationAccessLoading ? (
<Loader label="Loading organization settings" />
) : organization ? (
<section
aria-labelledby="organization-agent-settings"
className="flex flex-col gap-6"
>
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<h2
id="organization-agent-settings"
className="m-0 text-xl font-semibold"
>
Organization settings
</h2>
<p className="mt-1 mb-0 text-sm text-content-secondary">
Choose model and reasoning defaults for each Coder Agents
context.
</p>
</div>
{organizations.length > 1 && (
<OrganizationAutocomplete
value={organization}
ariaLabel={`Organization ${getOrganizationLabel(
organization,
organizations,
)}`}
options={organizations}
triggerClassName="w-60"
optionsTabbable
onChange={(nextOrganization) => {
if (nextOrganization) {
onSelectOrganization(nextOrganization);
}
}}
/>
)}
</div>
{requestedOrganizationDenied && (
<Alert severity="warning">
The requested organization is not available. Showing settings for{" "}
{organization.display_name || organization.name} instead.
</Alert>
)}
{organizationAccessError != null && (
<ErrorAlert error={organizationAccessError} />
)}
{organizationPermissionsError != null && (
<ErrorAlert error={organizationPermissionsError} />
)}
{organizationSettings}
</section>
) : organizationAccessError != null ? (
<ErrorAlert error={organizationAccessError} />
) : null}
{canEditDeploymentConfig && (
<section
aria-labelledby="deployment-agent-settings"
className="flex flex-col gap-6"
>
<div>
<h2
id="deployment-agent-settings"
className="m-0 text-xl font-semibold"
>
Deployment settings
</h2>
<p className="mt-1 mb-0 text-sm text-content-secondary">
Configure Coder Agents capabilities that apply to every
organization.
</p>
</div>
<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}
/>
{showVirtualDesktopSettings && (
<VirtualDesktopSettings
computerUseProviderData={computerUseProviderData}
isLoadingComputerUseProvider={isLoadingComputerUseProvider}
onSaveComputerUseProvider={onSaveComputerUseProvider}
isSavingComputerUseProvider={isSavingComputerUseProvider}
computerUseProviderSaveError={computerUseProviderSaveError}
/>
)}
{showAdvisorSettings && (
<AdvisorSettings
advisorConfigData={advisorConfigData}
isAdvisorConfigLoading={isAdvisorConfigLoading}
isAdvisorConfigFetching={isAdvisorConfigFetching}
isAdvisorConfigLoadError={isAdvisorConfigLoadError}
onSaveAdvisorConfig={onSaveAdvisorConfig}
isSavingAdvisorConfig={isSavingAdvisorConfig}
isSaveAdvisorConfigError={isSaveAdvisorConfigError}
saveAdvisorConfigError={saveAdvisorConfigError}
/>
)}
</div>
</section>
)}
</div>
</div>
);
);
};
@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
expect,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { API } from "#/api/api";
import {
MockChatModel,
MockChatModelProviderDescriptor,
} from "#/testHelpers/chatModels";
import { MockDefaultOrganization } from "#/testHelpers/entities";
import { OrganizationAgentSettings } from "./OrganizationAgentSettings";
const meta: Meta<typeof OrganizationAgentSettings> = {
title: "pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings",
component: OrganizationAgentSettings,
args: {
organization: MockDefaultOrganization,
canEdit: true,
showAdvisor: true,
},
};
export default meta;
type Story = StoryObj<typeof OrganizationAgentSettings>;
export const ClearableWhenModelCatalogFails: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChatModels").mockRejectedValue(
new Error("failed to load models"),
);
spyOn(
API.experimental,
"getOrganizationChatModelOverrides",
).mockResolvedValue({
overrides: [{ context: "general", model_config_id: "model-gone" }],
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// A failed model catalog fetch must not hide the override rows: an
// admin must still be able to clear a stale override without it.
await canvas.findAllByText(/failed to load models/i);
const generalSection = await canvas.findByRole("form", {
name: "General subagent",
});
const clear = within(generalSection).getByRole("button", {
name: "Clear",
});
await expect(clear).toBeEnabled();
await userEvent.click(clear);
await waitFor(() =>
expect(
within(generalSection).getByRole("button", { name: "Save" }),
).toBeEnabled(),
);
},
};
export const SavesOverrideForSelectedOrganization: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChatModels").mockResolvedValue({
models: [
{
...MockChatModel,
organization_id: MockDefaultOrganization.id,
},
],
providers: [MockChatModelProviderDescriptor],
unsupported_providers: [],
});
spyOn(
API.experimental,
"getOrganizationChatModelOverrides",
).mockResolvedValue({ overrides: [] });
spyOn(
API.experimental,
"updateOrganizationChatModelOverride",
).mockResolvedValue({
context: "explore",
model_config_id: MockChatModel.id,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const exploreSection = await canvas.findByRole("form", {
name: "Explore subagent",
});
await userEvent.click(
within(exploreSection).getByRole("combobox", { name: "Use default" }),
);
await userEvent.click(
await screen.findByRole("option", {
name: new RegExp(MockChatModel.model),
}),
);
const save = within(exploreSection).getByRole("button", { name: "Save" });
await waitFor(() => expect(save).toBeEnabled());
await userEvent.click(save);
await waitFor(() => {
expect(
API.experimental.updateOrganizationChatModelOverride,
).toHaveBeenCalledWith(MockDefaultOrganization.id, "explore", {
model_config_id: MockChatModel.id,
});
});
},
};
@@ -5,18 +5,18 @@ import {
organizationChatModelOverrides,
updateOrganizationChatModelOverride,
} from "#/api/queries/chats";
import type { ChatModelOverrideContext } from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import type {
ChatModelOverrideContext,
Organization,
} from "#/api/typesGenerated";
import {
filterModelsWithEnabledProvider,
providerInfoByIDFromDescriptors,
} from "#/pages/AgentsPage/utils/modelOptions";
import { pageTitle } from "#/utils/page";
import {
splitModelQueryErrors,
useOrganizationModels,
} from "../organizationModels";
import DefaultsPageView, { type SaveModelOverride } from "./DefaultsPageView";
import { splitModelQueryErrors } from "../ModelsPage/organizationModels";
import OrganizationAgentSettingsView, {
type SaveModelOverride,
} from "./OrganizationAgentSettingsView";
const contexts: readonly ChatModelOverrideContext[] = [
"general",
@@ -26,17 +26,31 @@ const contexts: readonly ChatModelOverrideContext[] = [
"advisor",
];
const DefaultsPage: FC = () => {
const { organization } = useOrganizationModels();
// Remount on organization change so mutation state (pending saves,
// errors) from one organization never renders on another's form.
return <DefaultsPageContent key={organization.id} />;
};
interface OrganizationAgentSettingsProps {
organization: Organization;
canEdit: boolean;
showAdvisor: boolean;
}
const DefaultsPageContent: FC = () => {
export const OrganizationAgentSettings: FC<OrganizationAgentSettingsProps> = ({
organization,
canEdit,
showAdvisor,
}) => (
<OrganizationAgentSettingsContent
key={organization.id}
organization={organization}
canEdit={canEdit}
showAdvisor={showAdvisor}
/>
);
const OrganizationAgentSettingsContent: FC<OrganizationAgentSettingsProps> = ({
organization,
canEdit,
showAdvisor,
}) => {
const queryClient = useQueryClient();
const { experiments } = useDashboard();
const { organization, permissions } = useOrganizationModels();
const modelsQuery = useQuery(chatModels(organization.id));
const overridesQuery = useQuery(
organizationChatModelOverrides(organization.id),
@@ -104,27 +118,22 @@ const DefaultsPageContent: FC = () => {
}
return (
<>
<title>{pageTitle("Defaults & overrides", "AI Settings")}</title>
<DefaultsPageView
overrides={overridesQuery.data?.overrides}
enabledModels={enabledModels}
providerInfoByID={providerInfoByID}
isLoading={modelsQuery.isLoading || overridesQuery.isLoading}
loadError={loadError}
refetchError={inlineError}
canEdit={permissions?.editChatModelConfigs ?? false}
showAdvisor={experiments.includes("chat-advisor")}
saveByContext={saveByContext}
savingContexts={
new Set(contexts.filter((_, index) => mutations[index]?.isPending))
}
errorContexts={
new Set(contexts.filter((_, index) => mutations[index]?.isError))
}
/>
</>
<OrganizationAgentSettingsView
overrides={overridesQuery.data?.overrides}
enabledModels={enabledModels}
providerInfoByID={providerInfoByID}
isLoading={modelsQuery.isLoading || overridesQuery.isLoading}
loadError={loadError}
refetchError={inlineError}
canEdit={canEdit}
showAdvisor={showAdvisor}
saveByContext={saveByContext}
savingContexts={
new Set(contexts.filter((_, index) => mutations[index]?.isPending))
}
errorContexts={
new Set(contexts.filter((_, index) => mutations[index]?.isError))
}
/>
);
};
export default DefaultsPage;
@@ -2,7 +2,7 @@ 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 { MockChatModel } from "#/testHelpers/chatModels";
import DefaultsPageView from "./DefaultsPageView";
import OrganizationAgentSettingsView from "./OrganizationAgentSettingsView";
const model: TypesGen.ChatModel = {
...MockChatModel,
@@ -32,9 +32,9 @@ const saveByContext = new Map<
["advisor", fn()],
]);
const meta: Meta<typeof DefaultsPageView> = {
title: "pages/AISettingsPage/ModelsPage/DefaultsPageView",
component: DefaultsPageView,
const meta: Meta<typeof OrganizationAgentSettingsView> = {
title: "pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView",
component: OrganizationAgentSettingsView,
args: {
overrides,
enabledModels: [model, alternateModel],
@@ -55,7 +55,7 @@ const meta: Meta<typeof DefaultsPageView> = {
},
};
export default meta;
type Story = StoryObj<typeof DefaultsPageView>;
type Story = StoryObj<typeof OrganizationAgentSettingsView>;
export const SetAndUnset: Story = {
beforeEach: () => {
@@ -1,11 +1,6 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions";
import { SubagentModelOverrideSettings } from "#/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings";
@@ -14,7 +9,7 @@ export type SaveModelOverride = (
options?: { onSuccess?: () => void; onError?: () => void },
) => void;
interface DefaultsPageViewProps {
interface OrganizationAgentSettingsViewProps {
overrides: readonly TypesGen.ChatModelOverrideResponse[] | undefined;
enabledModels: readonly TypesGen.ChatModel[];
providerInfoByID: ReadonlyMap<string, ProviderInfo>;
@@ -69,7 +64,7 @@ const settings: readonly {
},
];
const DefaultsPageView: FC<DefaultsPageViewProps> = ({
const OrganizationAgentSettingsView: FC<OrganizationAgentSettingsViewProps> = ({
overrides,
enabledModels,
providerInfoByID,
@@ -90,14 +85,7 @@ const DefaultsPageView: FC<DefaultsPageViewProps> = ({
);
return (
<div className="flex max-w-4xl flex-col gap-8">
<SettingsHeader>
<SettingsHeaderTitle>Defaults & overrides</SettingsHeaderTitle>
<SettingsHeaderDescription>
Choose organization defaults for each Coder Agents context. Leave a
model unset to use the default model selection.
</SettingsHeaderDescription>
</SettingsHeader>
<div className="flex flex-col gap-6">
{refetchError != null && <ErrorAlert error={refetchError} />}
{enabledModels.length === 0 && !isLoading && refetchError == null && (
<p role="status" className="m-0 text-content-secondary">
@@ -138,4 +126,4 @@ const DefaultsPageView: FC<DefaultsPageViewProps> = ({
);
};
export default DefaultsPageView;
export default OrganizationAgentSettingsView;
@@ -23,7 +23,6 @@ const meta: Meta<typeof AddModelPageView> = {
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -1,65 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import {
MockDefaultOrganization,
MockOrganizationPermissions,
} from "#/testHelpers/entities";
import { withDashboardProvider } from "#/testHelpers/storybook";
import { OrganizationModelsContext } from "../organizationModels";
import DefaultsPage from "./DefaultsPage";
const meta: Meta<typeof DefaultsPage> = {
title: "pages/AISettingsPage/ModelsPage/DefaultsPage",
component: DefaultsPage,
decorators: [
(Story) => (
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
>
<Story />
</OrganizationModelsContext.Provider>
),
withDashboardProvider,
],
};
export default meta;
type Story = StoryObj<typeof DefaultsPage>;
export const ClearableWhenModelCatalogFails: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChatModels").mockRejectedValue(
new Error("failed to load models"),
);
spyOn(
API.experimental,
"getOrganizationChatModelOverrides",
).mockResolvedValue({
overrides: [{ context: "general", model_config_id: "model-gone" }],
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// A failed model catalog fetch must not hide the override rows: an
// admin must still be able to clear a stale override without it.
await canvas.findAllByText(/failed to load models/i);
const generalSection = await canvas.findByRole("form", {
name: "General subagent",
});
const clear = within(generalSection).getByRole("button", {
name: "Clear",
});
await expect(clear).toBeEnabled();
await userEvent.click(clear);
await waitFor(() =>
expect(
within(generalSection).getByRole("button", { name: "Save" }),
).toBeEnabled(),
);
},
};
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useLocation } from "react-router";
import { expect, screen } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { ModelDefaultsRedirect } from "./ModelDefaultsRedirect";
const LocationProbe = () => {
const location = useLocation();
return (
<p role="status" aria-label="Current location">
{location.pathname}
{location.search}
</p>
);
};
const meta: Meta<typeof ModelDefaultsRedirect> = {
title: "pages/AISettingsPage/ModelDefaultsRedirect",
component: ModelDefaultsRedirect,
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/ai/settings/models/defaults",
searchParams: { org: "engineering", source: "bookmark" },
},
routing: [
{
path: "/ai/settings/models/defaults",
useStoryElement: true,
},
{
path: "/ai/settings/coder-agents",
element: <LocationProbe />,
},
],
}),
},
};
export default meta;
type Story = StoryObj<typeof ModelDefaultsRedirect>;
export const PreservesQueryParameters: Story = {
play: async () => {
await expect(
await screen.findByRole("status", { name: "Current location" }),
).toHaveTextContent(
"/ai/settings/coder-agents?org=engineering&source=bookmark",
);
},
};
@@ -0,0 +1,11 @@
import { Navigate, useLocation } from "react-router";
export const ModelDefaultsRedirect = () => {
const location = useLocation();
return (
<Navigate
to={{ pathname: "/ai/settings/coder-agents", search: location.search }}
replace
/>
);
};
@@ -29,7 +29,6 @@ const meta: Meta<typeof ModelsPageView> = {
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -54,6 +54,16 @@ const meta: Meta<typeof OrganizationModelsLayout> = {
key: chatModels(MockOrganization2.id).queryKey,
data: { models: [], providers: [], unsupported_providers: [] },
},
{
key: organizationsPermissions([
MockDefaultOrganization.id,
MockOrganization2.id,
]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
[MockOrganization2.id]: MockOrganizationPermissions,
},
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
@@ -121,46 +131,6 @@ export const SwitchOrganizationPreservesAuxiliaryParameters: Story = {
},
};
export const DefaultsTabNavigatesToOrganizationDefaults: Story = {
beforeEach: () => {
// Mock at the API layer so a background refetch cannot replace the
// seeded permissions.
spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) =>
Object.fromEntries(Object.keys(checks).map((id) => [id, true])),
);
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/ai/settings/models",
searchParams: { org: MockOrganization2.name },
},
routing: [{ path: "*", useStoryElement: true }],
}),
},
render: () => (
<>
<OrganizationModelsLayout />
<LocationProbe />
</>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const defaultsTab = await canvas.findByRole("link", {
name: "Defaults & overrides",
});
await userEvent.click(defaultsTab);
await waitFor(() => {
expect(screen.getByTestId("location-probe")).toHaveTextContent(
`/ai/settings/models/defaults?org=${MockOrganization2.name}`,
);
expect(
canvas.getByRole("link", { name: "Defaults & overrides" }),
).toHaveAttribute("aria-current", "page");
});
},
};
export const InvalidRequestedOrganizationFallsBackToDefault: Story = {
parameters: {
reactRouter: reactRouterParameters({
@@ -208,6 +178,16 @@ export const InvalidRequestedOrganizationDeniesAdd: Story = {
key: chatModels(MockOrganization2.id).queryKey,
data: { models: [], providers: [], unsupported_providers: [] },
},
{
key: organizationsPermissions([
MockDefaultOrganization.id,
MockOrganization2.id,
]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
[MockOrganization2.id]: MockOrganizationPermissions,
},
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
@@ -282,6 +262,16 @@ export const DuplicateDisplayNamesAreDisambiguated: Story = {
key: chatModels(duplicateNameOrganization.id).queryKey,
data: { models: [], providers: [], unsupported_providers: [] },
},
{
key: organizationsPermissions([
MockDefaultOrganization.id,
duplicateNameOrganization.id,
]).queryKey,
data: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
[duplicateNameOrganization.id]: MockOrganizationPermissions,
},
},
{
key: organizationsPermissions([MockDefaultOrganization.id]).queryKey,
data: {
@@ -383,6 +373,7 @@ export const Loading: Story = {
export const NoReadableOrganizationIsNotFound: Story = {
beforeEach: () => {
spyOn(API, "checkAuthorization").mockResolvedValue({});
spyOn(API.experimental, "getChatModels").mockRejectedValue({
isAxiosError: true,
response: { status: 403 },
@@ -13,14 +13,11 @@ import {
getOrganizationLabel,
OrganizationAutocomplete,
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import NotFoundPage from "#/pages/NotFoundPage/NotFoundPage";
import {
modelOrganizationSearchParam,
OrganizationModelsContext,
organizationModelDefaultsPath,
organizationModelsPath,
selectModelOrganization,
useAccessibleModelOrganizations,
} from "./organizationModels";
@@ -76,7 +73,6 @@ const OrganizationModelsLayout: FC = () => {
<OrganizationModelsContext.Provider
value={{
organization: activeOrganization,
organizations: accessibleOrganizations,
permissions: activePermissions,
requestedOrganizationDenied:
organizationSelection.requestedOrganizationDenied,
@@ -94,29 +90,6 @@ const OrganizationModelsLayout: FC = () => {
/>
</div>
)}
<LinkTabs
active={
location.pathname.endsWith("/defaults") ? "defaults" : "models"
}
>
<LinkTabsList>
<TabLink
value="models"
to={organizationModelsPath(activeOrganization, searchParams)}
>
Models
</TabLink>
<TabLink
value="defaults"
to={organizationModelDefaultsPath(
activeOrganization,
searchParams,
)}
>
Defaults & overrides
</TabLink>
</LinkTabsList>
</LinkTabs>
{accessibleOrganizations.length > 1 && (
<div>
<OrganizationAutocomplete
@@ -134,8 +107,7 @@ const OrganizationModelsLayout: FC = () => {
}
const next = new URLSearchParams(searchParams);
next.set(modelOrganizationSearchParam, organization.name);
const pathname = location.pathname.split("?", 1)[0];
void navigate(`${pathname}?${next.toString()}`);
void navigate(`${location.pathname}?${next.toString()}`);
}}
/>
</div>
@@ -35,7 +35,6 @@ const meta: Meta<typeof UpdateModelPageView> = {
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -58,7 +58,6 @@ const withOrganizationModels = (Story: React.FC) => (
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -17,7 +17,6 @@ const withOrganizationModels = (Story: React.FC) => (
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
organizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -96,7 +96,6 @@ export const splitModelQueryErrors = (
type OrganizationModelsContextValue = {
organization: Organization;
organizations: readonly Organization[];
permissions: OrganizationPermissions | undefined;
requestedOrganizationDenied: boolean;
};
@@ -124,17 +123,6 @@ const organizationModelSettingsPath = (
return `/ai/settings/models${suffix}?${next.toString()}`;
};
export const organizationModelsPath = (
organization: Organization,
searchParams?: URLSearchParams,
): string => organizationModelSettingsPath(organization, "", searchParams);
export const organizationModelDefaultsPath = (
organization: Organization,
searchParams?: URLSearchParams,
): string =>
organizationModelSettingsPath(organization, "/defaults", searchParams);
export const organizationAddModelPath = (
organization: Organization,
searchParams?: URLSearchParams,
@@ -154,5 +142,5 @@ export const organizationModelPath = (
export const useOrganizationModelsPath = (): string => {
const { organization } = useOrganizationModels();
const [searchParams] = useSearchParams();
return organizationModelsPath(organization, searchParams);
return organizationModelSettingsPath(organization, "", searchParams);
};
@@ -106,6 +106,12 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
const AgentsRouteElement = () => (
<CoderAgentsPageView
organization={undefined}
organizations={[]}
onSelectOrganization={fn()}
requestedOrganizationDenied={false}
isOrganizationAccessLoading={false}
canEditDeploymentConfig
adminOverridesData={{ allow_users: false }}
onSaveAdminOverrides={fn()}
isSavingAdminOverrides={false}
@@ -1,6 +1,5 @@
import { useFormik } from "formik";
import { type FC, useId } from "react";
import { Link } from "react-router";
import { getErrorMessage } from "#/api/errors";
import type {
AdvisorConfig,
@@ -117,13 +116,7 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
return (
<AgentSettingLayout
title="Advisor"
description={
<>
Cap advisor usage per turn. Configure its model in{" "}
<Link to="/ai/settings/models/defaults">Defaults & overrides</Link>.
Set limits to 0 for unlimited.
</>
}
description="Cap advisor usage per turn. Configure its model in Organization settings above. Set limits to 0 for unlimited."
showSave={canSave}
isSaving={isSavingAdvisorConfig}
isSavedVisible={isSavedVisible}
+2 -7
View File
@@ -14,6 +14,7 @@ import { RequireAuth } from "./contexts/auth/RequireAuth";
import { DashboardLayout } from "./modules/dashboard/DashboardLayout";
import { aiTasksEnabled } from "./modules/tasks/useAITasksEnabled";
import { AISettingsIndexRedirect } from "./pages/AISettingsPage/AISettingsIndexRedirect";
import { ModelDefaultsRedirect } from "./pages/AISettingsPage/ModelsPage/ModelDefaultsRedirect";
import AuditPage from "./pages/AuditPage/AuditPage";
import ConnectionLogPage from "./pages/ConnectionLogPage/ConnectionLogPage";
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
@@ -436,9 +437,6 @@ const AISettingsGatewayKeysPage = lazy(
const AISettingsModelsPage = lazy(
() => import("./pages/AISettingsPage/ModelsPage/ModelsPage"),
);
const AISettingsModelDefaultsPage = lazy(
() => import("./pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage"),
);
const AISettingsOrganizationModelsLayout = lazy(
() => import("./pages/AISettingsPage/ModelsPage/OrganizationModelsLayout"),
);
@@ -743,15 +741,12 @@ export const router = createBrowserRouter(
element={<AISettingsGatewayKeysPage />}
/>
<Route index element={<AISettingsIndexRedirect />} />
<Route path="models/defaults" element={<ModelDefaultsRedirect />} />
<Route
path="models"
element={<AISettingsOrganizationModelsLayout />}
>
<Route index element={<AISettingsModelsPage />} />
<Route
path="defaults"
element={<AISettingsModelDefaultsPage />}
/>
<Route path="add" element={<AISettingsAddModelPage />} />
<Route path=":modelId" element={<AISettingsUpdateModelPage />} />
</Route>