diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 1f59c29ad3b..f8208d21eec 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -206,11 +206,11 @@ To change the default model: The Models list reflects whether each model can actually be used: - When a model's connected provider has been deleted, the **Provider** column - shows **Unset** with an info tooltip that reads "The provider connected to - this model has been deleted." -- When a model's provider is missing or disabled, the **Status** column - shows **Disabled**, regardless of the model's own enabled setting. Such a - model cannot serve chat requests. + shows **Unset**. +- When a model's provider is missing or disabled, an **Unavailable** badge + appears beside the model name. The badge's tooltip explains whether the + provider was deleted or disabled. Such a model cannot serve chat requests. +- When a model is disabled, a **Disabled** badge appears beside the model name. To reconnect a model to a working provider, open the model from the list, pick a new provider from the **Provider** dropdown, and click **Save**. The diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index 92277ce1299..e36fa352611 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -12,6 +12,7 @@ import { CommandItem, CommandList, } from "#/components/Command/Command"; +import { Label } from "#/components/Label/Label"; import { Popover, PopoverContent, @@ -92,7 +93,7 @@ export const OrganizationAutocomplete: FC = ({ aria-required={required} data-testid="organization-autocomplete" className={cn( - "w-full justify-start gap-2 font-normal", + "group w-full justify-start gap-2 font-normal", triggerClassName, )} > @@ -154,3 +155,129 @@ export const OrganizationAutocomplete: FC = ({ ); }; + +type OrganizationValueProps = { + organization: Organization; + labelOrganizations?: readonly Organization[]; + id?: string; + className?: string; +}; + +const OrganizationValue: FC = ({ + organization, + labelOrganizations, + id, + className, +}) => { + const label = getOrganizationLabel( + organization, + labelOrganizations ?? [organization], + ); + return ( +
+ + {label} +
+ ); +}; + +type OrganizationFieldProps = { + id: string; + organization: Organization; + organizations: readonly Organization[]; + labelOrganizations?: readonly Organization[]; + onChange?: (organization: Organization) => void; + className?: string; + disabled?: boolean; + label?: string; + showLabel?: boolean; + showSingleOrganization?: boolean; + readOnly?: boolean; + triggerClassName?: string; + optionsTabbable?: boolean; + required?: boolean; +}; + +export const OrganizationField: FC = ({ + id, + organization, + organizations, + labelOrganizations, + onChange, + className, + disabled, + label = "Organization", + showLabel = true, + showSingleOrganization = false, + readOnly = false, + triggerClassName, + optionsTabbable, + required = true, +}) => { + const hasSingleSelectedOrganization = + organizations.length <= 1 && + organizations.some((option) => option.id === organization.id); + if (hasSingleSelectedOrganization && !showSingleOrganization && !readOnly) { + return null; + } + + const resolvedLabelOrganizations = + labelOrganizations ?? + (organizations.some((option) => option.id === organization.id) + ? organizations + : [...organizations, organization]); + const organizationLabel = getOrganizationLabel( + organization, + resolvedLabelOrganizations, + ); + const isReadOnly = readOnly || !onChange || hasSingleSelectedOrganization; + + return ( +
+ {showLabel && ( + + )} + {isReadOnly ? ( + + ) : ( + { + if (org) { + onChange?.(org); + } + }} + options={organizations} + labelOrganizations={resolvedLabelOrganizations} + required={required} + disabled={disabled} + triggerClassName={triggerClassName} + optionsTabbable={optionsTabbable} + /> + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 66b3d67726a..27a6acdde48 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -38,9 +38,9 @@ export const Default: Story = { const addButton = canvas.getByRole("button", { name: "Add server" }); await expect( - canvas.getByRole("button", { - name: `Organization ${MockDefaultOrganization.display_name}`, - }), + canvas.getByLabelText( + `Organization ${MockDefaultOrganization.display_name}`, + ), ).toBeVisible(); await expect(addButton).toBeDisabled(); await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub"); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index 8582d61fb0e..5775f97cbef 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -34,15 +34,6 @@ const AddMCPServerPageView: FC = ({ return ( <> Codestin Search App - {canCreate ? ( = ({ } isSaving={isSaving} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={ + + } onCreateServer={onCreateServer} onCancel={canViewServerList ? onCancel : undefined} /> ) : ( - - You cannot add servers to this organization - - Choose an organization where you have permission to add MCP servers. - - + <> + + + You cannot add servers to this organization + + Choose an organization where you have permission to add MCP + servers. + + + )} ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 409526712a5..32579b03ece 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -24,7 +24,7 @@ import { import AddMCPServerPage from "./AddMCPServerPage/AddMCPServerPage"; import MCPServersPage from "./MCPServersPage"; import { orgSearchParam } from "./organizationParam"; -import { MockCoderMCPServer } from "./testFixtures"; +import { MockCoderMCPServer, MockGitHubMCPServer } from "./testFixtures"; import UpdateMCPServerPage from "./UpdateMCPServerPage/UpdateMCPServerPage"; const MockOrganization2MCPServer: TypesGen.MCPServerConfig = { @@ -538,15 +538,55 @@ export const AddDeepLinkShowsSingleCreatableOrganization: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const organization = await canvas.findByRole("button", { - name: `Organization ${MockOrganization2.display_name}`, - }); + const organization = await canvas.findByLabelText( + `Organization ${MockOrganization2.display_name}`, + ); await expect(organization).toBeVisible(); - await expect(organization).toBeDisabled(); + expect( + canvas.queryByRole("button", { + name: `Organization ${MockOrganization2.display_name}`, + }), + ).not.toBeInTheDocument(); await expect(canvas.getByLabelText(/display name/i)).toBeVisible(); }, }; +export const ListSearchFiltersServers: Story = { + parameters: { + organizations: [MockDefaultOrganization, MockOrganization2], + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: { path: "/ai/settings/mcp-servers" }, + }), + }, + beforeEach: () => { + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + MockGitHubMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("Coder")).toBeVisible(); + await expect(canvas.getByText("GitHub")).toBeVisible(); + + const search = canvas.getByRole("searchbox", { name: "Search servers" }); + await userEvent.type(search, "github"); + await expect(canvas.getByText("GitHub")).toBeVisible(); + expect(canvas.queryByText("Coder")).not.toBeInTheDocument(); + + await userEvent.clear(search); + await userEvent.type(search, "no-such-server"); + await expect( + canvas.getByText("No servers match your search"), + ).toBeVisible(); + + await userEvent.clear(search); + await expect(canvas.getByText("Coder")).toBeVisible(); + await expect(canvas.getByText("GitHub")).toBeVisible(); + }, +}; + export const ListSwitchesOrganization: Story = { parameters: { organizations: [MockDefaultOrganization, MockOrganization2], @@ -1240,6 +1280,7 @@ export const UpdateOnlyOrgAdminCanUpdateMCPServer: Story = { await expect(await canvas.findByLabelText(/display name/i)).toHaveValue( "Coder", ); + await userEvent.type(canvas.getByLabelText(/display name/i), " v2"); await expect( canvas.getByRole("button", { name: "Update server" }), ).toBeEnabled(); @@ -1259,7 +1300,7 @@ export const UpdateOnlyOrgAdminCanUpdateMCPServer: Story = { body.queryByRole("option", { name: "User OIDC identity" }), ).not.toBeInTheDocument(); expect( - canvas.queryByRole("button", { name: "Server actions" }), + canvas.queryByRole("button", { name: "Delete" }), ).not.toBeInTheDocument(); expect( canvas.queryByRole("button", { name: /delete server/i }), @@ -1333,7 +1374,6 @@ export const UserOIDCOrgAdminCannotUpdate: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const body = within(canvasElement.ownerDocument.body); await expect(await canvas.findByLabelText(/display name/i)).toHaveValue( "Coder", ); @@ -1357,12 +1397,7 @@ export const UserOIDCOrgAdminCannotUpdate: Story = { await expect( canvas.getByLabelText(/authentication method/i), ).toHaveTextContent("User OIDC identity"); - await userEvent.click( - canvas.getByRole("button", { name: "Server actions" }), - ); - await expect( - await body.findByRole("menuitem", { name: "Remove" }), - ).toBeEnabled(); + await expect(canvas.getByRole("button", { name: "Delete" })).toBeEnabled(); }, }; @@ -1427,12 +1462,7 @@ export const DeleteOnlyOrgAdminCanDeleteWithoutUpdating: Story = { ).toBeDisabled(); await expect(canvas.getByLabelText(/tool allow list/i)).toBeDisabled(); await expect(canvas.getByLabelText(/tool deny list/i)).toBeDisabled(); - await userEvent.click( - canvas.getByRole("button", { name: "Server actions" }), - ); - await userEvent.click( - await body.findByRole("menuitem", { name: "Remove" }), - ); + await userEvent.click(canvas.getByRole("button", { name: "Delete" })); await userEvent.click( await body.findByRole("button", { name: "Delete MCP server" }), ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index b7fb6fe0f58..d96f46a3034 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -99,6 +99,8 @@ const MCPServersPage: FC = () => { )} {organization && ( = ({ onSelectOrganization, }) => { const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(""); + const normalizedQuery = searchQuery.trim().toLowerCase(); + const filteredServers = + normalizedQuery.length === 0 + ? servers + : servers.filter((server) => + [server.display_name, server.slug, server.url] + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ); // Disambiguate against every organization sharing the page context: // other creation targets and the currently selected organization. const addButtonLabel = @@ -85,13 +101,30 @@ const MCPServersPageView: FC = ({ Agents. - +
+
+ + + + + setSearchQuery(event.target.value)} + /> + +
+ +
{Boolean(error) && (
@@ -103,7 +136,6 @@ const MCPServersPageView: FC = ({ Name Auth Method Availability - Status Open server @@ -130,8 +162,13 @@ const MCPServersPageView: FC = ({ ) : undefined } /> + ) : servers.length > 0 && filteredServers.length === 0 ? ( + ) : ( - servers.map((server) => ( + filteredServers.map((server) => ( = ({ return ( <> Codestin Search App - = ({ isSaving={isSaving} isDeleting={isDeleting} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={ + + } onUpdateServer={onUpdateServer} onDeleteServer={onDeleteServer} onToggleEnabled={onToggleEnabled} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index f7066458779..783eda2d8ed 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { type FC, useState } from "react"; +import { type FC, type ReactNode, useState } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { MCPServerFormDialogs } from "./MCPServerFormDialogs"; @@ -21,6 +21,7 @@ type MCPServerFormCreateProps = { isSaving: boolean; isDeleting?: false; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCreateServer: ( req: TypesGen.CreateMCPServerConfigRequest, ) => Promise; @@ -36,6 +37,7 @@ type MCPServerFormEditProps = { isSaving: boolean; isDeleting: boolean; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCreateServer?: undefined; onUpdateServer?: ( serverId: string, @@ -54,6 +56,7 @@ export const MCPServerForm: FC = ({ isSaving, isDeleting = false, canSelectUserOIDC, + organizationPicker, onCreateServer, onUpdateServer, onDeleteServer, @@ -90,7 +93,10 @@ export const MCPServerForm: FC = ({ const isDisabled = isSaving || isDeleting; const areFieldsDisabled = isDisabled || (isEditing && onUpdateServer === undefined); - const canSubmit = canSubmitMCPServerForm(form.values, areFieldsDisabled); + // Editing requires a change before submitting, matching the provider form. + const canSubmit = + canSubmitMCPServerForm(form.values, areFieldsDisabled) && + (!isEditing || form.dirty); const unsavedChanges = useUnsavedChangesPrompt( form.dirty && !form.isSubmitting, ); @@ -120,6 +126,7 @@ export const MCPServerForm: FC = ({ canSubmit={canSubmit} isEditing={isEditing} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={organizationPicker} onCancel={onCancel} showDetails={showDetails} setShowDetails={setShowDetails} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx index 99f8aa0f99f..f7b54bf043c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx @@ -1,5 +1,5 @@ import type { FormikContextType } from "formik"; -import { type FC, useId } from "react"; +import { type FC, type ReactNode, useId } from "react"; import { Button } from "#/components/Button/Button"; import { IconField } from "#/components/IconField/IconField"; import { Input } from "#/components/Input/Input"; @@ -15,6 +15,7 @@ import { SelectValue, } from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; +import { cn } from "#/utils/cn"; import { MCPServerAuthSection } from "./MCPServerAuthSection"; import { MCPServerBehaviorSection } from "./MCPServerBehaviorSection"; import { CollapsibleSection, Field } from "./MCPServerFormFieldPrimitives"; @@ -31,6 +32,7 @@ interface MCPServerFormFieldsProps { canSubmit: boolean; isEditing: boolean; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCancel?: () => void; showDetails: boolean; setShowDetails: (open: boolean) => void; @@ -47,6 +49,7 @@ export const MCPServerFormFields: FC = ({ canSubmit, isEditing, canSelectUserOIDC, + organizationPicker, onCancel, showDetails, setShowDetails, @@ -65,7 +68,12 @@ export const MCPServerFormFields: FC = ({ autoComplete="off" className="flex flex-col gap-6" > -
+
= ({ disabled={isDisabled} /> -
+ {organizationPicker} +
= ({ onToggleEnabled, }) => { const disabledReasonId = useId(); - const lacksUpdatePermission = !onToggleEnabled; + const lacksUpdatePermission = isEditing && server && !onToggleEnabled; return ( <>
{listPath && } {isEditing && server && onRequestDelete && ( - - - - - - - - Remove - - - +
+ +
)}
-
-
- {isEditing && ( - - )} - - - {title} - - - {isEditing && server && !server.enabled && ( - Disabled - )} -
- {isEditing && server && ( +
+ {isEditing && ( + + )} + + + {title} + + + {isEditing && server && !server.enabled && ( + Disabled + )} +
+ {isEditing && server && ( +
+

+ Disabled servers are hidden from agents. +

@@ -111,16 +96,14 @@ export const MCPServerFormHeader: FC = ({ { - if (onToggleEnabled) { - onToggleEnabled(checked); - } + onToggleEnabled?.(checked); }} disabled={isDisabled} aria-disabled={lacksUpdatePermission} + aria-label="Server enabled" aria-describedby={ lacksUpdatePermission ? disabledReasonId : undefined } - aria-label="Server enabled" className="aria-disabled:cursor-not-allowed aria-disabled:data-[state=checked]:bg-surface-tertiary aria-disabled:data-[state=unchecked]:bg-surface-tertiary" /> @@ -140,8 +123,8 @@ export const MCPServerFormHeader: FC = ({ )} Enable
- )} -
+
+ )} ); }; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx index 29b4e357765..f79a4adc32c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerRow.tsx @@ -26,7 +26,7 @@ export const MCPServerRow: FC = ({ server, onClick }) => { = ({ server, onClick }) => { > {server.display_name} + {!enabled && ( + + Disabled + + )}
- + {AUTH_TYPE_LABELS[server.auth_type] ?? server.auth_type} - + {AVAILABILITY_LABELS[server.availability] ?? server.availability} - - {enabled ? "Enabled" : "Disabled"} - {onClick && ( diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx index 08444583a68..c3534d0aa4b 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -1,11 +1,6 @@ import type { FC } from "react"; import type { Organization } from "#/api/typesGenerated"; -import { Label } from "#/components/Label/Label"; -import { - getOrganizationLabel, - OrganizationAutocomplete, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; -import { cn } from "#/utils/cn"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; interface OrganizationPickerProps { id: string; @@ -14,6 +9,7 @@ interface OrganizationPickerProps { onChange?: (organization: Organization) => void; className?: string; disabled?: boolean; + showLabel?: boolean; showSingleOrganization?: boolean; } @@ -24,41 +20,17 @@ export const OrganizationPicker: FC = ({ onChange, className, disabled, + showLabel = true, showSingleOrganization = false, -}) => { - const hasSingleSelectedOrganization = - organizations.length <= 1 && - organizations.some((option) => option.id === organization.id); - if (hasSingleSelectedOrganization && !showSingleOrganization) { - return null; - } - - // The selected organization can fall outside the selectable options, - // such as a deep link to an organization where servers are listable - // but not creatable, so include it when disambiguating labels. - const labelOrganizations = organizations.some( - (option) => option.id === organization.id, - ) - ? organizations - : [...organizations, organization]; - - return ( -
- - { - if (org) { - onChange?.(org); - } - }} - options={organizations} - labelOrganizations={labelOrganizations} - required - disabled={disabled || !onChange || hasSingleSelectedOrganization} - /> -
- ); -}; +}) => ( + +); diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx index 0fb858b0094..dc43bcd2f23 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, within } from "storybook/test"; +import { expect, fn, screen, userEvent, within } from "storybook/test"; import { deriveProviderStates } from "#/modules/aiModels/providerStates"; import { MockChatModelProviderDescriptor } from "#/testHelpers/chatModels"; import { MockDefaultOrganization, + MockOrganization2, + MockOrganization3, MockOrganizationPermissions, } from "#/testHelpers/entities"; import { withToaster } from "#/testHelpers/storybook"; @@ -23,6 +25,7 @@ const meta: Meta = { ( + + + +); + +export const WithOrganizationPicker: Story = { + decorators: [multiOrgDecorator], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ); + await expect( + await screen.findByRole("option", { + name: MockOrganization3.display_name, + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { + name: MockOrganization2.display_name, + }), + ).not.toBeInTheDocument(); + }, +}; + export const WebSearchDependentFields: Story = { args: { selectedProviderState: MockAnthropicProviderState }, play: async ({ canvasElement }) => { @@ -105,18 +155,30 @@ export const ProviderWithoutConfiguredModels: Story = { }; export const ProviderNotFound: Story = { + decorators: [multiOrgDecorator], args: { selectedProviderState: null }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Provider not found")).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).toBeVisible(); }, }; export const LoadError: Story = { + decorators: [multiOrgDecorator], args: { loadError: new Error("Failed to load models") }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Failed to load models")).toBeVisible(); + await expect( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).toBeVisible(); }, }; diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx index f88e4302773..a82518f7030 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx @@ -1,11 +1,18 @@ import type { FC } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import type { ProviderState } from "#/modules/aiModels/providerStates"; import { ModelForm } from "../components/ModelForm"; import { ModelFormBackLink } from "../components/ModelFormHeader"; +import { + creatableModelOrganizations, + selectModelOrganizationPath, + useOrganizationModels, +} from "../organizationModels"; interface AddModelPageViewProps { isLoading: boolean; @@ -32,6 +39,35 @@ const AddModelPageView: FC = ({ onProviderChange, onCreateModel, }) => { + const { organization, accessibleOrganizations, permissionsByOrganization } = + useOrganizationModels(); + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const creatableOrganizations = creatableModelOrganizations( + accessibleOrganizations, + permissionsByOrganization, + ); + const organizationPicker = creatableOrganizations.length > 1 && ( + { + void navigate( + selectModelOrganizationPath( + location.pathname, + nextOrganization, + searchParams, + ), + ); + }} + /> + ); + if (isLoading) { return ; } @@ -41,6 +77,7 @@ const AddModelPageView: FC = ({
+ {organizationPicker}
); } @@ -56,6 +93,7 @@ const AddModelPageView: FC = ({ Please try again. + {organizationPicker}
); } diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx index 924ab98fe2c..8cb589cae73 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx @@ -29,6 +29,7 @@ const meta: Meta = { = ({ }) => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const { organization } = useOrganizationModels(); + const { organization, accessibleOrganizations } = useOrganizationModels(); const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(""); const [providerFilter, setProviderFilter] = @@ -247,6 +249,26 @@ const ModelsPageView: FC = ({ />
+ {accessibleOrganizations.length > 1 && ( + { + void navigate( + selectModelOrganizationPath( + "/ai/settings/models", + nextOrganization, + searchParams, + ), + ); + }} + /> + )}