From f5335dc00525dff380c0ce17e3396403202994b8 Mon Sep 17 00:00:00 2001
From: TJ
Date: Wed, 26 Aug 2026 12:14:47 -0700
Subject: [PATCH] feat: reposition org pickers in AI settings models and MCP
pages (#28564)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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`.
Decision log
- `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 `
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>
(cherry picked from commit 78c65ea556de627586a7e75314b98400db9705c8)
---
docs/ai-coder/agents/models.md | 10 +-
.../OrganizationAutocomplete.tsx | 129 +++++++++++++++++-
.../AddMCPServerPageView.stories.tsx | 6 +-
.../AddMCPServerPage/AddMCPServerPageView.tsx | 44 ++++--
.../MCPServersPage/MCPServersPage.stories.tsx | 68 ++++++---
.../MCPServersPage/MCPServersPage.tsx | 2 +
.../MCPServersPageView.stories.tsx | 5 +-
.../MCPServersPage/MCPServersPageView.tsx | 59 ++++++--
.../UpdateMCPServerPageView.stories.tsx | 24 ++--
.../UpdateMCPServerPageView.tsx | 16 ++-
.../components/MCPServerForm.tsx | 11 +-
.../components/MCPServerFormFields.tsx | 20 ++-
.../components/MCPServerFormHeader.tsx | 95 ++++++-------
.../components/MCPServerRow.tsx | 18 ++-
.../components/OrganizationPicker.tsx | 58 ++------
.../AddModelPage/AddModelPageView.stories.tsx | 64 ++++++++-
.../AddModelPage/AddModelPageView.tsx | 38 ++++++
.../ModelsPage/ModelsPageView.stories.tsx | 25 ++--
.../ModelsPage/ModelsPageView.tsx | 25 +++-
.../OrganizationModelsLayout.stories.tsx | 28 +++-
.../ModelsPage/OrganizationModelsLayout.tsx | 38 +-----
.../UpdateModelPageView.stories.tsx | 13 ++
.../components/ModelForm.stories.tsx | 1 +
.../ModelsPage/components/ModelForm.tsx | 36 ++++-
.../ModelsPage/components/ModelFormFields.tsx | 51 ++++++-
.../ModelFormProviderConfig.stories.tsx | 20 ++-
.../components/ModelRow.stories.tsx | 57 ++++----
.../ModelsPage/components/ModelRow.tsx | 90 ++++++++----
.../ModelsPage/organizationModels.tsx | 26 ++++
29 files changed, 782 insertions(+), 295 deletions(-)
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 (
+
)}
+ {addOrganizationField}
diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx
index 2d8aff1304a..5301b6672ec 100644
--- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx
+++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx
@@ -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 ? (
+
+ ) : selectableOrganizations.length > 1 ? (
+ {
+ void navigate(
+ selectModelOrganizationPath(
+ location.pathname,
+ nextOrganization,
+ searchParams,
+ ),
+ );
+ }}
+ />
+ ) : null;
const hasProviderConfigFields =
getVisibleProviderFields(selectedProviderState.provider).length > 0;
@@ -249,6 +295,7 @@ export const ModelFormFields: FC<{
+ {organizationField}
diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx
index 13572da3df6..c424aa40f44 100644
--- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx
+++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx
@@ -1,17 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
+import {
+ MockDefaultOrganization,
+ MockOrganizationPermissions,
+} from "#/testHelpers/entities";
import { withToaster } from "#/testHelpers/storybook";
+import { OrganizationModelsContext } from "../organizationModels";
import {
MockAnthropicProviderState,
MockOpenAIProviderState,
} from "../testFixtures";
import { ModelForm } from "./ModelForm";
+const withOrganizationModels = (Story: React.FC) => (
+
+
+
+);
+
const meta: Meta = {
title: "pages/AISettingsPage/ModelsPage/ModelForm",
component: ModelForm,
- decorators: [withToaster],
+ decorators: [withToaster, withOrganizationModels],
args: {
providerStates: [MockOpenAIProviderState, MockAnthropicProviderState],
selectedProviderState: MockOpenAIProviderState,
diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.stories.tsx
index 01a39ce6508..9aa149a8dd8 100644
--- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.stories.tsx
+++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.stories.tsx
@@ -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 = {
export default meta;
type Story = StoryObj;
-// 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();
},
};
diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx
index ee1e7e81e14..25b6e1432db 100644
--- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx
+++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx
@@ -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 = ({
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 (
@@ -48,7 +58,10 @@ export const ModelRow: FC = ({