fix(desktop): keep draft fallback rows across autosave echo

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 <liruixinch@outlook.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-13 12:57:34 -04:00
parent af250d8494
commit 3615545bca
2 changed files with 41 additions and 5 deletions

View file

@ -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))
})
})

View file

@ -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<FallbackEntry[]>(() => 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<FallbackEntry>) =>