Skip to content

Commit c2f1e2f

Browse files
authored
fix setting UI config to empty for AI custom & displayed models (#7991)
1 parent 6d3a6eb commit c2f1e2f

File tree

3 files changed

+120
-2
lines changed

3 files changed

+120
-2
lines changed

‎frontend/src/components/app-config/__tests__/get-dirty-values.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/* Copyright 2026 Marimo. All rights reserved. */
22

33
import { describe, expect, test } from "vitest";
4-
import { getDirtyValues } from "../user-config-form";
4+
import type { UserConfig } from "@/core/config/config-schema";
5+
import { applyManualInjections, getDirtyValues } from "../user-config-form";
56

67
describe("getDirtyValues", () => {
78
test("extracts only dirty fields", () => {
@@ -71,4 +72,49 @@ describe("getDirtyValues", () => {
7172
expect(result).toEqual({ display: { theme: "dark" } });
7273
expect(result).not.toHaveProperty("runtime");
7374
});
75+
76+
test("applyManualInjections injects touched ai model fields", () => {
77+
const values = {
78+
ai: {
79+
models: {
80+
displayed_models: ["openai/gpt-4"],
81+
custom_models: ["openai/custom-model"],
82+
},
83+
},
84+
} as UserConfig;
85+
const dirtyValues: Partial<UserConfig> = {};
86+
const touchedFields = {
87+
ai: { models: { displayed_models: true } },
88+
};
89+
90+
applyManualInjections({ values, dirtyValues, touchedFields });
91+
92+
expect(dirtyValues).toEqual({
93+
ai: {
94+
models: {
95+
displayed_models: ["openai/gpt-4"],
96+
custom_models: ["openai/custom-model"],
97+
},
98+
},
99+
});
100+
});
101+
102+
test("applyManualInjections skips when field not touched", () => {
103+
const values = {
104+
ai: {
105+
models: {
106+
displayed_models: ["openai/gpt-4"],
107+
custom_models: ["openai/custom-model"],
108+
},
109+
},
110+
} as UserConfig;
111+
const dirtyValues: Partial<UserConfig> = {};
112+
const touchedFields = {
113+
ai: { models: { displayed_models: false } },
114+
};
115+
116+
applyManualInjections({ values, dirtyValues, touchedFields });
117+
118+
expect(dirtyValues).toEqual({});
119+
});
74120
});

‎frontend/src/components/app-config/ai-config.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,6 +1434,7 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
14341434

14351435
form.setValue("ai.models.displayed_models", newModels, {
14361436
shouldDirty: true,
1437+
shouldTouch: true,
14371438
});
14381439
onSubmit(form.getValues());
14391440
});
@@ -1453,6 +1454,7 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
14531454

14541455
form.setValue("ai.models.displayed_models", newModels, {
14551456
shouldDirty: true,
1457+
shouldTouch: true,
14561458
});
14571459
onSubmit(form.getValues());
14581460
},
@@ -1466,9 +1468,11 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
14661468
);
14671469
form.setValue("ai.models.displayed_models", newDisplayedModels, {
14681470
shouldDirty: true,
1471+
shouldTouch: true,
14691472
});
14701473
form.setValue("ai.models.custom_models", newModels, {
14711474
shouldDirty: true,
1475+
shouldTouch: true,
14721476
});
14731477
onSubmit(form.getValues());
14741478
});
@@ -1548,6 +1552,7 @@ export const AddModelForm: React.FC<{
15481552

15491553
form.setValue("ai.models.custom_models", [newModel.id, ...customModels], {
15501554
shouldDirty: true,
1555+
shouldTouch: true,
15511556
});
15521557
onSubmit(form.getValues());
15531558
resetForm();

‎frontend/src/components/app-config/user-config-form.tsx‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from "lucide-react";
1515
import React, { useId, useRef } from "react";
1616
import { useLocale } from "react-aria";
17-
import type { FieldValues } from "react-hook-form";
17+
import type { FieldPath, FieldValues } from "react-hook-form";
1818
import { useForm } from "react-hook-form";
1919
import type z from "zod";
2020
import { Button } from "@/components/ui/button";
@@ -95,6 +95,68 @@ export function getDirtyValues<T extends FieldValues>(
9595
return result;
9696
}
9797

98+
type ManualInjector = (
99+
values: UserConfig,
100+
dirtyValues: Partial<UserConfig>,
101+
) => void;
102+
103+
const modelsAiInjection = (
104+
values: UserConfig,
105+
dirtyValues: Partial<UserConfig>,
106+
) => {
107+
dirtyValues.ai = {
108+
...dirtyValues.ai,
109+
models: {
110+
...dirtyValues.ai?.models,
111+
displayed_models: values.ai?.models?.displayed_models ?? [],
112+
custom_models: values.ai?.models?.custom_models ?? [],
113+
},
114+
};
115+
};
116+
117+
// Some fields (like AI model lists) have empty arrays as default values.
118+
// If a user explicitly clears them, RHF won't mark them dirty, so we use
119+
// touchedFields to force-include those values in the payload.
120+
const MANUAL_INJECT_ENTRIES = [
121+
["ai.models.displayed_models", modelsAiInjection],
122+
["ai.models.custom_models", modelsAiInjection],
123+
] as const satisfies readonly (readonly [
124+
FieldPath<UserConfig>,
125+
ManualInjector,
126+
])[];
127+
128+
const MANUAL_INJECT_FIELDS = new Map(MANUAL_INJECT_ENTRIES);
129+
130+
const isTouchedPath = (
131+
touched: unknown,
132+
path: FieldPath<UserConfig>,
133+
): boolean => {
134+
if (!touched) {
135+
return false;
136+
}
137+
let current: unknown = touched;
138+
for (const segment of path.split(".")) {
139+
if (typeof current !== "object" || current === null) {
140+
return false;
141+
}
142+
current = (current as Record<string, unknown>)[segment];
143+
}
144+
return current === true;
145+
};
146+
147+
export const applyManualInjections = (opts: {
148+
values: UserConfig;
149+
dirtyValues: Partial<UserConfig>;
150+
touchedFields: unknown;
151+
}) => {
152+
const { values, dirtyValues, touchedFields } = opts;
153+
for (const [fieldPath, injector] of MANUAL_INJECT_FIELDS) {
154+
if (isTouchedPath(touchedFields, fieldPath)) {
155+
injector(values, dirtyValues);
156+
}
157+
}
158+
};
159+
98160
const categories = [
99161
{
100162
id: "editor",
@@ -207,6 +269,11 @@ export const UserConfigForm: React.FC = () => {
207269
// Only send values that were actually changed to avoid
208270
// overwriting backend values the form doesn't manage
209271
const dirtyValues = getDirtyValues(values, form.formState.dirtyFields);
272+
applyManualInjections({
273+
values,
274+
dirtyValues,
275+
touchedFields: form.formState.touchedFields,
276+
});
210277
if (Object.keys(dirtyValues).length === 0) {
211278
return; // Nothing changed
212279
}

0 commit comments

Comments
 (0)