feat: reposition org pickers in AI settings models and MCP pages (#28564)

Repositions the organization picker on the AI settings models and MCP
servers pages so it lives with the content it scopes instead of floating
above the page header.

**Models** (`/ai/settings/models`)

- The picker moved out of `OrganizationModelsLayout` into a shared
`ModelOrganizationSelect` component that preserves the current path and
auxiliary query params when switching orgs.
- List page: rendered in the filter row to the right of search.
- Add/edit model form: rendered as row 3 of the form grid at 50% width,
labeled "Organization". On the edit page it is informational only (a
static value, not a picker), since switching org there would 404 the
model. Also rendered in the no-provider fallback and "Provider not
found" states so the switcher never disappears on those pages.

**MCP servers** (`/ai/settings/mcp-servers`)

- List page: new client-side search input (matches display name, slug,
and URL) with the org picker beside it, label hidden.
- Add/edit server form: the picker moved into the form as the third cell
of the first row (slug, display name, organization), making it a 3-up.
- `OrganizationPicker` now renders a static read-only value instead of a
disabled button when the org cannot be changed (no handler or single
org). This fixes the muted `content-disabled` text on the update page.
The read-only rendering is shared with the models edit page via a new
`OrganizationValue` component beside `OrganizationAutocomplete`.

**Status columns** (both list tables)

- The visible "Status" header and the Enabled/Disabled badges are
removed; both list tables drop the status column entirely. Models whose
provider is deleted or disabled show a warning "Unavailable" badge (with
an explanatory tooltip) beside the name.
- Disabled models and MCP servers show a "Disabled" badge beside the
name, matching the existing "Default" badge placement, and the row dims
like disabled providers: avatar/icon at half opacity, text in
`content-disabled`.

<details>
<summary>Decision log</summary>

- `ModelOrganizationSelect` reuses `OrganizationAutocomplete` and reads
accessible organizations from the models context
(`accessibleOrganizations` added to `OrganizationModelsContext`), rather
than duplicating the layout's navigation logic per page.
- Navigation semantics are unchanged: switching orgs rewrites the `org`
search param and preserves the path and remaining params, exactly as the
old layout-level picker did.
- The MCP `OrganizationPicker` read-only state uses an `<output>`
element (labelable, keeps the `Label` association) rather than a
disabled button, so non-interactive values do not render with disabled
styling or sit in the tab order.
- The add MCP server page keeps a standalone picker above the "cannot
add servers" alert since the form (and its picker slot) is not rendered
in that state.
- Story review: one interaction story per new behavior. A
`ModelsPageView` story duplicating the shared picker's
select-and-navigate flow was deliberately dropped; list-page placement
is covered by the `OrganizationModelsLayout` stories that mount the real
`ModelsPage`. The `ModelForm` fallback-branch picker has no dedicated
story since its sibling branch and the shared flow are covered.

</details>

Verification: `pnpm lint:types`, `pnpm check`, and all affected
Storybook tests pass (ModelsPage and MCPServersPage: 140), plus the
`organizationModels` and `mcpServerFormLogic` unit tests.

---

🤖 This PR was generated by Coder Agents on behalf of @tracyjohnsonux.

---------

Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
This commit is contained in:
TJ
2026-08-26 12:14:47 -07:00
committed by GitHub
parent c0e681863a
commit 78c65ea556
29 changed files with 776 additions and 312 deletions
+5 -5
View File
@@ -210,11 +210,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
@@ -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<OrganizationAutocompleteProps> = ({
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<OrganizationAutocompleteProps> = ({
</Popover>
);
};
type OrganizationValueProps = {
organization: Organization;
labelOrganizations?: readonly Organization[];
id?: string;
className?: string;
};
const OrganizationValue: FC<OrganizationValueProps> = ({
organization,
labelOrganizations,
id,
className,
}) => {
const label = getOrganizationLabel(
organization,
labelOrganizations ?? [organization],
);
return (
<div
id={id}
role="group"
aria-label={`Organization ${label}`}
className={cn(
"flex h-10 items-center gap-2 rounded-md border border-solid border-border px-3 py-2 text-sm text-content-primary",
className,
)}
>
<Avatar
size="sm"
src={organization.icon}
fallback={organization.display_name}
/>
<span className="truncate">{label}</span>
</div>
);
};
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<OrganizationFieldProps> = ({
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 (
<div className={cn("flex w-72 flex-col gap-1.5", className)}>
{showLabel && (
<Label
htmlFor={id}
className="flex items-center gap-1 leading-6 text-content-primary"
>
{label}
</Label>
)}
{isReadOnly ? (
<OrganizationValue
id={id}
organization={organization}
labelOrganizations={resolvedLabelOrganizations}
/>
) : (
<OrganizationAutocomplete
id={id}
ariaLabel={`${label} ${organizationLabel}`}
value={organization}
onChange={(org) => {
if (org) {
onChange?.(org);
}
}}
options={organizations}
labelOrganizations={resolvedLabelOrganizations}
required={required}
disabled={disabled}
triggerClassName={triggerClassName}
optionsTabbable={optionsTabbable}
/>
)}
</div>
);
};
@@ -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");
@@ -34,15 +34,6 @@ const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
return (
<>
<title>{pageTitle("Add server", "AI Settings")}</title>
<OrganizationPicker
id="mcp-add-organization"
className="mb-6"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
disabled={isSaving}
showSingleOrganization
/>
{canCreate ? (
<MCPServerForm
listPath={
@@ -50,16 +41,39 @@ const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
}
isSaving={isSaving}
canSelectUserOIDC={canSelectUserOIDC}
organizationPicker={
<OrganizationPicker
id="mcp-add-organization"
className="w-full"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
disabled={isSaving}
showSingleOrganization
/>
}
onCreateServer={onCreateServer}
onCancel={canViewServerList ? onCancel : undefined}
/>
) : (
<Alert severity="error" prominent>
<AlertTitle>You cannot add servers to this organization</AlertTitle>
<AlertDescription>
Choose an organization where you have permission to add MCP servers.
</AlertDescription>
</Alert>
<>
<OrganizationPicker
id="mcp-add-organization"
className="mb-6"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
disabled={isSaving}
showSingleOrganization
/>
<Alert severity="error" prominent>
<AlertTitle>You cannot add servers to this organization</AlertTitle>
<AlertDescription>
Choose an organization where you have permission to add MCP
servers.
</AlertDescription>
</Alert>
</>
)}
</>
);
@@ -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 = {
@@ -580,15 +580,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],
@@ -1282,6 +1322,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();
@@ -1301,7 +1342,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 }),
@@ -1375,7 +1416,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",
);
@@ -1399,12 +1439,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();
},
};
@@ -1469,12 +1504,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" }),
);
@@ -104,6 +104,8 @@ const MCPServersPage: FC = () => {
)}
{organization && (
<MCPServersPageView
// Reset view-local state (search) when the organization changes.
key={organization.id}
isLoading={serversQuery.isLoading}
error={serversQuery.error}
servers={servers}
@@ -58,8 +58,9 @@ export const Default: Story = {
await expect(canvas.getByText("GitHub")).toBeInTheDocument();
await expect(canvas.getByText("Image")).toBeInTheDocument();
await expect(canvas.getByText("API key")).toBeInTheDocument();
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
const disabledRow = canvas.getByRole("button", { name: /Image/i });
await expect(within(disabledRow).getByText("Disabled")).toBeInTheDocument();
},
};
@@ -1,9 +1,14 @@
import { PlusIcon } from "lucide-react";
import type { FC } from "react";
import { PlusIcon, SearchIcon } from "lucide-react";
import { type FC, useState } from "react";
import { useNavigate } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { getOrganizationLabel } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import {
SettingsHeader,
@@ -47,6 +52,17 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
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<MCPServersPageViewProps> = ({
Agents.
</SettingsHeaderDescription>
</SettingsHeader>
<OrganizationPicker
id="mcp-servers-organization"
className="mb-4"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
/>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex-1">
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
type="search"
placeholder="Search servers..."
aria-label="Search servers"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</InputGroup>
</div>
<OrganizationPicker
id="mcp-servers-organization"
className="w-full sm:w-60"
organizations={organizations}
organization={organization}
onChange={onSelectOrganization}
showLabel={false}
/>
</div>
{Boolean(error) && (
<div className="mb-4">
<ErrorAlert error={error} />
@@ -103,7 +136,6 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
<TableHead className="w-1/2">Name</TableHead>
<TableHead className="w-1/5">Auth Method</TableHead>
<TableHead className="w-1/5">Availability</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open server</span>
</TableHead>
@@ -130,8 +162,13 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
) : undefined
}
/>
) : servers.length > 0 && filteredServers.length === 0 ? (
<TableEmpty
message="No servers match your search"
description="Try a different search term."
/>
) : (
servers.map((server) => (
filteredServers.map((server) => (
<MCPServerRow
key={server.id}
server={server}
@@ -45,9 +45,9 @@ export const Default: Story = {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("button", {
name: `Organization ${MockDefaultOrganization.display_name}`,
}),
canvas.getByLabelText(
`Organization ${MockDefaultOrganization.display_name}`,
),
).toBeVisible();
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder");
await userEvent.click(
@@ -58,6 +58,10 @@ export const Default: Story = {
);
const updateButton = canvas.getByRole("button", { name: "Update server" });
await expect(updateButton).toBeDisabled();
const displayName = canvas.getByLabelText(/display name/i);
await userEvent.clear(displayName);
await userEvent.type(displayName, "Coder v2");
await expect(updateButton).toBeEnabled();
await userEvent.click(updateButton);
@@ -65,7 +69,7 @@ export const Default: Story = {
expect(onUpdateServer).toHaveBeenCalledWith(
"mcp-coder",
expect.objectContaining({
display_name: "Coder",
display_name: "Coder v2",
slug: "coder",
}),
);
@@ -111,9 +115,6 @@ export const DeleteOnly: Story = {
await expect(
canvas.getByRole("button", { name: "Update server" }),
).toBeDisabled();
const serverActions = canvas.getByRole("button", {
name: "Server actions",
});
const enabledSwitch = canvas.getByRole("switch", {
name: "Server enabled",
});
@@ -121,16 +122,11 @@ export const DeleteOnly: Story = {
await expect(enabledSwitch).toHaveAccessibleDescription(
"You do not have permission to update this server.",
);
serverActions.focus();
await userEvent.tab();
await expect(enabledSwitch).toHaveFocus();
enabledSwitch.focus();
await expect(await body.findByRole("tooltip")).toHaveTextContent(
"You do not have permission to update this server.",
);
await userEvent.click(serverActions);
await expect(
await body.findByRole("menuitem", { name: "Remove" }),
).toBeEnabled();
await expect(canvas.getByRole("button", { name: "Delete" })).toBeEnabled();
},
};
@@ -145,15 +141,11 @@ export const ShareOnlyAccess: Story = {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
await expect(canvas.getByLabelText(/display name/i)).toBeDisabled();
const serverActions = canvas.getByRole("button", {
name: "Server actions",
});
await userEvent.click(serverActions);
expect(
body.queryByRole("menuitem", { name: "Remove" }),
canvas.queryByRole("button", { name: "Delete" }),
).not.toBeInTheDocument();
await userEvent.click(
await body.findByRole("menuitem", { name: "Manage permissions" }),
canvas.getByRole("button", { name: "Manage permissions" }),
);
await expect(
await body.findByRole("dialog", { name: "Server permissions" }),
@@ -172,7 +164,7 @@ export const NoShareReadOnlyAccess: Story = {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText(/display name/i)).toBeDisabled();
expect(
canvas.queryByRole("button", { name: "Server actions" }),
canvas.queryByRole("button", { name: "Manage permissions" }),
).not.toBeInTheDocument();
},
};
@@ -39,13 +39,6 @@ const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
return (
<>
<title>{pageTitle(server.display_name, "AI Settings")}</title>
<OrganizationPicker
id="mcp-update-organization"
className="mb-6"
organizations={organizations}
organization={organization}
showSingleOrganization
/>
<MCPServerForm
key={server.id}
server={server}
@@ -54,6 +47,15 @@ const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
isDeleting={isDeleting}
canSelectUserOIDC={canSelectUserOIDC}
canShareServer={canShareServer}
organizationPicker={
<OrganizationPicker
id="mcp-update-organization"
className="w-full"
organizations={organizations}
organization={organization}
showSingleOrganization
/>
}
onUpdateServer={onUpdateServer}
onDeleteServer={onDeleteServer}
onToggleEnabled={onToggleEnabled}
@@ -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";
@@ -22,6 +22,7 @@ type MCPServerFormCreateProps = {
isSaving: boolean;
isDeleting?: false;
canSelectUserOIDC: boolean;
organizationPicker?: ReactNode;
canShareServer?: false;
onCreateServer: (
req: TypesGen.CreateMCPServerConfigRequest,
@@ -38,6 +39,7 @@ type MCPServerFormEditProps = {
isSaving: boolean;
isDeleting: boolean;
canSelectUserOIDC: boolean;
organizationPicker?: ReactNode;
canShareServer?: boolean;
onCreateServer?: undefined;
onUpdateServer?: (
@@ -57,6 +59,7 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
isSaving,
isDeleting = false,
canSelectUserOIDC,
organizationPicker,
canShareServer = false,
onCreateServer,
onUpdateServer,
@@ -95,7 +98,10 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
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,
);
@@ -126,6 +132,7 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
canSubmit={canSubmit}
isEditing={isEditing}
canSelectUserOIDC={canSelectUserOIDC}
organizationPicker={organizationPicker}
onCancel={onCancel}
showDetails={showDetails}
setShowDetails={setShowDetails}
@@ -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<MCPServerFormFieldsProps> = ({
canSubmit,
isEditing,
canSelectUserOIDC,
organizationPicker,
onCancel,
showDetails,
setShowDetails,
@@ -65,7 +68,12 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
autoComplete="off"
className="flex flex-col gap-6"
>
<div className="grid items-start gap-4 sm:grid-cols-2">
<div
className={cn(
"grid items-start gap-4",
organizationPicker ? "sm:grid-cols-3" : "sm:grid-cols-2",
)}
>
<Field label="Slug" htmlFor={`${formId}-slug`} required>
<Input
id={`${formId}-slug`}
@@ -97,7 +105,13 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
disabled={isDisabled}
/>
</Field>
<div className="grid items-start gap-4 sm:col-span-2 sm:grid-cols-[1fr_224px]">
{organizationPicker}
<div
className={cn(
"grid items-start gap-4 sm:grid-cols-[1fr_224px]",
organizationPicker ? "sm:col-span-3" : "sm:col-span-2",
)}
>
<Field label="Server URL" htmlFor={`${formId}-url`} required>
<InputGroup>
<InputGroupInput
@@ -1,21 +1,9 @@
import {
ArrowLeftIcon,
EllipsisVerticalIcon,
Share2Icon,
TrashIcon,
} from "lucide-react";
import { ArrowLeftIcon, Share2Icon } from "lucide-react";
import { type FC, useId } from "react";
import { Link } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { Switch } from "#/components/Switch/Switch";
import {
@@ -61,66 +49,61 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
onToggleEnabled,
}) => {
const disabledReasonId = useId();
const lacksUpdatePermission = !onToggleEnabled;
const lacksUpdatePermission = isEditing && server && !onToggleEnabled;
return (
<>
<div className="flex items-center justify-between">
{listPath && <MCPServerFormBackLink to={listPath} />}
{isEditing && server && (onRequestDelete || onShareServer) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{isEditing && server && (onShareServer || onRequestDelete) && (
<div className="flex items-center gap-2">
{onShareServer && (
<Button
variant="subtle"
size="icon"
type="button"
variant="outline"
disabled={isDisabled}
aria-label="Server actions"
onClick={onShareServer}
>
<EllipsisVerticalIcon />
<Share2Icon />
<span>Manage permissions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onShareServer && (
<DropdownMenuItem onClick={onShareServer}>
<Share2Icon />
Manage permissions
</DropdownMenuItem>
)}
{onShareServer && onRequestDelete && <DropdownMenuSeparator />}
{onRequestDelete && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={onRequestDelete}
>
<TrashIcon />
Remove
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{onRequestDelete && (
<Button
type="button"
variant="destructive"
disabled={isDisabled}
onClick={onRequestDelete}
>
<span>Delete</span>
</Button>
)}
</div>
)}
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
{isEditing && (
<MCPServerIcon iconUrl={iconUrl} name={title} className="size-12" />
)}
<SettingsHeaderTitle>
<span
className={cn(
"block min-w-0 truncate",
server?.enabled === false && "text-content-secondary",
)}
>
{title}
</span>
</SettingsHeaderTitle>
{isEditing && server && !server.enabled && (
<Badge variant="default">Disabled</Badge>
)}
</div>
{isEditing && server && (
<div className="flex items-center gap-4 pt-6 min-w-0">
{isEditing && (
<MCPServerIcon iconUrl={iconUrl} name={title} className="size-12" />
)}
<SettingsHeaderTitle>
<span
className={cn(
"block min-w-0 truncate",
server?.enabled === false && "text-content-secondary",
)}
>
{title}
</span>
</SettingsHeaderTitle>
{isEditing && server && !server.enabled && (
<Badge variant="default">Disabled</Badge>
)}
</div>
{isEditing && server && (
<div className="flex items-center justify-between w-full pt-6">
<p className="text-sm text-content-secondary m-0">
Disabled servers are hidden from agents.
</p>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
@@ -128,16 +111,14 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
<Switch
checked={server.enabled}
onCheckedChange={(checked) => {
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"
/>
</span>
@@ -157,8 +138,8 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
)}
<span className="text-sm">Enable</span>
</div>
)}
</div>
</div>
)}
</>
);
};
@@ -26,7 +26,7 @@ export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
<MCPServerIcon
iconUrl={server.icon_url}
name={server.display_name}
className="size-10"
className={cn("size-10", !enabled && "opacity-50")}
/>
<span
className={cn(
@@ -36,17 +36,23 @@ export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
>
{server.display_name}
</span>
{!enabled && (
<Badge variant="default" className="shrink-0">
Disabled
</Badge>
)}
</div>
</TableCell>
<TableCell className="w-1/5 text-sm">
<TableCell
className={cn("w-1/5 text-sm", !enabled && "text-content-disabled")}
>
{AUTH_TYPE_LABELS[server.auth_type] ?? server.auth_type}
</TableCell>
<TableCell className="w-1/5 text-sm">
<TableCell
className={cn("w-1/5 text-sm", !enabled && "text-content-disabled")}
>
{AVAILABILITY_LABELS[server.availability] ?? server.availability}
</TableCell>
<TableCell className="w-32">
<Badge variant="default">{enabled ? "Enabled" : "Disabled"}</Badge>
</TableCell>
<TableCell className="w-12">
{onClick && (
<ChevronRightIcon className="size-5 text-content-primary" />
@@ -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<OrganizationPickerProps> = ({
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 (
<div className={cn("flex w-72 flex-col gap-2", className)}>
<Label htmlFor={id}>Organization</Label>
<OrganizationAutocomplete
id={id}
ariaLabel={`Organization ${getOrganizationLabel(organization, labelOrganizations)}`}
value={organization}
onChange={(org) => {
if (org) {
onChange?.(org);
}
}}
options={organizations}
labelOrganizations={labelOrganizations}
required
disabled={disabled || !onChange || hasSingleSelectedOrganization}
/>
</div>
);
};
}) => (
<OrganizationField
id={id}
organizations={organizations}
organization={organization}
onChange={onChange}
className={className}
disabled={disabled}
showLabel={showLabel}
showSingleOrganization={showSingleOrganization}
/>
);
@@ -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<typeof AddModelPageView> = {
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
accessibleOrganizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -56,6 +59,53 @@ export const Default: Story = {
},
};
const multiOrgDecorator = (Story: React.FC) => (
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
accessibleOrganizations: [
MockDefaultOrganization,
MockOrganization2,
MockOrganization3,
],
permissions: MockOrganizationPermissions,
permissionsByOrganization: {
[MockDefaultOrganization.id]: MockOrganizationPermissions,
[MockOrganization2.id]: {
...MockOrganizationPermissions,
createChatModelConfigs: false,
},
[MockOrganization3.id]: MockOrganizationPermissions,
},
requestedOrganizationDenied: false,
}}
>
<Story />
</OrganizationModelsContext.Provider>
);
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();
},
};
@@ -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<AddModelPageViewProps> = ({
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 && (
<OrganizationField
id="add-model-organization"
organization={organization}
organizations={creatableOrganizations}
labelOrganizations={accessibleOrganizations}
className="w-60"
optionsTabbable
onChange={(nextOrganization) => {
void navigate(
selectModelOrganizationPath(
location.pathname,
nextOrganization,
searchParams,
),
);
}}
/>
);
if (isLoading) {
return <Loader fullscreen />;
}
@@ -41,6 +77,7 @@ const AddModelPageView: FC<AddModelPageViewProps> = ({
<div className="flex flex-col items-start gap-4">
<ModelFormBackLink />
<ErrorAlert error={loadError} />
{organizationPicker}
</div>
);
}
@@ -56,6 +93,7 @@ const AddModelPageView: FC<AddModelPageViewProps> = ({
Please try again.
</AlertDescription>
</Alert>
{organizationPicker}
</div>
);
}
@@ -29,6 +29,7 @@ const meta: Meta<typeof ModelsPageView> = {
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
accessibleOrganizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -93,9 +94,10 @@ export const Default: Story = {
await expect(canvas.getByText("AWS Bedrock")).toBeInTheDocument();
// The provider icon is decorative (alt=""), so its name comes from the
// visible label asserted above rather than the image alt text.
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
await expect(canvas.getByText("Default")).toBeInTheDocument();
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
const disabledRow = canvas.getByRole("button", { name: /GPT-4o mini/i });
await expect(within(disabledRow).getByText("Disabled")).toBeInTheDocument();
// The Add model menu lists each provider by exact accessible name; a
// regressed icon would turn a name into "Anthropic Anthropic".
@@ -183,11 +185,12 @@ export const DisabledProviderModelsStillListed: Story = {
// so the row is queried by its clickable role.
const row = canvas.getByRole("button", { name: /GPT-4o Secondary/i });
await expect(within(row).getByText("OpenAI Secondary")).toBeInTheDocument();
// A model under a disabled provider is not usable, so the status
// column must show "Disabled" even though the stored enabled flag is
// true. Scope to the target row so a fixture change cannot pass this
// assertion via an unrelated "Disabled" cell.
await expect(within(row).getByText("Disabled")).toBeInTheDocument();
// A model under a disabled provider is not usable regardless of its
// stored enabled flag; scope to the row so an unrelated cell cannot pass.
await expect(
within(row).getByRole("button", { name: "Unavailable" }),
).toBeInTheDocument();
expect(within(row).queryByText("Disabled")).not.toBeInTheDocument();
},
};
@@ -196,8 +199,8 @@ export const DisabledProviderModelsStillListed: Story = {
// models entirely, so the row reaches "Unset" via a map-miss and the
// `?? false` fallback at ModelsPageView.tsx wiring. Reproduce that shape
// here: the model appears in `models` but is not present in any
// providerState.models, so a `?? true` regression would flip this
// story to "Enabled" and be caught.
// providerState.models, so a `?? true` regression would hide the
// unavailable notice and be caught.
export const OrphanedModelShowsUnset: Story = {
args: {
models: [mockGPT5, mockOrphanedModel],
@@ -208,7 +211,9 @@ export const OrphanedModelShowsUnset: Story = {
const canvas = within(canvasElement);
const row = canvas.getByRole("button", { name: /Orphaned Model/i });
await expect(within(row).getByText("Unset")).toBeInTheDocument();
await expect(within(row).getByText("Disabled")).toBeInTheDocument();
await expect(
within(row).getByRole("button", { name: "Unavailable" }),
).toBeInTheDocument();
},
};
@@ -15,6 +15,7 @@ import {
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
import {
Select,
@@ -47,6 +48,7 @@ import { ModelRow } from "./components/ModelRow";
import {
organizationAddModelPath,
organizationModelPath,
selectModelOrganizationPath,
useOrganizationModels,
} from "./organizationModels";
@@ -120,7 +122,7 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
}) => {
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<ModelsPageViewProps> = ({
/>
</InputGroup>
</div>
{accessibleOrganizations.length > 1 && (
<OrganizationField
id="models-organization"
organization={organization}
organizations={accessibleOrganizations}
showLabel={false}
className="w-full sm:w-60"
triggerClassName="w-full sm:w-60"
optionsTabbable
onChange={(nextOrganization) => {
void navigate(
selectModelOrganizationPath(
"/ai/settings/models",
nextOrganization,
searchParams,
),
);
}}
/>
)}
<Select value={providerFilter} onValueChange={handleProviderChange}>
<SelectTrigger
className="w-full shadow-none sm:w-60"
@@ -273,7 +295,6 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
<TableHead className="w-1/3">Name</TableHead>
<TableHead className="w-1/4">Provider</TableHead>
<TableHead className="w-1/4">Context limit</TableHead>
<TableHead className="w-40">Status</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open model</span>
</TableHead>
@@ -19,6 +19,7 @@ import {
} from "#/testHelpers/entities";
import { withDashboardProvider } from "#/testHelpers/storybook";
import AddModelPage from "./AddModelPage/AddModelPage";
import ModelsPage from "./ModelsPage";
import OrganizationModelsLayout from "./OrganizationModelsLayout";
const LocationProbe = () => {
@@ -43,7 +44,16 @@ const meta: Meta<typeof OrganizationModelsLayout> = {
path: "/ai/settings/models",
searchParams: { org: MockDefaultOrganization.name },
},
routing: [{ path: "*", useStoryElement: true }],
routing: [
{
path: "/ai/settings/models",
useStoryElement: true,
children: [
{ index: true, element: <ModelsPage /> },
{ path: "add", element: <AddModelPage /> },
],
},
],
}),
queries: [
{
@@ -102,7 +112,13 @@ export const SwitchOrganizationPreservesAuxiliaryParameters: Story = {
duplicate: "model-id",
},
},
routing: [{ path: "*", useStoryElement: true }],
routing: [
{
path: "/ai/settings/models",
useStoryElement: true,
children: [{ path: "add", element: <AddModelPage /> }],
},
],
}),
},
render: () => (
@@ -138,7 +154,13 @@ export const InvalidRequestedOrganizationFallsBackToDefault: Story = {
path: "/ai/settings/models",
searchParams: { org: "missing" },
},
routing: [{ path: "*", useStoryElement: true }],
routing: [
{
path: "/ai/settings/models",
useStoryElement: true,
children: [{ index: true, element: <ModelsPage /> }],
},
],
}),
},
play: async ({ canvasElement }) => {
@@ -1,18 +1,9 @@
import type { FC } from "react";
import { useQuery } from "react-query";
import {
Outlet,
useLocation,
useNavigate,
useSearchParams,
} from "react-router";
import { Outlet, useSearchParams } from "react-router";
import { organizationsPermissions } from "#/api/queries/organizations";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import {
getOrganizationLabel,
OrganizationAutocomplete,
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import NotFoundPage from "#/pages/NotFoundPage/NotFoundPage";
import {
@@ -24,8 +15,6 @@ import {
const OrganizationModelsLayout: FC = () => {
const { organizations } = useDashboard();
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const accessibleOrganizationsQuery =
useAccessibleModelOrganizations(organizations);
@@ -73,7 +62,10 @@ const OrganizationModelsLayout: FC = () => {
<OrganizationModelsContext.Provider
value={{
organization: activeOrganization,
accessibleOrganizations,
permissions: activePermissions,
permissionsByOrganization:
accessibleOrganizationsQuery.permissionsByOrganization,
requestedOrganizationDenied:
organizationSelection.requestedOrganizationDenied,
}}
@@ -90,28 +82,6 @@ const OrganizationModelsLayout: FC = () => {
/>
</div>
)}
{accessibleOrganizations.length > 1 && (
<div>
<OrganizationAutocomplete
value={activeOrganization}
ariaLabel={`Organization ${getOrganizationLabel(
activeOrganization,
accessibleOrganizations,
)}`}
options={accessibleOrganizations}
triggerClassName="w-60"
optionsTabbable
onChange={(organization) => {
if (!organization) {
return;
}
const next = new URLSearchParams(searchParams);
next.set(modelOrganizationSearchParam, organization.name);
void navigate(`${location.pathname}?${next.toString()}`);
}}
/>
</div>
)}
<Outlet />
</div>
</OrganizationModelsContext.Provider>
@@ -35,6 +35,7 @@ const meta: Meta<typeof UpdateModelPageView> = {
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
accessibleOrganizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -72,6 +73,18 @@ export const Default: Story = {
canvas.getByRole("button", { name: /^update model$/i }),
).toBeVisible();
await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled();
// Switching org while editing would 404 the model, so the organization
// renders as a static value rather than a picker.
await expect(
canvas.getByLabelText(
`Organization ${MockDefaultOrganization.display_name}`,
),
).toBeVisible();
expect(
canvas.queryByRole("button", {
name: `Organization ${MockDefaultOrganization.display_name}`,
}),
).not.toBeInTheDocument();
},
};
@@ -58,6 +58,7 @@ const withOrganizationModels = (Story: React.FC) => (
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
accessibleOrganizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -1,7 +1,9 @@
import { useFormik } from "formik";
import { type FC, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
import {
@@ -16,7 +18,11 @@ import {
parseThresholdInteger,
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
import { getFormHelpers } from "#/utils/formUtils";
import { useOrganizationModels } from "../organizationModels";
import {
creatableModelOrganizations,
selectModelOrganizationPath,
useOrganizationModels,
} from "../organizationModels";
import { ChatModelSharingDialog } from "./ChatModelSharingDialog";
import { ModelFormDialogs } from "./ModelFormDialogs";
import { ModelFormFields } from "./ModelFormFields";
@@ -84,7 +90,11 @@ export const ModelForm: FC<ModelFormProps> = ({
currentDefaultModel,
onToggleEnabled,
}) => {
const { organization } = useOrganizationModels();
const { organization, accessibleOrganizations, permissionsByOrganization } =
useOrganizationModels();
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialModel = editingModel ?? duplicateSourceModel;
const isEditing = Boolean(editingModel);
const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
@@ -104,6 +114,27 @@ export const ModelForm: FC<ModelFormProps> = ({
const canAddModelForSelectedProvider = canManageProviderModels(
selectedProviderState ?? undefined,
);
const creatableOrganizations = creatableModelOrganizations(
accessibleOrganizations,
permissionsByOrganization,
);
const addOrganizationField = creatableOrganizations.length > 1 && (
<OrganizationField
id="model-form-organization"
organization={organization}
organizations={creatableOrganizations}
optionsTabbable
onChange={(nextOrganization) => {
void navigate(
selectModelOrganizationPath(
location.pathname,
nextOrganization,
searchParams,
),
);
}}
/>
);
const mode: "add" | "edit" | "duplicate" = isEditing
? "edit"
: isDuplicating
@@ -283,6 +314,7 @@ export const ModelForm: FC<ModelFormProps> = ({
: "Set an API key for this provider before adding models."}
</p>
)}
{addOrganizationField}
</div>
</div>
</div>
@@ -1,7 +1,12 @@
import type { FormikContextType } from "formik";
import { ChevronDownIcon, ChevronRightIcon, InfoIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { Link as RouterLink } from "react-router";
import {
Link as RouterLink,
useLocation,
useNavigate,
useSearchParams,
} from "react-router";
import { getVisibleProviderFields } from "#/api/chatModelOptions";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
@@ -19,6 +24,7 @@ import {
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import { Link } from "#/components/Link/Link";
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Tooltip,
@@ -40,7 +46,12 @@ import type {
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
import type { FormHelpers } from "#/utils/formUtils";
import { useOrganizationModelsPath } from "../organizationModels";
import {
creatableModelOrganizations,
selectModelOrganizationPath,
useOrganizationModels,
useOrganizationModelsPath,
} from "../organizationModels";
import { ModelFormProviderSelect } from "./ModelFormProviderSelect";
const CollapsibleSection: FC<{
@@ -140,6 +151,41 @@ export const ModelFormFields: FC<{
setShowAdvanced,
}) => {
const modelsPath = useOrganizationModelsPath();
const { organization, accessibleOrganizations, permissionsByOrganization } =
useOrganizationModels();
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectableOrganizations = isEditing
? accessibleOrganizations
: creatableModelOrganizations(
accessibleOrganizations,
permissionsByOrganization,
);
const organizationField = isEditing ? (
<OrganizationField
id="model-form-organization"
organization={organization}
organizations={accessibleOrganizations}
readOnly
/>
) : selectableOrganizations.length > 1 ? (
<OrganizationField
id="model-form-organization"
organization={organization}
organizations={selectableOrganizations}
optionsTabbable
onChange={(nextOrganization) => {
void navigate(
selectModelOrganizationPath(
location.pathname,
nextOrganization,
searchParams,
),
);
}}
/>
) : null;
const hasProviderConfigFields =
getVisibleProviderFields(selectedProviderState.provider).length > 0;
@@ -249,6 +295,7 @@ export const ModelFormFields: FC<{
</InputGroupAddon>
</InputGroup>
</div>
{organizationField}
</div>
<div className="overflow-hidden rounded-lg border border-solid border-border">
@@ -17,6 +17,7 @@ const withOrganizationModels = (Story: React.FC) => (
<OrganizationModelsContext.Provider
value={{
organization: MockDefaultOrganization,
accessibleOrganizations: [MockDefaultOrganization],
permissions: MockOrganizationPermissions,
requestedOrganizationDenied: false,
}}
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import { Table, TableBody } from "#/components/Table/Table";
import { mockClaude, mockGPT5 } from "../testFixtures";
import { ModelRow } from "./ModelRow";
@@ -32,49 +32,48 @@ const meta: Meta<typeof ModelRow> = {
export default meta;
type Story = StoryObj<typeof ModelRow>;
// Control case for the effective-status logic: when both `hasProvider` and
// `providerEnabled` are true, the status badge must reflect the persisted
// enabled flag as-is. Any regression that inverts this collapses every model
// to "Disabled" in the list.
// Control case: a healthy, enabled model renders no status badge at all.
export const WithProvider: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("OpenAI")).toBeInTheDocument();
await expect(canvas.getByText("Enabled")).toBeInTheDocument();
expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
expect(canvas.queryByText("Disabled")).not.toBeInTheDocument();
expect(canvas.queryByText("Unavailable")).not.toBeInTheDocument();
await expect(canvas.queryByText("Unset")).not.toBeInTheDocument();
},
};
// When the provider is missing (soft-deleted or otherwise unavailable) the
// Provider column shows "Unset" and the status collapses to "Disabled" even
// though the persisted model.enabled flag is true. An info icon next to the
// label reveals a tooltip explaining that the connected provider has been
// deleted.
// A missing (soft-deleted) provider shows "Unset" plus the "Unavailable"
// notice even though the persisted model.enabled flag is true.
export const WithoutProviderForcesDisabled: Story = {
args: {
model: { ...mockClaude, enabled: true },
providerLabel: "",
hasProvider: false,
providerEnabled: false,
onClick: fn(),
},
play: async ({ canvasElement }) => {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Unset")).toBeInTheDocument();
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
expect(canvas.queryByText("Disabled")).not.toBeInTheDocument();
const info = canvas.getByLabelText("Provider status");
await userEvent.hover(info);
// The badge is keyboard-focusable and must open its tooltip without
// activating the clickable row.
const notice = canvas.getByRole("button", { name: "Unavailable" });
notice.focus();
const tooltip = await within(document.body).findByRole("tooltip");
await expect(tooltip).toHaveTextContent(
"The provider connected to this model has been deleted.",
);
await userEvent.keyboard("{Enter}");
expect(args.onClick).not.toHaveBeenCalled();
},
};
// When the provider exists but is disabled, the label still renders (the
// provider is set) but the status collapses to "Disabled" because the model
// is not usable.
// A disabled provider keeps its label but the model shows the "Unavailable"
// notice because it is not usable.
export const DisabledProviderForcesDisabled: Story = {
args: {
model: { ...mockClaude, enabled: true, is_default: false },
@@ -85,15 +84,21 @@ export const DisabledProviderForcesDisabled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Anthropic")).toBeInTheDocument();
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
const notice = canvas.getByRole("button", { name: "Unavailable" });
await expect(notice).toBeInTheDocument();
expect(canvas.queryByText("Disabled")).not.toBeInTheDocument();
await expect(canvas.queryByText("Unset")).not.toBeInTheDocument();
await userEvent.hover(notice);
const tooltip = await within(document.body).findByRole("tooltip");
await expect(tooltip).toHaveTextContent(
"The provider connected to this model is disabled.",
);
},
};
// A disabled model with an enabled provider keeps its provider label but
// stays "Disabled". This exercises the enabled=false path so the "Unset"
// wording is only tied to the missing provider case.
// enabled=false with a healthy provider: "Disabled" badge beside the name,
// no "Unavailable" notice, and no "Unset" wording.
export const DisabledModelWithProvider: Story = {
args: {
model: { ...mockClaude, enabled: false, is_default: false },
@@ -104,7 +109,9 @@ export const DisabledModelWithProvider: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText("Anthropic")).toBeInTheDocument();
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
const nameCell = canvas.getByRole("cell", { name: /Claude Sonnet 4.5/ });
await expect(within(nameCell).getByText("Disabled")).toBeInTheDocument();
expect(canvas.queryByText("Unavailable")).not.toBeInTheDocument();
await expect(canvas.queryByText("Unset")).not.toBeInTheDocument();
},
};
@@ -1,4 +1,4 @@
import { ChevronRightIcon, InfoIcon } from "lucide-react";
import { ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
import type { ChatModel } from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
@@ -11,6 +11,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
import { cn } from "#/utils/cn";
type ModelRowProps = {
model: ChatModel;
@@ -39,8 +40,17 @@ export const ModelRow: FC<ModelRowProps> = ({
const clickableProps = useClickableTableRow({ onClick });
const displayName = model.display_name || model.model;
// Models whose provider is missing or disabled cannot be used, so the
// status column reflects that regardless of the persisted enabled flag.
const isEffectivelyEnabled = model.enabled && hasProvider && providerEnabled;
// status cell surfaces that regardless of the persisted enabled flag.
const providerNotice = !hasProvider
? "The provider connected to this model has been deleted."
: !providerEnabled
? "The provider connected to this model is disabled."
: null;
// Keep tooltip activation from triggering the clickable row's navigation.
const stopPropagation = (event: React.SyntheticEvent) => {
event.stopPropagation();
};
return (
<TableRow {...clickableProps}>
@@ -48,7 +58,10 @@ export const ModelRow: FC<ModelRowProps> = ({
<div className="flex min-w-0 items-center gap-4">
<Avatar
size="lg"
className="flex shrink-0 items-center justify-center"
className={cn(
"flex shrink-0 items-center justify-center",
!model.enabled && "opacity-50",
)}
>
<ProviderIcon
provider={providerTypeByID.get(model.ai_provider_id) ?? ""}
@@ -56,7 +69,12 @@ export const ModelRow: FC<ModelRowProps> = ({
</Avatar>
<div className="flex min-w-0 items-center gap-2">
<span
className="truncate text-sm font-medium leading-6 text-content-primary"
className={cn(
"truncate text-sm font-medium leading-6",
model.enabled
? "text-content-primary"
: "text-content-secondary",
)}
title={displayName}
>
{displayName}
@@ -66,46 +84,62 @@ export const ModelRow: FC<ModelRowProps> = ({
Default
</Badge>
)}
{!model.enabled && (
<Badge variant="default" className="shrink-0">
Disabled
</Badge>
)}
{providerNotice && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
asChild
variant="warning"
className="shrink-0"
onClick={stopPropagation}
onKeyDown={stopPropagation}
onKeyUp={stopPropagation}
>
<button type="button">Unavailable</button>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px]">
{providerNotice}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</TableCell>
<TableCell className="min-w-0">
{hasProvider ? (
<span
className="block truncate text-sm font-medium leading-6 text-content-secondary"
className={cn(
"block truncate text-sm font-medium leading-6",
model.enabled
? "text-content-secondary"
: "text-content-disabled",
)}
title={providerLabel}
>
{providerLabel}
</span>
) : (
<div className="flex items-center gap-1">
<span className="truncate text-sm font-medium leading-6 text-content-secondary">
Unset
</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
aria-label="Provider status"
className="size-3 text-content-secondary"
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px]">
The provider connected to this model has been deleted.
</TooltipContent>
</Tooltip>
</div>
<span className="truncate text-sm font-medium leading-6 text-content-secondary">
Unset
</span>
)}
</TableCell>
<TableCell className="min-w-0">
<span className="block truncate text-sm font-medium leading-6 text-content-secondary">
<span
className={cn(
"block truncate text-sm font-medium leading-6",
model.enabled ? "text-content-secondary" : "text-content-disabled",
)}
>
{formatContextLimit(model.context_limit)}
</span>
</TableCell>
<TableCell>
<Badge variant="default">
{isEffectivelyEnabled ? "Enabled" : "Disabled"}
</Badge>
</TableCell>
<TableCell className="w-10 text-center">
<div className="flex justify-end items-center gap-8 pr-4">
<ChevronRightIcon
@@ -42,6 +42,7 @@ export const useAccessibleModelOrganizations = (
return {
organizations: accessibleOrganizations,
permissionsByOrganization: permissionsQuery.data,
isLoading:
queries.some((query) => query.isLoading) || permissionsQuery.isLoading,
error: hasData ? null : (requestError ?? null),
@@ -96,7 +97,11 @@ export const splitModelQueryErrors = (
type OrganizationModelsContextValue = {
organization: Organization;
accessibleOrganizations: readonly Organization[];
permissions: OrganizationPermissions | undefined;
permissionsByOrganization?: Readonly<
Record<string, OrganizationPermissions | undefined>
>;
requestedOrganizationDenied: boolean;
};
@@ -113,6 +118,27 @@ export const useOrganizationModels = (): OrganizationModelsContextValue => {
return context;
};
export const selectModelOrganizationPath = (
pathname: string,
organization: Organization,
searchParams?: URLSearchParams,
): string => {
const next = new URLSearchParams(searchParams);
next.set(modelOrganizationSearchParam, organization.name);
return `${pathname}?${next.toString()}`;
};
export const creatableModelOrganizations = (
organizations: readonly Organization[],
permissionsByOrganization?: Readonly<
Record<string, OrganizationPermissions | undefined>
>,
): readonly Organization[] =>
organizations.filter(
(organization) =>
permissionsByOrganization?.[organization.id]?.createChatModelConfigs,
);
const organizationModelSettingsPath = (
organization: Organization,
suffix: string,