diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index 3f302edadf3dd..ea871982da26e 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -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(); }, }; diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 7b76847cec1c5..2a636f797348a 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -80,24 +80,22 @@ const AISettingsSidebarView: FC = ({ )} {(canAccessAnyChatModelConfig(permissions) || canAccessOrganizationModels) && } + {(permissions.editDeploymentConfig || canAccessOrganizationModels) && ( + + Coder Agents + + )} {permissions.editDeploymentConfig && ( - <> - - Coder Agents - -
- - MCP servers - - {permissions.updateTemplates && ( - Templates - )} - - Instructions - - Lifecycle -
- +
+ MCP servers + {permissions.updateTemplates && ( + Templates + )} + + Instructions + + Lifecycle +
)} {!permissions.editDeploymentConfig && (permissions.viewAnyMCPServerConfigs || diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx index 029dfe24e6a8c..3df7f7354722b 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx @@ -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 ? ( + + ) : activeOrganization && + organizationPermissionsQuery.data !== undefined ? ( + + ) : undefined; return ( - + Codestin Search App { + 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()} diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx index f02de8013a25c..a5e60955b1797 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx @@ -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:
Organization override controls
, + 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(); + }, +}; diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx index b1db5f679da86..fcfc15c7f1e2c 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx @@ -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 = ({ + organization, + organizations, + onSelectOrganization, + organizationAccessError, + organizationPermissionsError, + requestedOrganizationDenied, + isOrganizationAccessLoading, + organizationSettings, + canEditDeploymentConfig, adminOverridesData, adminOverridesError, onRetryAdminOverrides, @@ -71,47 +95,124 @@ export const CoderAgentsPageView: FC = ({ onSaveComputerUseProvider, isSavingComputerUseProvider, computerUseProviderSaveError, -}) => ( -
- - Coder Agents - - Configure deployment-wide Coder Agents capabilities. Model defaults are - configured per organization in{" "} - Defaults & overrides. - - -
- - {showVirtualDesktopSettings && ( - - )} - {showAdvisorSettings && ( - +}) => { + return ( +
+ + Coder Agents + + Configure organization model choices and deployment-wide Coder Agents + capabilities. + + + + {isOrganizationAccessLoading ? ( + + ) : organization ? ( +
+
+
+

+ Organization settings +

+

+ Choose model and reasoning defaults for each Coder Agents + context. +

+
+ {organizations.length > 1 && ( + { + if (nextOrganization) { + onSelectOrganization(nextOrganization); + } + }} + /> + )} +
+ {requestedOrganizationDenied && ( + + The requested organization is not available. Showing settings for{" "} + {organization.display_name || organization.name} instead. + + )} + {organizationAccessError != null && ( + + )} + {organizationPermissionsError != null && ( + + )} + {organizationSettings} +
+ ) : organizationAccessError != null ? ( + + ) : null} + + {canEditDeploymentConfig && ( +
+
+

+ Deployment settings +

+

+ Configure Coder Agents capabilities that apply to every + organization. +

+
+
+ + {showVirtualDesktopSettings && ( + + )} + {showAdvisorSettings && ( + + )} +
+
)}
-
-); + ); +}; diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings.stories.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings.stories.tsx new file mode 100644 index 0000000000000..d057b527cbcf1 --- /dev/null +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings.stories.tsx @@ -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 = { + title: "pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings", + component: OrganizationAgentSettings, + args: { + organization: MockDefaultOrganization, + canEdit: true, + showAdvisor: true, + }, +}; +export default meta; +type Story = StoryObj; + +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, + }); + }); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings.tsx similarity index 61% rename from site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx rename to site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings.tsx index a93f14d5cb723..16d240c3f2398 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettings.tsx @@ -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 ; -}; +interface OrganizationAgentSettingsProps { + organization: Organization; + canEdit: boolean; + showAdvisor: boolean; +} -const DefaultsPageContent: FC = () => { +export const OrganizationAgentSettings: FC = ({ + organization, + canEdit, + showAdvisor, +}) => ( + +); + +const OrganizationAgentSettingsContent: FC = ({ + 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 ( - <> - Codestin Search App - mutations[index]?.isPending)) - } - errorContexts={ - new Set(contexts.filter((_, index) => mutations[index]?.isError)) - } - /> - + mutations[index]?.isPending)) + } + errorContexts={ + new Set(contexts.filter((_, index) => mutations[index]?.isError)) + } + /> ); }; - -export default DefaultsPage; diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView.stories.tsx similarity index 94% rename from site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx rename to site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView.stories.tsx index 78c976a78de7e..b0a06ccd3c649 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView.stories.tsx @@ -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 = { - title: "pages/AISettingsPage/ModelsPage/DefaultsPageView", - component: DefaultsPageView, +const meta: Meta = { + title: "pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView", + component: OrganizationAgentSettingsView, args: { overrides, enabledModels: [model, alternateModel], @@ -55,7 +55,7 @@ const meta: Meta = { }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const SetAndUnset: Story = { beforeEach: () => { diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView.tsx similarity index 86% rename from site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx rename to site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView.tsx index 91075e4cea563..80342b733cb46 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/OrganizationAgentSettingsView.tsx @@ -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; @@ -69,7 +64,7 @@ const settings: readonly { }, ]; -const DefaultsPageView: FC = ({ +const OrganizationAgentSettingsView: FC = ({ overrides, enabledModels, providerInfoByID, @@ -90,14 +85,7 @@ const DefaultsPageView: FC = ({ ); return ( -
- - Defaults & overrides - - Choose organization defaults for each Coder Agents context. Leave a - model unset to use the default model selection. - - +
{refetchError != null && } {enabledModels.length === 0 && !isLoading && refetchError == null && (

@@ -138,4 +126,4 @@ const DefaultsPageView: FC = ({ ); }; -export default DefaultsPageView; +export default OrganizationAgentSettingsView; diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx index b0036a4d65174..0fb858b009481 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx @@ -23,7 +23,6 @@ const meta: Meta = { = { - title: "pages/AISettingsPage/ModelsPage/DefaultsPage", - component: DefaultsPage, - decorators: [ - (Story) => ( - - - - ), - withDashboardProvider, - ], -}; -export default meta; -type Story = StoryObj; - -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(), - ); - }, -}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelDefaultsRedirect.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelDefaultsRedirect.stories.tsx new file mode 100644 index 0000000000000..b50945ddc74ae --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelDefaultsRedirect.stories.tsx @@ -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 ( +

+ {location.pathname} + {location.search} +

+ ); +}; + +const meta: Meta = { + 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: , + }, + ], + }), + }, +}; +export default meta; +type Story = StoryObj; + +export const PreservesQueryParameters: Story = { + play: async () => { + await expect( + await screen.findByRole("status", { name: "Current location" }), + ).toHaveTextContent( + "/ai/settings/coder-agents?org=engineering&source=bookmark", + ); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelDefaultsRedirect.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelDefaultsRedirect.tsx new file mode 100644 index 0000000000000..9d9363a3f31b9 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelDefaultsRedirect.tsx @@ -0,0 +1,11 @@ +import { Navigate, useLocation } from "react-router"; + +export const ModelDefaultsRedirect = () => { + const location = useLocation(); + return ( + + ); +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx index 76d910dd44479..924ab98fe2c7c 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx @@ -29,7 +29,6 @@ const meta: Meta = { = { 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: () => ( - <> - - - - ), - 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 }, diff --git a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx index 18f47fb7e0cba..da62e0dc8b345 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx @@ -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 = () => { { />
)} - - - - Models - - - Defaults & overrides - - - {accessibleOrganizations.length > 1 && (
{ } 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()}`); }} />
diff --git a/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx index 8133894790216..63e750dd6c79a 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/UpdateModelPage/UpdateModelPageView.stories.tsx @@ -35,7 +35,6 @@ const meta: Meta = { ( ( 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); }; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index cf860c8ba97ea..03c60fd5d5fe0 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -106,6 +106,12 @@ const buildChat = (overrides: Partial = {}): Chat => ({ const AgentsRouteElement = () => ( = ({ return ( - Cap advisor usage per turn. Configure its model in{" "} - Defaults & overrides. - 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} diff --git a/site/src/router.tsx b/site/src/router.tsx index f078fa47b3487..2630b693a3348 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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={} /> } /> + } /> } > } /> - } - /> } /> } />