From bc16c5168c75cf4202372f5912385208cd676bdc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 25 Apr 2026 12:00:50 -0400 Subject: [PATCH] fix(ui/profiles): latch extraModels touch state for explicit-clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used react-hook-form's `dirtyFields.extraModels` to gate forwarding of the empty value. RHF compares each value to its `defaultValues` entry, so typing "x" and erasing back to "" reverts the dirty flag to false — making it impossible to clear a saved ANTHROPIC_EXTRA_MODELS through the dashboard. Track a local boolean that latches true on the first onChange and resets when the dialog closes. The latch survives a "type then delete" round-trip, so the empty-string delete signal still reaches the server, while a never-touched field still gets skipped to preserve the existing saved value. --- ui/src/components/profiles/profile-dialog.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/ui/src/components/profiles/profile-dialog.tsx b/ui/src/components/profiles/profile-dialog.tsx index 9331bda5..401ca8d5 100644 --- a/ui/src/components/profiles/profile-dialog.tsx +++ b/ui/src/components/profiles/profile-dialog.tsx @@ -54,10 +54,17 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { const updateMutation = useUpdateProfile(); const [showModelMapping, setShowModelMapping] = useState(false); + // Tracks whether the user has interacted with the Extra Models field in + // edit mode. RHF's `dirtyFields` compares the current value against + // `defaultValues`, so typing "x" and erasing back to "" reverts dirty to + // false. We need a latch so an explicit-clear (type then delete) still + // forwards the empty-string delete signal to the backend. + const [extraModelsTouched, setExtraModelsTouched] = useState(false); + const { register, handleSubmit, - formState: { errors, dirtyFields }, + formState: { errors }, reset, control, } = useForm({ @@ -91,6 +98,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { useEffect(() => { if (!open) { setShowModelMapping(false); + setExtraModelsTouched(false); } }, [open]); @@ -99,18 +107,19 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { if (profile) { // Update mode. The dialog cannot pre-populate `extraModels` from the // existing profile (Profile type carries no env data), so the field - // always starts blank in edit mode. Forwarding that blank value - // unconditionally would clobber any saved ANTHROPIC_EXTRA_MODELS via - // the route's "empty string deletes" semantics. Only forward the - // field if the user actually edited it — typing then clearing still - // marks it dirty, preserving the explicit-clear UX. + // always starts blank. Forwarding that blank value unconditionally + // would clobber any saved ANTHROPIC_EXTRA_MODELS, since the PUT route + // treats an empty string as a delete signal. Use a latch flipped on + // the first onChange so we can distinguish "user never touched it" + // (skip → preserve existing value) from "user typed then cleared" + // (forward "" → explicit delete). await updateMutation.mutateAsync({ name: profile.name, data: { baseUrl: data.baseUrl, apiKey: data.apiKey, model: data.model, - ...(dirtyFields.extraModels ? { extraModels: data.extraModels } : {}), + ...(extraModelsTouched ? { extraModels: data.extraModels } : {}), opusModel: data.opusModel, sonnetModel: data.sonnetModel, haikuModel: data.haikuModel, @@ -178,7 +187,9 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { setExtraModelsTouched(true), + })} placeholder="model-a,model-b,model-c" />