diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 1169e4dfded03..9bc3b24237c4b 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -86,6 +86,7 @@ import { handleTerminalCommandPaste, isTerminalCommandInput } from '../../../../ import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; import { setupDictationMicGlow } from '../../../../workbench/contrib/chat/browser/speechToText/dictationMicGlow.js'; +import { IDictationOnboardingService } from '../../../../workbench/contrib/chat/browser/speechToText/dictationOnboarding.js'; import { ChatVoiceInputModeAction, VoiceInputModeActionViewItem } from '../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.js'; import { IVoiceInputModeService } from '../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputMode.js'; import { toAction } from '../../../../base/common/actions.js'; @@ -370,6 +371,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IChatSpeechToTextService private readonly chatSpeechToTextService: IChatSpeechToTextService, + @IDictationOnboardingService private readonly dictationOnboardingService: IDictationOnboardingService, @IChatSubmitRequestHandlerService private readonly chatSubmitRequestHandlerService: IChatSubmitRequestHandlerService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @ICommandService private readonly commandService: ICommandService, @@ -451,6 +453,11 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation )); notificationContainer.appendChild(notificationWidget.domNode); + // 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')); + this._register(this.dictationOnboardingService.registerHost(dictationOnboardingContainer, chatInputContainer)); + // Input area inside the input slot const inputAreaWrapper = dom.append(chatInputContainer, dom.$('.new-chat-input-area-wrapper')); const inputArea = dom.append(inputAreaWrapper, dom.$('.new-chat-input-area')); @@ -995,6 +1002,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation speechService: this.chatSpeechToTextService, keybindingService: this.keybindingService, logService: this.logService, + onboardingService: this.dictationOnboardingService, }, TOGGLE_DICTATION_COMMAND_ID, this._editor); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts index c56648e094fb9..72fc0fd929f3f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts @@ -16,6 +16,7 @@ import { ServicesAccessor } from '../../../../../platform/instantiation/common/i import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED } from '../../../agentsVoice/common/agentsVoice.js'; @@ -26,6 +27,7 @@ import { CHAT_CATEGORY } from './chatActions.js'; import { IChatExecuteActionContext } from './chatExecuteActions.js'; import { IChatWidgetService } from '../chat.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../speechToText/chatSpeechToTextService.js'; +import { IDictationOnboardingService } from '../speechToText/dictationOnboarding.js'; import { cancelDictation, isDictating, startDictation, stopDictation } from '../speechToText/dictationSession.js'; // Gate on `ChatContextKeys.enabled` so the dictation UI and its commands are @@ -44,6 +46,13 @@ export interface IDictationShortcutContext { readonly speechService: IChatSpeechToTextService; readonly keybindingService: IKeybindingService; readonly logService: ILogService; + /** + * Supplied by chat inputs only. The first dictation started from one is + * handed to the introduction card so the microphone can be chosen and + * checked before anything is transcribed; editor and terminal dictation have + * nowhere to dock the card and dictate straight away. + */ + readonly onboardingService?: IDictationOnboardingService; } /** Resolves a toggle invocation against the current dictation lifecycle. */ @@ -80,6 +89,14 @@ export async function runDictationShortcut(context: IDictationShortcutContext, c const window = getWindow(editor.getDomNode()) ?? getActiveWindow(); + // First run: let the introduction card take this dictation over so the user + // can confirm the microphone is the right one and is actually being heard. + // Nothing is recorded until they confirm the card, which is what starts the + // dictation this press asked for. + if (context.onboardingService?.showIfNeeded(() => void startDictation(speechService, editor, window, logService))) { + return; + } + // Attempt to detect a held keybinding. Returns `undefined` when not invoked // through a held key (e.g. the toolbar mic or the command palette), which // collapses the behavior to a plain toggle. @@ -156,6 +173,7 @@ export class ToggleChatSpeechToTextAction extends Action2 { speechService: accessor.get(IChatSpeechToTextService), keybindingService: accessor.get(IKeybindingService), logService: accessor.get(ILogService), + onboardingService: accessor.get(IDictationOnboardingService), }, ToggleChatSpeechToTextAction.ID, widget.inputEditor); } } @@ -334,6 +352,31 @@ class SelectSpeechToTextMicrophoneAction extends Action2 { } } +class ShowChatSpeechToTextIntroductionAction extends Action2 { + static readonly ID = 'workbench.action.chat.showSpeechToTextIntroduction'; + + constructor() { + super({ + id: ShowChatSpeechToTextIntroductionAction.ID, + title: localize2('chat.speechToText.showIntroduction', "Dictate: Show Introduction"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatSpeechToTextConfigured, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const onboardingService = accessor.get(IDictationOnboardingService); + if (onboardingService.show()) { + return; + } + + // The card docks to a chat input, so there is nothing to show it in when + // no chat is open. Say so rather than appearing to do nothing. + accessor.get(INotificationService).info(localize('chatStt.introductionNeedsChat', "Open a chat to see the dictation introduction.")); + } +} + class CancelChatSpeechToTextAction extends Action2 { static readonly ID = 'workbench.action.chat.cancelSpeechToText'; @@ -371,6 +414,7 @@ export function registerChatSpeechToTextActions(): DisposableStore { store.add(registerAction2(ChatSpeechToTextConnectingAction)); store.add(registerAction2(HoldToSpeechToTextAction)); store.add(registerAction2(CancelChatSpeechToTextAction)); + store.add(registerAction2(ShowChatSpeechToTextIntroductionAction)); store.add(registerAction2(SelectSpeechToTextMicrophoneAction)); return store; } diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts new file mode 100644 index 0000000000000..838afb8e327b0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts @@ -0,0 +1,892 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { SelectBox } from '../../../../../base/browser/ui/selectBox/selectBox.js'; +import { disposableTimeout } from '../../../../../base/common/async.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 { localize } from '../../../../../nls.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +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 { defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { AgentsVoiceStorageKeys } from '../../../agentsVoice/common/agentsVoice.js'; +import { CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID } from '../actions/configureVoiceInstructionsAction.js'; +import { ChatInputOnboarding, ChatInputOnboardingCard } from '../widget/input/chatInputOnboarding.js'; +import './media/dictationOnboarding.css'; + +/** + * Marks the introduction as seen. Dictation-scoped and deliberately separate + * from the Voice Mode introduction, so neither feature's card suppresses the + * other's. + */ +const DICTATION_INTRO_SHOWN_KEY = 'chat.dictation.introShown'; + +/** Opens the settings editor, filtered by the query below. */ +const OPEN_SETTINGS_COMMAND = 'workbench.action.openSettings'; + +/** Narrows settings to dictation's own: enabled, model, showTranscript. */ +const DICTATION_SETTINGS_QUERY = 'dictation'; + +/** The `deviceId` value that means "whatever the system is using". */ +const SYSTEM_DEFAULT_DEVICE_ID = ''; + +// --- Level meter --------------------------------------------------------- + +/** + * Bar metrics. Voice Mode's waveform - both the toolbar pill and its own + * introduction card - is a row of hairline strokes with a hairline of air + * beside each, so this uses the same instrument at a larger size. + */ +const BAR_WIDTH = 1; +const BAR_GAP = 2; + +/** Amplitude with nothing being said: present, but clearly at rest. */ +const IDLE_GAIN = 0.55; + +/** Extra amplitude at peak loudness. */ +const SPEAKING_GAIN = 0.45; + +/** How quickly the row chases the microphone. Low and slow reads as smooth; a + * row that tracks every frame exactly reads as flicker rather than as level. */ +const LEVEL_EASING = 0.12; + +/** Opacity of the row when nothing is being said. */ +const RESTING_OPACITY = 0.35; + +/** Extra opacity at peak loudness, so the row brightens as the user speaks. */ +const SPEAKING_OPACITY = 0.5; + +/** + * Opacity when the microphone cannot be read at all. Dimmer than rest, because + * a row at resting strength implies a working device that simply is not hearing + * anything - which is the opposite of what is true. + */ +const UNAVAILABLE_OPACITY = 0.2; + +/** + * Shortest gap between repaints when reduced motion is on. The meter is + * feedback, not decoration - switching it off would remove the only answer the + * card has to "is my microphone working" - so it is slowed to a readable step + * rather than stopped. + */ +const REDUCED_MOTION_PAINT_INTERVAL_MS = 100; + +/** + * One sine component of the waveform's texture. The trace is a handful of these + * summed together, which is what gives it a recognisable ripple rather than a + * single pulsing curve. + */ +interface IWave { + readonly frequency: number; + readonly amplitude: number; + readonly speed: number; + readonly phase: number; +} + +/** + * The waveform's texture. Mirrors the signatures Voice Mode's introduction uses, + * so the two cards read as the same instrument rather than as two features that + * happen to both draw bars. + */ +const WAVES: readonly IWave[] = [ + { 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 }, +]; + +/** + * Half-height of the row 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 a waveform look like it is snapping up and down + * rather than flowing. + */ +function bandFraction(position: number, time: number): number { + let amplitude = 0; + let total = 0; + for (const wave of WAVES) { + const phase = position * wave.frequency * Math.PI * 2 + time * wave.speed + 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); +} + +/** Why the microphone preview is not showing a level. */ +const enum MicrophonePreviewError { + /** The user (or the OS) refused access to the microphone. */ + Denied = 'denied', + /** There is no microphone to listen to. */ + NoDevice = 'noDevice', + /** Anything else, including a browser without `getUserMedia`. */ + Unavailable = 'unavailable', +} + +/** + * Listens to a microphone purely so its loudness can be shown. Owns the media + * stream, the audio graph and nothing else; releasing it frees the microphone. + * + * This is deliberately independent of the dictation pipeline: the whole point of + * the card is to prove the chosen device works *before* anything is recorded. + */ +class MicrophonePreview extends Disposable { + + private readonly session = this._register(new MutableDisposable()); + + private analyser: AnalyserNode | undefined; + private waveform: Uint8Array | undefined; + + private readonly _onDidChangeError = this._register(new Emitter()); + /** Fires with the reason no level is available, or `undefined` once one is. */ + readonly onDidChangeError = this._onDidChangeError.event; + + private _error: MicrophonePreviewError | undefined; + get error(): MicrophonePreviewError | undefined { return this._error; } + + constructor( + private readonly element: HTMLElement, + @ILogService private readonly logService: ILogService, + ) { + super(); + } + + /** + * Current loudness, `0..1`, or `0` when nothing is being heard. Read every + * frame, so it stays allocation-free. + */ + getLevel(): number { + if (!this.analyser || !this.waveform) { + return 0; + } + this.analyser.getByteTimeDomainData(this.waveform); + let sum = 0; + for (const sample of this.waveform) { + const centered = (sample - 128) / 128; + sum += centered * centered; + } + // RMS, scaled so ordinary speech fills most of the row rather than a + // sliver of it. + return Math.min(1, Math.sqrt(sum / this.waveform.length) * 4); + } + + /** + * Listen to `deviceId` (empty means the system default). Replaces any stream + * already running, so switching devices never leaves two microphones open. + */ + async listen(deviceId: string): Promise { + if (this._store.isDisposed) { + return; + } + + this.releaseMicrophone(); + + const targetWindow = dom.getWindow(this.element); + const mediaDevices = targetWindow.navigator.mediaDevices; + if (!mediaDevices?.getUserMedia) { + this.setError(MicrophonePreviewError.Unavailable); + return; + } + + const constraints: MediaTrackConstraints = { channelCount: 1, echoCancellation: true, noiseSuppression: true }; + if (deviceId) { + constraints.deviceId = { exact: deviceId }; + } + + let stream: MediaStream; + try { + stream = await mediaDevices.getUserMedia({ audio: constraints }); + } catch (error) { + this.setError(toPreviewError(error)); + this.logService.trace(`[chat-stt] microphone preview unavailable: ${error}`); + return; + } + + const store = new DisposableStore(); + store.add(toDisposable(() => stream.getTracks().forEach(track => track.stop()))); + + let analyser: AnalyserNode; + try { + const context = new targetWindow.AudioContext(); + store.add(toDisposable(() => void context.close().catch(() => { /* already closing */ }))); + // Chromium starts an `AudioContext` suspended when the page has no + // sticky user activation, and a suspended graph reports silence - a + // dead meter that looks exactly like a dead microphone. + if (context.state === 'suspended') { + await context.resume(); + } + analyser = context.createAnalyser(); + // Time-domain only: the row's shape comes from the travelling wave, + // and all the analyser has to supply is how loud the room is. + analyser.fftSize = 256; + context.createMediaStreamSource(stream).connect(analyser); + } catch (error) { + store.dispose(); + this.setError(MicrophonePreviewError.Unavailable); + this.logService.trace(`[chat-stt] microphone preview analyser unavailable: ${error}`); + return; + } + + // The card can be dismissed while `getUserMedia` is still resolving; the + // session is already cleared in that case, so assigning here would leak a + // live microphone. + if (this._store.isDisposed) { + store.dispose(); + return; + } + + this.session.value = store; + this.analyser = analyser; + this.waveform = new Uint8Array(analyser.fftSize); + this.setError(undefined); + } + + /** + * Hand the microphone back. Called before dictation acquires its own stream: + * two captures of one device is what makes the audio service drop the + * capture, so the preview always lets go first. + */ + releaseMicrophone(): void { + this.analyser = undefined; + this.waveform = undefined; + this.session.clear(); + } + + private setError(error: MicrophonePreviewError | undefined): void { + if (this._error === error) { + return; + } + this._error = error; + this._onDidChangeError.fire(error); + } +} + +/** Map a `getUserMedia` rejection onto the reason shown in the card. */ +function toPreviewError(error: unknown): MicrophonePreviewError { + if (error instanceof DOMException) { + if (error.name === 'NotAllowedError' || error.name === 'SecurityError') { + return MicrophonePreviewError.Denied; + } + if (error.name === 'NotFoundError' || error.name === 'OverconstrainedError') { + return MicrophonePreviewError.NoDevice; + } + } + return MicrophonePreviewError.Unavailable; +} + +/** What the waveform needs each frame, supplied by the preview. */ +interface IWaveformSource { + /** Loudness of the room, `0` when nothing is being heard. */ + getLevel(): number; + /** `false` when the microphone cannot be read at all. */ + isAvailable(): boolean; +} + +/** + * The live waveform: a row of hairline strokes whose shape flows and whose + * height follows the microphone. The card's whole job is to answer "is this + * device hearing me", and a trace that swells when you speak answers it before + * any words are read. + * + * Deliberately not a spectrum analyser. Per-band bars make neighbours jump + * independently, which reads as a chart; the shape here is one continuous + * travelling wave - the same instrument Voice Mode uses - so the row moves like + * a voice. + */ +class MicrophoneWaveform extends Disposable { + + private bars: HTMLElement[] = []; + private readonly animationFrame = this._register(new MutableDisposable()); + + private running = false; + private lastPaint = 0; + private level = 0; + + constructor( + private readonly container: HTMLElement, + private readonly source: IWaveformSource, + observerCtor: typeof ResizeObserver | undefined, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(); + + container.setAttribute('aria-hidden', 'true'); + + // The bar count follows the measured width rather than being fixed: at a + // fixed count the gaps stretch or crowd as the panel resizes, and the + // 1px/2px rhythm the instrument is built on is the first thing lost. + const observer = new (observerCtor ?? dom.getWindow(container).ResizeObserver)(() => this.layout()); + observer.observe(container); + this._register(toDisposable(() => observer.disconnect())); + + this.layout(); + this._register(toDisposable(() => this.stop())); + } + + /** Rebuild the row for the current width, if the count actually changed. */ + private layout(): void { + const width = this.container.clientWidth; + if (!width) { + return; + } + const count = Math.max(1, Math.floor((width + BAR_GAP) / (BAR_WIDTH + BAR_GAP))); + if (count === this.bars.length) { + return; + } + dom.clearNode(this.container); + this.bars = []; + for (let i = 0; i < count; i++) { + this.bars.push(dom.append(this.container, dom.$('span.dictation-onboarding-bar'))); + } + } + + start(): void { + if (this.running) { + return; + } + this.running = true; + const targetWindow = dom.getWindow(this.container); + const tick = () => { + if (!this.running) { + return; + } + this.update(targetWindow.performance.now()); + this.animationFrame.value = dom.scheduleAtNextAnimationFrame(targetWindow, tick); + }; + this.animationFrame.value = dom.scheduleAtNextAnimationFrame(targetWindow, tick); + } + + stop(): void { + this.running = false; + this.animationFrame.clear(); + } + + private update(timestamp: number): void { + const interval = this.accessibilityService.isMotionReduced() ? REDUCED_MOTION_PAINT_INTERVAL_MS : 0; + if (timestamp - this.lastPaint < interval) { + return; + } + this.lastPaint = timestamp; + + // Ease towards the microphone rather than tracking it exactly: the level + // is what the row *means*, and a value that jumps every frame reads as + // flicker instead of as loudness. + this.level += (this.source.getLevel() - this.level) * LEVEL_EASING; + const gain = IDLE_GAIN + this.level * SPEAKING_GAIN; + const time = timestamp * 0.001; + + // Brightness rides the same level as the height, so the row is quiet at + // rest and lifts as the user speaks - and drops below rest entirely when + // there is no microphone to hear. Set on the container: one style write + // per frame rather than one per stroke. + this.container.style.opacity = (this.source.isAvailable() + ? RESTING_OPACITY + this.level * SPEAKING_OPACITY + : UNAVAILABLE_OPACITY).toFixed(3); + + const count = this.bars.length; + for (let i = 0; i < count; i++) { + const position = count > 1 ? i / (count - 1) : 0; + // Scaled rather than resized: transform stays off the layout path, so + // a row of hairlines at 60fps never reflows the chat input. The floor + // leaves a thin line rather than nothing, so a silent microphone + // still reads as present. + const amount = Math.max(0.08, Math.min(1, bandFraction(position, time) * gain)); + this.bars[i].style.transform = `scaleY(${amount.toFixed(3)})`; + } + } +} + +// --- Microphone options -------------------------------------------------- + +/** One entry in the card's microphone picker. */ +export interface IMicrophoneOption { + readonly deviceId: string; + readonly label: string; +} + +/** + * The pickable microphones, always led by "System default". + * + * Drops the virtual `default`/`communications` entries (which duplicate a real + * device under a synthetic id) and de-duplicates by `deviceId`, so one physical + * microphone appears exactly once - the same normalization the "Select + * Microphone" quick pick does, kept in one place so the two never disagree. + */ +export function buildMicrophoneOptions(devices: readonly MediaDeviceInfo[]): IMicrophoneOption[] { + const options: IMicrophoneOption[] = [{ + deviceId: SYSTEM_DEFAULT_DEVICE_ID, + label: localize('dictation.onboarding.systemDefault', "System default"), + }]; + + const seen = new Set(); + for (const device of devices) { + if (device.kind !== 'audioinput' || device.deviceId === 'default' || device.deviceId === 'communications') { + continue; + } + if (seen.has(device.deviceId)) { + continue; + } + seen.add(device.deviceId); + options.push({ + deviceId: device.deviceId, + // Labels are empty until microphone permission has been granted at + // least once; a truncated id is still better than a blank row. + label: device.label || localize('dictation.onboarding.unknownDevice', "Unknown device ({0})", device.deviceId.slice(0, 8)), + }); + } + + return options; +} + +/** + * Index of the microphone currently in use. Falls back to the system default + * when the remembered device has been unplugged, which is exactly what dictation + * itself does when it acquires the stream. + */ +export function indexOfMicrophone(options: readonly IMicrophoneOption[], deviceId: string): number { + const index = options.findIndex(option => option.deviceId === deviceId); + return index === -1 ? 0 : index; +} + +// --- Banner -------------------------------------------------------------- + +/** + * How long the card waits before dictation takes over. + * + * Load-bearing rather than cosmetic: the preview microphone is released at the + * start of it, and the audio service needs a moment to actually hand the device + * over before dictation asks for it. + */ +const HANDOFF_DELAY_MS = 300; + +export interface IDictationOnboardingBannerOptions { + /** The element the card attaches itself to. */ + readonly container: HTMLElement; + /** Dismiss the card without dictating (Escape). */ + readonly onCancel: () => void; + /** Dismiss the card and start the dictation it deferred. */ + readonly onStartDictation: () => void; +} + +/** + * The first-run dictation card: which microphone dictation will use, live proof + * that it is working, and one line on what dictation is. + * + * The card takes over the very first dictation rather than running alongside it, + * because "is my microphone even connected" cannot be answered while words are + * already being transcribed. Nothing is recorded while it is up: talking here + * only moves the waveform, and dictation starts when the user says it should. + */ +export class DictationOnboardingBanner extends Disposable { + + readonly domNode: HTMLElement; + + private readonly card: ChatInputOnboardingCard; + private readonly preview: MicrophonePreview; + private readonly waveform: MicrophoneWaveform; + private readonly hint: HTMLElement; + private readonly pickerContainer: HTMLElement; + + private readonly picker = this._register(new MutableDisposable()); + private readonly handoff = this._register(new MutableDisposable()); + private options: IMicrophoneOption[] = []; + private finished = false; + + constructor( + private readonly bannerOptions: IDictationOnboardingBannerOptions, + @ICommandService private readonly commandService: ICommandService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + this.card = this._register(new ChatInputOnboardingCard({ + container: bannerOptions.container, + className: 'dictation-onboarding-banner', + ariaLabel: localize('dictation.onboarding.region', "Dictation introduction"), + // Sighted users get this from the waveform moving as they talk. A + // screen-reader user has no waveform to watch, so the card has to say + // what it is for and how to leave it. + ariaDescription: localize('dictation.onboarding.regionDescription', "Say anything to check your microphone, then start dictating."), + onEscape: () => this.cancel(), + })); + this.domNode = this.card.domNode; + + const header = dom.append(this.domNode, dom.$('.dictation-onboarding-header')); + const title = dom.append(header, dom.$('.dictation-onboarding-title')); + title.textContent = localize('dictation.onboarding.title', "Dictation"); + this.renderDescription(header); + + // The device and its level are one group: the bars are *this* microphone's + // level, and separating them would leave the meter reading as decoration. + const device = dom.append(this.domNode, dom.$('.dictation-onboarding-device')); + this.pickerContainer = dom.append(device, dom.$('.dictation-onboarding-picker')); + const waveformContainer = dom.append(device, dom.$('.dictation-onboarding-waveform')); + + this.preview = this._register(instantiationService.createInstance(MicrophonePreview, this.domNode)); + this.waveform = this._register(instantiationService.createInstance(MicrophoneWaveform, waveformContainer, { + getLevel: () => this.preview.getLevel(), + isAvailable: () => this.preview.error === undefined, + }, undefined)); + this._register(this.preview.onDidChangeError(() => this.updateHint())); + + this.hint = dom.append(this.domNode, dom.$('.dictation-onboarding-hint')); + // The hint only appears when the microphone cannot be read, which can + // happen well after the card is opened, so it has to reach a screen + // reader as it changes rather than only on focus. + this.hint.setAttribute('aria-live', 'polite'); + this.updateHint(); + + this.renderClose(); + + // Paint the row before the microphone is up. Enumerating devices means + // waiting on the OS, and an empty row that fills in a second later reads + // as the card still loading; the system default is true no matter what + // comes back, so the row can start there and refine itself. + this.options = [{ + deviceId: SYSTEM_DEFAULT_DEVICE_ID, + label: localize('dictation.onboarding.systemDefault', "System default"), + }]; + this.renderPicker(); + + const mediaDevices = dom.getWindow(this.domNode).navigator.mediaDevices; + if (mediaDevices) { + this._register(dom.addDisposableListener(mediaDevices, 'devicechange', () => void this.refreshDevices())); + } + + this.waveform.start(); + void this.startPreview(); + } + + /** + * What dictation is, and that none of it is fixed. The card is shown once, so + * the two things a user might want to change afterwards - whether dictation + * runs at all, and how it writes what they say - have to be reachable from + * here rather than left to a command nobody knows to look for. + * + * `[[...]]` marks the clauses that become links, so translators can keep the + * sentence natural instead of having fixed phrases concatenated on. + */ + private renderDescription(container: HTMLElement): void { + const description = dom.append(container, dom.$('.dictation-onboarding-description')); + const text = localize({ + key: 'dictation.onboarding.description', + comment: ['Preserve the double square brackets: they mark the text that becomes a link. Keep both links, in this order - the first opens settings, the second opens the customization file.'], + }, "Speak and it becomes text. Adjust [[settings]] or [[how it's written]] any time."); + + dom.append(description, renderFormattedText(text, { + actionHandler: { + // The handler is given the link's index, so the two are told apart + // by position - hence the ordering note to translators above. + callback: index => { + const [commandId, ...args] = index === '0' + ? [OPEN_SETTINGS_COMMAND, { query: DICTATION_SETTINGS_QUERY }] + : [CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID]; + this.commandService.executeCommand(commandId as string, ...args) + .catch(error => this.logService.error(`[chat-stt] failed to open dictation customization: ${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. + // 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(); + } + })); + } + } + + /** + * Bring the card to life. The device list and the microphone are started + * together rather than in sequence: `getUserMedia` can take a second or more + * to return, and waiting for it would leave the picker empty for that whole + * time. Enumeration is repeated once the microphone is live, because device + * labels stay blank until permission has been granted at least once. + */ + private async startPreview(): Promise { + const listening = this.preview.listen(this.currentDeviceId()); + await Promise.all([listening, this.refreshDevices()]); + await this.refreshDevices(); + } + + private currentDeviceId(): string { + return this.storageService.get(AgentsVoiceStorageKeys.MicrophoneDevice, StorageScope.APPLICATION, SYSTEM_DEFAULT_DEVICE_ID); + } + + private async refreshDevices(): Promise { + const mediaDevices = dom.getWindow(this.domNode).navigator.mediaDevices; + if (!mediaDevices?.enumerateDevices) { + return; + } + + let devices: MediaDeviceInfo[]; + try { + devices = await mediaDevices.enumerateDevices(); + } catch (error) { + this.logService.trace(`[chat-stt] could not enumerate microphones: ${error}`); + return; + } + + if (this._store.isDisposed) { + return; + } + + const options = buildMicrophoneOptions(devices); + // Before permission is granted the browser reports the devices but not + // their names. Re-rendering a list of "Unknown device" rows and then + // swapping in the real names a moment later is worse than waiting: keep + // the row as it is until there is something worth showing. + if (this.options.length > 1 && !options.some(option => option.deviceId && option.label)) { + return; + } + + this.options = options; + this.renderPicker(); + } + + /** + * A picker with one entry is not a choice - it is a label that happens to + * open a menu. With a single microphone the card just names it. + */ + private renderPicker(): void { + this.picker.clear(); + dom.clearNode(this.pickerContainer); + + dom.append(this.pickerContainer, dom.$(`span.codicon.codicon-${Codicon.mic.id}.dictation-onboarding-picker-icon`)) + .setAttribute('aria-hidden', 'true'); + + const selected = indexOfMicrophone(this.options, this.currentDeviceId()); + + if (this.options.length <= 1) { + const label = dom.append(this.pickerContainer, dom.$('span.dictation-onboarding-picker-label')); + label.textContent = this.options[selected]?.label ?? localize('dictation.onboarding.noMicrophone', "No microphone found"); + label.title = label.textContent; + return; + } + + const store = new DisposableStore(); + // Custom-drawn rather than the platform control, and with the face colors + // blanked so the row inherits the card instead of carrying the platform's + // select chrome - that fill is exactly what this row should not have at + // rest. The dropdown keeps its own colors, so only the face changes. + const selectBox = store.add(new SelectBox( + this.options.map(option => ({ text: option.label })), + selected, + this.contextViewService, + { ...defaultSelectBoxStyles, selectBackground: undefined, selectBorder: undefined, selectForeground: undefined }, + { ariaLabel: localize('dictation.onboarding.microphone', "Microphone"), useCustomDrawn: true }, + )); + selectBox.render(this.pickerContainer); + store.add(selectBox.onDidSelect(event => this.selectMicrophone(event.index))); + this.picker.value = store; + } + + private selectMicrophone(index: number): void { + const option = this.options[index]; + if (!option) { + return; + } + + // Shared with Voice Mode and with the "Select Microphone" quick pick, so + // the choice made here is the one dictation actually records from. + if (option.deviceId) { + this.storageService.store(AgentsVoiceStorageKeys.MicrophoneDevice, option.deviceId, StorageScope.APPLICATION, StorageTarget.MACHINE); + } else { + this.storageService.remove(AgentsVoiceStorageKeys.MicrophoneDevice, StorageScope.APPLICATION); + } + + status(localize('dictation.onboarding.microphoneSelected', "{0} selected.", option.label)); + void this.preview.listen(option.deviceId).then(() => this.updateHint()); + } + + /** + * The hint only speaks when the microphone cannot be read. At rest the + * moving waveform is the instruction - a line of text telling you to talk is + * one the card can do without. + */ + private updateHint(): void { + if (this.finished) { + return; + } + const error = this.preview.error; + this.domNode.classList.toggle('has-error', error !== undefined); + this.hint.textContent = error === undefined ? '' : hintForError(error); + } + + /** + * The one control that starts dictation. A check rather than a cross: + * leaving this card is a confirmation that the microphone is right, not an + * abandonment. + * + * Pinned to the corner and out of the content flow, so it never competes + * with the picker for room and never moves as the card re-flows. + */ + private renderClose(): void { + this.card.addAction({ + className: 'dictation-onboarding-close', + ariaLabel: localize('dictation.onboarding.close', "Start Dictating"), + icon: Codicon.check, + onActivate: () => this.finish(), + }); + } + + /** + * Hand the session over to real dictation, at the user's word rather than on + * a guess about what they meant by talking. + * + * The preview microphone is released first and the hand-off is deferred by a + * beat: dictation would otherwise be asking the audio service for a device + * the card has not finished giving back. + */ + private finish(): void { + if (this.finished) { + return; + } + this.finished = true; + this.waveform.stop(); + this.preview.releaseMicrophone(); + + this.domNode.classList.remove('has-error'); + this.domNode.classList.add('handing-off'); + // Announced rather than shown: the card is on its way out, so a line of + // text nobody has time to read would only be noise. A screen reader has + // no waveform to watch and does need telling. + status(localize('dictation.onboarding.starting', "Starting dictation…")); + + this.handoff.value = disposableTimeout(() => this.bannerOptions.onStartDictation(), HANDOFF_DELAY_MS); + } + + /** Escape means "I did not want to dictate after all". */ + private cancel(): void { + if (this.finished) { + return; + } + this.finished = true; + this.waveform.stop(); + this.preview.releaseMicrophone(); + this.bannerOptions.onCancel(); + } +} + +function hintForError(error: MicrophonePreviewError): string { + switch (error) { + case MicrophonePreviewError.Denied: + return localize('dictation.onboarding.denied', "No microphone access. Check your system privacy settings."); + case MicrophonePreviewError.NoDevice: + return localize('dictation.onboarding.noDevice', "No microphone found."); + default: + return localize('dictation.onboarding.unavailable', "Can't read the microphone level."); + } +} + +// --- Service ------------------------------------------------------------- + +export const IDictationOnboardingService = createDecorator('dictationOnboardingService'); + +export interface IDictationOnboardingService { + readonly _serviceBrand: undefined; + + /** + * Register a container that can host the card (a chat input). The most + * recently focused host wins when the card is shown. + * + * @param container the element the card 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). + */ + registerHost(container: HTMLElement, focusRoot: HTMLElement): IDisposable; + + /** + * Show the card in place of the user's first dictation. Returns `true` when + * it took the dictation over, in which case `startDictation` is called once + * the user confirms the card - and never called at all if they pressed + * Escape. Returns `false` when the card has been seen before or there is no + * chat input to dock it to, and the caller should just dictate. + */ + showIfNeeded(startDictation: () => void): boolean; + + /** + * Show the card again regardless of whether it has been seen, for the "Show + * Introduction" command. Returns `false` when there is no visible chat input + * to dock it to, so the caller can explain why nothing happened. + */ + show(startDictation?: () => void): boolean; +} + +export class DictationOnboardingService extends Disposable implements IDictationOnboardingService { + + declare readonly _serviceBrand: undefined; + + private readonly onboarding: ChatInputOnboarding; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + this.onboarding = this._register(this.instantiationService.createInstance(ChatInputOnboarding, { + storageKey: DICTATION_INTRO_SHOWN_KEY, + hostClass: 'has-dictation-onboarding', + })); + } + + registerHost(container: HTMLElement, focusRoot: HTMLElement): IDisposable { + return this.onboarding.registerHost(container, focusRoot); + } + + showIfNeeded(startDictation: () => void): boolean { + return this.onboarding.showIfNeeded(context => this.createBanner(context.container, context.dismiss, startDictation)); + } + + show(startDictation?: () => void): boolean { + return this.onboarding.show(context => this.createBanner(context.container, context.dismiss, startDictation)); + } + + private createBanner(container: HTMLElement, dismiss: () => void, startDictation?: () => void): DictationOnboardingBanner { + return this.instantiationService.createInstance(DictationOnboardingBanner, { + container, + onCancel: dismiss, + onStartDictation: () => { + dismiss(); + startDictation?.(); + }, + }); + } +} + +registerSingleton(IDictationOnboardingService, DictationOnboardingService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/media/dictationOnboarding.css b/src/vs/workbench/contrib/chat/browser/speechToText/media/dictationOnboarding.css new file mode 100644 index 0000000000000..d3a52571a0269 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/speechToText/media/dictationOnboarding.css @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * The dictation 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. + * + * The card carries four tiers, and only one of them leads: the spectrum. The + * copy names the feature and gets out of the way, the picker is quiet until you + * reach for it, and the hint is the smallest type in the card. + * + * Spacing rhythm, all on the 8px scale: + * 16px card padding + * 12px between tiers + * 4px between a title and its sentence + */ + +/* The host is a peer directly above the chat input, and stays out of the layout + * entirely until the card is attached. */ +.dictation-onboarding-container { + display: none; +} + +.dictation-onboarding-container.has-dictation-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; +} + +.dictation-onboarding-banner { + position: relative; + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size120); + box-sizing: border-box; + padding: var(--vscode-spacing-size160); + 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 --- */ + +.dictation-onboarding-header { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size40); + min-width: 0; + /* Clear of the pinned close button. */ + padding-right: var(--vscode-spacing-size240); +} + +.dictation-onboarding-title { + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +/* One sentence, demoted: it explains the feature once and then supports. */ +.dictation-onboarding-description { + font-size: var(--vscode-fontSize-label2); + color: var(--vscode-descriptionForeground); + line-height: 1.45; +} + +/* + * The two ways out of the card - what dictation does, and how it writes - are + * the only actions in this tier, so they have to look like actions. Inherit the + * quiet type role and lift only the colour, so the sentence still reads as a + * sentence rather than as a row of buttons. + */ +.dictation-onboarding-description a { + color: var(--vscode-textLink-foreground); + cursor: pointer; +} + +.dictation-onboarding-description a:hover, +.dictation-onboarding-description a:active { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.dictation-onboarding-description a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); +} + +/* --- Device --- */ + +/* + * The picker and the spectrum are one group - the bars are *this* device's + * level. Stacked tight together so they read as a single control with its own + * readout rather than as two unrelated rows. + */ +.dictation-onboarding-device { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + min-width: 0; +} + +/* --- Microphone picker --- */ + +/* + * A real control, and it has to look like one: this is the only thing on the + * card you can act on, and a borderless row of text gave no sign it could be + * opened at all. It carries the standard dropdown surface so it reads the same + * as every other select in the product. + */ +.dictation-onboarding-picker { + position: relative; + display: flex; + align-items: center; + gap: var(--vscode-spacing-size60); + box-sizing: border-box; + min-width: 0; + height: var(--vscode-spacing-size280); + padding: 0 var(--vscode-spacing-size80); + border: var(--vscode-strokeThickness) solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent)); + border-radius: var(--vscode-cornerRadius-small); + background-color: var(--vscode-dropdown-background, var(--vscode-input-background)); + transition: border-color 100ms ease-out; +} + +.dictation-onboarding-picker:hover { + border-color: var(--vscode-focusBorder); +} + +.dictation-onboarding-picker:focus-within { + border-color: var(--vscode-focusBorder); + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -2px; +} + +.monaco-workbench .dictation-onboarding-picker-icon.codicon[class*='codicon-'] { + flex-shrink: 0; + font-size: var(--vscode-codiconFontSize-compact); + line-height: 1; + color: var(--vscode-descriptionForeground); +} + +/* + * The select itself stays transparent so the housing above draws the surface - + * one border, not two. It is still a real select: the dropdown, the keyboard + * handling and the screen-reader semantics are untouched. + */ +.dictation-onboarding-picker .monaco-select-box { + flex: 1 1 auto; + min-width: 0; + height: 100%; + padding: 0; + border: none; + border-radius: var(--vscode-cornerRadius-small); + background-color: transparent; + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); + font-family: inherit; + font-size: var(--vscode-fontSize-label2); + text-overflow: ellipsis; +} + +/* The housing already shows focus; a second ring inside it is noise. */ +.dictation-onboarding-picker .monaco-select-box:focus { + outline: none; +} + +/* A lone microphone is stated rather than offered. */ +.dictation-onboarding-picker-label { + overflow: hidden; + min-width: 0; + font-size: var(--vscode-fontSize-label2); + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); + white-space: nowrap; + text-overflow: ellipsis; +} + +/* --- Waveform --- */ + +/* + * The hero, and the same instrument Voice Mode uses: a row of hairline strokes + * whose shape flows and whose height follows the microphone. The card's whole + * job is to answer "is this device hearing me", and a trace that swells when you + * speak answers it before any words are read. + */ +.dictation-onboarding-waveform { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--vscode-spacing-size280); + padding: 0 var(--vscode-spacing-size60); + /* Quiet at rest and brightening as the level rises, so the row lifts with the + * voice rather than sitting at full strength in silence. The animation owns + * the exact value, since it also has to drop the row when there is no + * microphone to hear at all. */ + opacity: 0.35; + /* Eases into the card's edges rather than being sliced off at them. */ + mask-image: linear-gradient(to right, transparent, rgba(0, 0, 0, 1) 8%, rgba(0, 0, 0, 1) 92%, transparent); +} + +/* + * Strokes, not shapes - the same 1px hairline the Voice Mode waveform uses, so + * the two read as one instrument at different sizes. + * + * `currentColor` is the whole colour story: the card owns the tier and every + * theme override works for free. Voice Mode's canvas has to read and cache the + * computed `color` to reach the same place; drawing in the DOM gets it free. + */ +.dictation-onboarding-bar { + flex: 0 0 auto; + width: 1px; + height: 100%; + border-radius: var(--vscode-cornerRadius-circle); + background: currentColor; + /* Scaled from the middle, so the row grows symmetrically about its centre + * line the way a waveform does. Transform only - never height - so a row of + * hairlines at 60fps never reflows the chat input beneath it. */ + transform: scaleY(0.08); + transform-origin: center; + will-change: transform; +} + +/* + * The hand-off. The row settles rather than freezing mid-motion, which is what + * makes the card leaving read as a step instead of a cut. + */ +.dictation-onboarding-banner.handing-off .dictation-onboarding-bar { + transition: transform 350ms ease-out; +} + +/* --- Hint --- */ + +/* + * Silent unless it has something to say. At rest the waveform is the + * instruction; the hint is reserved for a microphone that cannot be read and + * for the hand-off, where a line of text is the only thing that explains why + * the card is leaving. + */ +.dictation-onboarding-hint { + font-size: var(--vscode-fontSize-label3); + color: var(--vscode-descriptionForeground); + line-height: 1.4; +} + +/* Takes no room at all when empty, so the card does not carry a blank tier. */ +.dictation-onboarding-hint:empty { + display: none; +} + +.dictation-onboarding-banner.has-error .dictation-onboarding-hint { + color: var(--vscode-inputValidation-errorForeground, var(--vscode-foreground)); +} + +/* --- Confirm --- */ + +/* + * A check, not a cross: leaving this card starts dictating, so the control is + * a confirmation rather than an abandonment. + * + * Pinned to the corner and out of the content flow, so it never competes with + * the picker for room and never moves as the card re-flows. Always available: a + * disabled dismiss would trap someone inside the card. + */ +.dictation-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-size240); + height: var(--vscode-spacing-size240); + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-foreground); + cursor: pointer; + transition: background-color 100ms ease-out; +} + +.dictation-onboarding-close:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.dictation-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 .dictation-onboarding-close .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize); + line-height: 1; + color: inherit; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts new file mode 100644 index 0000000000000..83ea97cea9f67 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, EventType, trackFocus } from '../../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; + +interface IChatInputOnboardingHost { + readonly container: HTMLElement; + readonly focusRoot: HTMLElement; + readonly focus: (() => void) | undefined; + lastFocused: number; +} + +export interface IChatInputOnboardingOptions { + readonly storageKey: string; + readonly hostClass: string; +} + +export interface IChatInputOnboardingContext { + readonly container: HTMLElement; + readonly dismiss: () => void; +} + +export interface IChatInputOnboardingCardOptions { + readonly container: HTMLElement; + readonly className: string; + readonly ariaLabel: string; + readonly ariaDescription?: string; + readonly onEscape: () => void; +} + +export interface IChatInputOnboardingActionOptions { + readonly className: string; + readonly ariaLabel: string; + readonly icon: ThemeIcon; + readonly onActivate: () => void; +} + +export class ChatInputOnboarding extends Disposable { + + private readonly hosts = new Set(); + private readonly currentOnboarding = this._register(new MutableDisposable()); + private activeHost: IChatInputOnboardingHost | undefined; + + constructor( + private readonly options: IChatInputOnboardingOptions, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + } + + registerHost(container: HTMLElement, focusRoot: HTMLElement, focus?: () => void): IDisposable { + const host: IChatInputOnboardingHost = { + container, + focusRoot, + focus, + lastFocused: 0, + }; + this.hosts.add(host); + + const focusTracker = trackFocus(focusRoot); + const focusListener = focusTracker.onDidFocus(() => host.lastFocused = Date.now()); + + return toDisposable(() => { + focusListener.dispose(); + focusTracker.dispose(); + this.hosts.delete(host); + if (this.activeHost === host) { + this.hide(false); + } + }); + } + + showIfNeeded(createOnboarding: (context: IChatInputOnboardingContext) => IDisposable): boolean { + if (this.currentOnboarding.value) { + return true; + } + if (this.storageService.getBoolean(this.options.storageKey, StorageScope.APPLICATION, false)) { + return false; + } + return this.show(createOnboarding); + } + + show(createOnboarding: (context: IChatInputOnboardingContext) => IDisposable): boolean { + const host = this.getActiveHost(); + if (!host) { + return false; + } + + this.hide(false); + this.activeHost = host; + + const onboardingStore = new DisposableStore(); + host.container.classList.add(this.options.hostClass); + onboardingStore.add(toDisposable(() => host.container.classList.remove(this.options.hostClass))); + + try { + onboardingStore.add(createOnboarding({ + container: host.container, + dismiss: () => this.hide(true), + })); + } catch (error) { + this.activeHost = undefined; + onboardingStore.dispose(); + throw error; + } + + this.currentOnboarding.value = onboardingStore; + this.storageService.store(this.options.storageKey, true, StorageScope.APPLICATION, StorageTarget.USER); + return true; + } + + private getActiveHost(): IChatInputOnboardingHost | undefined { + const visibleHosts = [...this.hosts].filter(host => host.container.isConnected && host.focusRoot.getClientRects().length > 0); + if (visibleHosts.length === 0) { + return undefined; + } + + return visibleHosts.reduce((mostRecent, host) => host.lastFocused > mostRecent.lastFocused ? host : mostRecent); + } + + private hide(restoreFocus: boolean): void { + const host = this.activeHost; + this.activeHost = undefined; + this.currentOnboarding.clear(); + if (restoreFocus) { + host?.focus?.(); + } + } +} + +export class ChatInputOnboardingCard extends Disposable { + + readonly domNode: HTMLElement; + + constructor(options: IChatInputOnboardingCardOptions) { + super(); + + this.domNode = options.container.ownerDocument.createElement('div'); + this.domNode.classList.add(options.className); + this.domNode.setAttribute('role', 'region'); + this.domNode.setAttribute('aria-label', options.ariaLabel); + if (options.ariaDescription) { + this.domNode.setAttribute('aria-description', options.ariaDescription); + } + + options.container.appendChild(this.domNode); + this._register(toDisposable(() => this.domNode.remove())); + + this._register(addDisposableListener(this.domNode, EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Escape)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + options.onEscape(); + } + })); + } + + addAction(options: IChatInputOnboardingActionOptions): HTMLElement { + const action = this.domNode.ownerDocument.createElement('div'); + action.classList.add(options.className); + action.setAttribute('role', 'button'); + action.tabIndex = 0; + action.setAttribute('aria-label', options.ariaLabel); + const icon = this.domNode.ownerDocument.createElement('span'); + icon.classList.add(...ThemeIcon.asClassNameArray(options.icon)); + icon.setAttribute('aria-hidden', 'true'); + action.appendChild(icon); + this.domNode.appendChild(action); + + const activate = () => options.onActivate(); + this._register(addDisposableListener(action, EventType.CLICK, activate)); + this._register(addDisposableListener(action, EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + activate(); + } + })); + + return action; + } +} 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 2da7089e50bf8..534d7f26aaeb2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -112,6 +112,7 @@ import { ChatVoiceInputModeAction, VoiceInputModeActionViewItem } from '../../vo import { ChatSpeechToTextConnectingAction, ChatSpeechToTextPreparingAction, ToggleChatSpeechToTextAction } from '../../actions/chatSpeechToTextActions.js'; import { DictationActionViewItem } from '../../speechToText/dictationActionViewItem.js'; import { DictationDownloadActionViewItem } from '../../speechToText/dictationDownloadActionViewItem.js'; +import { IDictationOnboardingService } from '../../speechToText/dictationOnboarding.js'; import { notifyDictationSubmitted } from '../../speechToText/dictationSession.js'; import { VoiceModeActionViewItem } from '../../voiceClient/voiceModeActionViewItem.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; @@ -701,6 +702,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IChatContextService private readonly chatContextService: IChatContextService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IDictationOnboardingService private readonly dictationOnboardingService: IDictationOnboardingService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISCMService private readonly scmService: ISCMService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @@ -2893,6 +2895,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('.dictation-onboarding-container@dictationOnboardingContainer'), dom.h('.chat-goal-banner-container@chatGoalBannerContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), dom.h('.chat-artifacts-widget-container@chatArtifactsWidgetContainer'), @@ -2922,6 +2925,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('.dictation-onboarding-container@dictationOnboardingContainer'), dom.h('.chat-goal-banner-container@chatGoalBannerContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), dom.h('.chat-artifacts-widget-container@chatArtifactsWidgetContainer'), @@ -2975,6 +2979,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatToolConfirmationCarouselContainer = elements.chatToolConfirmationCarouselContainer; dom.hide(this.chatToolConfirmationCarouselContainer); this.chatInputNotificationContainer = elements.chatInputNotificationContainer; + this._register(this.dictationOnboardingService.registerHost(elements.dictationOnboardingContainer, this.container)); this.chatGoalBannerContainer = elements.chatGoalBannerContainer; this.contextUsageWidgetContainer = elements.contextUsageWidgetContainer; this.statusToolbarContainer = elements.statusToolbarContainer; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatInputOnboarding.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatInputOnboarding.test.ts new file mode 100644 index 0000000000000..bf98856793f87 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatInputOnboarding.test.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { ChatInputOnboarding, ChatInputOnboardingCard, IChatInputOnboardingContext } from '../../browser/widget/input/chatInputOnboarding.js'; + +suite('Chat input onboarding', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHost(store: Pick): { root: HTMLElement; container: HTMLElement } { + const root = dom.$('div'); + root.tabIndex = 0; + const container = dom.append(root, dom.$('.chat-input-onboarding-container')); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + return { root, container }; + } + + function createOnboarding(store: Pick, storageKey: string): ChatInputOnboarding { + const instantiationService = workbenchInstantiationService(undefined, store); + return store.add(instantiationService.createInstance(ChatInputOnboarding, { + storageKey, + hostClass: 'has-chat-input-onboarding', + })); + } + + function createCard(context: IChatInputOnboardingContext) { + const card = context.container.ownerDocument.createElement('div'); + card.classList.add('chat-input-onboarding-card'); + context.container.appendChild(card); + return toDisposable(() => card.remove()); + } + + test('owns one card and restores focus when it is dismissed', () => { + const onboarding = createOnboarding(disposables, 'test.chatInputOnboarding.ownsCard'); + const host = createHost(disposables); + let focusCalls = 0; + disposables.add(onboarding.registerHost(host.container, host.root, () => focusCalls++)); + + let context: IChatInputOnboardingContext | undefined; + let cardsCreated = 0; + const shown = onboarding.showIfNeeded(value => { + context = value; + cardsCreated++; + return createCard(value); + }); + const stillTakenOver = onboarding.showIfNeeded(value => { + cardsCreated++; + return createCard(value); + }); + + assert.deepStrictEqual( + { + shown, + stillTakenOver, + cardsCreated, + visible: host.container.classList.contains('has-chat-input-onboarding'), + cards: host.container.querySelectorAll('.chat-input-onboarding-card').length, + }, + { shown: true, stillTakenOver: true, cardsCreated: 1, visible: true, cards: 1 }); + + context!.dismiss(); + + assert.deepStrictEqual( + { + focusCalls, + visible: host.container.classList.contains('has-chat-input-onboarding'), + cards: host.container.querySelectorAll('.chat-input-onboarding-card').length, + shownAgain: onboarding.showIfNeeded(createCard), + }, + { focusCalls: 1, visible: false, cards: 0, shownAgain: false }); + }); + + test('does not consume first-run state until a card can be shown', () => { + const onboarding = createOnboarding(disposables, 'test.chatInputOnboarding.waitsForHost'); + + assert.strictEqual(onboarding.showIfNeeded(createCard), false); + + const host = createHost(disposables); + disposables.add(onboarding.registerHost(host.container, host.root)); + + assert.strictEqual(onboarding.showIfNeeded(createCard), true); + }); + + test('handles unmodified keyboard dismissal and action activation', () => { + const host = createHost(disposables); + let dismissals = 0; + let activations = 0; + const card = disposables.add(new ChatInputOnboardingCard({ + container: host.container, + className: 'chat-input-onboarding-card', + ariaLabel: 'Test onboarding', + onEscape: () => dismissals++, + })); + const action = card.addAction({ + className: 'chat-input-onboarding-action', + ariaLabel: 'Continue', + icon: Codicon.check, + onActivate: () => activations++, + }); + + action.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, ctrlKey: true, bubbles: true })); + card.domNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, shiftKey: true, bubbles: true })); + action.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + card.domNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + + assert.deepStrictEqual({ dismissals, activations }, { dismissals: 1, activations: 1 }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/dictationOnboarding.test.ts b/src/vs/workbench/contrib/chat/test/browser/dictationOnboarding.test.ts new file mode 100644 index 0000000000000..0d02dcaa04309 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/dictationOnboarding.test.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { buildMicrophoneOptions, DictationOnboardingService, indexOfMicrophone } from '../../browser/speechToText/dictationOnboarding.js'; + +/** + * Comfortably longer than the card's hand-off delay, so the tests observe the + * settled state rather than racing it. + */ +const HANDOFF_GRACE_MS = 500; + +/** Minimal stand-in for the browser's device descriptor. */ +function device(kind: MediaDeviceKind, deviceId: string, label: string): MediaDeviceInfo { + return { kind, deviceId, label, groupId: '', toJSON: () => ({}) }; +} + +suite('Dictation onboarding', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHost(store: Pick): { root: HTMLElement; container: HTMLElement } { + const root = dom.$('div'); + root.tabIndex = 0; + const container = dom.append(root, dom.$('.dictation-onboarding-container')); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + return { root, container }; + } + + function createService(store: Pick, executed?: string[]): DictationOnboardingService { + const instantiationService = workbenchInstantiationService(undefined, store); + if (executed) { + instantiationService.stub(ICommandService, { + executeCommand: async (id: string) => { executed.push(id); }, + } as unknown as ICommandService); + } + return store.add(instantiationService.createInstance(DictationOnboardingService)); + } + + test('lists every real microphone once, behind the system default', () => { + const options = buildMicrophoneOptions([ + // The virtual entries duplicate a real device under a synthetic id. + device('audioinput', 'default', 'Default - Studio Mic'), + device('audioinput', 'communications', 'Communications - Studio Mic'), + device('audioinput', 'mic-a', 'Studio Mic'), + // Same device reported twice, and a device that is not a microphone. + device('audioinput', 'mic-a', 'Studio Mic'), + device('audiooutput', 'speaker-a', 'Speakers'), + // Labels stay empty until permission has been granted at least once. + device('audioinput', 'abcdefghij-unlabelled', ''), + ]); + + assert.deepStrictEqual(options, [ + { deviceId: '', label: 'System default' }, + { deviceId: 'mic-a', label: 'Studio Mic' }, + { deviceId: 'abcdefghij-unlabelled', label: 'Unknown device (abcdefgh)' }, + ]); + }); + + test('falls back to the system default when the remembered device is gone', () => { + const options = buildMicrophoneOptions([device('audioinput', 'mic-a', 'Studio Mic')]); + + assert.deepStrictEqual( + { + remembered: indexOfMicrophone(options, 'mic-a'), + systemDefault: indexOfMicrophone(options, ''), + unplugged: indexOfMicrophone(options, 'mic-that-was-unplugged'), + }, + { remembered: 1, systemDefault: 0, unplugged: 0 }); + }); + + test('takes over the first dictation, then never returns', async () => { + const service = createService(disposables); + const host = createHost(disposables); + disposables.add(service.registerHost(host.container, host.root)); + + let dictations = 0; + const tookOver = service.showIfNeeded(() => dictations++); + const shown = host.container.classList.contains('has-dictation-onboarding'); + + // Nothing is recorded while the card is up: confirming it is the only + // thing that starts the dictation it deferred, and even that waits a beat + // for the card to hand the microphone back. + const dictationsWhileOpen = dictations; + host.container.querySelector('.dictation-onboarding-close')!.click(); + const dictationsBeforeHandoff = dictations; + await timeout(HANDOFF_GRACE_MS); + + const tookOverAgain = service.showIfNeeded(() => dictations++); + + assert.deepStrictEqual( + { + tookOver, shown, dictationsWhileOpen, dictationsBeforeHandoff, + dictationsAfterHandoff: dictations, + visibleAfterHandoff: host.container.classList.contains('has-dictation-onboarding'), + tookOverAgain, + }, + { + tookOver: true, shown: true, dictationsWhileOpen: 0, dictationsBeforeHandoff: 0, + dictationsAfterHandoff: 1, + visibleAfterHandoff: false, + tookOverAgain: false, + }); + }); + + test('escape dismisses the card without dictating', async () => { + const service = createService(disposables); + const host = createHost(disposables); + disposables.add(service.registerHost(host.container, host.root)); + + let dictations = 0; + service.showIfNeeded(() => dictations++); + host.container.querySelector('.dictation-onboarding-banner')! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + await timeout(HANDOFF_GRACE_MS); + + assert.deepStrictEqual( + { dictations, visible: host.container.classList.contains('has-dictation-onboarding') }, + { dictations: 0, visible: false }); + }); + + test('dictates straight away when there is no chat input to dock to', () => { + const service = createService(disposables); + + assert.strictEqual(service.showIfNeeded(() => { }), false); + }); + + test('showing again replaces the card rather than hiding it', () => { + const service = createService(disposables); + const host = createHost(disposables); + disposables.add(service.registerHost(host.container, host.root)); + + service.show(); + service.show(); + + assert.deepStrictEqual( + { + visible: host.container.classList.contains('has-dictation-onboarding'), + cards: host.container.querySelectorAll('.dictation-onboarding-banner').length, + }, + { visible: true, cards: 1 }); + }); + + test('attaches to the most recently focused host', () => { + const service = createService(disposables); + const first = createHost(disposables); + const second = createHost(disposables); + disposables.add(service.registerHost(first.container, first.root)); + disposables.add(service.registerHost(second.container, second.root)); + + // 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-dictation-onboarding'), + second: second.container.classList.contains('has-dictation-onboarding'), + }, + { first: false, second: true }); + }); + + test('offers a way to change the settings and how dictation writes', () => { + const executed: string[] = []; + const service = createService(disposables, executed); + const host = createHost(disposables); + disposables.add(service.registerHost(host.container, host.root)); + + service.show(); + const links = host.container.querySelectorAll('.dictation-onboarding-description a'); + links.forEach(link => link.click()); + + assert.deepStrictEqual( + { + count: links.length, + // Every link has to be reachable without a mouse, not just the first. + keyboardReachable: Array.from(links).every(link => link.tabIndex === 0), + executed, + }, + { + count: 2, + keyboardReachable: true, + executed: ['workbench.action.openSettings', 'workbench.action.chat.configureDictationInstructions'], + }); + }); + + test('disposing the host it is docked to takes the card down with it', () => { + const service = createService(disposables); + const host = createHost(disposables); + const registration = service.registerHost(host.container, host.root); + + service.show(); + registration.dispose(); + + assert.deepStrictEqual( + { + visible: host.container.classList.contains('has-dictation-onboarding'), + cards: host.container.querySelectorAll('.dictation-onboarding-banner').length, + }, + { visible: false, cards: 0 }); + }); +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index f5c04c0788d5f..d5c59f6f4650d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../../base/common/event.js'; -import { IReference } from '../../../../../base/common/lifecycle.js'; +import { Disposable, IReference } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; @@ -54,6 +54,7 @@ import { IChatContextPickService } from '../../../../contrib/chat/browser/attach import { IChatContextService } from '../../../../contrib/chat/browser/contextContrib/chatContextService.js'; import { IChatImageCarouselService } from '../../../../contrib/chat/browser/chatImageCarouselService.js'; import { IChatInputNotificationService } from '../../../../contrib/chat/browser/widget/input/chatInputNotificationService.js'; +import { IDictationOnboardingService } from '../../../../contrib/chat/browser/speechToText/dictationOnboarding.js'; import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } from '../../../../contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { IChatMarkdownAnchorService } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMarkdownAnchorService.js'; import { IChatWidgetHistoryService } from '../../../../contrib/chat/common/widget/chatWidgetHistoryService.js'; @@ -191,6 +192,9 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override acceptResponse() { } override acceptElicitation() { } }()); + reg.defineInstance(IDictationOnboardingService, 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 6125b495bc281..d9a18f362b0a8 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -26,6 +27,7 @@ import { ITextFileService } from '../../../../services/textfile/common/textfiles import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.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'; import { IChatWidgetService, IChatAccessibilityService } from '../../../../contrib/chat/browser/chat.js'; import { IChatContextPickService } from '../../../../contrib/chat/browser/attachments/chatContextPickService.js'; @@ -324,6 +326,9 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo override getActiveNotification() { return undefined; } override announceRendered() { } }()); + reg.defineInstance(IDictationOnboardingService, 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;