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>
This commit is contained in:
Copilot
2026-08-03 22:29:32 +00:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> meganrogge
parent 5a467963eb
commit cfae0daa7f
2 changed files with 99 additions and 7 deletions
@@ -668,8 +668,14 @@ export class VoiceModeOnboardingBanner extends Disposable {
private readonly voiceElements = new Map<string, IVoiceElement>();
/** 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();
@@ -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<IAccessibilityService>() {
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<HTMLElement>('.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);