fix(site/src/pages/AISettingsPage/ModelsPage): disable Update model until form is dirty (#26684)

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
This commit is contained in:
TJ
2026-06-25 01:01:56 +00:00
committed by GitHub
parent 99c0362b26
commit 0f4731446b
2 changed files with 24 additions and 1 deletions
@@ -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,
@@ -235,7 +235,8 @@ export const ModelForm: FC<ModelFormProps> = ({
!hasFieldErrors &&
form.values.model.trim().length > 0 &&
contextLimitValid &&
compressionThresholdValid;
compressionThresholdValid &&
(!isEditing || form.dirty);
const handleConfirmReplaceDefault = () => {
replaceDefaultConfirmedRef.current = true;