mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-14 09:45:55 +01:00
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:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
meganrogge
parent
5a467963eb
commit
cfae0daa7f
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user