From cfae0daa7f825a8aecd8aa8a501ecce9070451ed Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:29:32 +0000 Subject: [PATCH] Re-render Voice Mode onboarding chips when the spoken language changes (#328833) * Initial plan * Re-render voice onboarding chips when spoken language changes Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --- .../browser/voiceModeOnboarding.ts | 63 ++++++++++++++++--- .../test/browser/voiceModeOnboarding.test.ts | 43 +++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts index d05e0d1e75b..a612bebb341 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts @@ -668,8 +668,14 @@ export class VoiceModeOnboardingBanner extends Disposable { private readonly voiceElements = new Map(); + /** Where the voice chips live, so they can be re-rendered on a language change. */ + private voicesContainer: HTMLElement | undefined; + + /** Listeners for the current set of voice chips, cleared when they re-render. */ + private readonly voicesDisposables = this._register(new DisposableStore()); + /** The native voice for the spoken language, when one exists. */ - private readonly localizedVoice: ILocalizedVoice | undefined; + private localizedVoice: ILocalizedVoice | undefined; /** The voice being auditioned, and the one that will be committed. */ private selectedVoice: IVoiceModeVoice | undefined; @@ -712,10 +718,20 @@ export class VoiceModeOnboardingBanner extends Disposable { this.renderMicrophonePicker(); const actions = dom.append(this.domNode, dom.$('.voice-mode-onboarding-actions')); - this.renderVoices(actions); + this.voicesContainer = actions; + this.renderVoices(); this.renderClose(); this.logAction('shown'); + // "Changing this while voice mode is connected takes effect immediately" + // applies to the card too: swap the chips for the new language's voice + // rather than leaving stale ones behind. + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(VOICE_LANGUAGE_SETTING)) { + this.updateForLanguage(); + } + })); + this.focusForScreenReader(); this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this.focusForScreenReader())); } @@ -832,8 +848,20 @@ export class VoiceModeOnboardingBanner extends Disposable { * because bare text gave no sign it could be clicked at all. In a language * Voice Mode speaks natively there is only one voice, so the card previews * that voice instead of offering the English chooser. + * + * Re-entrant: clears any previously rendered chips so the card can rebuild + * them when the spoken language changes. */ - private renderVoices(container: HTMLElement): void { + private renderVoices(): void { + const container = this.voicesContainer; + if (!container) { + return; + } + + this.voicesDisposables.clear(); + this.voiceElements.clear(); + dom.clearNode(container); + const labelText = localize('voiceMode.onboarding.voices', "Agent Voice:"); const label = dom.append(container, dom.$('.voice-mode-onboarding-voices-label')); label.textContent = labelText; @@ -859,13 +887,34 @@ export class VoiceModeOnboardingBanner extends Disposable { label.textContent = voice.label; this.voiceElements.set(voice.id, { element: option, label: voice.label, restingAria }); - this._register(dom.addDisposableListener(option, dom.EventType.CLICK, () => this.selectVoice(voice))); - this._register(dom.addDisposableListener(option, dom.EventType.KEY_DOWN, event => this.handleOptionKey(event, voice))); + this.voicesDisposables.add(dom.addDisposableListener(option, dom.EventType.CLICK, () => this.selectVoice(voice))); + this.voicesDisposables.add(dom.addDisposableListener(option, dom.EventType.KEY_DOWN, event => this.handleOptionKey(event, voice))); } this.updateSelection(); } + /** + * The spoken language changed, so swap the chips: the four English voices + * for a native language's single voice, or back again. Nothing is carried + * over - a voice chosen for the old language means nothing for the new one. + */ + private updateForLanguage(): void { + const localizedVoice = localizedVoiceForLanguage(this.resolveSpokenLanguage()); + if (localizedVoice?.id === this.localizedVoice?.id) { + return; + } + + const hadVoiceFocus = this.voicesContainer ? dom.isAncestorOfActiveElement(this.voicesContainer) : false; + this.player.stop(); + this.localizedVoice = localizedVoice; + this.selectedVoice = undefined; + this.renderVoices(); + if (hadVoiceFocus) { + this.voiceElements.values().next().value?.element.focus(); + } + } + /** * The single native voice for the spoken language, as a preview button: * there is nothing to choose, so it only ever plays and stops. @@ -886,8 +935,8 @@ export class VoiceModeOnboardingBanner extends Disposable { label.textContent = voice.label; this.voiceElements.set(voice.id, { element: option, label: voice.label, restingAria }); - this._register(dom.addDisposableListener(option, dom.EventType.CLICK, () => this.previewLocalizedVoice(voice))); - this._register(dom.addDisposableListener(option, dom.EventType.KEY_DOWN, event => { + this.voicesDisposables.add(dom.addDisposableListener(option, dom.EventType.CLICK, () => this.previewLocalizedVoice(voice))); + this.voicesDisposables.add(dom.addDisposableListener(option, dom.EventType.KEY_DOWN, event => { const keyboardEvent = new StandardKeyboardEvent(event); if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) { keyboardEvent.preventDefault(); diff --git a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts index 5edee20f9aa..876d7988875 100644 --- a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts +++ b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts @@ -11,6 +11,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { constObservable } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -235,6 +237,47 @@ suite('Voice Mode onboarding', () => { assert.deepStrictEqual(actual, cases); }); + test('swaps the chips when the spoken language changes', () => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + instantiationService.stub(IAccessibilityService, new class extends mock() { + override readonly onDidChangeScreenReaderOptimized = Event.None; + override readonly onDidChangeReducedMotion = Event.None; + override isScreenReaderOptimized(): boolean { return false; } + override isMotionReduced(): boolean { return false; } + }); + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration('agents.voice.language', 'en'); + instantiationService.stub(IConfigurationService, configurationService); + + const host = createHost(disposables); + const audio = document.createElement('audio'); + audio.play = () => Promise.resolve(); + disposables.add(instantiationService.createInstance(VoiceModeOnboardingBanner, { + container: host.container, + onDismiss: () => undefined, + source: 'manual', + audioFactory: () => audio, + })); + + const countVoices = () => host.container.querySelectorAll('.voice-mode-onboarding-voice').length; + const englishOptions = countVoices(); + + configurationService.setUserConfiguration('agents.voice.language', 'zh'); + configurationService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set(['agents.voice.language']), + change: { keys: ['agents.voice.language'], overrides: [] }, + affectsConfiguration: candidate => candidate === 'agents.voice.language', + }); + const chineseOptions = countVoices(); + host.container.querySelector('.voice-mode-onboarding-voice')!.click(); + const chineseSample = audio.src.split(/[?#]/)[0].split('/').pop() ?? ''; + + assert.deepStrictEqual( + { englishOptions, chineseOptions, chineseSample }, + { englishOptions: 4, chineseOptions: 1, chineseSample: 'zh_wuzhi_neutral.mp3' }); + }); + test('can be shown again manually', () => { const telemetryEvents: ITelemetryEvent[] = []; const service = createService(disposables, [], [], telemetryEvents);