From cbe72cacb657b7fef505db64949f1176f3a1401a Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 20 Apr 2026 00:45:35 +0800 Subject: [PATCH 1/3] refactor(desktop): unify API config IPC + drop the separate fast model slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cleanups shipped together because both reshape the API config surface: 1. Canonical IPC `config:v1:set-provider-and-models` atomically writes provider + key + model + activate flag and returns full OnboardingState. Existing onboarding:save-key, settings:v1:add-provider, and settings:v1:set-active-provider become thin delegates so back-compat is preserved. Settings → AddProviderModal now syncs Zustand after add — fixes the top-bar pill staleness bug. 2. Drop modelFast field. It was used in exactly one place (applyComment fallback) and indistinguishable from modelPrimary for users, which is why the UI confused everyone with two dropdowns. Schema bumps to v2; v1 configs are accepted and the dead field is dropped on next write. applyComment now falls back to modelPrimary. UI: AddProviderModal / ChooseModel / ModelsTab drop the "fast" dropdown. 337 desktop tests pass. Typecheck + biome clean. Signed-off-by: hqhq1025 <1506751656@qq.com> --- .changeset/unify-api-config.md | 28 ++++ apps/desktop/src/main/index.ts | 2 +- apps/desktop/src/main/onboarding-ipc.test.ts | 42 +++++- apps/desktop/src/main/onboarding-ipc.ts | 139 +++++++++++------- .../src/main/provider-settings.test.ts | 30 +--- apps/desktop/src/main/provider-settings.ts | 17 +-- apps/desktop/src/preload/index.ts | 16 +- .../renderer/src/components/Settings.test.ts | 1 - .../src/renderer/src/components/Settings.tsx | 91 +++++------- .../renderer/src/connection-status.test.ts | 1 - .../renderer/src/onboarding/ChooseModel.tsx | 25 +--- .../src/renderer/src/onboarding/index.tsx | 3 +- .../src/store.generationStage.test.ts | 1 - apps/desktop/src/renderer/src/store.test.ts | 2 - packages/shared/src/config.ts | 19 +-- 15 files changed, 226 insertions(+), 191 deletions(-) create mode 100644 .changeset/unify-api-config.md diff --git a/.changeset/unify-api-config.md b/.changeset/unify-api-config.md new file mode 100644 index 00000000..9f1a2a78 --- /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 12798a64..ab36e7ed 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 36700da7..3741dee9 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 101fe7ed..f2efe783 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 1b83070a..b09e3327 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 21c68acc..c08316a9 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 86c19a5f..8e72d60a 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 c4f63d10..5f20ac38 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 05d3d817..d6267179 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,19 @@ function AddProviderModal({ if (!window.codesign) return; try { const trimmedUrl = form.baseUrl.trim(); - const rows = await window.codesign.settings.addProvider({ + // Mirror the onboarding flow: when the user adds a brand-new provider + // through Settings, treat it as the active choice unless one already + // exists on disk. The legacy `addProvider` path returned ProviderRow[] + // but never synced the Zustand store; the canonical IPC returns the + // full state, and we re-read provider rows below for table rendering. + await window.codesign.config.setProviderAndModels({ provider: form.provider, apiKey: form.apiKey.trim(), modelPrimary: form.modelPrimary, - modelFast: form.modelFast, ...(trimmedUrl.length > 0 ? { baseUrl: trimmedUrl } : {}), + setAsActive: true, }); + const rows = await window.codesign.settings.listProviders(); onSave(rows); } catch (err) { setForm((prev) => ({ @@ -341,7 +345,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 +479,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 +632,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 +651,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 +671,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 +733,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 +750,21 @@ 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 { + // Best-effort sync — toast already fired above. + } + } 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 e6635dd6..3c9e2919 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 e4d79449..0b43b5b0 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({