Skip to content

Commit 371f6fd

Browse files
CopilotmeganroggeCopilot
authored
Allow editor/terminal dictation to take over an active Chat dictation session (#328830)
* Initial plan * Allow editor/terminal dictation to take over an active Chat session Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> * Scope dictation UI to active surface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Serialize dictation session takeover Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> Co-authored-by: meganrogge <merogge@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 620b881 commit 371f6fd

8 files changed

Lines changed: 226 additions & 58 deletions

File tree

src/vs/sessions/contrib/chat/browser/newChatInput.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHisto
8585
import { IChatStatusItemService } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusItemService.js';
8686
import { handleTerminalCommandPaste, isTerminalCommandInput } from '../../../../workbench/contrib/chat/browser/chatTerminalCommandPaste.js';
8787
import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js';
88-
import { ChatSpeechToTextState, DictationSettingId, IChatSpeechToTextService } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js';
88+
import { ChatSpeechToTextState, DictationSettingId, IChatSpeechToTextService, isDictationActiveOnSurface } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js';
8989
import { setupDictationMicGlow } from '../../../../workbench/contrib/chat/browser/speechToText/dictationMicGlow.js';
9090
import { IDictationOnboardingService } from '../../../../workbench/contrib/chat/browser/speechToText/dictationOnboarding.js';
9191
import { ChatVoiceInputModeAction, VoiceInputModeActionViewItem } from '../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.js';
@@ -930,23 +930,23 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
930930
// (which invites the user to click to cancel) so this composer matches
931931
// the main chat toolbar affordance. Idle gets the richer description
932932
// naming the configured dictation model.
933-
content: sttService.isPreparingModel
933+
content: sttService.currentSurface === 'chat' && sttService.isPreparingModel
934934
? getDictationDownloadHoverMarkdown(sttService)
935-
: (sttService.state !== ChatSpeechToTextState.Idle ? stopLabel : getDictationHoverMarkdown(micLabel, this.configurationService)),
935+
: (isDictationActiveOnSurface(sttService, 'chat') ? stopLabel : getDictationHoverMarkdown(micLabel, this.configurationService)),
936936
position: { hoverPosition: HoverPosition.BELOW },
937937
appearance: { showPointer: true }
938938
})));
939939

940940
const downloadRing = this._register(new MutableDisposable<DictationDownloadRing>());
941941
const renderState = () => {
942-
const preparing = sttService.isPreparingModel;
942+
const active = isDictationActiveOnSurface(sttService, 'chat');
943+
const preparing = active && sttService.isPreparingModel;
943944
// Only the active Recording state should read as "recording" (filled
944945
// mic). Once the user stops, the service enters Transcribing while it
945946
// waits for the final transcript (up to a few seconds on the cloud
946947
// backend); during that the mic must already read as idle, matching
947948
// the chat toolbar which flips as soon as recording stops.
948-
const recording = sttService.state === ChatSpeechToTextState.Recording;
949-
const active = sttService.state !== ChatSpeechToTextState.Idle;
949+
const recording = active && sttService.state === ChatSpeechToTextState.Recording;
950950
dom.clearNode(button);
951951
downloadRing.clear();
952952
if (preparing) {

src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts

Lines changed: 101 additions & 30 deletions
Large diffs are not rendered by default.

src/vs/workbench/contrib/chat/browser/speechToText/dictationMicGlow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { isDark } from '../../../../../platform/theme/common/theme.js';
1515
import { chatDictationActiveMicGlow } from '../../common/widget/chatColors.js';
1616
import { readVoiceGlowIntensity } from '../voiceClient/voiceGlow.js';
1717
import { createVoiceRimLight, IVoiceRimLight } from '../voiceClient/voiceGlowController.js';
18-
import { ChatSpeechToTextState, IChatSpeechToTextService } from './chatSpeechToTextService.js';
18+
import { ChatSpeechToTextState, IChatSpeechToTextService, isDictationActiveOnSurface } from './chatSpeechToTextService.js';
1919

2020
export type DictationMicGlowPhase = 'off' | 'live' | 'settling';
2121

@@ -134,6 +134,7 @@ export function setupDictationMicGlow(
134134
};
135135

136136
const update = (active = isActive?.get() !== false) => {
137+
active = active && isDictationActiveOnSurface(service, 'chat');
137138
const phase = active ? getDictationMicGlowPhase(service.state, service.isPreparingModel) : 'off';
138139
target.classList.toggle('dictation-mic-active', phase !== 'off');
139140
target.classList.toggle('dictation-mic-settling', phase === 'settling');

src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,22 @@ export function activeDictationEditor(): ICodeEditor | undefined {
315315

316316
/** Start dictating into `editor`, rendering the transcript live. */
317317
export async function startDictation(service: IChatSpeechToTextService, editor: ICodeEditor, window: Window & typeof globalThis, logService: ILogService, surface: ChatDictationSurface = 'chat'): Promise<void> {
318-
if (_active || service.state !== ChatSpeechToTextState.Idle) {
318+
// Already dictating into this exact editor: nothing to do (callers toggle
319+
// stopping separately).
320+
if (_active?.editor === editor) {
321+
return;
322+
}
323+
// Only one surface can use the shared on-device engine at a time. If a
324+
// dictation is already running — in the chat input, another editor, or the
325+
// terminal — cancel it so this surface can take over. The previous surface
326+
// clears its own state and UI when it observes the engine go Idle, keeping
327+
// whatever transcript it had already inserted.
328+
if (_active || service.isBusy) {
329+
await service.cancel();
330+
}
331+
// If the engine did not return to Idle (an unexpected busy state), do not
332+
// attach this surface's listeners to it.
333+
if (service.state !== ChatSpeechToTextState.Idle) {
319334
return;
320335
}
321336
const inserter = new LiveTranscriptInserter(editor, logService);
@@ -488,7 +503,7 @@ export function cancelDictation(): void {
488503
// the input exactly as it was before dictation started.
489504
active.inserter.revert();
490505
active.disposables.dispose();
491-
active.service.cancel();
506+
void active.service.cancel();
492507
}
493508

494509
/**

src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { ITextModel } from '../../../../../editor/common/model.js';
1414
import { createTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js';
1515
import { createTextModel } from '../../../../../editor/test/common/testTextModel.js';
1616
import { NullLogService } from '../../../../../platform/log/common/log.js';
17-
import { ChatSpeechToTextState, IChatDictationTranscript, IChatSpeechToTextService } from '../../browser/speechToText/chatSpeechToTextService.js';
17+
import { ChatDictationSurface, ChatSpeechToTextState, IChatDictationTranscript, IChatSpeechToTextService, isDictationActiveOnSurface } from '../../browser/speechToText/chatSpeechToTextService.js';
1818
import { isDictating, startDictation, stopDictation, stopDictationForEditor } from '../../browser/speechToText/dictationSession.js';
1919

2020
suite('DictationSession', () => {
@@ -26,10 +26,12 @@ suite('DictationSession', () => {
2626
* the test drive interim updates through the returned emitter. `setTranscript`
2727
* updates what a subsequent `stopAndTranscribe` resolves with.
2828
*/
29-
function createService(transcript: string, showTranscriptWhileDictating: boolean): { service: IChatSpeechToTextService; onDidUpdateTranscript: Emitter<IChatDictationTranscript>; setTranscript(text: string): void; blockStop(): () => void } {
29+
function createService(transcript: string, showTranscriptWhileDictating: boolean, cancelBarrier?: Promise<void>): { service: IChatSpeechToTextService; onDidUpdateTranscript: Emitter<IChatDictationTranscript>; setTranscript(text: string): void; starts: ChatDictationSurface[]; blockStop(): () => void } {
3030
const onDidUpdateTranscript = store.add(new Emitter<IChatDictationTranscript>());
3131
const onDidChangeState = store.add(new Emitter<ChatSpeechToTextState>());
32+
const starts: ChatDictationSurface[] = [];
3233
let state = ChatSpeechToTextState.Idle;
34+
let currentSurface: ChatDictationSurface = 'chat';
3335
let finalTranscript = transcript;
3436
let stopBarrier: Promise<void> | undefined;
3537
const service: IChatSpeechToTextService = {
@@ -40,6 +42,8 @@ suite('DictationSession', () => {
4042
onDidChangeDownloadingModel: store.add(new Emitter<boolean>()).event,
4143
onDidChangeModelDownloadProgress: store.add(new Emitter<void>()).event,
4244
get state() { return state; },
45+
get isBusy() { return state !== ChatSpeechToTextState.Idle; },
46+
get currentSurface() { return currentSurface; },
4347
get showTranscriptWhileDictating() { return showTranscriptWhileDictating; },
4448
get analyserNode() { return undefined; },
4549
get isConfigured() { return true; },
@@ -48,7 +52,9 @@ suite('DictationSession', () => {
4852
get modelDownloadProgress() { return undefined; },
4953
get currentBackend() { return 'mai' as const; },
5054
async switchMicrophone() { return undefined; },
51-
async start() {
55+
async start(_window, surface = 'chat') {
56+
currentSurface = surface;
57+
starts.push(surface);
5258
state = ChatSpeechToTextState.Recording;
5359
onDidChangeState.fire(state);
5460
},
@@ -59,13 +65,18 @@ suite('DictationSession', () => {
5965
onDidChangeState.fire(state);
6066
return finalTranscript;
6167
},
62-
cancel() { },
68+
async cancel() {
69+
state = ChatSpeechToTextState.Idle;
70+
onDidChangeState.fire(state);
71+
await cancelBarrier;
72+
},
6373
logDictationAccuracy() { },
6474
};
6575
return {
6676
service,
6777
onDidUpdateTranscript,
6878
setTranscript: text => { finalTranscript = text; },
79+
starts,
6980
blockStop: () => {
7081
const deferred = new DeferredPromise<void>();
7182
stopBarrier = deferred.p;
@@ -216,4 +227,66 @@ suite('DictationSession', () => {
216227

217228
assert.deepStrictEqual([afterMore, editor.getValue()], ['one twox three', 'one twox three']);
218229
});
230+
231+
test('starting dictation in another editor takes over the shared session', async () => {
232+
const { service, onDidUpdateTranscript, setTranscript } = createService('hello', true);
233+
const model1 = store.add(createTextModel(''));
234+
const editor1 = store.add(createTestCodeEditor(model1));
235+
const model2 = store.add(createTextModel(''));
236+
const editor2 = store.add(createTestCodeEditor(model2));
237+
238+
await startDictation(service, editor1, mainWindow, new NullLogService());
239+
onDidUpdateTranscript.fire({ text: 'hello', finalizedText: '' });
240+
const editor1WhileDictating = editor1.getValue();
241+
// Starting dictation in a second editor cancels the first session (keeping
242+
// its already-inserted text) and takes over the shared engine.
243+
await startDictation(service, editor2, mainWindow, new NullLogService());
244+
onDidUpdateTranscript.fire({ text: 'world', finalizedText: '' });
245+
const editor2WhileDictating = editor2.getValue();
246+
setTranscript('world');
247+
await stopDictation();
248+
249+
assert.deepStrictEqual(
250+
[editor1WhileDictating, editor1.getValue(), editor2WhileDictating, editor2.getValue()],
251+
['hello', 'hello', 'world', 'world'],
252+
);
253+
});
254+
255+
test('reports dictation activity only for the active surface', async () => {
256+
const { service } = createService('', true);
257+
const model = store.add(createTextModel(''));
258+
const editor = store.add(createTestCodeEditor(model));
259+
260+
await startDictation(service, editor, mainWindow, new NullLogService(), 'editor');
261+
const whileDictating = [
262+
isDictationActiveOnSurface(service, 'chat'),
263+
isDictationActiveOnSurface(service, 'editor'),
264+
isDictationActiveOnSurface(service, 'terminal'),
265+
];
266+
await stopDictation();
267+
268+
assert.deepStrictEqual(
269+
[whileDictating, isDictationActiveOnSurface(service, 'editor')],
270+
[[false, true, false], false],
271+
);
272+
});
273+
274+
test('waits for cancellation to settle before starting a replacement', async () => {
275+
const cancelBarrier = new DeferredPromise<void>();
276+
const { service, starts } = createService('', true, cancelBarrier.p);
277+
const firstModel = store.add(createTextModel(''));
278+
const firstEditor = store.add(createTestCodeEditor(firstModel));
279+
const secondModel = store.add(createTextModel(''));
280+
const secondEditor = store.add(createTestCodeEditor(secondModel));
281+
282+
await startDictation(service, firstEditor, mainWindow, new NullLogService(), 'chat');
283+
const replacement = startDictation(service, secondEditor, mainWindow, new NullLogService(), 'terminal');
284+
assert.deepStrictEqual(starts, ['chat']);
285+
286+
cancelBarrier.complete();
287+
await replacement;
288+
await stopDictation();
289+
290+
assert.deepStrictEqual(starts, ['chat', 'terminal']);
291+
});
219292
});

src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -265,22 +265,25 @@ export class EditorDictation extends Disposable implements IEditorContribution {
265265

266266
disposables.add(this.editor.onDidChangeCursorPosition(() => this.widget.layout()));
267267

268-
// When the shared session ends on its own (final transcript applied, an
269-
// error, or the model failing to load), tear down the editor-side UI.
270-
disposables.add(this.chatSpeechToTextService.onDidChangeState(state => {
271-
if (state === ChatSpeechToTextState.Idle) {
272-
this.sessionDisposables.clear();
273-
}
274-
}));
275-
276268
const window = getWindow(this.editor.getDomNode()) ?? getActiveWindow();
277269
await startDictation(this.chatSpeechToTextService, this.editor, window, this.logService, 'editor');
278270

279-
// If the session did not take (already dictating elsewhere, or start
280-
// failed without a state transition), do not leave the widget stranded.
271+
// If the session did not take (start failed without a state transition),
272+
// do not leave the widget stranded.
281273
if (activeDictationEditor() !== this.editor) {
282274
this.sessionDisposables.clear();
275+
return;
283276
}
277+
278+
// When the shared session ends on its own (final transcript applied, an
279+
// error, or the model failing to load), tear down the editor-side UI. This
280+
// is registered only after the takeover in `startDictation` has settled so
281+
// cancelling a previous surface's session cannot tear down this one.
282+
disposables.add(this.chatSpeechToTextService.onDidChangeState(state => {
283+
if (state === ChatSpeechToTextState.Idle) {
284+
this.sessionDisposables.clear();
285+
}
286+
}));
284287
}
285288

286289
private async startWithProvider(): Promise<void> {

src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,14 @@ export class TerminalVoiceSession extends Disposable {
227227

228228
// Only one dictation can run at a time (the on-device engine is a shared
229229
// singleton). If it is already recording elsewhere (chat input or an
230-
// editor), `service.start()` would no-op while these listeners stayed
231-
// attached and streamed that other surface's transcript into the
232-
// terminal. Reject a non-idle engine before subscribing.
230+
// editor), cancel that session so the terminal can take over — the other
231+
// surface clears its own state and UI when it observes the engine go Idle.
232+
// This runs before we attach our own listeners below, so it cannot tear
233+
// down this new terminal session.
234+
if (service.isBusy) {
235+
await service.cancel();
236+
}
237+
// If the engine somehow stayed busy, bail rather than subscribing to it.
233238
if (service.state !== ChatSpeechToTextState.Idle) {
234239
this.stop();
235240
return;
@@ -347,7 +352,7 @@ export class TerminalVoiceSession extends Disposable {
347352
// Abort the on-device engine on teardown. On the accept path the engine
348353
// has already finished via stopAndTranscribe(), so this is a no-op there.
349354
if (this._usingBuiltin) {
350-
this._chatSpeechToTextService.cancel();
355+
void this._chatSpeechToTextService.cancel();
351356
}
352357
this._disposables.clear();
353358
this._input = '';

src/vs/workbench/contrib/terminalContrib/voice/test/browser/terminalVoice.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ suite('TerminalVoiceSession', () => {
103103
stateEmitter.fire(state);
104104
return finalText;
105105
}
106-
override cancel(): void { }
106+
override async cancel(): Promise<void> { }
107107
});
108108
}
109109

0 commit comments

Comments
 (0)