diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 8f46b5c01fda9c..52db3d2c065e66 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -28,6 +28,8 @@ "--vscode-agentSessionSelectedBadge-border", "--vscode-agentSessionSelectedUnfocusedBadge-border", "--vscode-agentStatusIndicator-background", + "--vscode-agentsVoice-speakingBackground", + "--vscode-agentsVoice-speakingForeground", "--vscode-activeSessionView-background", "--vscode-activeSessionView-foreground", "--vscode-inactiveSessionView-background", diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 9bc3b24237c4bb..0e1e6045001b5e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -97,6 +97,7 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio import { DictationDownloadRing, getDictationDownloadHoverMarkdown, getDictationPreparingLabel } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js'; import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { ChatPetWidget } from '../../../../workbench/contrib/chat/browser/widget/chatPetWidget.js'; +import { IVoiceModeOnboardingService } from '../../../../workbench/contrib/agentsVoice/browser/voiceModeOnboarding.js'; const OPEN_OTEL_SETTINGS_COMMAND = 'github.copilot.chat.otel.openSettings'; @@ -378,6 +379,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @IVoiceInputModeService private readonly voiceInputModeService: IVoiceInputModeService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IVoiceModeOnboardingService private readonly voiceModeOnboardingService: IVoiceModeOnboardingService, ) { super(); this._sessionModelSelectionModel = this._register(this.instantiationService.createInstance(SessionModelSelectionModel, this.options.session)); @@ -453,6 +455,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation )); notificationContainer.appendChild(notificationWidget.domNode); + // First-run Voice Mode introduction, docked above the input area + const voiceOnboardingContainer = dom.append(chatInputContainer, dom.$('.voice-mode-onboarding-container')); + this._register(this.voiceModeOnboardingService.registerHost(voiceOnboardingContainer, chatInputContainer, () => this.focus())); + // First-run dictation introduction, docked directly above the input area // so it reads as one stack with it - the same slot the chat view uses. const dictationOnboardingContainer = dom.append(chatInputContainer, dom.$('.dictation-onboarding-container')); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 9cea18c2f804e3..02ebf653426215 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -37,6 +37,7 @@ import { AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { VoiceEnabledClassification, VoiceEnabledEvent, VoiceDisabledClassification, VoiceDisabledEvent, @@ -48,6 +49,8 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j import { ChatAgentLocation } from '../../chat/common/constants.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { CONFIGURE_VOICE_INSTRUCTIONS_ACTION_ID } from '../../chat/browser/actions/configureVoiceInstructionsAction.js'; +import { IVoiceModeOnboardingService } from './voiceModeOnboarding.js'; +import { SHOW_VOICE_MODE_ONBOARDING_COMMAND } from '../../chat/browser/speechToText/micButtonMenuActions.js'; // --- Context Keys --- @@ -113,6 +116,35 @@ class AgentsVoiceTelemetryContribution extends Disposable implements IWorkbenchC registerWorkbenchContribution2(AgentsVoiceTelemetryContribution.ID, AgentsVoiceTelemetryContribution, WorkbenchPhase.AfterRestored); +// --- First-run introduction --- + +/** + * Shows the Voice Mode introduction the first time a session starts. This + * watches the connection state rather than any one entry point, because Voice + * Mode can be started from the input-mode pill, a command, a keybinding or the + * Agents window - all of which land here. + */ +class AgentsVoiceOnboardingContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.agentsVoiceOnboarding'; + + constructor( + @IVoiceSessionController voiceSessionController: IVoiceSessionController, + @IVoiceModeOnboardingService voiceModeOnboardingService: IVoiceModeOnboardingService, + ) { + super(); + + this._register(autorun(reader => { + if (voiceSessionController.isConnecting.read(reader) || voiceSessionController.isConnected.read(reader)) { + voiceModeOnboardingService.showIfNeeded(); + } + })); + } +} + +// Registered at the same late phase as the connected-key contribution so it +// does not force `IVoiceSessionController` to instantiate early. +registerWorkbenchContribution2(AgentsVoiceOnboardingContribution.ID, AgentsVoiceOnboardingContribution, WorkbenchPhase.Eventually); + // --- 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. @@ -379,6 +411,23 @@ registerAction2(class extends Action2 { } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: SHOW_VOICE_MODE_ONBOARDING_COMMAND, + title: nls.localize2('agentsVoice.showOnboarding', "Voice Mode: Show Introduction"), + f1: true, + precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), + }); + } + + run(accessor: ServicesAccessor): void { + if (!accessor.get(IVoiceModeOnboardingService).show()) { + accessor.get(INotificationService).info(nls.localize('agentsVoice.onboardingNeedsChat', "Open a chat to see the Voice Mode introduction.")); + } + } +}); + // --- Simulate Voice Connection (dev utility, backend down) --- registerAction2(class extends Action2 { @@ -408,6 +457,7 @@ registerAction2(class extends Action2 { async run(accessor: ServicesAccessor): Promise { const storageService = accessor.get(IStorageService); storageService.remove(AgentsVoiceStorageKeys.OnboardingCompleted, StorageScope.PROFILE); + storageService.remove(AgentsVoiceStorageKeys.IntroBannerShown, StorageScope.APPLICATION); } }); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css b/src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css new file mode 100644 index 00000000000000..9d5c1cf7141250 --- /dev/null +++ b/src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css @@ -0,0 +1,355 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * The Voice Mode introduction card. + * + * One layout at every width - a vertical stack - because the chat panel is + * routinely ~250px and a card that re-tiers as it narrows never settles into a + * rhythm. Stacked, the spacing is identical everywhere and nothing has to be + * dropped, scrolled or clipped. + * + * Spacing rhythm, all from the shared spacing ramp: + * 12px card padding (size120) + * 8px between tiers, and voices to close (size80) + * 2px between voice chips, and title to its sentence (size20) + * 2/4px inside a voice chip (size20 / size40) + */ + +/* The host is a peer directly above the chat input, and stays out of the layout + * entirely until the card is attached. */ +.voice-mode-onboarding-container { + display: none; +} + +.voice-mode-onboarding-container.has-voice-mode-onboarding { + display: block; + /* Tuck the squared-off bottom edge behind the rounded top of the chat input + * so the two surfaces read as one stack with no visible seam. */ + margin-bottom: -10px; + padding-bottom: 10px; +} + +.voice-mode-onboarding-banner { + position: relative; + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + box-sizing: border-box; + padding: var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-xSmall) var(--vscode-cornerRadius-xSmall); + background-color: var(--vscode-agentsChatInput-background, var(--vscode-input-background)); + color: var(--vscode-foreground); +} + +/* --- Copy --- */ + +.voice-mode-onboarding-copy { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size20); + min-width: 0; + /* Clear of the pinned close button. */ + padding-right: var(--vscode-spacing-size240); +} + +.voice-mode-onboarding-title { + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +.voice-mode-onboarding-description { + font-size: var(--vscode-fontSize-label2); + color: var(--vscode-descriptionForeground); + /* Wraps rather than truncating: this sentence carries the promise, the ask + * and the escape hatch, so it survives at every width. */ + line-height: 1.4; +} + +.voice-mode-onboarding-listening-notice { + display: flex; + align-items: flex-start; + gap: var(--vscode-spacing-size40); + margin-top: var(--vscode-spacing-size40); + font-size: var(--vscode-fontSize-label2); + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.voice-mode-onboarding-listening-notice .codicon { + flex-shrink: 0; + margin-top: 1px; +} + +/* The settings link sits inside the sentence, so it takes its colour from the + * link token and nothing else - no weight, no size, no box that would lift it + * out of the prose it belongs to. */ +.voice-mode-onboarding-description a { + color: var(--vscode-textLink-foreground); + cursor: pointer; + border-radius: var(--vscode-cornerRadius-xSmall); +} + +.voice-mode-onboarding-description a:hover, +.voice-mode-onboarding-description a:active { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.voice-mode-onboarding-description a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +/* --- Waveform --- */ + +/* + * The waveform gets a row of its own. Earlier it sat behind the copy, which + * forced a scrim over it, which erased it - a waveform competing with a + * paragraph is a fight neither side wins. With its own space it needs no mask, + * no scrim and no dimming, so it can simply be seen. + */ +.voice-mode-onboarding-wave { + height: 34px; + pointer-events: none; + /* Eases into the card's edges rather than being sliced off at them. */ + mask-image: linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 1) 12%, rgba(0, 0, 0, 1) 88%, transparent 100%); +} + +/* + * Monochrome, and it is this declaration that decides the tier: the canvas + * paints its bars in its own computed `color`, the same way the toolbar + * waveform uses `currentColor`. It sits at the description's tier because it is + * ambient - present, but never louder than the choice below it - and taking a + * theme token means it stays legible in light, dark and high-contrast alike. + */ +.voice-mode-onboarding-canvas { + display: block; + width: 100%; + height: 100%; + color: var(--vscode-descriptionForeground); +} + +/* --- Voices and confirmation --- */ + +.voice-mode-onboarding-actions { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); +} + +/* --- Voice options --- */ + +/* + * The options are buttons and have to look like it. Bare text gave no sign it + * could be clicked, let alone that clicking would speak *and* stick. + */ +.voice-mode-onboarding-voices { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--vscode-spacing-size20); + flex: 1 1 auto; + min-width: 0; +} + +.voice-mode-onboarding-voice { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size20); + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); + border-radius: var(--vscode-cornerRadius-medium); + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font-size: var(--vscode-fontSize-label2); + white-space: nowrap; + cursor: pointer; + transition: background-color 100ms ease-out, border-color 100ms ease-out; +} + +.voice-mode-onboarding-voice:hover { + background-color: var(--vscode-button-secondaryHoverBackground); + border-color: var(--vscode-agentsVoice-speakingForeground); +} + +/* Pressed feedback, so a click is felt as well as seen. */ +.voice-mode-onboarding-voice:active { + transform: translateY(1px); +} + +.voice-mode-onboarding-voice:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.voice-mode-onboarding-voice.selected { + background-color: var(--vscode-agentsVoice-speakingBackground); + border-color: var(--vscode-agentsVoice-speakingForeground); + color: var(--vscode-agentsVoice-speakingForeground); + font-weight: var(--vscode-fontWeight-semiBold); +} + +/* --- The icon carries the story --- */ + +/* + * One slot, three states. Play says "this will speak", the bars say "it is + * speaking", the check says "this one is yours". Stacking them in a fixed-size + * slot means swapping state never reflows the row. + */ +/* + * The slot collapses to nothing at rest and slides open on hover or once the + * voice is chosen - the same move the Voice Mode pill makes with its own cells. + * That is what lets four named buttons share one row in a narrow panel. + */ +.voice-mode-onboarding-voice-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 0; + height: var(--vscode-codiconFontSize-compact, 12px); + overflow: hidden; + transition: width 0.2s cubic-bezier(0.2, 0.9, 0.2, 1), margin-right 0.2s cubic-bezier(0.2, 0.9, 0.2, 1); +} + +.voice-mode-onboarding-voice:hover .voice-mode-onboarding-voice-icon, +.voice-mode-onboarding-voice:focus-visible .voice-mode-onboarding-voice-icon, +.voice-mode-onboarding-voice.selected .voice-mode-onboarding-voice-icon, +.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-icon { + width: var(--vscode-codiconFontSize-compact, 12px); + margin-right: var(--vscode-spacing-size20); +} + +.voice-mode-onboarding-voice-icon > * { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 120ms ease-out; +} + +.monaco-workbench .voice-mode-onboarding-voice-icon .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize-compact, 12px); + line-height: 1; + color: inherit; +} + +/* Idle: the play glyph, brightening on hover. */ +.voice-mode-onboarding-voice .voice-mode-onboarding-voice-idle { + opacity: 0.6; +} + +.voice-mode-onboarding-voice:hover .voice-mode-onboarding-voice-idle { + opacity: 1; +} + +/* Chosen: the check replaces play, so the row says which voice is in use. */ +.voice-mode-onboarding-voice.selected .voice-mode-onboarding-voice-idle { + opacity: 0; +} + +.voice-mode-onboarding-voice.selected .voice-mode-onboarding-voice-chosen { + opacity: 1; +} + +/* Speaking: miniature bars, the same instrument as the big waveform. */ +.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-idle, +.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-chosen { + opacity: 0; +} + +.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bars { + opacity: 1; +} + +.voice-mode-onboarding-voice-bars { + gap: 1px; + height: var(--vscode-codiconFontSize-compact, 12px); +} + +.voice-mode-onboarding-voice-bar { + width: 1.5px; + height: 4px; + border-radius: 1px; + background: currentColor; +} + +.monaco-workbench.monaco-enable-motion .voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bar { + animation: voice-mode-onboarding-eq 0.85s ease-in-out infinite; +} + +.monaco-workbench.monaco-enable-motion .voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bar:nth-child(2) { + animation-delay: 0.12s; +} + +.monaco-workbench.monaco-enable-motion .voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bar:nth-child(3) { + animation-delay: 0.24s; +} + +/* Mirrors `chat-voice-input-mode-eq`, so the two waveforms move alike. */ +@keyframes voice-mode-onboarding-eq { + 0%, 100% { height: 3px; } + 50% { height: 10px; } +} + +/* --- Reduced motion --- */ + +/* + * The keyframe animations above are already opt-in via `.monaco-enable-motion`, + * but transitions are not: without this the icon slot would still slide open + * over 200ms and its glyphs still crossfade, which is exactly the movement the + * preference asks us not to make. Colour changes are left alone - they convey + * state rather than motion. + */ +.monaco-reduce-motion .voice-mode-onboarding-voice-icon, +.monaco-reduce-motion .voice-mode-onboarding-voice-icon > * { + transition: none; +} + +/* --- Close --- */ + +/* + * Pinned to the corner and out of the content flow, so it never competes with + * the voices for room and never moves as the card re-tiers. Always available: + * a disabled dismiss would trap someone inside the card. + */ +.voice-mode-onboarding-close { + position: absolute; + top: var(--vscode-spacing-size80); + right: var(--vscode-spacing-size80); + display: flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size200); + height: var(--vscode-spacing-size200); + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.voice-mode-onboarding-close:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.voice-mode-onboarding-close:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* + * `.monaco-workbench .codicon` sets colour and size directly on the glyph, so + * without out-ranking it this renders as a dark 16px icon rather than a compact + * one inheriting the button's foreground. + */ +.monaco-workbench .voice-mode-onboarding-close .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize-compact, 12px); + line-height: 1; + color: inherit; +} diff --git a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts new file mode 100644 index 00000000000000..2aa691e00c20ac --- /dev/null +++ b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts @@ -0,0 +1,899 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { renderFormattedText } from '../../../../base/browser/formattedTextRenderer.js'; +import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { localize } from '../../../../nls.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IVoiceSessionController } from '../../chat/browser/voiceClient/voiceSessionController.js'; +import { ChatInputOnboarding, ChatInputOnboardingCard, IChatInputOnboardingContext } from '../../chat/browser/widget/input/chatInputOnboarding.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { AgentsVoiceStorageKeys } from '../common/agentsVoice.js'; +import './media/voiceModeOnboarding.css'; + +/** Setting the banner writes when a voice chip is picked. */ +const VOICE_SETTING = 'agents.voice.voice'; + +/** Where the first link sends anyone who wants to change their mind later. */ +const VOICE_SETTINGS_COMMAND = 'agentsVoice.openSettings'; + +/** + * Where the second link goes: `voice.md`, the customization file sent to the + * backend as `voice_instructions`. It shapes what the agent *says back*, which + * is why the link reads "how it responds" rather than naming the file. + */ +const VOICE_INSTRUCTIONS_COMMAND = 'workbench.action.chat.configureVoiceInstructions'; + +type VoiceModeOnboardingAction = 'shown' | 'selectVoice' | 'openSettings' | 'openInstructions' | 'close' | 'escape'; + +type VoiceModeOnboardingActionClassification = { + action: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The action taken in the Voice Mode onboarding card.' }; + source: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'Whether the card appeared automatically on first use or was opened manually.' }; + owner: 'meganrogge'; + comment: 'Tracks engagement with the Voice Mode onboarding card.'; +}; + +type VoiceModeOnboardingActionEvent = { + action: VoiceModeOnboardingAction; + source: 'automatic' | 'manual'; +}; + +/** + * The voices Voice Mode actually speaks with (mirrors the `agents.voice.voice` + * enum). Each one ships a short pre-recorded sample rendered with that exact + * model voice, so the preview a user hears in the banner is what they get in a + * real conversation. + */ +interface IVoiceModeVoice { + readonly id: string; + readonly label: string; + /** This voice's waveform texture. See {@link IWave}. */ + readonly signature: readonly IWave[]; +} + +/** + * One sine component of a waveform texture. A voice's signature is a handful of + * these summed together, which is what gives each voice a recognisably + * different trace rather than four copies of the same ripple. + */ +interface IWave { + readonly frequency: number; + readonly amplitude: number; + readonly speed: number; + readonly phase: number; +} + +const VOICES: readonly IVoiceModeVoice[] = [ + { + id: 'maya_neutral', + label: localize('voiceMode.onboarding.voice.maya', "Maya"), + // Flowing mid-range: even spread, gentle drift. + signature: [ + { frequency: 1.0, amplitude: 0.42, speed: 0.42, phase: 0.0 }, + { frequency: 1.7, amplitude: 0.26, speed: -0.31, phase: 1.1 }, + { frequency: 2.6, amplitude: 0.19, speed: 0.24, phase: 2.4 }, + { frequency: 4.1, amplitude: 0.13, speed: -0.18, phase: 0.7 }, + ], + }, + { + id: 'victoria_neutral', + label: localize('voiceMode.onboarding.voice.victoria', "Victoria"), + // Bright and quick: higher frequencies, tighter ripple. + signature: [ + { frequency: 1.4, amplitude: 0.38, speed: 0.52, phase: 0.0 }, + { frequency: 2.3, amplitude: 0.27, speed: -0.38, phase: 1.1 }, + { frequency: 3.6, amplitude: 0.21, speed: 0.30, phase: 2.4 }, + { frequency: 5.2, amplitude: 0.14, speed: -0.22, phase: 0.7 }, + ], + }, + { + id: 'kevin_neutral', + label: localize('voiceMode.onboarding.voice.kevin', "Kevin"), + // Low and broad: long swells with little high-frequency detail. + signature: [ + { frequency: 0.7, amplitude: 0.48, speed: 0.30, phase: 0.4 }, + { frequency: 1.2, amplitude: 0.28, speed: -0.22, phase: 1.7 }, + { frequency: 2.0, amplitude: 0.16, speed: 0.18, phase: 0.9 }, + { frequency: 3.1, amplitude: 0.09, speed: -0.14, phase: 2.2 }, + ], + }, + { + id: 'daniel_neutral', + label: localize('voiceMode.onboarding.voice.daniel', "Daniel"), + // Steady and measured: slow drift, calm regular crests. + signature: [ + { frequency: 0.9, amplitude: 0.44, speed: 0.24, phase: 1.3 }, + { frequency: 1.5, amplitude: 0.30, speed: -0.18, phase: 0.2 }, + { frequency: 2.4, amplitude: 0.14, speed: 0.15, phase: 2.0 }, + { frequency: 3.4, amplitude: 0.10, speed: -0.12, phase: 1.5 }, + ], + }, +]; + +/** + * The trace before anyone has chosen: the four signatures averaged component by + * component, so it belongs to no voice in particular rather than quietly being + * the first one in the list. The declared phases all sit within a couple of + * radians of each other, so a plain mean lands between them rather than on the + * far side of the circle. + */ +const RESTING_SIGNATURE: readonly IWave[] = VOICES[0].signature.map((_, index) => { + const components = VOICES.map(voice => voice.signature[index]); + const mean = (pick: (wave: IWave) => number) => + components.reduce((sum, wave) => sum + pick(wave), 0) / components.length; + return { + frequency: mean(wave => wave.frequency), + amplitude: mean(wave => wave.amplitude), + speed: mean(wave => wave.speed), + phase: mean(wave => wave.phase), + }; +}); + +/** + * How long the dominant component takes to complete one cycle, matching the + * `chat-voice-input-mode-wave` keyframe the toolbar waveform idles on. Same + * instrument, same tempo. + */ +const IDLE_CYCLE_SECONDS = 2.6; + +/** + * Scales every declared `speed` so the resting trace cycles at + * {@link IDLE_CYCLE_SECONDS}. The signatures are written as a *relative* set - + * component 1 drifts against component 0, and so on - which makes them + * readable, but taken literally the dominant component turns once every ~17 + * seconds. That is roughly 1px of movement per bar per second: technically + * animating, visibly frozen. Derived rather than hardcoded so editing a + * signature cannot silently put the trace back to sleep. + */ +const WAVE_TEMPO = (2 * Math.PI) / IDLE_CYCLE_SECONDS / Math.abs(RESTING_SIGNATURE[0].speed); + +// --- Waveform ----------------------------------------------------------- + +/** Amplitude with nothing playing: present, but clearly at rest. */ +const IDLE_GAIN = 0.55; +/** Extra amplitude at peak loudness while a voice sample plays. */ +const SPEAKING_GAIN = 0.45; +/** How quickly the band chases the audio. Low and slow reads as smooth. */ +const LEVEL_EASING = 0.08; +/** How quickly the trace morphs from one voice's signature to another. */ +const SIGNATURE_EASING = 0.06; +/** + * Bar metrics, taken from Voice Mode's own waveform in `voiceInputMode.css`, + * which states the rule directly: *bars are strokes, not shapes* - they carry + * the same visual weight as the codicon glyphs beside them so the waveform + * never reads as bolder than the mic. Same 1px stroke and 2px gap here, just + * many more of them. + */ +const BAR_WIDTH = 1; +const BAR_GAP = 2; +/** Shortest a bar ever gets: a dot, so a resting bar keeps its round cap. */ +const BAR_MIN = 1; + +/** A signature being eased towards another, one component at a time. */ +type MutableWave = { frequency: number; amplitude: number; speed: number; phase: number }; + +function cloneSignature(signature: readonly IWave[]): MutableWave[] { + return signature.map(wave => ({ ...wave })); +} + +/** + * Ease a signature towards a target in place. Morphing the numbers rather than + * swapping them is what makes a voice change read as the trace *becoming* the + * new voice instead of cutting to it. + * + * `phase` eases with the rest: it is a static offset per component (the motion + * comes from `time * speed`), so leaving it behind would strand every voice on + * whichever phases the trace happened to start with. Every declared phase sits + * within a radian or two of its neighbours, well inside half a turn, so easing + * straight to the target is also the shortest way round the circle. + */ +function easeSignature(current: MutableWave[], target: readonly IWave[]): void { + for (let i = 0; i < current.length && i < target.length; i++) { + current[i].frequency += (target[i].frequency - current[i].frequency) * SIGNATURE_EASING; + current[i].amplitude += (target[i].amplitude - current[i].amplitude) * SIGNATURE_EASING; + current[i].speed += (target[i].speed - current[i].speed) * SIGNATURE_EASING; + current[i].phase += (target[i].phase - current[i].phase) * SIGNATURE_EASING; + } +} + +/** + * Draw the row of bars. Heights are symmetric about the centre line and follow + * the same centre-peak silhouette as the toolbar waveform, so the two read as + * the same instrument at different sizes. + */ +function drawBars( + context: CanvasRenderingContext2D, + width: number, height: number, + waves: readonly MutableWave[], time: number, gain: number, +): void { + const pitch = BAR_WIDTH + BAR_GAP; + const count = Math.max(1, Math.floor(width / pitch)); + // Centre the row: whatever does not divide evenly becomes even margins + // rather than a ragged right edge. + const inset = (width - (count * pitch - BAR_GAP)) / 2; + const centerY = height / 2; + const maxHalf = height / 2; + + for (let index = 0; index < count; index++) { + const position = count > 1 ? index / (count - 1) : 0; + const amount = bandFraction(position, time, waves) * gain; + const half = Math.max(BAR_MIN / 2, Math.min(maxHalf, amount * maxHalf)); + context.beginPath(); + context.roundRect(inset + index * pitch, centerY - half, BAR_WIDTH, half * 2, BAR_WIDTH / 2); + context.fill(); + } +} + +/** + * Half-height of the band at `position` (0..1 across the strip), as a fraction + * of the available half-height. + * + * Each component contributes an already-positive, cusp-free curve. Summing raw + * sines and taking their magnitude would put a sharp corner at every zero + * crossing - that is what makes an ASCII waveform look like it is snapping up + * and down rather than flowing. + */ +function bandFraction(position: number, time: number, waves: readonly MutableWave[]): number { + let amplitude = 0; + let total = 0; + for (const wave of waves) { + const phase = position * wave.frequency * Math.PI * 2 + time * wave.speed * WAVE_TEMPO + wave.phase; + amplitude += (0.5 + 0.5 * Math.sin(phase)) * wave.amplitude; + total += wave.amplitude; + } + if (total === 0) { + return 0; + } + // Centre-peak silhouette, matching the toolbar waveform: tallest in the + // middle, tapering to the ends, so the row reads as one instrument rather + // than a strip cut off at both edges. + const taper = Math.sin(Math.PI * Math.min(1, Math.max(0, position))); + return (amplitude / total) * (0.35 + 0.65 * taper); +} + +/** What the animator needs to know each frame, supplied by the banner. */ +interface IWaveformSource { + /** Loudness of the voice being previewed, `0` when silent. */ + getLevel(): number; + /** The signature the trace should be easing towards. */ + getSignature(): readonly IWave[]; +} + +/** + * Draws the animated waveform. Owns a single canvas, a `ResizeObserver` and an + * animation-frame loop; disposing stops both. Honors reduced motion by painting + * a single static frame instead of animating. + */ +class VoiceModeOnboardingAnimator extends Disposable { + + private readonly context: CanvasRenderingContext2D; + private readonly animationFrame = this._register(new MutableDisposable()); + private width = 0; + private height = 0; + private running = false; + private level = 0; + private readonly waves: MutableWave[]; + /** + * The stroke colour, taken from the canvas's own computed `color` so CSS + * owns the tier and theme overrides work for free - the same `currentColor` + * arrangement the toolbar waveform uses. Cached rather than read per frame: + * `getComputedStyle` inside the animation loop forces a style recalculation + * on every tick. + */ + private stroke = ''; + + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly container: HTMLElement, + private readonly source: IWaveformSource, + @IThemeService private readonly themeService: IThemeService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(); + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to create the Voice Mode onboarding canvas context'); + } + this.context = context; + this.waves = cloneSignature(this.source.getSignature()); + + const targetWindow = dom.getWindow(container); + const observer = new targetWindow.ResizeObserver(() => this.resize()); + observer.observe(container); + this._register(toDisposable(() => observer.disconnect())); + + this._register(this.themeService.onDidColorThemeChange(() => { + this.readStroke(); + this.draw(targetWindow.performance.now()); + })); + this._register(this.accessibilityService.onDidChangeReducedMotion(() => this.updateMotion())); + this._register(toDisposable(() => this.stop())); + + this.readStroke(); + this.resize(); + this.updateMotion(); + } + + private readStroke(): void { + this.stroke = dom.getWindow(this.canvas).getComputedStyle(this.canvas).color; + } + + private updateMotion(): void { + if (this.accessibilityService.isMotionReduced()) { + this.stop(); + this.draw(dom.getWindow(this.container).performance.now()); + } else { + this.start(); + } + } + + private start(): void { + if (this.running) { + return; + } + this.running = true; + const targetWindow = dom.getWindow(this.container); + const tick = (time: number) => { + if (!this.running) { + return; + } + this.draw(time); + this.animationFrame.value = dom.scheduleAtNextAnimationFrame(targetWindow, () => tick(targetWindow.performance.now())); + }; + this.animationFrame.value = dom.scheduleAtNextAnimationFrame(targetWindow, () => tick(targetWindow.performance.now())); + } + + private stop(): void { + this.running = false; + this.animationFrame.clear(); + } + + private resize(): void { + const targetWindow = dom.getWindow(this.container); + const devicePixelRatio = targetWindow.devicePixelRatio || 1; + this.width = this.container.offsetWidth; + this.height = this.container.offsetHeight; + if (!this.width || !this.height) { + return; + } + this.canvas.width = this.width * devicePixelRatio; + this.canvas.height = this.height * devicePixelRatio; + this.context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + this.draw(targetWindow.performance.now()); + } + + private draw(timestamp: number): void { + if (!this.width || !this.height) { + return; + } + + const time = timestamp * 0.001; + + // Idle, the waveform breathes gently; while a voice plays it swells with + // that voice. Both the level and the shape are eased so the ribbon glides + // rather than snapping between frames. + this.level += (this.source.getLevel() - this.level) * LEVEL_EASING; + easeSignature(this.waves, this.source.getSignature()); + const gain = IDLE_GAIN + this.level * SPEAKING_GAIN; + + this.context.clearRect(0, 0, this.width, this.height); + this.context.fillStyle = this.stroke; + + drawBars(this.context, this.width, this.height, this.waves, time, gain); + } + +} + +// --- Banner ------------------------------------------------------------- + +/** + * Plays the pre-recorded voice samples that ship next to this file. One element + * is reused for every preview so picking a second voice cuts the first one off. + */ +class VoiceSamplePlayer extends Disposable { + + private readonly playback = this._register(new MutableDisposable()); + + /** Reused across previews: a media element source can only be created once + * per element, so the element, context and analyser are all long-lived. */ + private audio: HTMLAudioElement | undefined; + private analyser: AnalyserNode | undefined; + private levels: Uint8Array | undefined; + + private readonly _onDidChangePlayingVoice = this._register(new Emitter()); + /** Fires with the voice currently being heard, or `undefined` once it stops. */ + readonly onDidChangePlayingVoice = this._onDidChangePlayingVoice.event; + + private _playingVoice: string | undefined; + get playingVoice(): string | undefined { return this._playingVoice; } + + constructor( + private readonly element: HTMLElement, + @ILogService private readonly logService: ILogService, + ) { + super(); + this._register(toDisposable(() => this.stop())); + } + + /** + * Current loudness of the sample being played, `0` when silent. The waveform + * reads this so it moves to the voice the user is actually hearing. + */ + getLevel(): number { + if (!this.analyser || !this.levels || !this._playingVoice) { + return 0; + } + this.analyser.getByteTimeDomainData(this.levels); + let sum = 0; + for (const sample of this.levels) { + const centered = (sample - 128) / 128; + sum += centered * centered; + } + // RMS, scaled so ordinary speech lands near 1 rather than a fraction. + return Math.min(1, Math.sqrt(sum / this.levels.length) * 3.2); + } + + play(voiceId: string): void { + this.stop(); + try { + const audio = this.ensureAudio(); + audio.src = FileAccess.asBrowserUri(`vs/workbench/contrib/agentsVoice/browser/media/${voiceId}.mp3`).toString(true); + + const store = new DisposableStore(); + store.add(dom.addDisposableListener(audio, 'ended', () => this.stop())); + store.add(dom.addDisposableListener(audio, 'error', () => this.stop())); + store.add(toDisposable(() => audio.pause())); + this.playback.value = store; + + this.setPlayingVoice(voiceId); + audio.play().catch(error => { + this.logService.trace(`[voice] Voice Mode onboarding preview failed: ${error}`); + this.stop(); + }); + } catch (error) { + this.logService.trace(`[voice] Voice Mode onboarding preview unavailable: ${error}`); + this.stop(); + } + } + + /** + * Build the audio element and, best-effort, the analyser graph feeding the + * waveform. Analysis is a nicety: if the Web Audio graph cannot be created + * the sample still plays, the waveform just keeps its idle motion. + */ + private ensureAudio(): HTMLAudioElement { + if (this.audio) { + return this.audio; + } + + const targetWindow = dom.getWindow(this.element); + const audio = new targetWindow.Audio(); + this.audio = audio; + this._register(toDisposable(() => { + audio.pause(); + audio.src = ''; + })); + + try { + const context = new targetWindow.AudioContext(); + this._register(toDisposable(() => void context.close().catch(() => { /* already closing */ }))); + const analyser = context.createAnalyser(); + analyser.fftSize = 256; + context.createMediaElementSource(audio).connect(analyser); + analyser.connect(context.destination); + this.analyser = analyser; + this.levels = new Uint8Array(analyser.fftSize); + } catch (error) { + this.logService.trace(`[voice] Voice Mode onboarding analyser unavailable: ${error}`); + } + + return audio; + } + + stop(): void { + this.playback.clear(); + this.setPlayingVoice(undefined); + } + + private setPlayingVoice(voiceId: string | undefined): void { + if (this._playingVoice === voiceId) { + return; + } + this._playingVoice = voiceId; + this._onDidChangePlayingVoice.fire(voiceId); + } +} + +export interface IVoiceModeOnboardingBannerOptions { + /** The element the banner attaches itself to. */ + readonly container: HTMLElement; + readonly onDismiss: () => void; + readonly source: 'automatic' | 'manual'; +} + +/** + * The first-run Voice Mode card: what Voice Mode is, that it costs nothing, the + * voices it can speak with, and the mic as the alternative for anyone who would + * rather not be spoken to at all. + * + * Clicking a voice both plays it and adopts it, so there is nothing to confirm + * afterwards. The leading icon carries that story: play before the click, + * animating bars while it speaks, then a check once it is yours. + */ +export class VoiceModeOnboardingBanner extends Disposable { + + readonly domNode: HTMLElement; + + private readonly card: ChatInputOnboardingCard; + private readonly player: VoiceSamplePlayer; + private readonly options: IVoiceModeOnboardingBannerOptions; + + private readonly voiceElements = new Map(); + + /** The voice being auditioned, and the one that will be committed. */ + private selectedVoice: IVoiceModeVoice | undefined; + + constructor( + options: IVoiceModeOnboardingBannerOptions, + @ICommandService private readonly commandService: ICommandService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, + ) { + super(); + + this.options = options; + + this.card = this._register(new ChatInputOnboardingCard({ + container: options.container, + className: 'voice-mode-onboarding-banner', + ariaLabel: localize('voiceMode.onboarding.region', "Voice Mode introduction"), + onEscape: () => { + this.logAction('escape'); + this.options.onDismiss(); + }, + })); + this.domNode = this.card.domNode; + this.player = this._register(instantiationService.createInstance(VoiceSamplePlayer, this.domNode)); + this._register(this.player.onDidChangePlayingVoice(voiceId => this.updatePlaying(voiceId))); + + // Voice Mode is live, but it must not be listening while the user is still + // reading and picking a voice. A hold is used rather than `stopListening` + // because the card goes up on `isConnecting`, before the session exists: + // `stopListening` no-ops until connected, and hands-free would then open + // the microphone on `session_init` with the card still on screen. + // Released in `finish()`, which is the only way out of the card. + this.voiceSessionController.setAutoListenHeld(true); + this._register(toDisposable(() => this.voiceSessionController.setAutoListenHeld(false))); + + const copy = dom.append(this.domNode, dom.$('.voice-mode-onboarding-copy')); + const title = dom.append(copy, dom.$('.voice-mode-onboarding-title')); + title.textContent = localize('voiceMode.onboarding.title', "Welcome to Voice Mode"); + this.renderDescription(copy); + this.renderListeningNotice(copy); + + this.renderSharedWaveform(instantiationService); + + const actions = dom.append(this.domNode, dom.$('.voice-mode-onboarding-actions')); + this.renderVoices(actions); + this.renderClose(); + this.logAction('shown'); + + this.focusForScreenReader(); + this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this.focusForScreenReader())); + } + + /** + * The signature the shared trace should be showing: the selected voice's, or + * {@link RESTING_SIGNATURE} before anything has been chosen. + */ + private currentSignature(): readonly IWave[] { + return this.selectedVoice?.signature ?? RESTING_SIGNATURE; + } + + /** The single full-width trace the whole card shares. */ + private renderSharedWaveform(instantiationService: IInstantiationService): void { + const wave = dom.append(this.domNode, dom.$('.voice-mode-onboarding-wave')); + const canvas = dom.append(wave, dom.$('canvas.voice-mode-onboarding-canvas')) as HTMLCanvasElement; + canvas.setAttribute('aria-hidden', 'true'); + this._register(instantiationService.createInstance(VoiceModeOnboardingAnimator, canvas, wave, { + getLevel: () => this.player.getLevel(), + getSignature: () => this.currentSignature(), + })); + } + + /** + * The four voices as real buttons - border, hover lift, pressed feedback - + * because bare text gave no sign it could be clicked at all. + */ + private renderVoices(container: HTMLElement): void { + const group = dom.append(container, dom.$('.voice-mode-onboarding-voices')); + group.setAttribute('role', 'radiogroup'); + group.setAttribute('aria-label', localize('voiceMode.onboarding.voices', "Voice Mode voice")); + + for (const voice of VOICES) { + const option = dom.append(group, dom.$('.voice-mode-onboarding-voice')); + option.setAttribute('role', 'radio'); + // Spells out both halves of what a click does: it speaks, and it sticks. + option.setAttribute('aria-label', localize('voiceMode.onboarding.voice.ariaLabel', "{0}. Hear this voice and use it for every conversation.", voice.label)); + + // The icon is the affordance: it says "this will speak" before the + // click, and "this is yours" after it. + const icon = dom.append(option, dom.$('span.voice-mode-onboarding-voice-icon')); + dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.play.id}.voice-mode-onboarding-voice-idle`)).setAttribute('aria-hidden', 'true'); + dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.checkCompact.id}.voice-mode-onboarding-voice-chosen`)).setAttribute('aria-hidden', 'true'); + const bars = dom.append(icon, dom.$('span.voice-mode-onboarding-voice-bars')); + bars.setAttribute('aria-hidden', 'true'); + for (let bar = 0; bar < 3; bar++) { + dom.append(bars, dom.$('span.voice-mode-onboarding-voice-bar')); + } + + const label = dom.append(option, dom.$('span.voice-mode-onboarding-voice-label')); + label.textContent = voice.label; + this.voiceElements.set(voice.id, option); + + 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.updateSelection(); + } + + // --- Shared behaviour --- + + private handleOptionKey(event: KeyboardEvent, voice: IVoiceModeVoice): void { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) { + keyboardEvent.preventDefault(); + this.selectVoice(voice); + return; + } + + // A radiogroup is a single tab stop: the arrow keys move between the + // options (selecting as they go, as a radio group should) rather than Tab + // walking through every one of them. + const forward = keyboardEvent.equals(KeyCode.RightArrow) || keyboardEvent.equals(KeyCode.DownArrow); + const backward = keyboardEvent.equals(KeyCode.LeftArrow) || keyboardEvent.equals(KeyCode.UpArrow); + if (forward || backward) { + keyboardEvent.preventDefault(); + const index = VOICES.indexOf(voice); + const next = VOICES[(index + (forward ? 1 : VOICES.length - 1)) % VOICES.length]; + this.selectVoice(next); + this.voiceElements.get(next.id)?.focus(); + } + } + + /** + * One short paragraph: what Voice Mode does, and the two places to change + * your mind - the settings that control it, and the customization file that + * shapes what it says back. + * + * `[[...]]` marks each clause that becomes a link, so translators can place + * them naturally in the sentence instead of receiving fixed phrases + * concatenated onto the end. `renderFormattedText` numbers them in source + * order and hands the index to the callback. + */ + private renderDescription(container: HTMLElement): void { + const description = dom.append(container, dom.$('.voice-mode-onboarding-description')); + const text = localize({ + key: 'voiceMode.onboarding.description', + comment: [ + 'Preserve the double square brackets: they mark the two pieces of text that become links.', + 'The first link opens Voice Mode settings; the second opens a file for customizing how the agent speaks.', + ], + }, "Your agent can speak back to you, free of charge. Adjust [[settings]] or [[how it responds]] anytime."); + + const commands = [VOICE_SETTINGS_COMMAND, VOICE_INSTRUCTIONS_COMMAND]; + dom.append(description, renderFormattedText(text, { + actionHandler: { + callback: index => { + const command = commands[Number(index)]; + if (command) { + this.logAction(index === '0' ? 'openSettings' : 'openInstructions'); + this.commandService.executeCommand(command) + .catch(error => this.logService.error(`[voice] Failed to run ${command}: ${error}`)); + } + }, + disposables: this._store, + }, + }, dom.$('span'))); + + // `renderFormattedText` gives each anchor a click listener and nothing + // else, so make them real controls: reachable by Tab and operable by + // Enter or Space like any other button. The renderer owns this DOM, so a + // selector is the only handle on it - same as the empty-editor hint. + // eslint-disable-next-line no-restricted-syntax + for (const link of description.querySelectorAll('a')) { + link.tabIndex = 0; + link.setAttribute('role', 'button'); + this._register(dom.addDisposableListener(link, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) { + keyboardEvent.preventDefault(); + link.click(); + } + })); + } + } + + private renderListeningNotice(container: HTMLElement): void { + const notice = dom.append(container, dom.$('.voice-mode-onboarding-listening-notice')); + const icon = dom.append(notice, dom.$(`span.codicon.codicon-${Codicon.mic.id}`)); + icon.setAttribute('aria-hidden', 'true'); + const text = dom.append(notice, dom.$('span')); + text.textContent = localize('voiceMode.onboarding.closeWhenReady', "Close this when you're ready to speak."); + } + + /** + * Dismissal is always available and never gated: a disabled close would trap + * someone in the card. Choosing a voice already commits it, so this is only + * ever "I am done here" - and closing is what hands the session back. + */ + private renderClose(): void { + this.card.addAction({ + className: 'voice-mode-onboarding-close', + ariaLabel: localize('voiceMode.onboarding.close', "Close the introduction and continue to Voice Mode"), + icon: Codicon.checkCompact, + onActivate: () => this.finish(), + }); + } + + private focusForScreenReader(): void { + if (this.accessibilityService.isScreenReaderOptimized()) { + this.domNode.tabIndex = -1; + this.domNode.focus(); + } + } + + private selectVoice(voice: IVoiceModeVoice): void { + this.logAction('selectVoice'); + this.selectedVoice = voice; + this.updateSelection(); + this.player.play(voice.id); + status(localize('voiceMode.onboarding.voice.selected', "{0} selected.", voice.label)); + this.configurationService.updateValue(VOICE_SETTING, voice.id, ConfigurationTarget.USER) + .catch(error => this.logService.error(`[voice] Failed to persist the Voice Mode voice: ${error}`)); + } + + private updateSelection(): void { + for (const [id, element] of this.voiceElements) { + const selected = id === this.selectedVoice?.id; + element.classList.toggle('selected', selected); + element.setAttribute('aria-checked', String(selected)); + } + this.updateTabStop(); + } + + /** + * Keeps a single tab stop on the group: the chosen voice, or the first one + * when nothing has been chosen yet. + */ + private updateTabStop(): void { + let first = true; + for (const [id, element] of this.voiceElements) { + const isTabStop = this.selectedVoice === undefined ? first : id === this.selectedVoice.id; + element.tabIndex = isTabStop ? 0 : -1; + first = false; + } + } + + private updatePlaying(playingVoice: string | undefined): void { + for (const [id, element] of this.voiceElements) { + element.classList.toggle('playing', id === playingVoice); + } + this.domNode.classList.toggle('playing', playingVoice !== undefined); + } + + /** + * Close the introduction and hand the session back to the user. Voice Mode + * stays connected either way; hands-free starts listening immediately, while + * push-to-talk waits for the mic button so nobody is recorded unexpectedly. + */ + private finish(): void { + this.player.stop(); + this.logAction('close'); + + // Releasing the hold is what hands the session back: hands-free picks up + // and starts listening, push-to-talk stays quiet until the mic button. + // The release itself runs on dispose, below. + status(this.configurationService.getValue('agents.voice.handsFree') === true + ? localize('voiceMode.onboarding.listening', "Voice Mode is listening.") + : localize('voiceMode.onboarding.ready', "Voice Mode is ready. Press the mic button to start talking.")); + + this.options.onDismiss(); + } + + private logAction(action: VoiceModeOnboardingAction): void { + this.telemetryService.publicLog2( + 'voiceModeOnboarding.action', + { action, source: this.options.source } + ); + } +} + +export const IVoiceModeOnboardingService = createDecorator('voiceModeOnboardingService'); + +export interface IVoiceModeOnboardingService { + readonly _serviceBrand: undefined; + + /** + * Register a container that can host the banner (a chat input). The most + * recently focused host wins when the banner is shown. + * + * @param container the element the banner is appended to. + * @param focusRoot the element whose focus marks this host as the active one + * (typically the chat input part the container lives in). + * @param focus hands focus back to this host's input when the banner closes. + * Passed explicitly because `focusRoot` is a container, not a control - the + * host knows where its caret belongs and this service does not. + */ + registerHost(container: HTMLElement, focusRoot: HTMLElement, focus: () => void): IDisposable; + + /** + * Show the introduction if the user has never seen it. Marks it as seen on + * the first successful show, so it never appears again. + */ + showIfNeeded(): void; + + /** Show the introduction again regardless of whether it has been seen. */ + show(): boolean; +} + +export class VoiceModeOnboardingService extends Disposable implements IVoiceModeOnboardingService { + + declare readonly _serviceBrand: undefined; + + private readonly onboarding: ChatInputOnboarding; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + this.onboarding = this._register(this.instantiationService.createInstance(ChatInputOnboarding, { + storageKey: AgentsVoiceStorageKeys.IntroBannerShown, + hostClass: 'has-voice-mode-onboarding', + })); + } + + registerHost(container: HTMLElement, focusRoot: HTMLElement, focus: () => void): IDisposable { + return this.onboarding.registerHost(container, focusRoot, focus); + } + + showIfNeeded(): void { + this.onboarding.showIfNeeded(context => this.createBanner(context, 'automatic')); + } + + show(): boolean { + return this.onboarding.show(context => this.createBanner(context, 'manual')); + } + + private createBanner(context: IChatInputOnboardingContext, source: 'automatic' | 'manual'): VoiceModeOnboardingBanner { + return this.instantiationService.createInstance(VoiceModeOnboardingBanner, { + container: context.container, + onDismiss: () => context.dismiss(dom.isAncestorOfActiveElement(context.container)), + source, + }); + } +} + +registerSingleton(IVoiceModeOnboardingService, VoiceModeOnboardingService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts index 6cc3aad25c3509..a3fcaab8519698 100644 --- a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts +++ b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts @@ -32,6 +32,12 @@ export const enum AgentsVoiceStorageKeys { WindowBounds = 'agentsVoice.windowBounds', TranscriptIndex = 'agentsVoice.transcriptIndex', OnboardingCompleted = 'agentsVoice.onboardingCompleted', + /** + * First-run introduction shown above the chat input. Distinct from + * {@link OnboardingCompleted}, which tracks the Voice Mode window's own + * onboarding. + */ + IntroBannerShown = 'agentsVoice.introBannerShown', MicrophoneDevice = 'agentsVoice.microphoneDevice', } diff --git a/src/vs/workbench/contrib/agentsVoice/common/agentsVoiceColors.ts b/src/vs/workbench/contrib/agentsVoice/common/agentsVoiceColors.ts index 8aa5cf51885433..f68f103b45c481 100644 --- a/src/vs/workbench/contrib/agentsVoice/common/agentsVoiceColors.ts +++ b/src/vs/workbench/contrib/agentsVoice/common/agentsVoiceColors.ts @@ -15,3 +15,4 @@ export const agentsVoiceSpeakingBackground = registerColor('agentsVoice.speaking { dark: '#a371f714', light: '#8250df14', hcDark: '#d2a8ff14', hcLight: '#6639ba14' }, localize('agentsVoice.speakingBackground', "Background color for the speaking voice state row highlight") ); + diff --git a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts new file mode 100644 index 00000000000000..d8019fbc49d86e --- /dev/null +++ b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts @@ -0,0 +1,344 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.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'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AgentsVoiceStorageKeys } from '../../common/agentsVoice.js'; +import { IVoiceSessionController, VoiceState } from '../../../chat/browser/voiceClient/voiceSessionController.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { VoiceModeOnboardingService } from '../../browser/voiceModeOnboarding.js'; + +suite('Voice Mode onboarding', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + interface ITestHost { root: HTMLElement; container: HTMLElement; focused: number } + interface ITelemetryEvent { readonly name: string; readonly data: unknown } + + class TestTelemetryService extends NullTelemetryServiceShape { + constructor(private readonly events: ITelemetryEvent[]) { + super(); + } + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } + } + + function createHost(store: Pick): ITestHost { + const root = dom.$('div'); + root.tabIndex = 0; + const container = dom.append(root, dom.$('.voice-mode-onboarding-container')); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + return { root, container, focused: 0 }; + } + + function register(service: VoiceModeOnboardingService, host: ITestHost) { + return service.registerHost(host.container, host.root, () => { + host.focused++; + host.root.focus(); + }); + } + + function createService(store: Pick, executed: string[] = [], holds: boolean[] = [], telemetryEvents: ITelemetryEvent[] = [], screenReaderOptimized = false): VoiceModeOnboardingService { + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IAccessibilityService, new class extends mock() { + override readonly onDidChangeScreenReaderOptimized = Event.None; + override readonly onDidChangeReducedMotion = Event.None; + override isScreenReaderOptimized(): boolean { return screenReaderOptimized; } + override isMotionReduced(): boolean { return false; } + }); + instantiationService.stub(ICommandService, new class extends mock() { + override executeCommand(id: string): Promise { + executed.push(id); + return Promise.resolve(undefined); + } + }); + instantiationService.stub(IVoiceSessionController, new class extends mock() { + override readonly voiceState = constObservable('idle'); + override setAutoListenHeld(held: boolean): void { holds.push(held); } + override stopListening(): void { } + override pttDown(): void { } + override pttUp(): void { } + }); + instantiationService.stub(ITelemetryService, new TestTelemetryService(telemetryEvents)); + return store.add(instantiationService.createInstance(VoiceModeOnboardingService)); + } + + test('auditions a voice, dismisses, and never returns', () => { + const telemetryEvents: ITelemetryEvent[] = []; + const service = createService(disposables, [], [], telemetryEvents); + const host = createHost(disposables); + disposables.add(register(service, host)); + + service.showIfNeeded(); + const shown = host.container.classList.contains('has-voice-mode-onboarding'); + + // Nothing is chosen until the user chooses: the card asks a question + // rather than arriving with an answer already filled in. + const selectedOnOpen = host.container.querySelectorAll('.voice-mode-onboarding-voice.selected').length; + const voices = [...host.container.querySelectorAll('.voice-mode-onboarding-voice-label')].map(element => element.textContent); + host.container.querySelector('.voice-mode-onboarding-voice')!.click(); + const selectedAfterPick = host.container.querySelectorAll('.voice-mode-onboarding-voice.selected').length; + + // Dismissal is never gated, and having been seen it must not come back. + host.container.querySelector('.voice-mode-onboarding-close')!.click(); + const shownAfterClose = host.container.classList.contains('has-voice-mode-onboarding'); + service.showIfNeeded(); + const shownAgain = host.container.classList.contains('has-voice-mode-onboarding'); + + assert.deepStrictEqual( + { shown, selectedOnOpen, voices, selectedAfterPick, shownAfterClose, shownAgain, telemetryEvents }, + { + shown: true, + selectedOnOpen: 0, + voices: ['Maya', 'Victoria', 'Kevin', 'Daniel'], + selectedAfterPick: 1, + shownAfterClose: false, + shownAgain: false, + telemetryEvents: [ + { name: 'voiceModeOnboarding.action', data: { action: 'shown', source: 'automatic' } }, + { name: 'voiceModeOnboarding.action', data: { action: 'selectVoice', source: 'automatic' } }, + { name: 'voiceModeOnboarding.action', data: { action: 'close', source: 'automatic' } }, + ], + }); + }); + + test('can be shown again manually', () => { + const telemetryEvents: ITelemetryEvent[] = []; + const service = createService(disposables, [], [], telemetryEvents); + const host = createHost(disposables); + disposables.add(register(service, host)); + + const shown = service.show(); + host.container.querySelector('.voice-mode-onboarding-close')!.click(); + + assert.deepStrictEqual( + { shown, telemetryEvents }, + { + shown: true, + telemetryEvents: [ + { name: 'voiceModeOnboarding.action', data: { action: 'shown', source: 'manual' } }, + { name: 'voiceModeOnboarding.action', data: { action: 'close', source: 'manual' } }, + ], + }); + }); + + test('can be dismissed without choosing a voice', () => { + const service = createService(disposables); + const host = createHost(disposables); + disposables.add(register(service, host)); + + service.showIfNeeded(); + host.container.querySelector('.voice-mode-onboarding-close')!.click(); + + assert.strictEqual(host.container.classList.contains('has-voice-mode-onboarding'), false); + }); + + test('focuses the introduction in screen reader mode', () => { + const service = createService(disposables, [], [], [], true); + const host = createHost(disposables); + disposables.add(register(service, host)); + + service.showIfNeeded(); + const card = host.container.querySelector('.voice-mode-onboarding-banner'); + + assert.deepStrictEqual( + { + activeElement: document.activeElement, + card, + tabIndex: card?.tabIndex, + closeIcon: host.container.querySelector('.voice-mode-onboarding-close .codicon')?.className, + listeningNotice: host.container.querySelector('.voice-mode-onboarding-listening-notice')?.textContent, + }, + { + activeElement: card, + card, + tabIndex: -1, + closeIcon: 'codicon codicon-check-compact', + listeningNotice: 'Close this when you\'re ready to speak.', + }); + }); + + test('asking twice in one session leaves exactly one card', () => { + const service = createService(disposables); + const host = createHost(disposables); + disposables.add(register(service, host)); + + // Voice Mode reports connecting and then connected, so the trigger fires + // more than once for a single session start. + service.showIfNeeded(); + service.showIfNeeded(); + + assert.deepStrictEqual( + { + visible: host.container.classList.contains('has-voice-mode-onboarding'), + cards: host.container.querySelectorAll('.voice-mode-onboarding-banner').length, + }, + { visible: true, cards: 1 }); + }); + + test('keeps its one showing when there is no chat to dock to', () => { + const service = createService(disposables); + + // Nothing registered yet: the introduction cannot be shown, and must not + // burn its single appearance doing nothing. + service.showIfNeeded(); + + const host = createHost(disposables); + disposables.add(register(service, host)); + service.showIfNeeded(); + + assert.strictEqual(host.container.classList.contains('has-voice-mode-onboarding'), true); + }); + + test('each link in the sentence routes to its own command', () => { + const executed: string[] = []; + const service = createService(disposables, executed); + const host = createHost(disposables); + disposables.add(register(service, host)); + + service.showIfNeeded(); + const links = [...host.container.querySelectorAll('.voice-mode-onboarding-description a')]; + for (const link of links) { + link.click(); + } + + // Order matters: `renderFormattedText` numbers the `[[...]]` segments in + // source order, and the callback dispatches on that index. + assert.deepStrictEqual( + { labels: links.map(link => link.textContent), executed }, + { + labels: ['settings', 'how it responds'], + executed: ['agentsVoice.openSettings', 'workbench.action.chat.configureVoiceInstructions'], + }); + }); + + test('holds the microphone shut for as long as the card is up', () => { + const holds: boolean[] = []; + const service = createService(disposables, [], holds); + const host = createHost(disposables); + disposables.add(register(service, host)); + + // The card goes up while the session is still connecting, so a plain + // `stopListening()` would no-op and hands-free would open the microphone + // on `session_init` with the card still on screen. + service.showIfNeeded(); + const heldWhileOpen = holds.slice(); + const listeningNotice = host.container.querySelector('.voice-mode-onboarding-listening-notice')?.textContent; + host.container.querySelector('.voice-mode-onboarding-close')!.click(); + + assert.deepStrictEqual( + { heldWhileOpen, listeningNotice, afterDismiss: holds }, + { + heldWhileOpen: [true], + listeningNotice: 'Close this when you\'re ready to speak.', + afterDismiss: [true, false], + }); + }); + + test('the one appearance is only spent once the card is really up', () => { + // The guarantee is ordering. Anything the card needs at construction can + // throw, and if the key were written first the user would silently lose + // their only showing - so by the time it is written the card must already + // be built and attached. + let cardWhenStored: { visible: boolean; cards: number } | undefined; + const instantiationService = workbenchInstantiationService(undefined, disposables); + instantiationService.stub(IVoiceSessionController, new class extends mock() { + override readonly voiceState = constObservable('idle'); + override setAutoListenHeld(): void { } + override stopListening(): void { } + }); + const host = createHost(disposables); + const storageService = instantiationService.get(IStorageService); + const store = storageService.store.bind(storageService); + instantiationService.stub(IStorageService, new Proxy(storageService, { + get: (target, property, receiver) => property === 'store' + ? (key: string, value: boolean, scope: StorageScope, target2: StorageTarget) => { + if (key === AgentsVoiceStorageKeys.IntroBannerShown) { + cardWhenStored = { + visible: host.container.classList.contains('has-voice-mode-onboarding'), + cards: host.container.querySelectorAll('.voice-mode-onboarding-banner').length, + }; + } + store(key, value, scope, target2); + } + : Reflect.get(target, property, receiver), + })); + + const service = disposables.add(instantiationService.createInstance(VoiceModeOnboardingService)); + disposables.add(register(service, host)); + service.showIfNeeded(); + + assert.deepStrictEqual(cardWhenStored, { visible: true, cards: 1 }); + }); + + test('hands focus back to the chat input when dismissed from the keyboard', () => { + const service = createService(disposables); + const host = createHost(disposables); + disposables.add(register(service, host)); + + service.showIfNeeded(); + const close = host.container.querySelector('.voice-mode-onboarding-close')!; + close.focus(); + const dismissedFromInside = dom.isAncestorOfActiveElement(host.container); + close.click(); + + // Dismissing from inside the card must not drop the caret on the body. + assert.deepStrictEqual( + { dismissedFromInside, focused: host.focused }, + { dismissedFromInside: true, focused: 1 }); + }); + + test('leaves focus alone when the card is dismissed from elsewhere', () => { + const service = createService(disposables); + const host = createHost(disposables); + disposables.add(register(service, host)); + + service.showIfNeeded(); + const elsewhere = document.body.appendChild(dom.$('div')); + disposables.add(toDisposable(() => elsewhere.remove())); + elsewhere.tabIndex = 0; + elsewhere.focus(); + host.container.querySelector('.voice-mode-onboarding-close')!.click(); + + // The user already moved on; yanking the caret back would be rude. + assert.strictEqual(host.focused, 0); + }); + + test('attaches to the most recently focused host', () => { + const service = createService(disposables); + const first = createHost(disposables); + const second = createHost(disposables); + disposables.add(register(service, first)); + disposables.add(register(service, second)); + + // The renderer running these tests does not reliably hand out real focus, + // so raise the same event the focus tracker listens for. + second.root.focus(); + second.root.dispatchEvent(new FocusEvent('focus')); + service.showIfNeeded(); + + assert.deepStrictEqual( + { + first: first.container.classList.contains('has-voice-mode-onboarding'), + second: second.container.classList.contains('has-voice-mode-onboarding'), + }, + { first: false, second: true }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index a6dd5f4eb68123..535af375cc366f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -91,6 +91,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.speechToText.contextMenu', 'To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu{0} (for example Shift+F10).', '')); content.push(localize('chat.voiceInputMode.segmented', 'When the segmented voice input control is enabled, the input toolbar offers Dictation, Voice Mode, and, in manual Voice Mode, a Start or Stop Listening button. Stopping listening sends the completed turn. Each button can be focused and activated with Enter or Space.')); content.push(localize('chat.voiceInputMode.holdToTalk', 'In manual Voice Mode, the Start or Stop Listening button toggles listening when tapped, or you can press and hold it to talk and release to send. You can also hold the Voice Mode: Hold to Talk keybinding{0} to talk and release to send; this interrupts the assistant to barge in.', '')); + content.push(localize('chat.voiceMode.introduction', 'The first time Voice Mode starts, an introduction appears above the input box. Tab to reach it, then use the arrow keys to move between the available voices; Enter or Space plays a voice and keeps it for future conversations. Its description also contains two links: Settings, which opens the Voice Mode settings, and How It Responds, which opens a file for customizing what the agent says back. Voice Mode stays connected but does not listen while the introduction is open. Press Escape, or activate the Close button, to dismiss it and return to the input box.')); content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '')); content.push(localize('chat.inspectResponseThinkingToggle', 'To include or exclude thinking content in the accessible view, run the Toggle Thinking Content in Accessible View command from the Command Palette.')); content.push(localize('workbench.action.chat.focus', 'To focus the chat request and response list, invoke the Focus Chat command{0}. This will move focus to the most recent response, which you can then navigate using the up and down arrow keys.', getChatFocusKeybindingLabel(keybindingService, type, 'last'))); diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts index 838afb8e327b0d..7fbf5af6bcadd9 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts @@ -21,6 +21,7 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/in import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { AgentsVoiceStorageKeys } from '../../../agentsVoice/common/agentsVoice.js'; import { CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID } from '../actions/configureVoiceInstructionsAction.js'; @@ -43,6 +44,20 @@ const DICTATION_SETTINGS_QUERY = 'dictation'; /** The `deviceId` value that means "whatever the system is using". */ const SYSTEM_DEFAULT_DEVICE_ID = ''; +type DictationOnboardingAction = 'shown' | 'selectMicrophone' | 'openSettings' | 'openInstructions' | 'startDictation' | 'cancel'; + +type DictationOnboardingActionClassification = { + action: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The action taken in the Dictation onboarding card.' }; + source: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'Whether the card appeared automatically on first use or was opened manually.' }; + owner: 'meganrogge'; + comment: 'Tracks engagement with the Dictation onboarding card.'; +}; + +type DictationOnboardingActionEvent = { + action: DictationOnboardingAction; + source: 'automatic' | 'manual'; +}; + // --- Level meter --------------------------------------------------------- /** @@ -488,6 +503,7 @@ export interface IDictationOnboardingBannerOptions { readonly onCancel: () => void; /** Dismiss the card and start the dictation it deferred. */ readonly onStartDictation: () => void; + readonly source: 'automatic' | 'manual'; } /** @@ -521,6 +537,7 @@ export class DictationOnboardingBanner extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @IStorageService private readonly storageService: IStorageService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); @@ -580,6 +597,7 @@ export class DictationOnboardingBanner extends Disposable { this.waveform.start(); void this.startPreview(); + this.logAction('shown'); } /** @@ -606,6 +624,7 @@ export class DictationOnboardingBanner extends Disposable { const [commandId, ...args] = index === '0' ? [OPEN_SETTINGS_COMMAND, { query: DICTATION_SETTINGS_QUERY }] : [CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID]; + this.logAction(index === '0' ? 'openSettings' : 'openInstructions'); this.commandService.executeCommand(commandId as string, ...args) .catch(error => this.logService.error(`[chat-stt] failed to open dictation customization: ${error}`)); }, @@ -721,6 +740,7 @@ export class DictationOnboardingBanner extends Disposable { if (!option) { return; } + this.logAction('selectMicrophone'); // Shared with Voice Mode and with the "Select Microphone" quick pick, so // the choice made here is the one dictation actually records from. @@ -778,6 +798,7 @@ export class DictationOnboardingBanner extends Disposable { return; } this.finished = true; + this.logAction('startDictation'); this.waveform.stop(); this.preview.releaseMicrophone(); @@ -797,10 +818,18 @@ export class DictationOnboardingBanner extends Disposable { return; } this.finished = true; + this.logAction('cancel'); this.waveform.stop(); this.preview.releaseMicrophone(); this.bannerOptions.onCancel(); } + + private logAction(action: DictationOnboardingAction): void { + this.telemetryService.publicLog2( + 'dictationOnboarding.action', + { action, source: this.bannerOptions.source } + ); + } } function hintForError(error: MicrophonePreviewError): string { @@ -870,14 +899,14 @@ export class DictationOnboardingService extends Disposable implements IDictation } showIfNeeded(startDictation: () => void): boolean { - return this.onboarding.showIfNeeded(context => this.createBanner(context.container, context.dismiss, startDictation)); + return this.onboarding.showIfNeeded(context => this.createBanner(context.container, context.dismiss, 'automatic', startDictation)); } show(startDictation?: () => void): boolean { - return this.onboarding.show(context => this.createBanner(context.container, context.dismiss, startDictation)); + return this.onboarding.show(context => this.createBanner(context.container, context.dismiss, 'manual', startDictation)); } - private createBanner(container: HTMLElement, dismiss: () => void, startDictation?: () => void): DictationOnboardingBanner { + private createBanner(container: HTMLElement, dismiss: () => void, source: 'automatic' | 'manual', startDictation?: () => void): DictationOnboardingBanner { return this.instantiationService.createInstance(DictationOnboardingBanner, { container, onCancel: dismiss, @@ -885,6 +914,7 @@ export class DictationOnboardingService extends Disposable implements IDictation dismiss(); startDictation?.(); }, + source, }); } } diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts index daea3fabf27fb1..da13634f7a4b5a 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -23,6 +23,8 @@ const CANCEL_DICTATION_COMMAND = 'workbench.action.chat.cancelSpeechToText'; 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'; +/** Command that shows the Voice Mode onboarding card again. */ +export const SHOW_VOICE_MODE_ONBOARDING_COMMAND = 'agentsVoice.showOnboarding'; /** 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". */ @@ -99,6 +101,14 @@ function createVoiceModeSettingsAction(commandService: ICommandService): IAction }); } +function createShowVoiceModeOnboardingAction(commandService: ICommandService): IAction { + return toAction({ + id: SHOW_VOICE_MODE_ONBOARDING_COMMAND, + label: localize('voiceMode.showIntroduction', "Show Introduction"), + run: () => commandService.executeCommand(SHOW_VOICE_MODE_ONBOARDING_COMMAND), + }); +} + function createConfigureInstructionsAction(commandService: ICommandService, commandId: string, label: string): IAction { return toAction({ id: commandId, @@ -120,6 +130,7 @@ export function getVoiceModeContextMenuActions(commandService: ICommandService, createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), createVoiceModeSettingsAction(commandService), createConfigureInstructionsAction(commandService, CONFIGURE_VOICE_INSTRUCTIONS_ACTION_ID, localize('voiceMode.configureInstructions', "Configure Voice Mode Instructions")), + createShowVoiceModeOnboardingAction(commandService), createSelectMicrophoneAction(commandService), createDisableVoiceModeAction(commandService, configurationService), ]; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index f81f7f92104217..5d8cf737a36e37 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -154,6 +154,17 @@ export interface IVoiceSessionController { */ stopListening(source?: 'explicit' | 'internal'): void; + /** + * Hold hands-free auto-listen off until released. + * + * Unlike {@link stopListening}, this is safe to call *before* the session is + * connected: it survives the connect handshake, so a caller that needs the + * microphone to stay shut while the user reads or decides something can take + * the hold at `connect()` time rather than racing `session_init`. Releasing + * enters listening immediately if hands-free would have done so. + */ + setAutoListenHeld(held: boolean): void; + /** * Stop the current recording WITHOUT finalizing the turn: any in-flight * push-to-talk press is aborted (no `ptt_end` is sent), so the backend @@ -292,6 +303,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC /** When true, the auto-listen loop is suppressed (user pressed Stop * Recording). Cleared on the next explicit `pttDown` or on connect. */ private _autoListenSuppressed = false; + /** + * Auto-listen hold taken by UI that must not be talked over (see + * {@link setAutoListenHeld}). Deliberately separate from + * `_autoListenSuppressed`, which pttDown, playback prep and disconnect all + * clear as part of normal turn-taking - a hold has to outlive all of that. + */ + private _autoListenHeld = false; /** Timestamp (ms) until which an incoming `send_to_chat` is dropped after a * discarded turn, so buffered speech from a focus-change discard can't be * misrouted to the newly focused session. Cleared on the next `pttDown`. */ @@ -2359,6 +2377,29 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._finishPtt('local', source); } + setAutoListenHeld(held: boolean): void { + if (this._autoListenHeld === held) { + return; + } + this._autoListenHeld = held; + this.logService.trace(`[voice] setAutoListenHeld: ${held}`); + if (held) { + // The session may already have opened the mic before the hold was + // taken, so close it rather than only blocking the next turn. + this._clearAutoListenTimer(); + if (this._isConnected.get() && this._pttHeld) { + this._finishPtt('local', 'internal'); + } + return; + } + // Released: hands-free resumes where it left off. `_enterAutoListen` + // re-checks connection, playback and focus, so this is safe whether or + // not the session ever finished connecting while the hold was in place. + if (this._isConnected.get() && this._isHandsFreeEnabled()) { + this._enterAutoListen('connect'); + } + } + stopListening(source: 'explicit' | 'internal' = 'explicit'): void { // Stop the current recording / auto-listen loop WITHOUT tearing down // the WebSocket. Any in-flight press is finished through the normal @@ -2625,8 +2666,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC /** Re-enter listening via synthetic short tap. */ private _enterAutoListen(source: 'auto' | 'connect' = 'auto'): void { this._clearAutoListenTimer(); - if (this._autoListenSuppressed || !this._isConnected.get() || this._pttHeld) { - this.logService.trace(`[voice] _enterAutoListen skipped: suppressed=${this._autoListenSuppressed} connected=${this._isConnected.get()} pttHeld=${this._pttHeld}`); + if (this._autoListenHeld || this._autoListenSuppressed || !this._isConnected.get() || this._pttHeld) { + this.logService.trace(`[voice] _enterAutoListen skipped: held=${this._autoListenHeld} suppressed=${this._autoListenSuppressed} connected=${this._isConnected.get()} pttHeld=${this._pttHeld}`); return; } // In multi-window hands-free, only the focused window keeps auto-listening @@ -2679,7 +2720,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * already held. */ private _startBargeInListen(): void { - if (!this._isHandsFreeEnabled() || !this._isConnected.get() || this._pttHeld || this._autoListenSuppressed || !this._window) { + if (!this._isHandsFreeEnabled() || !this._isConnected.get() || this._pttHeld || this._autoListenHeld || this._autoListenSuppressed || !this._window) { return; } // Only barge-in listen in the focused window so background windows don't diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts index 83ea97cea9f677..11a0b70f52ac12 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts @@ -24,7 +24,7 @@ export interface IChatInputOnboardingOptions { export interface IChatInputOnboardingContext { readonly container: HTMLElement; - readonly dismiss: () => void; + readonly dismiss: (restoreFocus?: boolean) => void; } export interface IChatInputOnboardingCardOptions { @@ -103,7 +103,7 @@ export class ChatInputOnboarding extends Disposable { try { onboardingStore.add(createOnboarding({ container: host.container, - dismiss: () => this.hide(true), + dismiss: (restoreFocus = true) => this.hide(restoreFocus), })); } catch (error) { this.activeHost = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 534d7f26aaeb26..e72b0495d4ec19 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -71,6 +71,7 @@ import { canLog, ILogService, LogLevel } from '../../../../../../platform/log/co import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IVoiceModeOnboardingService } from '../../../../agentsVoice/browser/voiceModeOnboarding.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { ISharedWebContentExtractorService } from '../../../../../../platform/webContentExtractor/common/webContentExtractor.js'; @@ -711,6 +712,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatInputNotificationService private readonly chatInputNotificationService: IChatInputNotificationService, @IChatPhoneInputPresenter private readonly chatPhoneInputPresenter: IChatPhoneInputPresenter, @IProductService private readonly productService: IProductService, + @IVoiceModeOnboardingService private readonly voiceModeOnboardingService: IVoiceModeOnboardingService, ) { super(); this._modelSelectionDiagnostics = new ChatModelSelectionDiagnostics(this.logService, this.storageService, () => ({ @@ -2895,6 +2897,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), dom.h('.chat-tool-confirmation-carousel-container@chatToolConfirmationCarouselContainer'), dom.h('.chat-input-notification-container@chatInputNotificationContainer'), + dom.h('.voice-mode-onboarding-container@voiceModeOnboardingContainer'), dom.h('.dictation-onboarding-container@dictationOnboardingContainer'), dom.h('.chat-goal-banner-container@chatGoalBannerContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), @@ -2925,6 +2928,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-tool-confirmation-carousel-container@chatToolConfirmationCarouselContainer'), dom.h('.interactive-input-followups@followupsContainer'), dom.h('.chat-input-notification-container@chatInputNotificationContainer'), + dom.h('.voice-mode-onboarding-container@voiceModeOnboardingContainer'), dom.h('.dictation-onboarding-container@dictationOnboardingContainer'), dom.h('.chat-goal-banner-container@chatGoalBannerContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), @@ -2979,6 +2983,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatToolConfirmationCarouselContainer = elements.chatToolConfirmationCarouselContainer; dom.hide(this.chatToolConfirmationCarouselContainer); this.chatInputNotificationContainer = elements.chatInputNotificationContainer; + this._register(this.voiceModeOnboardingService.registerHost(elements.voiceModeOnboardingContainer, this.container, () => this.focus())); this._register(this.dictationOnboardingService.registerHost(elements.dictationOnboardingContainer, this.container)); this.chatGoalBannerContainer = elements.chatGoalBannerContainer; this.contextUsageWidgetContainer = elements.contextUsageWidgetContainer; diff --git a/src/vs/workbench/contrib/chat/test/browser/dictationOnboarding.test.ts b/src/vs/workbench/contrib/chat/test/browser/dictationOnboarding.test.ts index 0d02dcaa04309a..14340df2a265c0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/dictationOnboarding.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/dictationOnboarding.test.ts @@ -9,6 +9,8 @@ import { timeout } from '../../../../../base/common/async.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { buildMicrophoneOptions, DictationOnboardingService, indexOfMicrophone } from '../../browser/speechToText/dictationOnboarding.js'; @@ -26,6 +28,19 @@ function device(kind: MediaDeviceKind, deviceId: string, label: string): MediaDe suite('Dictation onboarding', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + interface ITelemetryEvent { readonly name: string; readonly data: unknown } + + class TestTelemetryService extends NullTelemetryServiceShape { + constructor(private readonly events: ITelemetryEvent[]) { + super(); + } + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } + } function createHost(store: Pick): { root: HTMLElement; container: HTMLElement } { const root = dom.$('div'); @@ -36,13 +51,14 @@ suite('Dictation onboarding', () => { return { root, container }; } - function createService(store: Pick, executed?: string[]): DictationOnboardingService { + function createService(store: Pick, executed?: string[], telemetryEvents: ITelemetryEvent[] = []): DictationOnboardingService { const instantiationService = workbenchInstantiationService(undefined, store); if (executed) { instantiationService.stub(ICommandService, { executeCommand: async (id: string) => { executed.push(id); }, } as unknown as ICommandService); } + instantiationService.stub(ITelemetryService, new TestTelemetryService(telemetryEvents)); return store.add(instantiationService.createInstance(DictationOnboardingService)); } @@ -79,7 +95,8 @@ suite('Dictation onboarding', () => { }); test('takes over the first dictation, then never returns', async () => { - const service = createService(disposables); + const telemetryEvents: ITelemetryEvent[] = []; + const service = createService(disposables, undefined, telemetryEvents); const host = createHost(disposables); disposables.add(service.registerHost(host.container, host.root)); @@ -103,12 +120,17 @@ suite('Dictation onboarding', () => { dictationsAfterHandoff: dictations, visibleAfterHandoff: host.container.classList.contains('has-dictation-onboarding'), tookOverAgain, + telemetryEvents, }, { tookOver: true, shown: true, dictationsWhileOpen: 0, dictationsBeforeHandoff: 0, dictationsAfterHandoff: 1, visibleAfterHandoff: false, tookOverAgain: false, + telemetryEvents: [ + { name: 'dictationOnboarding.action', data: { action: 'shown', source: 'automatic' } }, + { name: 'dictationOnboarding.action', data: { action: 'startDictation', source: 'automatic' } }, + ], }); }); @@ -173,7 +195,8 @@ suite('Dictation onboarding', () => { test('offers a way to change the settings and how dictation writes', () => { const executed: string[] = []; - const service = createService(disposables, executed); + const telemetryEvents: ITelemetryEvent[] = []; + const service = createService(disposables, executed, telemetryEvents); const host = createHost(disposables); disposables.add(service.registerHost(host.container, host.root)); @@ -187,11 +210,17 @@ suite('Dictation onboarding', () => { // Every link has to be reachable without a mouse, not just the first. keyboardReachable: Array.from(links).every(link => link.tabIndex === 0), executed, + telemetryEvents, }, { count: 2, keyboardReachable: true, executed: ['workbench.action.openSettings', 'workbench.action.chat.configureDictationInstructions'], + telemetryEvents: [ + { name: 'dictationOnboarding.action', data: { action: 'shown', source: 'manual' } }, + { name: 'dictationOnboarding.action', data: { action: 'openSettings', source: 'manual' } }, + { name: 'dictationOnboarding.action', data: { action: 'openInstructions', source: 'manual' } }, + ], }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/agentsVoice/voiceModeOnboarding.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/agentsVoice/voiceModeOnboarding.fixture.ts new file mode 100644 index 00000000000000..4ca336afbcd720 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/agentsVoice/voiceModeOnboarding.fixture.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constObservable } from '../../../../../base/common/observable.js'; +import { VoiceModeOnboardingBanner } from '../../../../contrib/agentsVoice/browser/voiceModeOnboarding.js'; +import { IVoiceSessionController, VoiceState } from '../../../../contrib/chat/browser/voiceClient/voiceSessionController.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +function renderVoiceModeOnboarding(width: string) { + return (context: ComponentFixtureContext): void => { + const { container, disposableStore, theme } = context; + container.classList.add('monaco-workbench'); + container.style.width = width; + container.style.padding = '24px'; + container.style.background = 'var(--vscode-editor-background)'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: services => services.definePartialInstance(IVoiceSessionController, { + voiceState: constObservable('idle'), + setAutoListenHeld: () => undefined, + stopListening: () => undefined, + pttDown: () => undefined, + pttUp: () => undefined, + }), + }); + const host = document.createElement('div'); + host.className = 'voice-mode-onboarding-container has-voice-mode-onboarding'; + container.append(host); + + disposableStore.add(instantiationService.createInstance(VoiceModeOnboardingBanner, { + container: host, + onDismiss: () => undefined, + source: 'automatic', + })); + }; +} + +/** + * The introduction at the width of a default chat panel (the common case), a + * wide auxiliary bar, and the narrowest panel the card has to survive. + */ +export default defineThemedFixtureGroup({ path: 'agentsVoice/' }, { + 'Voice Mode onboarding (panel)': defineComponentFixture({ + render: renderVoiceModeOnboarding('280px'), + }), + 'Voice Mode onboarding (wide)': defineComponentFixture({ + render: renderVoiceModeOnboarding('620px'), + }), + 'Voice Mode onboarding (narrow)': defineComponentFixture({ + render: renderVoiceModeOnboarding('240px'), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index d5c59f6f4650d1..1d8865cc71a489 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -43,6 +43,7 @@ import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSes import { IAgentHostUntitledProvisionalSessionService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostNewSessionFolderService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; +import { IVoiceModeOnboardingService } from '../../../../contrib/agentsVoice/browser/voiceModeOnboarding.js'; import { IChatAccessibilityService, IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; import { IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; import { IChatOutputRendererService } from '../../../../contrib/chat/browser/chatOutputItemRenderer.js'; @@ -195,6 +196,9 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.defineInstance(IDictationOnboardingService, new class extends mock() { override registerHost() { return Disposable.None; } }()); + reg.defineInstance(IVoiceModeOnboardingService, new class extends mock() { + override registerHost() { return Disposable.None; } + }()); reg.defineInstance(IWorkbenchEnvironmentService, new class extends mock() { override readonly isExtensionDevelopment = false; override readonly isBuilt = true; diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index d9a18f362b0a84..58b2fe12f9a50e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -26,6 +26,7 @@ import { IDecorationsService } from '../../../../services/decorations/common/dec import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IVoiceModeOnboardingService } from '../../../../contrib/agentsVoice/browser/voiceModeOnboarding.js'; import { IChatInputNotificationService } from '../../../../contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { IDictationOnboardingService } from '../../../../contrib/chat/browser/speechToText/dictationOnboarding.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; @@ -329,6 +330,9 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo reg.defineInstance(IDictationOnboardingService, new class extends mock() { override registerHost() { return Disposable.None; } }()); + reg.defineInstance(IVoiceModeOnboardingService, new class extends mock() { + override registerHost() { return Disposable.None; } + }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { override readonly onDidChangeSlashCommands = Event.None; override readonly onDidChangeCustomAgents = Event.None;