From 4aec1ea5922457c3b27dbda5df5e8db189762799 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 4 Aug 2026 14:40:48 +0100 Subject: [PATCH] fix(site): tidy AI model provider configuration layout (#27843) --- .../ModelFormProviderConfig.stories.tsx | 111 ++++++++++++++++++ .../ChatModelAdminPanel/ModelConfigFields.tsx | 59 ++++------ 2 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx new file mode 100644 index 0000000000..13572da3df --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderConfig.stories.tsx @@ -0,0 +1,111 @@ +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 { withToaster } from "#/testHelpers/storybook"; +import { + MockAnthropicProviderState, + MockOpenAIProviderState, +} from "../testFixtures"; +import { ModelForm } from "./ModelForm"; + +const meta: Meta = { + title: "pages/AISettingsPage/ModelsPage/ModelForm", + component: ModelForm, + decorators: [withToaster], + args: { + providerStates: [MockOpenAIProviderState, MockAnthropicProviderState], + selectedProviderState: MockOpenAIProviderState, + onProviderChange: fn(), + isSaving: false, + isDeleting: false, + onCreateModel: fn(async () => undefined), + onUpdateModel: fn(async () => undefined), + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/models/add" }, + routing: [ + { path: "/ai/settings/models/add", useStoryElement: true }, + { path: "/ai/settings/models", element:
Models
}, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +const openProviderConfig = (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + return userEvent.click( + canvas.getByRole("button", { name: /provider configuration/i }), + ); +}; + +// String enums render as dropdowns, booleans as on/off/default switches, and +// every control shares the same column width with its label on top. OpenAI is +// the densest provider, so it exercises the mixed grid layout. +export const ProviderConfigOpenAI: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await openProviderConfig(canvasElement); + + // String enums are dropdowns. + for (const name of [ + /reasoning summary/i, + /text verbosity/i, + /service tier/i, + ]) { + await expect(canvas.getByRole("combobox", { name })).toBeInTheDocument(); + } + + // Booleans keep the on/off/default segmented switch. + for (const name of [ + /parallel tool calls/i, + /store/i, + /web search enabled/i, + ]) { + const group = canvas.getByRole("radiogroup", { name }); + await expect(group).toBeInTheDocument(); + for (const option of ["Off", "On", "Default"]) { + await expect( + within(group).getByRole("radio", { name: option }), + ).toBeInTheDocument(); + } + } + }, +}; + +// Anthropic is boolean-heavy, so it exercises the stacked tri-state switches. +export const ProviderConfigAnthropic: Story = { + args: { + selectedProviderState: MockAnthropicProviderState, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await openProviderConfig(canvasElement); + await expect( + canvas.getByRole("combobox", { name: /thinking display/i }), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("radiogroup", { name: /send reasoning/i }), + ).toBeInTheDocument(); + }, +}; + +// Enabling web search reveals the gated search_context_size dropdown and the +// full-width allowed_domains JSON field. +export const ProviderConfigOpenAIWebSearch: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await openProviderConfig(canvasElement); + const webSearch = canvas.getByRole("radiogroup", { + name: /web search enabled/i, + }); + await userEvent.click(within(webSearch).getByRole("radio", { name: "On" })); + await expect( + canvas.getByRole("combobox", { name: /search context size/i }), + ).toBeInTheDocument(); + await expect(canvas.getByLabelText(/allowed domains/i)).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx index 2abc3f795e..f4fac7008e 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx @@ -296,12 +296,25 @@ const SegmentedField: FC< const currentValue = (getIn(form.values, fieldKey) as string) || ""; return ( -
+
+
+ {label} + {description && ( + + + + + + {description} + + + )} +
@@ -315,7 +328,7 @@ const SegmentedField: FC< aria-checked={isActive} disabled={disabled} className={cn( - "flex h-6 cursor-pointer items-center justify-center gap-2.5 rounded-xl border-0 px-2 pb-px text-sm font-normal leading-6 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", + "flex h-6 flex-1 cursor-pointer items-center justify-center gap-2.5 rounded-xl border-0 px-2 pb-px text-sm font-normal leading-6 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", isActive ? "rounded bg-surface-tertiary text-content-primary" : "bg-transparent text-content-secondary hover:text-content-primary", @@ -328,19 +341,6 @@ const SegmentedField: FC< ); })}
-
- {label} - {description && ( - - - - - - {description} - - - )} -
{fieldError && (

{fieldError} @@ -439,6 +439,8 @@ const SchemaField: FC = ({ /> ); case "select": { + // Booleans keep the on/off/default segmented switch; every string + // enum renders as a dropdown so the switch stays a tri-state control. if (field.type === "boolean") { return ( = ({ ); } const options: readonly string[] = field.enum ?? []; - const maxSegmented = 6; - if (options.length > 0 && options.length <= maxSegmented) { - return ( - ({ - label: capitalize(value), - value, - }))} - /> - ); - } return ( = ({ /** * How many grid columns a field should span in the 3-col layout. - * 1 = default (inputs, small enums) - * 3 = full-width (booleans, large enums, json textareas) + * 1 = default (inputs, selects, boolean switches) + * 3 = full-width (json textareas, which need room for multi-line content) */ function colSpan(field: FieldSchema): 1 | 3 { - if (field.type === "boolean" || field.input_type === "json") { - return 3; - } - if (field.input_type === "select" && (field.enum?.length ?? 0) > 3) { + if (field.input_type === "json") { return 3; } return 1;