Merge pull request #63864 from NousResearch/bb/salvage-63822-fallback-draft

fix(desktop): keep draft fallback rows across autosave echo (supersedes #63822)
This commit is contained in:
brooklyn! 2026-07-13 12:58:57 -04:00 committed by GitHub
commit 7f7a40381e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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>) =>