From 0f4731446b1cfc107fb39e4aa3c94e37f18ba91d Mon Sep 17 00:00:00 2001 From: TJ Date: Wed, 24 Jun 2026 18:01:56 -0700 Subject: [PATCH] fix(site/src/pages/AISettingsPage/ModelsPage): disable Update model until form is dirty (#26684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Update model button on `/ai/settings/models/:modelId` was enabled on mount even when the form had not been edited, so it was possible to submit an unchanged update. This matches the Provider form behavior already established in #25551 by gating submit on `form.dirty` when editing. ### Changes - `ModelForm.tsx` adds `(!isEditing || form.dirty)` to the `canSubmit` predicate so Update is disabled until the user changes a field. Add/duplicate flows are unaffected because their existing `model.trim().length > 0` requirement already enforces user input. - `ModelForm.stories.tsx` tightens `EditSaveSubmits` to assert the disabled-on-mount and enabled-after-edit transitions, and adds `EditUpdateDisabledUntilDirty` covering the case where the user reverts an edit back to the original value. ### Verification - `pnpm exec biome check src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx`: clean - `pnpm exec tsc -p . --noEmit`: clean - `pnpm test:storybook --project=chromium src/pages/AISettingsPage/ModelsPage`: 21/21 stories pass (including the two new dirty-state stories) - `make pre-commit` via the project git hooks: passed (lint/ts, lint/go, lint/emdash, lint/agents, lint/check-scopes, build, all green) > [!NOTE] > 🤖 This PR was written by Coder Agents on behalf of @tracyjohnsonux --- .../components/ModelForm.stories.tsx | 22 +++++++++++++++++++ .../ModelsPage/components/ModelForm.tsx | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 53dc60d4e0..6af8ea1529 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -183,11 +183,33 @@ export const EditSaveSubmits: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); const save = canvas.getByRole("button", { name: /^update model$/i }); + // Update is disabled until the user makes a change. + await expect(save).toBeDisabled(); + await userEvent.type(canvas.getByLabelText(/display name/i), " (updated)"); + await expect(save).toBeEnabled(); await userEvent.click(save); await expect(args.onUpdateModel).toHaveBeenCalledTimes(1); }, }; +export const EditUpdateDisabledUntilDirty: Story = { + args: { + editingModel: mockGPT5, + onDeleteModel: fn(async () => undefined), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const save = canvas.getByRole("button", { name: /^update model$/i }); + await expect(save).toBeDisabled(); + const displayName = canvas.getByLabelText(/display name/i); + await userEvent.type(displayName, " (edited)"); + await expect(save).toBeEnabled(); + await userEvent.clear(displayName); + await userEvent.type(displayName, mockGPT5.display_name); + await expect(save).toBeDisabled(); + }, +}; + export const CostTrackingExpanded: Story = { args: { editingModel: mockGPT5, diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx index e20ac5673d..1f16c44d31 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx @@ -235,7 +235,8 @@ export const ModelForm: FC = ({ !hasFieldErrors && form.values.model.trim().length > 0 && contextLimitValid && - compressionThresholdValid; + compressionThresholdValid && + (!isEditing || form.dirty); const handleConfirmReplaceDefault = () => { replaceDefaultConfirmedRef.current = true;