mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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:
@@ -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,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
+48
-39
@@ -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;
|
||||
+5
-5
@@ -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: () => {
|
||||
+4
-16
@@ -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>
|
||||
|
||||
-1
@@ -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,
|
||||
}}
|
||||
|
||||
-1
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user