From ef996473a73b5ccf917e1121451a1e24d7491114 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:43:17 +0800 Subject: [PATCH] fix(web): restore the default model catalog without a reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore defaults dropped the user's `models` override from the draft but the rows kept showing it, so the catalog only looked restored after closing and reopening the card. The inherited rows were read from the namespace's effective value, which still carries the stored override until the unset is applied — so dropping the override echoed it straight back. They now come from the layer beneath the user's: what the composition entry pinned, or else the schema default that resolution would supply. --- .../ui-models/src/client/ProviderEditor.tsx | 14 ++++- .../ui-models/tests/components.spec.tsx | 59 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 746d1cba35..a27fa14002 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -239,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const keyLocked = keyState?.writable === false + /** + * The catalog beneath the user layer: what the composition entry pinned, or + * else the schema default that `resolve` would supply. The effective value + * cannot answer this — it still carries the stored override until the unset + * is applied, so reading it would echo that override straight back the + * moment reset drops it, leaving the rows unchanged until a reload. + */ + const inheritedModels = (): unknown => { + const pinned = getPath(namespace.base, [...settingsPath, 'models']) + return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default + } + /** * The curated fields of one known adapter family. Taking the narrowed * family as a parameter is what makes `EFFORT_FIELD` total here: an @@ -248,7 +260,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const effortField = EFFORT_FIELD[family] const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) - const models = modelDrafts(modelsOverridden ? customModels : getPath(fallback, ['models'])) + const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) return ( <> diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 6f080cdddb..d9ebadd1f3 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -41,7 +41,22 @@ const DeepSeekConfig = Schema.object({ name: Schema.string(), description: Schema.string(), contextWindow: Schema.number().step(1).min(1), - })), + // The adapter declares its catalog as a schema default rather than a + // composition entry, which is what the restore-defaults path has to read. + })).default([ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: '', + contextWindow: 1_000_000, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: '', + contextWindow: 1_000_000, + }, + ]), }) const DEFAULT_DEEPSEEK_MODELS = [ @@ -420,6 +435,48 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) + it.each([ + ['the schema default', undefined], + ['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }], + ])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => { + // The regression: reset read the EFFECTIVE value, which still carries the + // stored override until the unset is applied — so the rows did not change + // and the catalog only looked restored after reopening the card. + const { face } = scriptedFace() + const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] } + const overridden: SettingsNamespaceView = { + ns: 'llm-deepseek', + schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown, + value: { ...stored, defaultContextWindow: 1_000_000 }, + ...base === undefined ? {} : { base }, + user: stored, + applies: 'live', + secrets: [], + revision: 0, + } + const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx') + render( {}} + />) + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByText(en.modelsCustomized)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(['user-only-model']) + + fireEvent.click(screen.getByText(en.resetModels)) + + expect(screen.getByText(en.modelsInherited)).toBeTruthy() + expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value)) + .toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment']) + }) + it('renders malformed draft fallbacks without inventing catalog values', () => { render(