diff --git a/.changeset/unify-api-config.md b/.changeset/unify-api-config.md new file mode 100644 index 0000000..9f1a2a7 --- /dev/null +++ b/.changeset/unify-api-config.md @@ -0,0 +1,28 @@ +--- +'@open-codesign/desktop': minor +'@open-codesign/shared': minor +--- + +refactor(desktop): unify API config IPC + drop the separate "fast" model slot + +Two threads of cleanup, shipped together because they both reshape the API config surface area. + +## 1. Canonical IPC for adding/updating a provider + +The 3 surfaces that touch API config (top-bar pill, Settings → API 服务, onboarding) used to call 3 different IPC handlers with 3 different return shapes. The Settings `add-provider` path returned `ProviderRow[]` and never updated the Zustand store, so the top-bar pill could lag behind reality until a manual reload. + +- New canonical handler `config:v1:set-provider-and-models` accepts `{ provider, apiKey, modelPrimary, baseUrl?, setAsActive }` and atomically writes config + returns full `OnboardingState`. +- Existing `onboarding:save-key`, `settings:v1:add-provider`, `settings:v1:set-active-provider` are now thin delegates of the new handler — back-compat preserved. +- Settings → AddProviderModal and the post-add Zustand sync now both go through the new handler. `handleAddSave` re-pulls `OnboardingState` so the top-bar pill reflects the new active provider immediately. +- Preload bridge: new `window.codesign.config.setProviderAndModels()`. + +## 2. Drop the separate `modelFast` model slot + +The `modelFast` field was used in exactly one place (`applyComment` fallback), and its value was indistinguishable from `modelPrimary` for users — which is why every Settings UI showed two near-identical dropdowns and confused everyone. + +- `Config.modelFast` is dropped from the v2 schema. v1 configs are accepted (Zod treats `modelFast` as optional and the new handler never writes it back). +- `OnboardingState.modelFast` removed; `ProviderShortlist.fast` / `defaultFast` removed. +- `applyComment` now falls back to `modelPrimary` instead of `modelFast`. +- All AddProviderModal / ChooseModel / ModelsTab UI drops the second dropdown. + +Schema bump: `Config.version: 1 → 2`. Migration is read-only (drop the dead field on next write); no data loss. diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 12798a6..ab36e7e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -376,7 +376,7 @@ function registerIpcHandlers(): void { // Inline-comment edits don't need to be tied to whatever provider was // pinned in the original generate; resolve fresh against the canonical // active provider so a switch in Settings takes effect immediately. - const hint = payload.model ?? { provider: cfg.provider, modelId: cfg.modelFast }; + const hint = payload.model ?? { provider: cfg.provider, modelId: cfg.modelPrimary }; const active = resolveActiveModel(cfg, hint); const apiKey = getApiKeyForProvider(active.model.provider); const baseUrl = active.baseUrl ?? undefined; diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index 36700da..3741dee 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -112,6 +112,45 @@ describe('registerOnboardingIpc — channel versioning', () => { expect(registeredChannels).toContain(ch); } }); + + it('registers the canonical config:v1:set-provider-and-models handler', async () => { + const { registerOnboardingIpc } = await import('./onboarding-ipc'); + registerOnboardingIpc(); + expect(registeredChannels).toContain('config:v1:set-provider-and-models'); + }); +}); + +describe('config:v1:set-provider-and-models — payload validation', () => { + it('rejects payloads without a setAsActive boolean', async () => { + const { registerOnboardingIpc } = await import('./onboarding-ipc'); + registerOnboardingIpc(); + const handler = handlers.get('config:v1:set-provider-and-models'); + expect(handler).toBeDefined(); + if (!handler) return; + await expect( + handler({} as never, { + provider: 'openrouter', + apiKey: 'sk-test', + modelPrimary: 'a', + }), + ).rejects.toThrow(/setAsActive/); + }); + + it('rejects payloads with an unsupported schemaVersion', async () => { + const { registerOnboardingIpc } = await import('./onboarding-ipc'); + registerOnboardingIpc(); + const handler = handlers.get('config:v1:set-provider-and-models'); + if (!handler) throw new Error('handler missing'); + await expect( + handler({} as never, { + schemaVersion: 99, + provider: 'openrouter', + apiKey: 'sk-test', + modelPrimary: 'a', + setAsActive: true, + }), + ).rejects.toThrow(/schemaVersion/); + }); }); describe('registerOnboardingIpc — validate-key passes baseUrl to pingProvider', () => { it('forwards baseUrl to pingProvider when provided', async () => { @@ -151,10 +190,9 @@ describe('getApiKeyForProvider — API key retrieval', () => { // Override readConfig to return a config with an anthropic secret. const { readConfig } = await import('./config'); vi.mocked(readConfig).mockResolvedValueOnce({ - version: 1, + version: 2, provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', secrets: { anthropic: { ciphertext: 'enc:sk-ant-test' } }, baseUrls: {}, }); diff --git a/apps/desktop/src/main/onboarding-ipc.ts b/apps/desktop/src/main/onboarding-ipc.ts index 101fe7e..f2efe78 100644 --- a/apps/desktop/src/main/onboarding-ipc.ts +++ b/apps/desktop/src/main/onboarding-ipc.ts @@ -27,7 +27,6 @@ interface SaveKeyInput { provider: SupportedOnboardingProvider; apiKey: string; modelPrimary: string; - modelFast: string; baseUrl?: string; } @@ -82,7 +81,6 @@ function toState(cfg: Config | null): OnboardingState { hasKey: false, provider: null, modelPrimary: null, - modelFast: null, baseUrl: null, designSystem: null, }; @@ -92,7 +90,6 @@ function toState(cfg: Config | null): OnboardingState { hasKey: false, provider: null, modelPrimary: null, - modelFast: null, baseUrl: null, designSystem: cfg.designSystem ?? null, }; @@ -103,7 +100,6 @@ function toState(cfg: Config | null): OnboardingState { hasKey: false, provider: cfg.provider, modelPrimary: null, - modelFast: null, baseUrl: null, designSystem: cfg.designSystem ?? null, }; @@ -112,7 +108,6 @@ function toState(cfg: Config | null): OnboardingState { hasKey: true, provider: cfg.provider, modelPrimary: cfg.modelPrimary, - modelFast: cfg.modelFast, baseUrl: cfg.baseUrls?.[cfg.provider]?.baseUrl ?? null, designSystem: cfg.designSystem ?? null, }; @@ -153,7 +148,6 @@ function parseSaveKey(raw: unknown): SaveKeyInput { const provider = r['provider']; const apiKey = r['apiKey']; const modelPrimary = r['modelPrimary']; - const modelFast = r['modelFast']; const baseUrl = r['baseUrl']; if (typeof provider !== 'string' || !isSupportedOnboardingProvider(provider)) { throw new CodesignError( @@ -167,10 +161,7 @@ function parseSaveKey(raw: unknown): SaveKeyInput { if (typeof modelPrimary !== 'string' || modelPrimary.trim().length === 0) { throw new CodesignError('modelPrimary must be a non-empty string', 'IPC_BAD_INPUT'); } - if (typeof modelFast !== 'string' || modelFast.trim().length === 0) { - throw new CodesignError('modelFast must be a non-empty string', 'IPC_BAD_INPUT'); - } - const out: SaveKeyInput = { provider, apiKey, modelPrimary, modelFast }; + const out: SaveKeyInput = { provider, apiKey, modelPrimary }; if (typeof baseUrl === 'string' && baseUrl.trim().length > 0) { try { new URL(baseUrl); @@ -213,8 +204,38 @@ function runListProviders(): ProviderRow[] { return toProviderRows(getCachedConfig(), decryptSecret); } -async function runAddProvider(raw: unknown): Promise { - const input = parseSaveKey(raw); +interface SetProviderAndModelsInput extends SaveKeyInput { + setAsActive: boolean; +} + +function parseSetProviderAndModels(raw: unknown): SetProviderAndModelsInput { + if (typeof raw !== 'object' || raw === null) { + throw new CodesignError('set-provider-and-models expects an object payload', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + const sv = r['schemaVersion']; + if (sv !== undefined && sv !== 1) { + throw new CodesignError( + `Unsupported schemaVersion ${String(sv)} (expected 1)`, + 'IPC_BAD_INPUT', + ); + } + const setAsActive = r['setAsActive']; + if (typeof setAsActive !== 'boolean') { + throw new CodesignError('setAsActive must be a boolean', 'IPC_BAD_INPUT'); + } + return { ...parseSaveKey(raw), setAsActive }; +} + +/** + * Canonical "add or update a provider" mutation. Atomic: writes secret + + * baseUrl + (optionally) flips active provider in a single writeConfig. + * + * Returns the full OnboardingState so renderer can hydrate Zustand without a + * follow-up read — that store-sync gap is what made TopBar drift out of date + * after Settings mutations. + */ +async function runSetProviderAndModels(input: SetProviderAndModelsInput): Promise { const ciphertext = encryptSecret(input.apiKey); const nextBaseUrls = { ...(cachedConfig?.baseUrls ?? {}) }; if (input.baseUrl !== undefined) { @@ -222,20 +243,40 @@ async function runAddProvider(raw: unknown): Promise { } else { delete nextBaseUrls[input.provider]; } - const nextDefaults = getAddProviderDefaults(cachedConfig, input); - const next: Config = { - version: 1, - provider: nextDefaults.activeProvider, - modelPrimary: nextDefaults.modelPrimary, - modelFast: nextDefaults.modelFast, - secrets: { - ...(cachedConfig?.secrets ?? {}), - [input.provider]: { ciphertext }, - }, - baseUrls: nextBaseUrls, + const nextSecrets = { + ...(cachedConfig?.secrets ?? {}), + [input.provider]: { ciphertext }, }; + const activate = input.setAsActive || cachedConfig === null; + const next: Config = activate + ? { + version: 2, + provider: input.provider, + modelPrimary: input.modelPrimary, + secrets: nextSecrets, + baseUrls: nextBaseUrls, + } + : { + version: 2, + provider: cachedConfig?.provider ?? input.provider, + modelPrimary: cachedConfig?.modelPrimary ?? input.modelPrimary, + secrets: nextSecrets, + baseUrls: nextBaseUrls, + }; await writeConfig(next); cachedConfig = next; + configLoaded = true; + return toState(cachedConfig); +} + +async function runAddProvider(raw: unknown): Promise { + const input = parseSaveKey(raw); + const defaults = getAddProviderDefaults(cachedConfig, input); + await runSetProviderAndModels({ + ...input, + setAsActive: defaults.activeProvider === input.provider, + modelPrimary: defaults.modelPrimary, + }); return toProviderRows(cachedConfig, decryptSecret); } @@ -250,15 +291,13 @@ async function runDeleteProvider(raw: unknown): Promise { const nextBaseUrls = { ...(cfg.baseUrls ?? {}) }; delete nextBaseUrls[raw]; - const { nextActive, modelPrimary, modelFast } = computeDeleteProviderResult(cfg, raw); + const { nextActive, modelPrimary } = computeDeleteProviderResult(cfg, raw); if (nextActive === null) { - // No providers left — write a tombstone config so onboarding triggers again. const emptyNext: Config = { - version: 1, + version: 2, provider: cfg.provider, modelPrimary: '', - modelFast: '', secrets: {}, baseUrls: {}, }; @@ -268,10 +307,9 @@ async function runDeleteProvider(raw: unknown): Promise { } const next: Config = { - version: 1, + version: 2, provider: nextActive, modelPrimary, - modelFast, secrets: nextSecrets, baseUrls: nextBaseUrls, }; @@ -287,16 +325,12 @@ async function runSetActiveProvider(raw: unknown): Promise { const r = raw as Record; const provider = r['provider']; const modelPrimary = r['modelPrimary']; - const modelFast = r['modelFast']; if (typeof provider !== 'string' || !isSupportedOnboardingProvider(provider)) { throw new CodesignError('provider must be a supported provider string', 'IPC_BAD_INPUT'); } if (typeof modelPrimary !== 'string' || modelPrimary.trim().length === 0) { throw new CodesignError('modelPrimary must be a non-empty string', 'IPC_BAD_INPUT'); } - if (typeof modelFast !== 'string' || modelFast.trim().length === 0) { - throw new CodesignError('modelFast must be a non-empty string', 'IPC_BAD_INPUT'); - } const cfg = getCachedConfig(); if (cfg === null) { throw new CodesignError('No configuration found', 'CONFIG_MISSING'); @@ -304,10 +338,11 @@ async function runSetActiveProvider(raw: unknown): Promise { assertProviderHasStoredSecret(cfg, provider); const next: Config = { ...cfg, + version: 2, provider, modelPrimary, - modelFast, }; + next.modelFast = undefined; await writeConfig(next); cachedConfig = next; return toState(cachedConfig); @@ -348,35 +383,25 @@ export function registerOnboardingIpc(): void { }); ipcMain.handle('onboarding:save-key', async (_e, raw: unknown): Promise => { - const input = parseSaveKey(raw); - const ciphertext = encryptSecret(input.apiKey); - const nextBaseUrls = { ...(cachedConfig?.baseUrls ?? {}) }; - if (input.baseUrl !== undefined) { - nextBaseUrls[input.provider] = { baseUrl: input.baseUrl }; - } else { - delete nextBaseUrls[input.provider]; - } - const next: Config = { - version: 1, - provider: input.provider, - modelPrimary: input.modelPrimary, - modelFast: input.modelFast, - secrets: { - ...(cachedConfig?.secrets ?? {}), - [input.provider]: { ciphertext }, - }, - baseUrls: nextBaseUrls, - }; - await writeConfig(next); - cachedConfig = next; - configLoaded = true; - return toState(cachedConfig); + // Onboarding always activates the provider it just saved — that's the + // whole point of the first-time flow. Delegated to the canonical handler + // so behavior matches Settings exactly. + return runSetProviderAndModels({ ...parseSaveKey(raw), setAsActive: true }); }); ipcMain.handle('onboarding:skip', async (): Promise => { return toState(cachedConfig); }); + // ── Canonical config mutation (preferred entry point) ───────────────────── + + ipcMain.handle( + 'config:v1:set-provider-and-models', + async (_e, raw: unknown): Promise => { + return runSetProviderAndModels(parseSetProviderAndModels(raw)); + }, + ); + // ── Settings v1 channels ──────────────────────────────────────────────────── ipcMain.handle('settings:v1:list-providers', (): ProviderRow[] => runListProviders()); diff --git a/apps/desktop/src/main/provider-settings.test.ts b/apps/desktop/src/main/provider-settings.test.ts index 1b83070..b09e332 100644 --- a/apps/desktop/src/main/provider-settings.test.ts +++ b/apps/desktop/src/main/provider-settings.test.ts @@ -11,10 +11,9 @@ import { describe('getAddProviderDefaults', () => { it('activates the newly added provider when the cached active provider has no saved secret', () => { const cfg: Config = { - version: 1, + version: 2, provider: 'openai', modelPrimary: 'gpt-4o', - modelFast: 'gpt-4o-mini', secrets: {}, baseUrls: {}, }; @@ -22,13 +21,11 @@ describe('getAddProviderDefaults', () => { const defaults = getAddProviderDefaults(cfg, { provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', }); expect(defaults).toEqual({ activeProvider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', }); }); }); @@ -36,10 +33,9 @@ describe('getAddProviderDefaults', () => { describe('toProviderRows', () => { it('returns a row with error:decryption_failed and empty maskedKey when decrypt throws', () => { const cfg: Config = { - version: 1, + version: 2, provider: 'openai', modelPrimary: 'gpt-4o', - modelFast: 'gpt-4o-mini', secrets: { openai: { ciphertext: 'bad-ciphertext' }, }, @@ -59,10 +55,9 @@ describe('toProviderRows', () => { it('returns a normal masked row when decrypt succeeds', () => { const cfg: Config = { - version: 1, + version: 2, provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', secrets: { anthropic: { ciphertext: 'enc' }, }, @@ -81,10 +76,9 @@ describe('toProviderRows', () => { describe('assertProviderHasStoredSecret', () => { it('throws when activating a provider without a stored API key', () => { const cfg: Config = { - version: 1, + version: 2, provider: 'openai', modelPrimary: 'gpt-4o', - modelFast: 'gpt-4o-mini', secrets: { openai: { ciphertext: 'ciphertext' }, }, @@ -98,10 +92,9 @@ describe('assertProviderHasStoredSecret', () => { describe('computeDeleteProviderResult', () => { it('switches to the next provider default models when the active provider is deleted', () => { const cfg: Config = { - version: 1, + version: 2, provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', secrets: { anthropic: { ciphertext: 'enc-ant' }, openai: { ciphertext: 'enc-oai' }, @@ -113,15 +106,13 @@ describe('computeDeleteProviderResult', () => { expect(result.nextActive).toBe('openai'); expect(result.modelPrimary).toBe('gpt-4o'); - expect(result.modelFast).toBe('gpt-4o-mini'); }); it('keeps existing models when a non-active provider is deleted', () => { const cfg: Config = { - version: 1, + version: 2, provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', secrets: { anthropic: { ciphertext: 'enc-ant' }, openai: { ciphertext: 'enc-oai' }, @@ -133,15 +124,13 @@ describe('computeDeleteProviderResult', () => { expect(result.nextActive).toBe('anthropic'); expect(result.modelPrimary).toBe('claude-sonnet-4-6'); - expect(result.modelFast).toBe('claude-haiku-3'); }); it('returns nextActive null and empty models when the last provider is deleted', () => { const cfg: Config = { - version: 1, + version: 2, provider: 'openai', modelPrimary: 'gpt-4o', - modelFast: 'gpt-4o-mini', secrets: { openai: { ciphertext: 'enc-oai' }, }, @@ -152,16 +141,14 @@ describe('computeDeleteProviderResult', () => { expect(result.nextActive).toBeNull(); expect(result.modelPrimary).toBe(''); - expect(result.modelFast).toBe(''); }); }); describe('resolveActiveModel', () => { const baseCfg: Config = { - version: 1, + version: 2, provider: 'openrouter', modelPrimary: 'anthropic/claude-sonnet-4.6', - modelFast: 'anthropic/claude-haiku-3', secrets: { openai: { ciphertext: 'enc-oai' }, openrouter: { ciphertext: 'enc-or' }, @@ -248,7 +235,6 @@ describe('resolveActiveModel', () => { ...baseCfg, provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', }; expect(() => resolveActiveModel(cfg, { provider: 'anthropic', modelId: 'claude-sonnet-4-6' }), diff --git a/apps/desktop/src/main/provider-settings.ts b/apps/desktop/src/main/provider-settings.ts index 21c68ac..c08316a 100644 --- a/apps/desktop/src/main/provider-settings.ts +++ b/apps/desktop/src/main/provider-settings.ts @@ -27,12 +27,10 @@ export function getAddProviderDefaults( input: { provider: SupportedOnboardingProvider; modelPrimary: string; - modelFast: string; }, ): { activeProvider: SupportedOnboardingProvider; modelPrimary: string; - modelFast: string; } { if ( cfg === null || @@ -42,7 +40,6 @@ export function getAddProviderDefaults( return { activeProvider: input.provider, modelPrimary: input.modelPrimary, - modelFast: input.modelFast, }; } const activeProvider: SupportedOnboardingProvider = cfg.provider; @@ -50,7 +47,6 @@ export function getAddProviderDefaults( return { activeProvider, modelPrimary: cfg.modelPrimary, - modelFast: cfg.modelFast, }; } @@ -100,7 +96,6 @@ export interface DeleteProviderResult { /** null means tombstone: all providers removed, onboarding should re-run. */ nextActive: SupportedOnboardingProvider | null; modelPrimary: string; - modelFast: string; } /** @@ -117,7 +112,7 @@ export function computeDeleteProviderResult( .filter(isSupportedOnboardingProvider); if (remaining.length === 0) { - return { nextActive: null, modelPrimary: '', modelFast: '' }; + return { nextActive: null, modelPrimary: '' }; } const keepCurrent = cfg.provider !== toDelete && isSupportedOnboardingProvider(cfg.provider); @@ -125,17 +120,15 @@ export function computeDeleteProviderResult( ? (cfg.provider as SupportedOnboardingProvider) : (remaining[0] as SupportedOnboardingProvider); - // Only reset models when the active provider is the one being deleted. if (cfg.provider === toDelete) { const defaults = PROVIDER_SHORTLIST[nextActive]; return { nextActive, modelPrimary: defaults.defaultPrimary, - modelFast: defaults.defaultFast, }; } - return { nextActive, modelPrimary: cfg.modelPrimary, modelFast: cfg.modelFast }; + return { nextActive, modelPrimary: cfg.modelPrimary }; } /** @@ -175,10 +168,8 @@ export function resolveActiveModel( ); } const overridden = cfg.provider !== hint.provider; - // When the hint's provider matches active, trust the modelId (lets the - // renderer pick between primary and fast). When it doesn't match, the - // hint's modelId likely belongs to the wrong provider's catalog, so snap - // to the active provider's primary model to keep the call coherent. + // When the hint's provider doesn't match active, snap to the active + // provider's primary model to keep the call coherent. const modelId = overridden ? cfg.modelPrimary : hint.modelId; return { model: { provider: cfg.provider, modelId }, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 86c19a5..8e72d60 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -142,7 +142,6 @@ const api = { provider: SupportedOnboardingProvider; apiKey: string; modelPrimary: string; - modelFast: string; baseUrl?: string; }) => ipcRenderer.invoke('onboarding:save-key', input) as Promise, skip: () => ipcRenderer.invoke('onboarding:skip') as Promise, @@ -153,7 +152,6 @@ const api = { provider: SupportedOnboardingProvider; apiKey: string; modelPrimary: string; - modelFast: string; baseUrl?: string; }) => ipcRenderer.invoke('settings:v1:add-provider', input) as Promise, deleteProvider: (provider: SupportedOnboardingProvider) => @@ -161,7 +159,6 @@ const api = { setActiveProvider: (input: { provider: SupportedOnboardingProvider; modelPrimary: string; - modelFast: string; }) => ipcRenderer.invoke('settings:v1:set-active-provider', input) as Promise, getPaths: () => ipcRenderer.invoke('settings:v1:get-paths') as Promise, openFolder: (path: string) => @@ -177,6 +174,19 @@ const api = { ValidateKeyResult | ValidateKeyError >, }, + config: { + setProviderAndModels: (input: { + provider: SupportedOnboardingProvider; + apiKey: string; + modelPrimary: string; + baseUrl?: string; + setAsActive: boolean; + }) => + ipcRenderer.invoke('config:v1:set-provider-and-models', { + schemaVersion: 1, + ...input, + }) as Promise, + }, preferences: { get: () => ipcRenderer.invoke('preferences:v1:get') as Promise, update: (patch: Partial) => diff --git a/apps/desktop/src/renderer/src/components/Settings.test.ts b/apps/desktop/src/renderer/src/components/Settings.test.ts index c4f63d1..5f20ac3 100644 --- a/apps/desktop/src/renderer/src/components/Settings.test.ts +++ b/apps/desktop/src/renderer/src/components/Settings.test.ts @@ -44,7 +44,6 @@ describe('applyValidateResult', () => { apiKey: 'sk-ant-original', baseUrl: '', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', validating: true, error: null, errorCode: null, diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index 05d3d81..c86cc53 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -179,7 +179,6 @@ interface AddProviderFormState { apiKey: string; baseUrl: string; modelPrimary: string; - modelFast: string; validating: boolean; error: string | null; errorCode: ErrorCode | null; @@ -193,7 +192,6 @@ function makeDefaultForm(provider: SupportedOnboardingProvider): AddProviderForm apiKey: '', baseUrl: '', modelPrimary: sl.defaultPrimary, - modelFast: sl.defaultFast, validating: false, error: null, errorCode: null, @@ -323,13 +321,20 @@ function AddProviderModal({ if (!window.codesign) return; try { const trimmedUrl = form.baseUrl.trim(); - const rows = await window.codesign.settings.addProvider({ + // Mirror the legacy add-provider semantics: only flip the active + // provider when nothing is configured yet. Adding a backup provider + // from Settings should NOT route subsequent generations away from the + // user's current choice. + const current = await window.codesign.onboarding.getState(); + const setAsActive = !current.hasKey; + await window.codesign.config.setProviderAndModels({ provider: form.provider, apiKey: form.apiKey.trim(), modelPrimary: form.modelPrimary, - modelFast: form.modelFast, ...(trimmedUrl.length > 0 ? { baseUrl: trimmedUrl } : {}), + setAsActive, }); + const rows = await window.codesign.settings.listProviders(); onSave(rows); } catch (err) { setForm((prev) => ({ @@ -341,7 +346,6 @@ function AddProviderModal({ const sl = SHORTLIST[form.provider]; const primaryOptions = sl.primary.map((m) => ({ value: m, label: m })); - const fastOptions = sl.fast.map((m) => ({ value: m, label: m })); const canSave = canSaveProvider(form); return ( @@ -476,27 +480,15 @@ function AddProviderModal({ /> -
-
-

- {t('settings.providers.modal.primaryModel')} -

- setField('modelPrimary', v)} - options={primaryOptions} - /> -
-
-

- {t('settings.providers.modal.fastModel')} -

- setField('modelFast', v)} - options={fastOptions} - /> -
+
+

+ {t('settings.providers.modal.primaryModel')} +

+ setField('modelPrimary', v)} + options={primaryOptions} + />
@@ -641,18 +633,15 @@ function ActiveModelSelector({ const t = useT(); const sl = SHORTLIST[provider]; const primaryOptions = sl.primary.map((m) => ({ value: m, label: m })); - const fastOptions = sl.fast.map((m) => ({ value: m, label: m })); const setConfig = useCodesignStore((s) => s.completeOnboarding); const pushToast = useCodesignStore((s) => s.pushToast); const [primary, setPrimary] = useState(config.modelPrimary ?? sl.defaultPrimary); - const [fast, setFast] = useState(config.modelFast ?? sl.defaultFast); const saveTimeout = useRef | null>(null); useEffect(() => { setPrimary(config.modelPrimary ?? sl.defaultPrimary); - setFast(config.modelFast ?? sl.defaultFast); - }, [config.modelPrimary, config.modelFast, sl.defaultPrimary, sl.defaultFast]); + }, [config.modelPrimary, sl.defaultPrimary]); useEffect(() => { return () => { @@ -663,13 +652,12 @@ function ActiveModelSelector({ }; }, []); - async function save(p: string, f: string) { + async function save(p: string) { if (!window.codesign) return; try { const next = await window.codesign.settings.setActiveProvider({ provider, modelPrimary: p, - modelFast: f, }); setConfig(next); } catch (err) { @@ -684,29 +672,15 @@ function ActiveModelSelector({ function handlePrimaryChange(v: string) { setPrimary(v); if (saveTimeout.current !== null) clearTimeout(saveTimeout.current); - saveTimeout.current = setTimeout(() => void save(v, fast), 400); - } - - function handleFastChange(v: string) { - setFast(v); - if (saveTimeout.current !== null) clearTimeout(saveTimeout.current); - saveTimeout.current = setTimeout(() => void save(primary, v), 400); + saveTimeout.current = setTimeout(() => void save(v), 400); } return ( -
-
-

- {t('settings.providers.primary')} -

- -
-
-

- {t('settings.providers.fast')} -

- -
+
+

+ {t('settings.providers.primary')} +

+
); } @@ -760,7 +734,6 @@ function ModelsTab() { const next = await window.codesign.settings.setActiveProvider({ provider, modelPrimary: sl.defaultPrimary, - modelFast: sl.defaultFast, }); setConfig(next); const updatedRows = await window.codesign.settings.listProviders(); @@ -778,10 +751,26 @@ function ModelsTab() { } } - function handleAddSave(nextRows: ProviderRow[]) { + async function handleAddSave(nextRows: ProviderRow[]) { setRows(nextRows); setShowAdd(false); setReEnterProvider(null); + // Sync Zustand so TopBar (and any other config-bound surface) reflects + // the freshly-added provider immediately. Without this, the active + // provider/model display can lag until a manual reload. + if (window.codesign) { + try { + const state = await window.codesign.onboarding.getState(); + setConfig(state); + } catch (err) { + pushToast({ + variant: 'error', + title: t('settings.providers.toast.modelSaveFailed'), + description: err instanceof Error ? err.message : t('settings.common.unknownError'), + }); + return; + } + } pushToast({ variant: 'success', title: t('settings.providers.toast.saved') }); } diff --git a/apps/desktop/src/renderer/src/connection-status.test.ts b/apps/desktop/src/renderer/src/connection-status.test.ts index e6635dd..3c9e291 100644 --- a/apps/desktop/src/renderer/src/connection-status.test.ts +++ b/apps/desktop/src/renderer/src/connection-status.test.ts @@ -7,7 +7,6 @@ const READY_CONFIG: OnboardingState = { hasKey: true, provider: 'anthropic', modelPrimary: 'claude-sonnet-4-6', - modelFast: 'claude-haiku-3', baseUrl: null, designSystem: null, }; diff --git a/apps/desktop/src/renderer/src/onboarding/ChooseModel.tsx b/apps/desktop/src/renderer/src/onboarding/ChooseModel.tsx index e4d7944..0b43b5b 100644 --- a/apps/desktop/src/renderer/src/onboarding/ChooseModel.tsx +++ b/apps/desktop/src/renderer/src/onboarding/ChooseModel.tsx @@ -11,7 +11,7 @@ interface ChooseModelProps { baseUrl: string | null; saving: boolean; errorMessage: string | null; - onConfirm: (modelPrimary: string, modelFast: string) => void; + onConfirm: (modelPrimary: string) => void; onBack: () => void; } @@ -28,22 +28,16 @@ export function ChooseModel({ const shortlist = PROVIDER_SHORTLIST[provider]; const useFreeTierDefaults = provider === 'openrouter' && preferFreeTier; const primaryOptions = withFreeTierSuggestion(shortlist.primary, useFreeTierDefaults); - const fastOptions = withFreeTierSuggestion(shortlist.fast, useFreeTierDefaults); const [modelPrimary, setModelPrimary] = useState( getDefaultModel(shortlist.defaultPrimary, useFreeTierDefaults), ); - const [modelFast, setModelFast] = useState( - getDefaultModel(shortlist.defaultFast, useFreeTierDefaults), - ); useEffect(() => { setModelPrimary(getDefaultModel(shortlist.defaultPrimary, useFreeTierDefaults)); - setModelFast(getDefaultModel(shortlist.defaultFast, useFreeTierDefaults)); - }, [shortlist.defaultPrimary, shortlist.defaultFast, useFreeTierDefaults]); + }, [shortlist.defaultPrimary, useFreeTierDefaults]); const trimmedPrimary = modelPrimary.trim(); - const trimmedFast = modelFast.trim(); - const canFinish = trimmedPrimary.length > 0 && trimmedFast.length > 0 && !saving; + const canFinish = trimmedPrimary.length > 0 && !saving; return (
@@ -67,17 +61,6 @@ export function ChooseModel({ options={primaryOptions} onChange={setModelPrimary} /> -

{baseUrl !== null ? ( @@ -114,7 +97,7 @@ export function ChooseModel({