|
51 | 51 | let secretError = $state<SecretErrorMessage | null>(null) |
52 | 52 | const secretErrorToastId = 'ai-secret-store-error' |
53 | 53 |
|
| 54 | + // Debounce API key saves so manual typing doesn't fire one secret-store write per keystroke |
| 55 | + // (especially relevant on Linux where every Secret Service call is a D-Bus round trip). |
| 56 | + // Paste arrives as a single oninput so it sees no added latency. 300 ms is short enough that |
| 57 | + // the save feels instantaneous after the user pauses, and well under the connection-check |
| 58 | + // debounce (1000 ms) so the order is: type → save → check. |
| 59 | + const API_KEY_SAVE_DEBOUNCE_MS = 300 |
| 60 | + let apiKeySaveTimer: ReturnType<typeof setTimeout> | null = null |
| 61 | + // Captured at schedule time so a switch-provider-mid-typing flushes against the right key. |
| 62 | + let pendingApiKeySave: { providerId: string; value: string } | null = null |
| 63 | +
|
54 | 64 | // Model combobox state |
55 | 65 | let comboboxOpen = $state(false) |
56 | 66 | let comboboxFilter = $state('') |
|
72 | 82 |
|
73 | 83 | // Subscribe to cloud provider changes |
74 | 84 | const unsubCloudProvider = onSpecificSettingChange('ai.cloudProvider', (_id, newValue) => { |
| 85 | + // Commit any in-flight typing to the OLD provider's keychain entry before we switch — |
| 86 | + // otherwise the pending save would silently target the wrong provider after `cloudProviderId` |
| 87 | + // changes below. |
| 88 | + flushPendingApiKeySave() |
75 | 89 | cloudProviderId = newValue |
76 | 90 | void loadCloudProviderConfig(cloudProviderId) |
77 | 91 | void pushConfigToBackend() |
|
85 | 99 | unlistenFns.push(unsubCloudConfigs) |
86 | 100 |
|
87 | 101 | onDestroy(() => { |
| 102 | + // Flush any in-flight typing before tearing down — closing Settings (or navigating to a |
| 103 | + // different section) shouldn't drop a key the user already typed. |
| 104 | + flushPendingApiKeySave() |
88 | 105 | for (const fn of unlistenFns) { |
89 | 106 | fn() |
90 | 107 | } |
|
199 | 216 | } |
200 | 217 | } |
201 | 218 |
|
202 | | - async function handleApiKeyChange(value: string): Promise<void> { |
| 219 | + function handleApiKeyChange(value: string): void { |
203 | 220 | // Reflect the typed value locally so the input stays in sync regardless of save outcome. |
204 | 221 | currentApiKey = value |
205 | 222 | clearSecretError() |
| 223 | + // Capture the provider at schedule time. If the user switches providers before the timer |
| 224 | + // fires, the trailing keystroke from the previous provider still targets the right entry. |
| 225 | + pendingApiKeySave = { providerId: cloudProviderId, value } |
| 226 | + if (apiKeySaveTimer) clearTimeout(apiKeySaveTimer) |
| 227 | + apiKeySaveTimer = setTimeout(() => { |
| 228 | + const pending = pendingApiKeySave |
| 229 | + apiKeySaveTimer = null |
| 230 | + pendingApiKeySave = null |
| 231 | + if (pending) void persistApiKey(pending.providerId, pending.value) |
| 232 | + }, API_KEY_SAVE_DEBOUNCE_MS) |
| 233 | + } |
| 234 | +
|
| 235 | + /** Immediately commit any pending API key save. Idempotent — safe to call when nothing's queued. */ |
| 236 | + function flushPendingApiKeySave(): void { |
| 237 | + if (!apiKeySaveTimer || !pendingApiKeySave) return |
| 238 | + clearTimeout(apiKeySaveTimer) |
| 239 | + const pending = pendingApiKeySave |
| 240 | + apiKeySaveTimer = null |
| 241 | + pendingApiKeySave = null |
| 242 | + void persistApiKey(pending.providerId, pending.value) |
| 243 | + } |
| 244 | +
|
| 245 | + async function persistApiKey(providerId: string, value: string): Promise<void> { |
206 | 246 | try { |
207 | | - await saveAiApiKey(cloudProviderId, value) |
| 247 | + await saveAiApiKey(providerId, value) |
208 | 248 | } catch (e) { |
209 | 249 | // Failed to persist — surface it visibly and SKIP pushing config + scheduling the |
210 | 250 | // connection check. The in-memory value would mislead the user into thinking it worked. |
211 | 251 | setSecretError(describeSecretError(e, 'save')) |
212 | 252 | return |
213 | 253 | } |
214 | | - // Push the new key to the backend so suggestions and the connection check both see it. |
| 254 | + // Only sync the backend if the user is still on this provider. Otherwise the new |
| 255 | + // provider's pushConfigToBackend (triggered by the switch) is the authoritative push. |
| 256 | + if (providerId !== cloudProviderId) return |
215 | 257 | void pushConfigToBackend() |
216 | 258 | scheduleConnectionCheck() |
217 | 259 | } |
|
375 | 417 | placeholder={apiKeyPlaceholder} |
376 | 418 | ariaLabel="API key" |
377 | 419 | value={currentApiKey} |
378 | | - onchange={(value: string) => { |
379 | | - void handleApiKeyChange(value) |
380 | | - }} |
| 420 | + onchange={handleApiKeyChange} |
381 | 421 | /> |
382 | 422 | </SettingRow> |
383 | 423 | {/if} |
|
0 commit comments