From 0a67959e840e520cf37027c68f8142662b6e2c07 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Wed, 22 Apr 2026 22:23:14 +0800 Subject: [PATCH] fix(renderer): Settings active card no longer misrepresents model when not in fetched list (#136) When the provider /models endpoint returns a list that does not contain the currently-active model id, the native ` fell back to rendering `options[0]`. The card then visually claimed the active model was whatever happened to sit at the top of the fetched list, while the top-bar `ModelSwitcher` and the actual generation request still used the real active id (see issue #136). + +Now when `config.modelPrimary` is not in the fetched list, the active id is pinned at the top of the dropdown with an `(active, not in provider list)` hint. The select always matches reality, and users can see at a glance that their configured model is not one the provider advertised — a useful signal when debugging 4xx errors (related: #124, #134). diff --git a/apps/desktop/src/renderer/src/components/Settings.test.ts b/apps/desktop/src/renderer/src/components/Settings.test.ts index 1a3aa4d4..f60551ac 100644 --- a/apps/desktop/src/renderer/src/components/Settings.test.ts +++ b/apps/desktop/src/renderer/src/components/Settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { applyLocaleChange } from './Settings'; +import { applyLocaleChange, computeModelOptions } from './Settings'; vi.mock('@open-codesign/i18n', () => ({ setLocale: vi.fn((locale: string) => Promise.resolve(locale)), @@ -34,3 +34,57 @@ describe('applyLocaleChange', () => { expect(result).toBe('zh-CN'); }); }); + +describe('computeModelOptions', () => { + const suffix = '(active, not in provider list)'; + + it('returns null while the list is still loading', () => { + expect( + computeModelOptions({ models: null, activeModelId: 'opus-4-7', notInListSuffix: suffix }), + ).toBeNull(); + }); + + it('returns null when the provider returned an empty list', () => { + expect( + computeModelOptions({ models: [], activeModelId: 'opus-4-7', notInListSuffix: suffix }), + ).toBeNull(); + }); + + it('returns the fetched list unchanged when the active model is in it', () => { + const result = computeModelOptions({ + models: ['haiku', 'sonnet', 'opus-4-7'], + activeModelId: 'opus-4-7', + notInListSuffix: suffix, + }); + expect(result).toEqual([ + { value: 'haiku', label: 'haiku' }, + { value: 'sonnet', label: 'sonnet' }, + { value: 'opus-4-7', label: 'opus-4-7' }, + ]); + }); + + it('pins the active model at the top when it is not in the fetched list (issue #136)', () => { + const result = computeModelOptions({ + models: ['haiku', 'sonnet'], + activeModelId: 'opus-4-7', + notInListSuffix: suffix, + }); + expect(result).toEqual([ + { value: 'opus-4-7', label: `opus-4-7 ${suffix}` }, + { value: 'haiku', label: 'haiku' }, + { value: 'sonnet', label: 'sonnet' }, + ]); + }); + + it('does not inject anything for inactive rows (activeModelId = null)', () => { + const result = computeModelOptions({ + models: ['haiku', 'sonnet'], + activeModelId: null, + notInListSuffix: suffix, + }); + expect(result).toEqual([ + { value: 'haiku', label: 'haiku' }, + { value: 'sonnet', label: 'sonnet' }, + ]); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index 297f25fb..7ad2d4a6 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -23,7 +23,7 @@ import { Sliders, Trash2, } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { AppPaths, Preferences, ProviderRow, StorageKind } from '../../../preload/index'; import { recordAction } from '../lib/action-timeline'; import { useCodesignStore } from '../store'; @@ -483,8 +483,16 @@ function RowModelSelector({ }); } - const options = - models !== null && models.length > 0 ? models.map((m) => ({ value: m, label: m })) : null; + const notInListSuffix = t('settings.providers.activeNotInList'); + const options = useMemo( + () => + computeModelOptions({ + models, + activeModelId: isActive ? primary : null, + notInListSuffix, + }), + [models, isActive, primary, notInListSuffix], + ); return (
@@ -1593,6 +1601,33 @@ export async function applyLocaleChange( return applied; } +/** + * Build the would silently fall back to options[0] and lie about which + * model is in use (issue #136). Pin the active id at the top with a hint so + * the UI always matches reality. + * + * Returns null when there is nothing to render (loading completed, no models, + * no active id) so the caller can fall back to a plain text label. + */ +export function computeModelOptions(input: { + models: string[] | null; + activeModelId: string | null; + notInListSuffix: string; +}): { value: string; label: string }[] | null { + const { models, activeModelId, notInListSuffix } = input; + if (models === null || models.length === 0) return null; + const base = models.map((m) => ({ value: m, label: m })); + if (activeModelId && !models.includes(activeModelId)) { + return [{ value: activeModelId, label: `${activeModelId} ${notInListSuffix}` }, ...base]; + } + return base; +} + function AppearanceTab() { const t = useT(); const theme = useCodesignStore((s) => s.theme); diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index ebae3dd5..2781665e 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -187,6 +187,7 @@ "addCustom": "Add custom", "empty": "No providers configured yet. Add one to start generating.", "active": "Active", + "activeNotInList": "(active, not in provider list)", "decryptionFailed": "Decryption failed", "setActive": "Set active", "reEnterKey": "Re-enter key", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 454de464..68d19619 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -187,6 +187,7 @@ "addCustom": "添加自定义", "empty": "还没有配置任何服务,添加一个即可开始生成。", "active": "当前", + "activeNotInList": "(当前,不在服务返回的列表中)", "decryptionFailed": "解密失败", "setActive": "设为当前", "reEnterKey": "重新输入 Key",