Skip to content

Commit 5d22e60

Browse files
authored
fix(renderer): Settings active card no longer misrepresents model when not in fetched list (#136) (#166)
## Summary Fixes #136. Related: #124, #134. The active-provider card in Settings was displaying the wrong model whenever `config.modelPrimary` was not in the list returned by the provider's `/models` endpoint. The native `<select value={config.modelPrimary}>` silently fell back to rendering `options[0]` — so the card visually claimed the active model was, say, `claude-3-5-haiku-20241022`, while: - the top-bar `ModelSwitcher` correctly read `config.modelPrimary` (e.g. `opus-4-7`) - the main process snapped requests to the canonical `cfg.activeModel` (also `opus-4-7`) Pure UI inconsistency, but seriously misleading when debugging 4xx failures ("I thought I was using opus-4-7 but the card says haiku — am I?"), which is exactly the debugging loop #124/#134 are stuck in. ## Fix `RowModelSelector` now injects the active model id at the top of the options list with an `(active, not in provider list)` hint whenever it's missing from the fetched list. The dropdown always matches reality, and users can spot at a glance that their configured model is not one the provider's `/models` returned — a useful diagnostic signal. Extracted the option-building logic into a pure `computeModelOptions` helper and covered it with deterministic unit tests (loading / empty / active-in-list / active-not-in-list / inactive-row). ## Files - `apps/desktop/src/renderer/src/components/Settings.tsx` — inject pinned active option, exported helper - `apps/desktop/src/renderer/src/components/Settings.test.ts` — 5 new tests for `computeModelOptions` - `packages/i18n/src/locales/{en,zh-CN}.json` — new `settings.providers.activeNotInList` key ## PRINCIPLES §5b - Compatibility: green — pure UI fix, no schema / IPC / storage changes - Upgradeability: green — no new abstractions, helper is trivially inlinable later - No bloat: green — net +41 lines in Settings.tsx, +1 i18n key per locale - Elegance: green — helper is pure and framework-free; dropdown tells the truth regardless of what the provider returns ## Test plan - [x] `pnpm --filter @open-codesign/desktop` vitest: 875/875 pass (includes 5 new) - [x] `pnpm typecheck`: 10/10 pass - [x] `pnpm lint` (biome): clean - [ ] Manual: activate a custom OpenAI-compatible provider whose `/models` hides your configured model id; open Settings; confirm the card's `<select>` shows the active id with the hint, top-bar and card agree - [ ] Manual: repro #136's original case (Anthropic + opus-4-7 with a list that only returns haiku/sonnet) and verify card no longer lies Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 803efab commit 5d22e60

5 files changed

Lines changed: 105 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@open-codesign/desktop': patch
3+
'@open-codesign/i18n': patch
4+
---
5+
6+
fix(renderer): Settings active-provider card no longer misrepresents the current model
7+
8+
When the `/models` endpoint returns a partial list (or one that does not include the currently-active model id — common with custom gateways, manually-edited TOML, or provider-specific aliasing), the native `<select>` 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).
9+
10+
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).

apps/desktop/src/renderer/src/components/Settings.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest';
2-
import { applyLocaleChange } from './Settings';
2+
import { applyLocaleChange, computeModelOptions } from './Settings';
33

44
vi.mock('@open-codesign/i18n', () => ({
55
setLocale: vi.fn((locale: string) => Promise.resolve(locale)),
@@ -34,3 +34,57 @@ describe('applyLocaleChange', () => {
3434
expect(result).toBe('zh-CN');
3535
});
3636
});
37+
38+
describe('computeModelOptions', () => {
39+
const suffix = '(active, not in provider list)';
40+
41+
it('returns null while the list is still loading', () => {
42+
expect(
43+
computeModelOptions({ models: null, activeModelId: 'opus-4-7', notInListSuffix: suffix }),
44+
).toBeNull();
45+
});
46+
47+
it('returns null when the provider returned an empty list', () => {
48+
expect(
49+
computeModelOptions({ models: [], activeModelId: 'opus-4-7', notInListSuffix: suffix }),
50+
).toBeNull();
51+
});
52+
53+
it('returns the fetched list unchanged when the active model is in it', () => {
54+
const result = computeModelOptions({
55+
models: ['haiku', 'sonnet', 'opus-4-7'],
56+
activeModelId: 'opus-4-7',
57+
notInListSuffix: suffix,
58+
});
59+
expect(result).toEqual([
60+
{ value: 'haiku', label: 'haiku' },
61+
{ value: 'sonnet', label: 'sonnet' },
62+
{ value: 'opus-4-7', label: 'opus-4-7' },
63+
]);
64+
});
65+
66+
it('pins the active model at the top when it is not in the fetched list (issue #136)', () => {
67+
const result = computeModelOptions({
68+
models: ['haiku', 'sonnet'],
69+
activeModelId: 'opus-4-7',
70+
notInListSuffix: suffix,
71+
});
72+
expect(result).toEqual([
73+
{ value: 'opus-4-7', label: `opus-4-7 ${suffix}` },
74+
{ value: 'haiku', label: 'haiku' },
75+
{ value: 'sonnet', label: 'sonnet' },
76+
]);
77+
});
78+
79+
it('does not inject anything for inactive rows (activeModelId = null)', () => {
80+
const result = computeModelOptions({
81+
models: ['haiku', 'sonnet'],
82+
activeModelId: null,
83+
notInListSuffix: suffix,
84+
});
85+
expect(result).toEqual([
86+
{ value: 'haiku', label: 'haiku' },
87+
{ value: 'sonnet', label: 'sonnet' },
88+
]);
89+
});
90+
});

apps/desktop/src/renderer/src/components/Settings.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
Sliders,
2424
Trash2,
2525
} from 'lucide-react';
26-
import { useEffect, useRef, useState } from 'react';
26+
import { useEffect, useMemo, useRef, useState } from 'react';
2727
import type { AppPaths, Preferences, ProviderRow, StorageKind } from '../../../preload/index';
2828
import { recordAction } from '../lib/action-timeline';
2929
import { useCodesignStore } from '../store';
@@ -483,8 +483,16 @@ function RowModelSelector({
483483
});
484484
}
485485

486-
const options =
487-
models !== null && models.length > 0 ? models.map((m) => ({ value: m, label: m })) : null;
486+
const notInListSuffix = t('settings.providers.activeNotInList');
487+
const options = useMemo(
488+
() =>
489+
computeModelOptions({
490+
models,
491+
activeModelId: isActive ? primary : null,
492+
notInListSuffix,
493+
}),
494+
[models, isActive, primary, notInListSuffix],
495+
);
488496

489497
return (
490498
<div className="mt-[var(--space-2)] flex items-center gap-[var(--space-2)] text-[var(--text-xs)] text-[var(--color-text-muted)]">
@@ -1609,6 +1617,33 @@ export async function applyLocaleChange(
16091617
return applied;
16101618
}
16111619

1620+
/**
1621+
* Build the <select> options for the active-provider model dropdown.
1622+
*
1623+
* The /models endpoint may return a partial list (or none at all), and the
1624+
* active model id may have been set via TOML import or a previous list that
1625+
* the gateway no longer returns. If the active id is not in `models`, the
1626+
* native <select> would silently fall back to options[0] and lie about which
1627+
* model is in use (issue #136). Pin the active id at the top with a hint so
1628+
* the UI always matches reality.
1629+
*
1630+
* Returns null when there is nothing to render (loading completed, no models,
1631+
* no active id) so the caller can fall back to a plain text label.
1632+
*/
1633+
export function computeModelOptions(input: {
1634+
models: string[] | null;
1635+
activeModelId: string | null;
1636+
notInListSuffix: string;
1637+
}): { value: string; label: string }[] | null {
1638+
const { models, activeModelId, notInListSuffix } = input;
1639+
if (models === null || models.length === 0) return null;
1640+
const base = models.map((m) => ({ value: m, label: m }));
1641+
if (activeModelId && !models.includes(activeModelId)) {
1642+
return [{ value: activeModelId, label: `${activeModelId} ${notInListSuffix}` }, ...base];
1643+
}
1644+
return base;
1645+
}
1646+
16121647
function AppearanceTab() {
16131648
const t = useT();
16141649
const theme = useCodesignStore((s) => s.theme);

packages/i18n/src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@
187187
"addCustom": "Add custom",
188188
"empty": "No providers configured yet. Add one to start generating.",
189189
"active": "Active",
190+
"activeNotInList": "(active, not in provider list)",
190191
"decryptionFailed": "Decryption failed",
191192
"setActive": "Set active",
192193
"reEnterKey": "Re-enter key",

packages/i18n/src/locales/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@
187187
"addCustom": "添加自定义",
188188
"empty": "还没有配置任何服务,添加一个即可开始生成。",
189189
"active": "当前",
190+
"activeNotInList": "(当前,不在服务返回的列表中)",
190191
"decryptionFailed": "解密失败",
191192
"setActive": "设为当前",
192193
"reEnterKey": "重新输入 Key",

0 commit comments

Comments
 (0)