privateboard 0.1.32 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/version.d.ts CHANGED
@@ -12,6 +12,6 @@
12
12
  * number ends up surfaced in the user-facing footer or banner. Keep
13
13
  * this file as the canonical source — every callsite reads from here.
14
14
  */
15
- declare const VERSION = "0.1.32";
15
+ declare const VERSION = "0.1.37";
16
16
 
17
17
  export { VERSION };
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/version.ts
4
- var VERSION = "0.1.32";
4
+ var VERSION = "0.1.37";
5
5
  export {
6
6
  VERSION
7
7
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.32\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
1
+ {"version":3,"sources":["../src/version.ts"],"sourcesContent":["/**\n * Single source of truth for the app version.\n *\n * Imported by `cli.ts` (CLI banner / `--version`), `server.ts` (the\n * `/health` payload + the `/api/version` endpoint), and bundled into\n * the frontend via the version endpoint. Bump alongside `package.json`\n * on every release — the existing `npm version <patch|minor|major>`\n * + commit pattern updates package.json automatically; this file\n * needs the matching manual bump.\n *\n * If two strings drift (bumped one but not the other), the wrong\n * number ends up surfaced in the user-facing footer or banner. Keep\n * this file as the canonical source — every callsite reads from here.\n */\nexport const VERSION = \"0.1.37\";\n"],"mappings":";;;AAcO,IAAM,UAAU;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateboard",
3
- "version": "0.1.32",
3
+ "version": "0.1.37",
4
4
  "description": "PrivateBoard · your private board meeting, on call. Local-first, multi-agent thinking amplifier.",
5
5
  "type": "module",
6
6
  "main": "electron-entry.cjs",
@@ -30,7 +30,8 @@
30
30
  "electron:build-ts": "tsup && tsc -p electron/tsconfig.json",
31
31
  "electron:build-icon": "node scripts/build-app-icon.mjs",
32
32
  "electron:dev": "npm run electron:rebuild && npm run electron:build-ts && electron .",
33
- "electron:dist": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish always",
33
+ "electron:dist": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish always && npm run release:mac:finalize",
34
+ "release:mac:finalize": "node scripts/finalize-mac-release.mjs",
34
35
  "electron:dist:local": "electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && electron-builder --mac --publish never",
35
36
  "electron:dist:unsigned": "CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder install-app-deps && npm run electron:build-ts && npm run electron:build-icon && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac --publish never --config.mac.hardenedRuntime=false --config.mac.notarize=false"
36
37
  },
@@ -569,6 +569,14 @@
569
569
  accent-color: var(--lime);
570
570
  }
571
571
 
572
+ /* Custom dropdown popover (spine / house-style) · `_openRenderDropdown`
573
+ appends it to <body> with class `ap-model-picker adjourn-render-pop`.
574
+ `.ap-model-picker` defaults to z-index 9100 (agent-profile chrome),
575
+ but the adjourn / supplement modals sit at 9400 / 9500 — without
576
+ this lift, the popover renders BEHIND the modal and the user sees
577
+ the trigger open but no menu content. Pinned above both modals. */
578
+ .ap-model-picker.adjourn-render-pop { z-index: 9900; }
579
+
572
580
  /* Narrow viewport · summary rows tighten. The modal width itself
573
581
  already collapses gracefully below ~592px via the
574
582
  `min(560px, calc(100vw - 32px))` rule on .adjourn-modal. */
@@ -2507,6 +2507,52 @@
2507
2507
  .ap-model-picker-loading .ap-loading-dots i:nth-child(2) { animation-delay: 0.15s; }
2508
2508
  .ap-model-picker-loading .ap-loading-dots i:nth-child(3) { animation-delay: 0.30s; }
2509
2509
 
2510
+ /* Upstream-error banner inside the voice picker · fires when the
2511
+ catalogue fetch failed in an actionable way (missing scope on
2512
+ the API key, auth rejected, etc.). Sits at the top of the
2513
+ popover above any voice rows; the `fixUrl` CTA opens the
2514
+ provider's API key settings page in a new tab. */
2515
+ .ap-voice-picker-err {
2516
+ padding: 14px 14px 12px;
2517
+ border-bottom: 1px solid var(--line-bright);
2518
+ font-family: var(--sans);
2519
+ display: flex;
2520
+ flex-direction: column;
2521
+ gap: 6px;
2522
+ }
2523
+ .ap-voice-picker-err-title {
2524
+ font-size: 12px;
2525
+ font-weight: 600;
2526
+ color: var(--text);
2527
+ letter-spacing: -0.005em;
2528
+ }
2529
+ .ap-voice-picker-err-body {
2530
+ font-size: 11px;
2531
+ color: var(--text-soft);
2532
+ line-height: 1.45;
2533
+ }
2534
+ .ap-voice-picker-err-upstream {
2535
+ font-family: var(--mono);
2536
+ font-size: 10px;
2537
+ color: var(--text-dim);
2538
+ letter-spacing: 0;
2539
+ line-height: 1.4;
2540
+ margin-top: 2px;
2541
+ /* Long upstream messages (200 char cap) wrap rather than overflow. */
2542
+ word-break: break-word;
2543
+ }
2544
+ .ap-voice-picker-err-cta {
2545
+ margin-top: 4px;
2546
+ font-family: var(--mono);
2547
+ font-size: 11px;
2548
+ text-transform: uppercase;
2549
+ letter-spacing: 0.10em;
2550
+ color: var(--lime);
2551
+ text-decoration: none;
2552
+ align-self: flex-start;
2553
+ }
2554
+ .ap-voice-picker-err-cta:hover { color: var(--text); }
2555
+
2510
2556
  /* Provider section header · matches .cmp-dd-group · mono / faint /
2511
2557
  uppercase, hairline divider above (suppressed on the first one). */
2512
2558
  .ap-model-group {
@@ -2895,17 +2895,109 @@
2895
2895
  /** API emotion slugs mirrored in PATCH body `voice.emotion`. */
2896
2896
  const VOICE_EMOTION_VALUES = ["", "happy", "sad", "angry", "fearful", "disgusted", "surprised", "calm", "fluent"];
2897
2897
 
2898
- let voiceOptionsCache = null;
2899
- async function ensureVoiceOptions() {
2900
- if (voiceOptionsCache) return voiceOptionsCache;
2901
- try {
2902
- const r = await fetch("/api/voices");
2903
- const j = r.ok ? await r.json() : {};
2904
- voiceOptionsCache = Array.isArray(j.voices) ? j.voices : [];
2905
- } catch {
2906
- voiceOptionsCache = [];
2898
+ /** Voice-picker pager state · drives infinite-scroll loading inside
2899
+ * the dropdown. ElevenLabs accounts can carry hundreds of voices
2900
+ * and MiniMax has dozens; rendering them all at once was visibly
2901
+ * slow on first picker open. Now we fetch one page at a time and
2902
+ * append on scroll-to-bottom. State persists across picker
2903
+ * opens/closes within a session · `invalidateVoicePager()` resets
2904
+ * it on key changes (called from `refreshAgentProfileSkills`). */
2905
+ let voicePagerState = null;
2906
+ function invalidateVoicePager() {
2907
+ voicePagerState = null;
2908
+ // Drop the in-flight reference too · the old fetch is still running
2909
+ // but it'll write into the (now orphaned) old state object and the
2910
+ // result is silently discarded. Clearing the reference lets the
2911
+ // next caller start a fresh fetch against the new state instead of
2912
+ // awaiting a promise that will populate the wrong object.
2913
+ voicePageInFlight = null;
2914
+ }
2915
+ function getVoicePagerState() {
2916
+ if (!voicePagerState) {
2917
+ voicePagerState = {
2918
+ voices: [],
2919
+ cursor: null,
2920
+ hasMore: true,
2921
+ loading: false,
2922
+ initialised: false,
2923
+ provider: null,
2924
+ configured: false,
2925
+ // Structured upstream error from the catalogue fetch · null
2926
+ // on success / before first call. When present, the picker
2927
+ // renders a banner with the title/body keys and an optional
2928
+ // CTA link instead of (or above) the voice rows.
2929
+ error: null,
2930
+ };
2907
2931
  }
2908
- return voiceOptionsCache;
2932
+ return voicePagerState;
2933
+ }
2934
+ /** Fetch the next page of voices and append to the pager. Idempotent
2935
+ * · concurrent callers SHARE the in-flight promise so the second
2936
+ * call resolves with the same result instead of short-circuiting
2937
+ * to `false`. That sharing was the missing piece: previously the
2938
+ * prefetch fired by `renderVoiceBlock` could be in-flight when the
2939
+ * user clicked the picker; the second `fetchNextVoicePage` saw
2940
+ * `state.loading` and returned immediately, so the picker rendered
2941
+ * an empty list with a loading sentinel and never refreshed once
2942
+ * the prefetch landed. Returns true when new voices were appended. */
2943
+ let voicePageInFlight = null;
2944
+ async function fetchNextVoicePage(pageSize) {
2945
+ if (voicePageInFlight) return voicePageInFlight;
2946
+ const state = getVoicePagerState();
2947
+ if (state.initialised && !state.hasMore) return false;
2948
+ state.loading = true;
2949
+ voicePageInFlight = (async () => {
2950
+ try {
2951
+ const url = new URL("/api/voices", window.location.origin);
2952
+ url.searchParams.set("pageSize", String(pageSize || 30));
2953
+ if (state.cursor) url.searchParams.set("cursor", state.cursor);
2954
+ const r = await fetch(url.toString());
2955
+ const j = r.ok ? await r.json() : {};
2956
+ const newVoices = Array.isArray(j.voices) ? j.voices : [];
2957
+ // De-dupe by (provider | model | voiceId) in case the cursor
2958
+ // round-trip races with a key swap that triggered a reset
2959
+ // mid-fetch. Without this a re-emitted first page would land
2960
+ // on top of an already-rendered first page.
2961
+ const seen = new Set(
2962
+ state.voices.map((v) => `${v.provider}|${v.model || ""}|${v.voiceId || ""}`),
2963
+ );
2964
+ for (const v of newVoices) {
2965
+ const id = `${v.provider}|${v.model || ""}|${v.voiceId || ""}`;
2966
+ if (!seen.has(id)) {
2967
+ state.voices.push(v);
2968
+ seen.add(id);
2969
+ }
2970
+ }
2971
+ state.cursor = typeof j.nextCursor === "string" ? j.nextCursor : null;
2972
+ state.hasMore = !!j.hasMore;
2973
+ state.provider = typeof j.provider === "string" ? j.provider : null;
2974
+ state.configured = !!j.configured;
2975
+ // Structured upstream error · forwarded as-is from the server.
2976
+ // null / undefined means the fetch succeeded (even if 0 voices
2977
+ // returned · empty + no error is a real "you have no voices"
2978
+ // state, distinct from "fetch failed").
2979
+ state.error = (j.error && typeof j.error === "object") ? j.error : null;
2980
+ state.initialised = true;
2981
+ return newVoices.length > 0;
2982
+ } catch {
2983
+ state.hasMore = false;
2984
+ state.initialised = true;
2985
+ return false;
2986
+ } finally {
2987
+ state.loading = false;
2988
+ voicePageInFlight = null;
2989
+ }
2990
+ })();
2991
+ return voicePageInFlight;
2992
+ }
2993
+ /** Convenience · matches the old `ensureVoiceOptions()` shape so
2994
+ * the renderVoiceBlock prefetch can stay a single-line call. Just
2995
+ * primes the first page. */
2996
+ async function ensureVoiceOptions() {
2997
+ const state = getVoicePagerState();
2998
+ if (state.initialised) return state.voices;
2999
+ await fetchNextVoicePage(30);
3000
+ return state.voices;
2909
3001
  }
2910
3002
  function voiceForAgent(slug) {
2911
3003
  const live = window.app && window.app.agentsById ? window.app.agentsById[slug] : null;
@@ -3073,26 +3165,119 @@
3073
3165
  }
3074
3166
  }
3075
3167
 
3168
+ /** Build the upstream-error banner shown inside the voice picker
3169
+ * when the catalogue fetch failed in an actionable way (e.g. the
3170
+ * ElevenLabs API key is missing the `voices_read` scope). Returns
3171
+ * empty string when there's no error · callers can skip the
3172
+ * banner. Includes a CTA link to the provider's settings page
3173
+ * when the structured error carries a `fixUrl`. */
3174
+ function voicePickerErrorHtml(error) {
3175
+ if (!error || typeof error !== "object" || typeof error.code !== "string") return "";
3176
+ // i18n keys per error code · falls back to the generic
3177
+ // fetch-failed copy when an unknown code lands so a future
3178
+ // server-side addition still renders something sensible.
3179
+ const titleKey = `ap_voice_err_${error.code}_title`;
3180
+ const bodyKey = `ap_voice_err_${error.code}_body`;
3181
+ const title = uiT(titleKey) === titleKey ? uiT("ap_voice_err_fetch_failed_title") : uiT(titleKey);
3182
+ const body = uiT(bodyKey) === bodyKey ? uiT("ap_voice_err_fetch_failed_body") : uiT(bodyKey);
3183
+ const upstream = typeof error.message === "string" && error.message.trim()
3184
+ ? error.message.trim().slice(0, 200)
3185
+ : "";
3186
+ const cta = typeof error.fixUrl === "string" && error.fixUrl.startsWith("https://")
3187
+ ? `<a href="${escape(error.fixUrl)}" target="_blank" rel="noopener" class="ap-voice-picker-err-cta">${escape(uiT("ap_voice_err_fix_cta"))}</a>`
3188
+ : "";
3189
+ return `
3190
+ <div class="ap-voice-picker-err" role="alert">
3191
+ <div class="ap-voice-picker-err-title">${escape(title)}</div>
3192
+ <div class="ap-voice-picker-err-body">${escape(body)}</div>
3193
+ ${upstream ? `<div class="ap-voice-picker-err-upstream">${escape(upstream)}</div>` : ""}
3194
+ ${cta}
3195
+ </div>`;
3196
+ }
3197
+
3198
+ /** Render the picker's body from the current pager state · used by
3199
+ * both the initial open and every infinite-scroll append. Idempotent
3200
+ * rebuild (full innerHTML rewrite) so we don't have to track which
3201
+ * rows we've already mounted; the popover is small enough that
3202
+ * repaint cost is negligible compared to a scroll-position-preserving
3203
+ * partial update. Returns the trailing sentinel element (loading
3204
+ * indicator or end marker) so the scroll handler can keep its
3205
+ * reference stable across repaints. */
3206
+ function renderVoicePickerBody(pop, slug) {
3207
+ const state = getVoicePagerState();
3208
+ const current = voiceForAgent(slug);
3209
+ const voices = state.voices;
3210
+
3211
+ if (voices.length === 0 && state.initialised) {
3212
+ // Structured upstream error · most common case is the
3213
+ // ElevenLabs `voices_read` permission being missing on the API
3214
+ // key. Render a clear banner with the fix CTA instead of the
3215
+ // generic "no provider configured" fallback so the user knows
3216
+ // exactly what to do.
3217
+ const errHtml = voicePickerErrorHtml(state.error);
3218
+ if (errHtml) {
3219
+ pop.innerHTML = errHtml;
3220
+ return;
3221
+ }
3222
+ pop.innerHTML = `<div class="ap-model-group">${escape(uiT("ap_voice_no_provider"))}</div>`;
3223
+ return;
3224
+ }
3225
+
3226
+ const groups = [];
3227
+ // Error banner above the voice rows · fires when the catalogue
3228
+ // fetch errored but the picker still has at least one fallback
3229
+ // voice (e.g. browser default) so we don't take over the whole
3230
+ // popover. Same treatment as the empty-state error.
3231
+ const errBannerHtml = voicePickerErrorHtml(state.error);
3232
+ if (errBannerHtml) groups.push(errBannerHtml);
3233
+ let last = null;
3234
+ for (const v of voices) {
3235
+ const provider = String(v.provider || "browser");
3236
+ if (provider !== last) {
3237
+ groups.push(`<div class="ap-model-group">${escape(provider)}</div>`);
3238
+ last = provider;
3239
+ }
3240
+ const id = [provider, v.model || "", v.voiceId || ""].join("|");
3241
+ const active = current && current.provider === provider && current.model === v.model && current.voiceId === v.voiceId;
3242
+ groups.push(`
3243
+ <button type="button" class="ap-model-opt${active ? " active" : ""}" data-ap-voice-pick="${escape(id)}">
3244
+ <span class="ap-model-opt-label">${escape(v.label || v.voiceId || uiT("ap_voice_fallback_voice"))}</span>
3245
+ <span class="ap-model-opt-hint">${escape((v.model || "") + (v.language ? " · " + v.language : ""))}</span>
3246
+ </button>
3247
+ `);
3248
+ }
3249
+ // Trailing sentinel · either a "loading more" pulse (when we're
3250
+ // mid-fetch or there's known-more to load and the user just
3251
+ // scrolled into range) or nothing when the catalogue is fully
3252
+ // loaded. The scroll handler reads `data-voice-pager-sentinel`
3253
+ // to decide whether to trigger another fetch.
3254
+ if (state.hasMore) {
3255
+ groups.push(`
3256
+ <div class="ap-model-picker-loading" data-voice-pager-sentinel="loading">
3257
+ <span class="ap-loading-dots" aria-hidden="true"><i></i><i></i><i></i></span>
3258
+ <span>${escape(uiT("ap_voice_loading"))}</span>
3259
+ </div>`);
3260
+ }
3261
+ pop.innerHTML = groups.join("");
3262
+ }
3263
+
3076
3264
  async function openVoicePicker(triggerEl) {
3077
3265
  closeVoicePicker();
3078
3266
  closeEmotionPicker();
3079
3267
  const row = triggerEl.closest("[data-ap-voice-row]");
3080
3268
  const slug = row?.getAttribute("data-slug");
3081
3269
  if (!slug) return;
3082
- const current = voiceForAgent(slug);
3083
3270
 
3084
3271
  // Mount the popover shell IMMEDIATELY · the user gets visual
3085
- // confirmation of their click in the same frame. If the cache
3086
- // is cold, the skeleton holds the place while /api/voices
3087
- // round-trips; once it lands we swap content in. Without this,
3088
- // a cold cache produced "click → nothing → eventually picker"
3272
+ // confirmation of their click in the same frame. If the pager is
3273
+ // cold, the skeleton holds the place while /api/voices?pageSize=N
3274
+ // round-trips; once it lands we render the rows in. Without this,
3275
+ // a cold pager produced "click → nothing → eventually picker"
3089
3276
  // which felt unresponsive on the first open of every session.
3090
3277
  const pop = document.createElement("div");
3091
3278
  pop.id = "ap-voice-picker";
3092
3279
  pop.className = "ap-model-picker";
3093
3280
  pop.dataset.slug = slug;
3094
- // Loading row · animated dot trio + label. Visible feedback so
3095
- // users don't read a static "loading" line as a frozen popover.
3096
3281
  pop.innerHTML = `
3097
3282
  <div class="ap-model-picker-loading">
3098
3283
  <span class="ap-loading-dots" aria-hidden="true"><i></i><i></i><i></i></span>
@@ -3101,33 +3286,41 @@
3101
3286
  document.body.appendChild(pop);
3102
3287
  placePickerNearTrigger(pop, triggerEl, 280);
3103
3288
 
3104
- const voices = await ensureVoiceOptions();
3289
+ // Prime the pager if cold. Returning state is cached across
3290
+ // re-opens within the session so a user who pages, closes, then
3291
+ // reopens the picker sees the same rendered list instantly.
3292
+ if (!getVoicePagerState().initialised) {
3293
+ await fetchNextVoicePage(30);
3294
+ }
3105
3295
  // The user (or a sibling open) may have closed this picker
3106
3296
  // during the await · don't write into a detached node.
3107
3297
  if (!document.body.contains(pop)) return;
3108
3298
 
3109
- if (voices.length === 0) {
3110
- pop.innerHTML = `<div class="ap-model-group">${escape(uiT("ap_voice_no_provider"))}</div>`;
3111
- return;
3112
- }
3113
- const groups = [];
3114
- let last = null;
3115
- for (const v of voices) {
3116
- const provider = String(v.provider || "browser");
3117
- if (provider !== last) {
3118
- groups.push(`<div class="ap-model-group">${escape(provider)}</div>`);
3119
- last = provider;
3120
- }
3121
- const id = [provider, v.model || "", v.voiceId || ""].join("|");
3122
- const active = current && current.provider === provider && current.model === v.model && current.voiceId === v.voiceId;
3123
- groups.push(`
3124
- <button type="button" class="ap-model-opt${active ? " active" : ""}" data-ap-voice-pick="${escape(id)}">
3125
- <span class="ap-model-opt-label">${escape(v.label || v.voiceId || uiT("ap_voice_fallback_voice"))}</span>
3126
- <span class="ap-model-opt-hint">${escape((v.model || "") + (v.language ? " · " + v.language : ""))}</span>
3127
- </button>
3128
- `);
3129
- }
3130
- pop.innerHTML = groups.join("");
3299
+ renderVoicePickerBody(pop, slug);
3300
+
3301
+ // Infinite-scroll · when the user scrolls within 80 px of the
3302
+ // bottom AND there's more to fetch AND nothing's in flight, load
3303
+ // the next page. 80 px is one row height plus padding; firing
3304
+ // before the sentinel hits the fold means the next page is
3305
+ // landing while the user is still reading the current bottom row.
3306
+ pop.addEventListener("scroll", async () => {
3307
+ const state = getVoicePagerState();
3308
+ if (state.loading || !state.hasMore) return;
3309
+ const distanceFromBottom = pop.scrollHeight - pop.scrollTop - pop.clientHeight;
3310
+ if (distanceFromBottom > 80) return;
3311
+ const grew = await fetchNextVoicePage(30);
3312
+ // Re-render only after a successful fetch · a fetch that
3313
+ // returned no new rows (network error, race) shouldn't
3314
+ // discard the existing list. The detached-node guard means a
3315
+ // user who closed the picker mid-fetch sees no surprise repaint.
3316
+ if (!grew) return;
3317
+ if (!document.body.contains(pop)) return;
3318
+ // Preserve scroll position across the innerHTML rewrite so the
3319
+ // user's reading flow isn't yanked to the top.
3320
+ const savedScroll = pop.scrollTop;
3321
+ renderVoicePickerBody(pop, slug);
3322
+ pop.scrollTop = savedScroll;
3323
+ }, { passive: true });
3131
3324
  }
3132
3325
  function closeVoicePicker() {
3133
3326
  const el = document.getElementById("ap-voice-picker");
@@ -3214,6 +3407,12 @@
3214
3407
  method: "POST",
3215
3408
  headers: { "content-type": "application/json" },
3216
3409
  body: JSON.stringify({
3410
+ // Localised sample line · the server falls back to a
3411
+ // hardcoded Chinese default when `text` is omitted, which
3412
+ // surfaced in English / Japanese / Spanish locales as a
3413
+ // Chinese phrase being read out of the voice. Sending the
3414
+ // i18n key value matches the user's active UI locale.
3415
+ text: uiT("ap_voice_preview_sample"),
3217
3416
  provider: v.provider,
3218
3417
  model: v.model,
3219
3418
  voiceId: v.voiceId,
@@ -4993,14 +5192,14 @@
4993
5192
  window.refreshAgentProfileSkills = function () {
4994
5193
  // Voices list is keyed off the user's configured providers · when
4995
5194
  // keys change (added / deleted / swapped between minimax ↔
4996
- // elevenlabs), the cached `/api/voices` payload is stale and the
4997
- // picker would still show the prior provider's voices until a hard
4998
- // refresh. Invalidate here so the next ensureVoiceOptions()
4999
- // re-fetches; also closes any open picker that's painting from
5000
- // the stale cache so the user doesn't see the wrong list flash
5001
- // before it reopens. This function is the canonical "keys may
5002
- // have changed" hook called by user-settings on modal close.
5003
- voiceOptionsCache = null;
5195
+ // elevenlabs), the cached pager state is stale and the picker
5196
+ // would still show the prior provider's voices until a hard
5197
+ // refresh. Invalidate here so the next openVoicePicker() refetches
5198
+ // the first page; also closes any open picker that's painting
5199
+ // from the stale cache so the user doesn't see the wrong list
5200
+ // flash before it reopens. This function is the canonical "keys
5201
+ // may have changed" hook called by user-settings on modal close.
5202
+ invalidateVoicePager();
5004
5203
  closeVoicePicker();
5005
5204
  closeEmotionPicker();
5006
5205
  if (!currentlyOpenSlug) return;