Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build/gulpfile.vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const vscodeResourceIncludes = [

// Accessibility Signals
'out-build/vs/platform/accessibilitySignal/browser/media/*.mp3',
'out-build/vs/workbench/contrib/agentsVoice/browser/media/*.mp3',

// Welcome
'out-build/vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.{svg,png}',
Expand Down
1 change: 1 addition & 0 deletions build/gulpfile.vscode.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const vscodeWebResourceIncludes = [

// Accessibility Signals
'out-build/vs/platform/accessibilitySignal/browser/media/*.mp3',
'out-build/vs/workbench/contrib/agentsVoice/browser/media/*.mp3',

// Welcome
'out-build/vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.{svg,png}',
Expand Down
3 changes: 3 additions & 0 deletions build/next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ const desktopResourcePatterns = [

// Media - audio
'vs/platform/accessibilitySignal/browser/media/*.mp3',
'vs/workbench/contrib/agentsVoice/browser/media/*.mp3',

// Media - images
'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg',
Expand Down Expand Up @@ -344,6 +345,7 @@ const serverWebResourcePatterns = [

// Media - audio
'vs/platform/accessibilitySignal/browser/media/*.mp3',
'vs/workbench/contrib/agentsVoice/browser/media/*.mp3',

// Media - images
'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg',
Expand Down Expand Up @@ -371,6 +373,7 @@ const webResourcePatterns = [

// Media - audio
'vs/platform/accessibilitySignal/browser/media/*.mp3',
'vs/workbench/contrib/agentsVoice/browser/media/*.mp3',

// Media - images
'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import '../common/voiceTranscriptStore.js';
// Register the Voice Transcripts view + show-command + chat-menu entry
import './transcriptsView/voiceTranscripts.contribution.js';

import { Disposable } from '../../../../base/common/lifecycle.js';
import { addDisposableListener } from '../../../../base/browser/dom.js';
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
import { FileAccess } from '../../../../base/common/network.js';
import * as nls from '../../../../nls.js';
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
Expand All @@ -33,20 +35,24 @@ import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2
import { ConfigurationKeyValuePairs, IConfigurationMigrationRegistry, Extensions as WorkbenchConfigurationExtensions } from '../../../common/configuration.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';

import { AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING, AGENTS_VOICE_LISTENING } from '../common/agentsVoice.js';
import { AgentsVoiceStorageKeys, AGENTS_VOICE_CONFIGURE_COMMAND_ID, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING, AGENTS_VOICE_DISABLE_COMMAND_ID, AGENTS_VOICE_LISTENING, AGENTS_VOICE_PREVIEW_COMMAND_ID } from '../common/agentsVoice.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import {
VoiceEnabledClassification, VoiceEnabledEvent,
VoiceDisabledClassification, VoiceDisabledEvent,
VoiceOnboardingCompletedClassification, VoiceOnboardingCompletedEvent,
} from '../../chat/browser/voiceClient/voiceTelemetry.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js';
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
import { ChatAgentLocation } from '../../chat/common/constants.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IBannerService } from '../../../services/banner/browser/bannerService.js';
import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';

// --- Context Keys ---

Expand Down Expand Up @@ -112,6 +118,81 @@ class AgentsVoiceTelemetryContribution extends Disposable implements IWorkbenchC

registerWorkbenchContribution2(AgentsVoiceTelemetryContribution.ID, AgentsVoiceTelemetryContribution, WorkbenchPhase.AfterRestored);

// --- First-use banner ---

const AGENTS_VOICE_ONBOARDING_BANNER_ID = 'agentsVoice.onboarding';

class AgentsVoiceOnboardingBannerContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.agentsVoiceOnboardingBanner';

constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IStorageService private readonly storageService: IStorageService,
@IBannerService private readonly bannerService: IBannerService,
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
) {
super();

this._register(this.configurationService.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('agents.voice.enabled')) {
this.updateBanner();
}
}));
this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, AgentsVoiceStorageKeys.OnboardingCompleted, this._store)(() => this.updateBanner()));
this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.updateBanner()));
this.updateBanner();
}

private updateBanner(): void {
const sentiment = this.chatEntitlementService.sentiment;
const shouldShow = this.configurationService.getValue<boolean>('agents.voice.enabled') === true
&& !this.storageService.getBoolean(AgentsVoiceStorageKeys.OnboardingCompleted, StorageScope.PROFILE, false)
&& !sentiment.hidden
&& !sentiment.disabled
&& !sentiment.disabledInWorkspace
&& !sentiment.untrusted;

if (!shouldShow) {
this.bannerService.hide(AGENTS_VOICE_ONBOARDING_BANNER_ID);
return;
}

const message = nls.localize('agentsVoice.onboardingBanner.message', "Voice Mode is ready. Configure it, preview the selected voice, or turn it off.");
this.bannerService.show({
id: AGENTS_VOICE_ONBOARDING_BANNER_ID,
icon: Codicon.voiceMode,
message,
ariaLabel: nls.localize('agentsVoice.onboardingBanner.ariaLabel', "{0} Use the banner actions to configure settings, preview or choose the voice, or disable Voice Mode.", message),
actions: [
{
label: nls.localize('agentsVoice.onboardingBanner.configure', "Configure Settings"),
href: 'command:agentsVoice.openSettings',
},
{
label: nls.localize('agentsVoice.onboardingBanner.preview', "Preview Voice"),
href: `command:${AGENTS_VOICE_PREVIEW_COMMAND_ID}`,
},
{
label: nls.localize('agentsVoice.onboardingBanner.chooseVoice', "Choose Voice"),
href: `command:${AGENTS_VOICE_CONFIGURE_COMMAND_ID}`,
},
{
label: nls.localize('agentsVoice.onboardingBanner.disable', "Disable Voice Mode"),
href: `command:${AGENTS_VOICE_DISABLE_COMMAND_ID}`,
},
],
closeLabel: nls.localize('agentsVoice.onboardingBanner.dismiss', "Dismiss Voice Mode Setup"),
onClose: () => {
this.storageService.store(AgentsVoiceStorageKeys.OnboardingCompleted, true, StorageScope.PROFILE, StorageTarget.USER);
this.telemetryService.publicLog2<VoiceOnboardingCompletedEvent, VoiceOnboardingCompletedClassification>('voiceOnboardingCompleted', {});
},
});
}
}

registerWorkbenchContribution2(AgentsVoiceOnboardingBannerContribution.ID, AgentsVoiceOnboardingBannerContribution, WorkbenchPhase.AfterRestored);

// --- Voice mode button in Chat toolbar ---
// Shows the voice mode icon in both idle and active states.
// Click to connect if disconnected, or toggle PTT if connected.
Expand Down Expand Up @@ -378,6 +459,140 @@ registerAction2(class extends Action2 {
}
});

const voicePreviewFiles = {
victoria_neutral: 'victoria_neutral.mp3',
kevin_neutral: 'kevin_neutral.mp3',
maya_neutral: 'maya_neutral.mp3',
daniel_neutral: 'daniel_neutral.mp3',
} as const;

type VoicePreview = keyof typeof voicePreviewFiles;

function isVoicePreview(voice: string): voice is VoicePreview {
return Object.prototype.hasOwnProperty.call(voicePreviewFiles, voice);
}

let currentVoicePreview: { audio: HTMLAudioElement; endedListener: IDisposable } | undefined;

function stopVoicePreview(): void {
if (!currentVoicePreview) {
return;
}

const { audio, endedListener } = currentVoicePreview;
currentVoicePreview = undefined;
audio.pause();
endedListener.dispose();
}

async function playVoicePreview(voice: VoicePreview, notificationService: INotificationService): Promise<void> {
stopVoicePreview();

const audio = new mainWindow.Audio(FileAccess.asBrowserUri(`vs/workbench/contrib/agentsVoice/browser/media/${voicePreviewFiles[voice]}`).toString(true));
const endedListener = addDisposableListener(audio, 'ended', () => {
if (currentVoicePreview?.audio === audio) {
stopVoicePreview();
}
});
currentVoicePreview = { audio, endedListener };

try {
await audio.play();
} catch {
if (currentVoicePreview?.audio === audio) {
stopVoicePreview();
notificationService.error(nls.localize('agentsVoice.previewVoiceFailed', "Unable to preview the selected Voice Mode voice."));
}
}
}

class PreviewVoiceAction extends Action2 {

constructor() {
super({
id: AGENTS_VOICE_PREVIEW_COMMAND_ID,
title: nls.localize2('agentsVoice.previewVoice', "Voice Mode: Preview Voice"),
f1: true,
precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true),
});
}

async run(accessor: ServicesAccessor): Promise<void> {
const configurationService = accessor.get(IConfigurationService);
const notificationService = accessor.get(INotificationService);
const configuredVoice = configurationService.getValue<string>('agents.voice.voice') ?? 'maya_neutral';
const voice = isVoicePreview(configuredVoice) ? configuredVoice : 'maya_neutral';

await playVoicePreview(voice, notificationService);
}
}

registerAction2(PreviewVoiceAction);

interface VoiceQuickPickItem extends IQuickPickItem {
readonly voice: VoicePreview;
}

registerAction2(class extends Action2 {
constructor() {
super({
id: AGENTS_VOICE_CONFIGURE_COMMAND_ID,
title: nls.localize2('agentsVoice.configureVoice', "Voice Mode: Configure Voice"),
f1: true,
});
}

async run(accessor: ServicesAccessor): Promise<void> {
const configurationService = accessor.get(IConfigurationService);
const notificationService = accessor.get(INotificationService);
const quickInputService = accessor.get(IQuickInputService);
const configuredVoice = configurationService.getValue<string>('agents.voice.voice') ?? 'maya_neutral';
const items: VoiceQuickPickItem[] = [
{ label: nls.localize('agentsVoice.voice.victoria', "Victoria"), voice: 'victoria_neutral' },
{ label: nls.localize('agentsVoice.voice.kevin', "Kevin"), voice: 'kevin_neutral' },
{ label: nls.localize('agentsVoice.voice.maya', "Maya"), voice: 'maya_neutral' },
{ label: nls.localize('agentsVoice.voice.daniel', "Daniel"), voice: 'daniel_neutral' },
];
const activeItem = items.find(item => item.voice === configuredVoice) ?? items[2];
let selectedItem: VoiceQuickPickItem | undefined;

try {
selectedItem = await quickInputService.pick(items, {
title: nls.localize('agentsVoice.configureVoice.title', "Select Voice Mode Voice"),
placeHolder: nls.localize('agentsVoice.configureVoice.placeholder', "Focus a voice to preview it"),
activeItem,
onDidFocus: item => {
void playVoicePreview(item.voice, notificationService);
},
});
} finally {
stopVoicePreview();
}

if (selectedItem) {
await configurationService.updateValue('agents.voice.voice', selectedItem.voice);
}
}
});

registerAction2(class extends Action2 {
constructor() {
super({
id: AGENTS_VOICE_DISABLE_COMMAND_ID,
title: nls.localize2('agentsVoice.disable', "Voice Mode: Disable"),
f1: true,
precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true),
});
}

async run(accessor: ServicesAccessor): Promise<void> {
const commandService = accessor.get(ICommandService);
const configurationService = accessor.get(IConfigurationService);
await commandService.executeCommand('agentsVoice.disconnect');
await configurationService.updateValue('agents.voice.enabled', false);
}
});

// --- Simulate Voice Connection (dev utility, backend down) ---

registerAction2(class extends Action2 {
Expand Down Expand Up @@ -556,7 +771,7 @@ configurationRegistry.registerConfiguration({
nls.localize('agents.voice.voice.maya', "Maya."),
nls.localize('agents.voice.voice.daniel', "Daniel."),
],
description: nls.localize('agents.voice.voice', "The voice used when the assistant reads responses aloud. Changing this while voice mode is connected takes effect immediately."),
markdownDescription: nls.localize('agents.voice.voice', "The voice used when the assistant reads responses aloud. [Choose a voice and preview samples](command:{0}). Changing this while voice mode is connected takes effect immediately.", AGENTS_VOICE_CONFIGURE_COMMAND_ID),
default: 'maya_neutral',
scope: ConfigurationScope.APPLICATION,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice
const anchor = new StandardMouseEvent(getWindow(e.target as Node ?? auxiliaryWindow.container), e);
this.contextMenuService.showContextMenu({
getAnchor: () => anchor,
getActions: () => getVoiceModeContextMenuActions(this.commandService, this.configurationService, this.keybindingService, 'agentsVoice.pushToTalk'),
getActions: () => getVoiceModeContextMenuActions(this.commandService, this.keybindingService, 'agentsVoice.pushToTalk'),
});
},
submitFeedback: (text) => this.voiceSessionController.submitFeedback(text),
Expand Down
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import './agentsVoiceColors.js'; // Register custom voice theme colors
export const AGENTS_VOICE_CONNECTED = new RawContextKey<boolean>('agentsVoiceConnected', false);
export const AGENTS_VOICE_CONNECTING = new RawContextKey<boolean>('agentsVoiceConnecting', false);
export const AGENTS_VOICE_LISTENING = new RawContextKey<boolean>('agentsVoiceListening', false);
export const AGENTS_VOICE_CONFIGURE_COMMAND_ID = 'agentsVoice.configureVoice';
export const AGENTS_VOICE_DISABLE_COMMAND_ID = 'agentsVoice.disable';
export const AGENTS_VOICE_PREVIEW_COMMAND_ID = 'agentsVoice.previewVoice';

/**
* Default dimensions for the Agents Voice floating window.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,16 @@ import { ICommandService } from '../../../../../platform/commands/common/command
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
import { AGENTS_VOICE_DISABLE_COMMAND_ID } from '../../../agentsVoice/common/agentsVoice.js';

/** Command that opens the microphone picker shared by dictation and Voice Mode. */
export const SELECT_MICROPHONE_COMMAND = 'workbench.action.chat.selectSpeechToTextMicrophone';
/** Command that cancels the active/preparing dictation session. */
const CANCEL_DICTATION_COMMAND = 'workbench.action.chat.cancelSpeechToText';
/** Command that tears down an active Voice Mode session. */
const VOICE_DISCONNECT_COMMAND = 'agentsVoice.disconnect';
/** Command that opens the Voice Mode settings; the affordance that used to live behind the toolbar gear. */
const VOICE_OPEN_SETTINGS_COMMAND = 'agentsVoice.openSettings';
/** Setting that enables dictation; toggled off by "Disable Dictation". */
const DICTATION_ENABLED_SETTING = 'dictation.enabled';
/** Setting that enables Voice Mode; toggled off by "Disable Voice Mode". */
const VOICE_ENABLED_SETTING = 'agents.voice.enabled';

/**
* "Select Microphone" entry shared by every dictation / Voice Mode mic button
Expand Down Expand Up @@ -60,14 +57,11 @@ function createDisableDictationAction(commandService: ICommandService, configura
* the setting doesn't leave the microphone capturing while the toolbar
* affordance disappears, then turns off the feature setting.
*/
function createDisableVoiceModeAction(commandService: ICommandService, configurationService: IConfigurationService): IAction {
function createDisableVoiceModeAction(commandService: ICommandService): IAction {
return toAction({
id: 'chat.voiceMode.disable',
label: localize('voiceMode.disable', "Disable Voice Mode"),
run: async () => {
await commandService.executeCommand(VOICE_DISCONNECT_COMMAND);
await configurationService.updateValue(VOICE_ENABLED_SETTING, false);
},
run: () => commandService.executeCommand(AGENTS_VOICE_DISABLE_COMMAND_ID),
});
}

Expand Down Expand Up @@ -105,12 +99,12 @@ function createVoiceModeSettingsAction(commandService: ICommandService): IAction
* affordances that used to live behind the toolbar gear button.
* `keybindingCommandId` is the stable command the keybinding entry targets.
*/
export function getVoiceModeContextMenuActions(commandService: ICommandService, configurationService: IConfigurationService, keybindingService: IKeybindingService, keybindingCommandId: string): IAction[] {
export function getVoiceModeContextMenuActions(commandService: ICommandService, keybindingService: IKeybindingService, keybindingCommandId: string): IAction[] {
return [
createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId),
createVoiceModeSettingsAction(commandService),
createSelectMicrophoneAction(commandService),
createDisableVoiceModeAction(commandService, configurationService),
createDisableVoiceModeAction(commandService),
];
}

Expand Down
Loading
Loading