Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/settings-active-model-dropdown-truth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@open-codesign/desktop': patch
'@open-codesign/i18n': patch
---

fix(renderer): Settings active-provider card no longer misrepresents the current model

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).

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).
56 changes: 55 additions & 1 deletion apps/desktop/src/renderer/src/components/Settings.test.ts
Original file line number Diff line number Diff line change
@@ -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)),
Expand Down Expand Up @@ -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' },
]);
});
});
41 changes: 38 additions & 3 deletions apps/desktop/src/renderer/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="mt-[var(--space-2)] flex items-center gap-[var(--space-2)] text-[var(--text-xs)] text-[var(--color-text-muted)]">
Expand Down Expand Up @@ -1593,6 +1601,33 @@ export async function applyLocaleChange(
return applied;
}

/**
* Build the <select> options for the active-provider model dropdown.
*
* The /models endpoint may return a partial list (or none at all), and the
* active model id may have been set via TOML import or a previous list that
* the gateway no longer returns. If the active id is not in `models`, the
* native <select> 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);
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
"addCustom": "添加自定义",
"empty": "还没有配置任何服务,添加一个即可开始生成。",
"active": "当前",
"activeNotInList": "(当前,不在服务返回的列表中)",
"decryptionFailed": "解密失败",
"setActive": "设为当前",
"reEnterKey": "重新输入 Key",
Expand Down
Loading