Skip to content

Commit 10f8525

Browse files
committed
AI API key: debounce save 300 ms
- `SettingPasswordInput.onchange` fires per keystroke, so typing a key by hand previously fired one secret-store write per character (one D-Bus round trip per character on Linux Secret Service). Paste is unaffected since it arrives as a single `oninput`. - Debounce window 300 ms — long enough to coalesce a burst of keystrokes, short enough to feel instantaneous after the user pauses. Sits well under the connection-check debounce (1000 ms) so the order stays type → save → check. - Flush on unmount and on provider switch so a pending save isn't silently dropped when the user closes Settings or picks a different provider mid-type. The captured `providerId` ensures a switch-during-typing commits to the OLD provider's keychain entry, not the new one. - No tests directly cover `handleApiKeyChange`; existing checks (clippy, svelte-check, stylelint, eslint) all green.
1 parent bd0b7a9 commit 10f8525

1 file changed

Lines changed: 46 additions & 6 deletions

File tree

apps/desktop/src/lib/settings/sections/AiCloudSection.svelte

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@
5151
let secretError = $state<SecretErrorMessage | null>(null)
5252
const secretErrorToastId = 'ai-secret-store-error'
5353
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+
5464
// Model combobox state
5565
let comboboxOpen = $state(false)
5666
let comboboxFilter = $state('')
@@ -72,6 +82,10 @@
7282
7383
// Subscribe to cloud provider changes
7484
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()
7589
cloudProviderId = newValue
7690
void loadCloudProviderConfig(cloudProviderId)
7791
void pushConfigToBackend()
@@ -85,6 +99,9 @@
8599
unlistenFns.push(unsubCloudConfigs)
86100
87101
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()
88105
for (const fn of unlistenFns) {
89106
fn()
90107
}
@@ -199,19 +216,44 @@
199216
}
200217
}
201218
202-
async function handleApiKeyChange(value: string): Promise<void> {
219+
function handleApiKeyChange(value: string): void {
203220
// Reflect the typed value locally so the input stays in sync regardless of save outcome.
204221
currentApiKey = value
205222
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> {
206246
try {
207-
await saveAiApiKey(cloudProviderId, value)
247+
await saveAiApiKey(providerId, value)
208248
} catch (e) {
209249
// Failed to persist — surface it visibly and SKIP pushing config + scheduling the
210250
// connection check. The in-memory value would mislead the user into thinking it worked.
211251
setSecretError(describeSecretError(e, 'save'))
212252
return
213253
}
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
215257
void pushConfigToBackend()
216258
scheduleConnectionCheck()
217259
}
@@ -375,9 +417,7 @@
375417
placeholder={apiKeyPlaceholder}
376418
ariaLabel="API key"
377419
value={currentApiKey}
378-
onchange={(value: string) => {
379-
void handleApiKeyChange(value)
380-
}}
420+
onchange={handleApiKeyChange}
381421
/>
382422
</SettingRow>
383423
{/if}

0 commit comments

Comments
 (0)