From bdac5c8e2e8316ae9ae6a00dd8d6d3ade77ae063 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:36:53 -0700 Subject: [PATCH] fix(desktop): Capabilities TTS voice fields write the scoped profile's config, not the active one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolsetConfigPanel threads its profile scope into every fetch but rendered VoiceProviderFields without it, and the fields were hard-wired unscoped — configuring profile B's TTS from the Capabilities selector read and autosaved profile A's whole config record. New capability-scoped saveHermesConfigRecord (symmetric with getHermesConfigRecord), profile prop threaded through, per-scope cache write-through. Unscoped callers (Settings → Voice) unchanged. --- .../settings/toolset-config-panel.test.tsx | 10 +++++-- .../src/app/settings/toolset-config-panel.tsx | 7 +++-- .../app/settings/voice-provider-fields.tsx | 29 ++++++++++++++----- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx index 68c1b1630c..0c1fc10030 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -45,6 +45,7 @@ const pollOAuthSession = vi.fn() const getHermesConfigRecord = vi.fn() const getHermesConfigSchema = vi.fn() const saveHermesConfig = vi.fn() +const saveHermesConfigRecord = vi.fn() const getElevenLabsVoices = vi.fn() vi.mock('@/hermes', () => ({ @@ -65,6 +66,7 @@ vi.mock('@/hermes', () => ({ getHermesConfigRecord: () => getHermesConfigRecord(), getHermesConfigSchema: () => getHermesConfigSchema(), saveHermesConfig: (config: unknown) => saveHermesConfig(config), + saveHermesConfigRecord: (config: unknown, profile?: unknown) => saveHermesConfigRecord(config, profile), getElevenLabsVoices: () => getElevenLabsVoices(), // @/store/profile (pulled in transitively via use-config-record's // normalizeProfileKey import) calls this at module-init; the full-replacement @@ -152,6 +154,7 @@ beforeEach(() => { }) getHermesConfigSchema.mockResolvedValue({ fields: {}, category_order: [] }) saveHermesConfig.mockResolvedValue({ ok: true }) + saveHermesConfigRecord.mockResolvedValue({ ok: true }) getElevenLabsVoices.mockResolvedValue({ available: false, voices: [] }) }) @@ -195,9 +198,12 @@ describe('ToolsetConfigPanel', () => { // closed Select. const voiceInput = screen.getByDisplayValue('alloy') fireEvent.change(voiceInput, { target: { value: 'marin' } }) - await waitFor(() => expect(saveHermesConfig).toHaveBeenCalled(), { timeout: 3000 }) - const saved = saveHermesConfig.mock.calls.at(-1)?.[0] as Record>> + await waitFor(() => expect(saveHermesConfigRecord).toHaveBeenCalled(), { timeout: 3000 }) + const saved = saveHermesConfigRecord.mock.calls.at(-1)?.[0] as Record>> expect(saved.tts.openai.voice).toBe('marin') + // Unscoped panel (no Capabilities override) → profile rides as undefined, + // preserving the active-profile default. A scoped panel forwards its scope. + expect(saveHermesConfigRecord.mock.calls.at(-1)?.[1]).toBeUndefined() }) it('renders no inline voice fields for rows without tts_provider (older backend)', async () => { diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 8f5ae81540..cfb8d695b2 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -889,8 +889,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange, profile }: Too {toolset === 'tts' && provider.tts_provider && ( // Voice/model settings for this backend (tts..*) — // the same fields Settings → Voice renders, inline so the - // Capabilities panel is a complete setup surface. - + // Capabilities panel is a complete setup surface. Profile + // threaded like every other fetch in this panel: unscoped, + // these fields read AND autosaved the ACTIVE profile's + // config while the panel claimed to configure another. + )} {MODEL_CATALOG_TOOLSETS.has(toolset) && ( voiceProviderKeys(section, providerKey), [section, providerKey]) - const { data: loadedConfig } = useHermesConfigRecord() + const { data: loadedConfig } = useHermesConfigRecord(profile) + const writeConfigCache = useMemo(() => hermesConfigCacheWriter(profile), [profile]) const { data: schemaResponse } = useQuery({ queryKey: ['hermes-config-schema'], @@ -70,17 +85,17 @@ export function VoiceProviderFields({ section, providerKey }: { section: 'tts' | } const timeout = window.setTimeout(() => { - void saveHermesConfig(diffConfig(baseline ?? {}, config)) + void saveHermesConfigRecord(diffConfig(baseline ?? {}, config), profile) .then(() => { setBaseline(config) - setHermesConfigCache(config) + writeConfigCache(config) }) .catch(err => notifyError(err, t.settings.config.autosaveFailed)) }, 550) return () => window.clearTimeout(timeout) // eslint-disable-next-line react-hooks/exhaustive-deps -- copy is stable; avoid re-scheduling autosave on locale change - }, [config, saveVersion]) + }, [config, profile, saveVersion, writeConfigCache]) // ElevenLabs cloned/library voices from the live account, when available — // mirrors the Settings → Voice dynamic voice list.