mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): tidy AI model provider configuration layout (#27843)
This commit is contained in:
+111
@@ -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<typeof ModelForm> = {
|
||||
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: <div>Models</div> },
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ModelForm>;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -296,12 +296,25 @@ const SegmentedField: FC<
|
||||
const currentValue = (getIn(form.values, fieldKey) as string) || "";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 self-stretch">
|
||||
<div className="flex min-w-0 flex-col gap-1.5 self-stretch">
|
||||
<div className="flex items-center gap-1 text-sm font-normal leading-6 text-content-primary">
|
||||
<span>{label}</span>
|
||||
{description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex items-center gap-0.75 rounded-lg border border-solid border-border p-2",
|
||||
"flex w-full items-center gap-0.75 rounded-lg border border-solid border-border p-2",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
>
|
||||
@@ -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<
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm font-normal leading-6 text-content-primary">
|
||||
<span>{label}</span>
|
||||
{description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{fieldError && (
|
||||
<p id={errorId} className="m-0 w-full text-xs text-content-destructive">
|
||||
{fieldError}
|
||||
@@ -439,6 +439,8 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<SegmentedField
|
||||
@@ -452,22 +454,6 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
);
|
||||
}
|
||||
const options: readonly string[] = field.enum ?? [];
|
||||
const maxSegmented = 6;
|
||||
if (options.length > 0 && options.length <= maxSegmented) {
|
||||
return (
|
||||
<SegmentedField
|
||||
{...ctx}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={errorKey}
|
||||
label={label}
|
||||
description={field.description}
|
||||
options={options.map((value) => ({
|
||||
label: capitalize(value),
|
||||
value,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SelectField
|
||||
{...ctx}
|
||||
@@ -499,14 +485,11 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user