From 810b509290990b2ab0a118c556cc9f6ed0b710b3 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 3 Mar 2026 15:19:07 -0500 Subject: [PATCH] feat: refactor the agents admin UI layout (#22567) I am working on a subsequent change to make the fields auto-generated with `make gen` from the Go code itself, rather than us needing to create a UI compatibility layer. Once the above is done, I'll be adding in the payload so users can very easily just click "Opus 4.6" to add the model, and the config values will be set appropriately. This is really just UI changes, nothing functionally should change here. But the code will be cleaned up a lot post the above changes. image image image --- site/src/api/queries/chats.ts | 8 + .../ChatModelAdminPanel.stories.tsx | 169 ++-- .../ChatModelAdminPanel.tsx | 117 +-- .../ChatModelAdminPanel/ModelConfigFields.tsx | 573 +++++++------- .../ChatModelAdminPanel/ModelForm.tsx | 747 ++++++++---------- .../ChatModelAdminPanel/ModelsSection.tsx | 302 ++++--- .../ChatModelAdminPanel/ProviderForm.tsx | 376 ++++++--- .../ChatModelAdminPanel/ProviderIcon.tsx | 31 +- .../ChatModelAdminPanel/ProvidersSection.tsx | 200 +++-- .../ChatModelAdminPanel/modelConfigSchemas.ts | 166 ---- .../AgentsPage/ConfigureAgentsDialog.tsx | 51 +- site/src/pages/AgentsPage/SectionHeader.tsx | 28 + 12 files changed, 1358 insertions(+), 1410 deletions(-) delete mode 100644 site/src/pages/AgentsPage/ChatModelAdminPanel/modelConfigSchemas.ts create mode 100644 site/src/pages/AgentsPage/SectionHeader.tsx diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 43f6ade2db..fc7ad5ddc0 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -159,6 +159,14 @@ export const updateChatProviderConfig = (queryClient: QueryClient) => ({ }, }); +export const deleteChatProviderConfig = (queryClient: QueryClient) => ({ + mutationFn: (providerConfigId: string) => + API.deleteChatProviderConfig(providerConfigId), + onSuccess: async () => { + await invalidateChatConfigurationQueries(queryClient); + }, +}); + export const createChatModelConfig = (queryClient: QueryClient) => ({ mutationFn: (req: TypesGen.CreateChatModelConfigRequest) => API.createChatModelConfig(req), diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index 9c90afc93b..236cf1b834 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -217,27 +217,47 @@ export const EnvPresetProviders: Story = { }, play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); + + // Both providers should be visible in the list. await expect( - await body.findByText("API key managed by environment variable."), - ).toBeVisible(); - expect(body.getByText("Anthropic")).toBeInTheDocument(); + await body.findByRole("button", { name: /OpenAI/i }), + ).toBeInTheDocument(); expect( - body.getByText( - "This provider API key is managed by an environment variable.", - ), - ).toBeVisible(); - expect( - body.getByText( + body.getByRole("button", { name: /Anthropic/i }), + ).toBeInTheDocument(); + + // Navigate to OpenAI detail view. + await userEvent.click(body.getByRole("button", { name: /OpenAI/i })); + + // In the detail view we should see the env-managed alert. + await expect( + await body.findByText( "This provider key is configured from deployment environment settings and cannot be edited in this UI.", ), ).toBeVisible(); + // No API key input or create button should be present. expect(body.queryByLabelText(/API key/i)).not.toBeInTheDocument(); expect( body.queryByRole("button", { name: "Create provider config", }), ).not.toBeInTheDocument(); + + // Navigate back to the list. + await userEvent.click(body.getByText("Back")); + + // Verify Anthropic is visible in the list again. + await expect( + await body.findByRole("button", { name: /Anthropic/i }), + ).toBeInTheDocument(); + + // Navigate to Anthropic detail view and verify it's also env-managed. + await userEvent.click(body.getByRole("button", { name: /Anthropic/i })); + await expect( + await body.findByText( + "This provider key is configured from deployment environment settings and cannot be edited in this UI.", + ), + ).toBeVisible(); }, }; @@ -271,7 +291,7 @@ export const CreateAndUpdateProvider: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // Expand the accordion. + // Navigate to the OpenAI detail view. await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); // Fill in form to create a provider config. @@ -299,26 +319,24 @@ export const CreateAndUpdateProvider: Story = { }), ); - // After creation the form should switch to "Save changes". + // After creation, queries refetch and the component re-keys + // because providerConfig now exists. Navigate back to the list + // and re-enter the detail view to interact with the updated form. await waitFor(() => { expect( body.getByRole("button", { name: "Save changes" }), ).toBeInTheDocument(); }); - // Update the display name and base URL. - const displayNameInput = body.getByPlaceholderText( - "Friendly provider label", - ); - await userEvent.clear(displayNameInput); - await userEvent.type(displayNameInput, "Primary OpenAI"); + // The form was re-rendered with the new providerConfig. + // Focus the API key field, type a new key, update the base URL, + // and save. + const apiKeyInput = body.getByLabelText(/API key/i); + await userEvent.clear(apiKeyInput); + await userEvent.type(apiKeyInput, "sk-updated-provider-key"); const baseURLInput = body.getByLabelText("Base URL"); await userEvent.clear(baseURLInput); await userEvent.type(baseURLInput, "https://internal-proxy.example.com/v2"); - await userEvent.type( - body.getByLabelText(/API key/i), - "sk-updated-provider-key", - ); await userEvent.click(body.getByRole("button", { name: "Save changes" })); await waitFor(() => { @@ -327,7 +345,6 @@ export const CreateAndUpdateProvider: Story = { expect(API.updateChatProviderConfig).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - display_name: "Primary OpenAI", api_key: "sk-updated-provider-key", base_url: "https://internal-proxy.example.com/v2", }), @@ -337,60 +354,26 @@ export const CreateAndUpdateProvider: Story = { // ── Models section stories ───────────────────────────────────── -export const ProviderSpecificModelConfigSchema: Story = { - args: { section: "models" as ChatModelAdminSection }, - beforeEach: () => { - setupChatSpies({ - providerConfigs: [ - createProviderConfig({ - id: "provider-openai", - provider: "openai", - display_name: "OpenAI", - source: "database", - has_api_key: true, - }), - createProviderConfig({ - id: "provider-anthropic", - provider: "anthropic", - display_name: "Anthropic", - source: "database", - has_api_key: true, - }), - ], - modelConfigs: [], - modelCatalog: { providers: [] }, +/** + * Helper to open the "Add model" dropdown and select a provider. + * The "Add model" button is a DropdownMenuTrigger. Clicking it opens + * a dropdown of addable providers. We then select the given provider. + */ +const openAddModelForm = async ( + body: ReturnType, + providerLabel: string, +) => { + // Click the dropdown trigger to open the provider menu. + const trigger = await body.findByRole("button", { name: "Add model" }); + await userEvent.click(trigger); + // Radix portals dropdown content into the document body. + // Wait for the menu to appear and click the provider item. + await waitFor(async () => { + const item = body.getByRole("menuitem", { + name: new RegExp(providerLabel, "i"), }); - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), - ); - - const schemaBlock = await body.findByTestId("chat-model-config-schema"); - expect(schemaBlock).toHaveTextContent('"provider": "openai"'); - expect(schemaBlock).toHaveTextContent('"openai": {'); - expect(schemaBlock).toHaveTextContent('"reasoning_effort": "high"'); - - // Switch provider to Anthropic. - await userEvent.click(body.getByRole("combobox", { name: "Provider" })); - await userEvent.click( - await body.findByRole("option", { name: /Anthropic/i }), - ); - - await waitFor(() => { - expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( - '"provider": "anthropic"', - ); - }); - expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( - '"anthropic": {', - ); - expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent( - '"thinking": {', - ); - }, + await userEvent.click(item); + }); }; export const NoModelConfigByDefault: Story = { @@ -413,16 +396,19 @@ export const NoModelConfigByDefault: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), - ); - await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro"); + // Open "Add model" dropdown and select the OpenAI provider. + await openAddModelForm(body, "OpenAI"); + + await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro"); await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + // Max output tokens is under the "Advanced" toggle. + await userEvent.click(body.getByText("Advanced")); await expect(await body.findByLabelText(/Max output tokens/i)).toHaveValue( "", ); + // The submit button in ModelForm also says "Add model". await userEvent.click(body.getByRole("button", { name: "Add model" })); await waitFor(() => { expect(API.createChatModelConfig).toHaveBeenCalledTimes(1); @@ -461,18 +447,23 @@ export const SubmitModelConfigExplicitly: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), + // Open "Add model" dropdown and select the OpenAI provider. + await openAddModelForm(body, "OpenAI"); + + await userEvent.type( + body.getByLabelText(/Model Identifier/i), + "gpt-5-pro-custom", ); - await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro-custom"); await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + // Max output tokens and provider options are under "Advanced". + await userEvent.click(body.getByText("Advanced")); await userEvent.type( await body.findByLabelText(/Max output tokens/i), "32000", ); await userEvent.click( body.getByRole("combobox", { - name: "Reasoning effort", + name: "Reasoning Effort", }), ); await userEvent.click(await body.findByRole("option", { name: "high" })); @@ -518,11 +509,13 @@ export const ValidatesModelConfigFields: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - await userEvent.click( - await body.findByRole("button", { name: "Add model" }), - ); - await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro"); + // Open "Add model" dropdown and select the OpenAI provider. + await openAddModelForm(body, "OpenAI"); + + await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro"); await userEvent.type(body.getByLabelText(/Context limit/i), "200000"); + // Max output tokens is under the "Advanced" toggle. + await userEvent.click(body.getByText("Advanced")); const maxOutputTokensInput = await body.findByLabelText(/Max output tokens/i); await userEvent.type(maxOutputTokensInput, "not-a-number"); diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx index caa63ac884..0897ec13f9 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -5,6 +5,7 @@ import { createChatModelConfig as createChatModelConfigMutation, createChatProviderConfig as createChatProviderConfigMutation, deleteChatModelConfig as deleteChatModelConfigMutation, + deleteChatProviderConfig as deleteChatProviderConfigMutation, updateChatModelConfig as updateChatModelConfigMutation, updateChatProviderConfig as updateChatProviderConfigMutation, } from "api/queries/chats"; @@ -201,11 +202,13 @@ const useProviderStates = ( type ChatModelAdminPanelProps = { className?: string; section?: ChatModelAdminSection; + sectionLabel?: string; }; export const ChatModelAdminPanel: FC = ({ className, section = "providers", + sectionLabel, }) => { const queryClient = useQueryClient(); const [requestedProvider, setRequestedProvider] = useState( @@ -230,6 +233,9 @@ export const ChatModelAdminPanel: FC = ({ const updateModelMut = useMutation( updateChatModelConfigMutation(queryClient), ); + const deleteProviderMut = useMutation( + deleteChatProviderConfigMutation(queryClient), + ); const deleteModelMut = useMutation( deleteChatModelConfigMutation(queryClient), ); @@ -281,30 +287,68 @@ export const ChatModelAdminPanel: FC = ({ const providerConfigsUnavailable = providerConfigsQuery.data === null; const modelConfigsUnavailable = modelConfigsQuery.data === null; const isProviderMutationPending = - createProviderMut.isPending || updateProviderMut.isPending; + createProviderMut.isPending || + updateProviderMut.isPending || + deleteProviderMut.isPending; const providerMutationError = - createProviderMut.error ?? updateProviderMut.error; + createProviderMut.error ?? + updateProviderMut.error ?? + deleteProviderMut.error; const modelMutationError = createModelMut.error ?? updateModelMut.error ?? deleteModelMut.error; return ( -
- {/* Header */} -
-

- {section === "providers" - ? "Configure provider credentials and network settings." - : "Manage models available in Agents across all providers."} -

- {isLoading && ( -
- - Loading -
+
+ {isLoading && ( +
+ + Loading +
+ )} + + {/* Content */} +
+ {section === "providers" ? ( + createProviderMut.mutateAsync(req)} + onUpdateProvider={(providerConfigId, req) => + updateProviderMut.mutateAsync({ + providerConfigId, + req, + }) + } + onDeleteProvider={(id) => deleteProviderMut.mutateAsync(id)} + onSelectedProviderChange={setRequestedProvider} + /> + ) : ( + createModelMut.mutateAsync(req)} + onUpdateModel={(modelConfigId, req) => + updateModelMut.mutateAsync({ + modelConfigId, + req, + }) + } + onDeleteModel={(id) => deleteModelMut.mutateAsync(id)} + /> )}
- {/* Alerts */} + {/* Errors — rendered at the bottom */} {providerConfigsQuery.isError && ( )} @@ -318,7 +362,7 @@ export const ChatModelAdminPanel: FC = ({ {modelMutationError && } {providerConfigsUnavailable && ( - + Chat provider admin API is unavailable on this deployment. @@ -327,50 +371,13 @@ export const ChatModelAdminPanel: FC = ({ )} {modelConfigsUnavailable && ( - + Chat model admin API is unavailable on this deployment. /api/v2/chats/model-configs is missing. )} - - {/* Content */} - {section === "providers" ? ( - createProviderMut.mutateAsync(req)} - onUpdateProvider={(providerConfigId, req) => - updateProviderMut.mutateAsync({ - providerConfigId, - req, - }) - } - onSelectedProviderChange={setRequestedProvider} - /> - ) : ( - createModelMut.mutateAsync(req)} - onUpdateModel={(modelConfigId, req) => - updateModelMut.mutateAsync({ - modelConfigId, - req, - }) - } - onDeleteModel={(id) => deleteModelMut.mutateAsync(id)} - /> - )}
); }; diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx index 49f302a2ae..ff7f3ad84b 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx @@ -61,7 +61,7 @@ const InputField: FC< const fieldError = fieldErrors[fieldKey]; const fieldProps = form.getFieldProps(fieldKey); return ( -
+