From 3615545bca2b22e3e57257bfd5ef5bb3b47bbc28 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 12:57:34 -0400 Subject: [PATCH] fix(desktop): keep draft fallback rows across autosave echo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fallback only updates local editor state; complete pairs are filtered before onChange. The post-#7b5ba205 resync effect then saw the unchanged persisted chain and wiped the draft — button looked dead. Ignore value updates that match the last chain we emitted; still resync on real external changes (profile/config reload). Co-authored-by: HexLab98 --- .../settings/fallback-models-field.test.tsx | 15 +++++++++ .../app/settings/fallback-models-field.tsx | 31 ++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/settings/fallback-models-field.test.tsx b/apps/desktop/src/app/settings/fallback-models-field.test.tsx index 8abc71a65214..1f2401010e61 100644 --- a/apps/desktop/src/app/settings/fallback-models-field.test.tsx +++ b/apps/desktop/src/app/settings/fallback-models-field.test.tsx @@ -110,4 +110,19 @@ describe('FallbackModelsField', () => { await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1)) }) + + it('keeps a draft row visible after autosave re-renders the same persisted chain', async () => { + const onChange = vi.fn() + const rerender = await renderFieldWithRerender([], onChange) + + fireEvent.click(screen.getByText('Add fallback')) + + expect(onChange.mock.calls.at(-1)?.[0]).toEqual([]) + expect(screen.getAllByLabelText('Remove')).toHaveLength(1) + + // Parent autosave echo — same complete chain, new array identity. + rerender([]) + + await waitFor(() => expect(screen.getAllByLabelText('Remove')).toHaveLength(1)) + }) }) diff --git a/apps/desktop/src/app/settings/fallback-models-field.tsx b/apps/desktop/src/app/settings/fallback-models-field.tsx index d904d021f7b7..0254492ae075 100644 --- a/apps/desktop/src/app/settings/fallback-models-field.tsx +++ b/apps/desktop/src/app/settings/fallback-models-field.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -40,6 +40,14 @@ function normalizeEntries(value: unknown): FallbackEntry[] { }) } +function completeEntries(rows: FallbackEntry[]): FallbackEntry[] { + return rows.filter(entry => entry.provider && entry.model) +} + +function entriesEqual(a: FallbackEntry[], b: FallbackEntry[]): boolean { + return a.length === b.length && a.every((entry, index) => entry.provider === b[index]?.provider && entry.model === b[index]?.model) +} + /** * Structured editor for the top-level `fallback_providers` config list — a * chain of `{provider, model}` pairs tried in order when the default model @@ -69,16 +77,29 @@ export function FallbackModelsField({ const providers = (modelOptions.data?.providers ?? []).filter(provider => provider.slug) const [rows, setRows] = useState(() => normalizeEntries(value)) + // Last complete chain we emitted (or seeded). Autosave echoes the same + // filtered list back through `value`; ignore that echo so draft rows stay. + const lastEmittedRef = useRef(normalizeEntries(value)) - // Settings can reload after a profile/config change while this component - // stays mounted. Avoid displaying or saving the previous profile's chain. + // Resync on real external changes (profile switch / config reload). Skip + // when `value` is just our own commit echoing through the parent. useEffect(() => { - setRows(normalizeEntries(value)) + const persisted = normalizeEntries(value) + + if (entriesEqual(persisted, lastEmittedRef.current)) { + return + } + + lastEmittedRef.current = persisted + setRows(persisted) }, [value]) const commit = (next: FallbackEntry[]) => { + const complete = completeEntries(next) + setRows(next) - onChange(next.filter(entry => entry.provider && entry.model)) + lastEmittedRef.current = complete + onChange(complete) } const updateRow = (index: number, patch: Partial) =>