From a18da4a3f360f1f99bfdf12c6aa7f55cc3368984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:15:37 +0000 Subject: [PATCH 1/2] Initial plan From d4f3da6d4e83beb1e896a5d95436e2ab912c9751 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:30:49 +0000 Subject: [PATCH 2/2] Allow editor/terminal dictation to take over an active Chat session Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --- .../browser/speechToText/dictationSession.ts | 15 ++++++++++ .../test/browser/dictationSession.test.ts | 29 ++++++++++++++++++- .../browser/dictation/editorDictation.ts | 23 ++++++++------- .../voice/browser/terminalVoice.ts | 11 +++++-- 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts index 5bbc3f070edb7..c9635e398ccb7 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts @@ -307,7 +307,22 @@ export function activeDictationEditor(): ICodeEditor | undefined { /** Start dictating into `editor`, rendering the transcript live. */ export async function startDictation(service: IChatSpeechToTextService, editor: ICodeEditor, window: Window & typeof globalThis, logService: ILogService, surface: ChatDictationSurface = 'chat'): Promise { + // Already dictating into this exact editor: nothing to do (callers toggle + // stopping separately). + if (_active?.editor === editor) { + return; + } + // Only one surface can use the shared on-device engine at a time. If a + // dictation is already running — in the chat input, another editor, or the + // terminal — cancel it so this surface can take over. The previous surface + // clears its own state and UI when it observes the engine go Idle, keeping + // whatever transcript it had already inserted. if (_active || service.state !== ChatSpeechToTextState.Idle) { + service.cancel(); + } + // If the engine did not return to Idle (an unexpected busy state), do not + // attach this surface's listeners to it. + if (service.state !== ChatSpeechToTextState.Idle) { return; } const inserter = new LiveTranscriptInserter(editor, logService); diff --git a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts index 1caa0529dc633..53a62cb282c24 100644 --- a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts @@ -55,7 +55,10 @@ suite('DictationSession', () => { onDidChangeState.fire(state); return finalTranscript; }, - cancel() { }, + cancel() { + state = ChatSpeechToTextState.Idle; + onDidChangeState.fire(state); + }, logDictationAccuracy() { }, }; return { service, onDidUpdateTranscript, setTranscript: text => { finalTranscript = text; } }; @@ -150,4 +153,28 @@ suite('DictationSession', () => { assert.deepStrictEqual([afterMore, editor.getValue()], ['one twox three', 'one twox three']); }); + + test('starting dictation in another editor takes over the shared session', async () => { + const { service, onDidUpdateTranscript, setTranscript } = createService('hello', true); + const model1 = store.add(createTextModel('')); + const editor1 = store.add(createTestCodeEditor(model1)); + const model2 = store.add(createTextModel('')); + const editor2 = store.add(createTestCodeEditor(model2)); + + await startDictation(service, editor1, mainWindow, new NullLogService()); + onDidUpdateTranscript.fire({ text: 'hello', finalizedText: '' }); + const editor1WhileDictating = editor1.getValue(); + // Starting dictation in a second editor cancels the first session (keeping + // its already-inserted text) and takes over the shared engine. + await startDictation(service, editor2, mainWindow, new NullLogService()); + onDidUpdateTranscript.fire({ text: 'world', finalizedText: '' }); + const editor2WhileDictating = editor2.getValue(); + setTranscript('world'); + await stopDictation(); + + assert.deepStrictEqual( + [editor1WhileDictating, editor1.getValue(), editor2WhileDictating, editor2.getValue()], + ['hello', 'hello', 'world', 'world'], + ); + }); }); diff --git a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts index 5cd18f074ec94..6cb40de5b298b 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts @@ -265,22 +265,25 @@ export class EditorDictation extends Disposable implements IEditorContribution { disposables.add(this.editor.onDidChangeCursorPosition(() => this.widget.layout())); - // When the shared session ends on its own (final transcript applied, an - // error, or the model failing to load), tear down the editor-side UI. - disposables.add(this.chatSpeechToTextService.onDidChangeState(state => { - if (state === ChatSpeechToTextState.Idle) { - this.sessionDisposables.clear(); - } - })); - const window = getWindow(this.editor.getDomNode()) ?? getActiveWindow(); await startDictation(this.chatSpeechToTextService, this.editor, window, this.logService, 'editor'); - // If the session did not take (already dictating elsewhere, or start - // failed without a state transition), do not leave the widget stranded. + // If the session did not take (start failed without a state transition), + // do not leave the widget stranded. if (activeDictationEditor() !== this.editor) { this.sessionDisposables.clear(); + return; } + + // When the shared session ends on its own (final transcript applied, an + // error, or the model failing to load), tear down the editor-side UI. This + // is registered only after the takeover in `startDictation` has settled so + // cancelling a previous surface's session cannot tear down this one. + disposables.add(this.chatSpeechToTextService.onDidChangeState(state => { + if (state === ChatSpeechToTextState.Idle) { + this.sessionDisposables.clear(); + } + })); } private async startWithProvider(): Promise { diff --git a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts index bd2cdc194bd38..5a88299abfe33 100644 --- a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts +++ b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts @@ -214,9 +214,14 @@ export class TerminalVoiceSession extends Disposable { // Only one dictation can run at a time (the on-device engine is a shared // singleton). If it is already recording elsewhere (chat input or an - // editor), `service.start()` would no-op while these listeners stayed - // attached and streamed that other surface's transcript into the - // terminal. Reject a non-idle engine before subscribing. + // editor), cancel that session so the terminal can take over — the other + // surface clears its own state and UI when it observes the engine go Idle. + // This runs before we attach our own listeners below, so it cannot tear + // down this new terminal session. + if (service.state !== ChatSpeechToTextState.Idle) { + service.cancel(); + } + // If the engine somehow stayed busy, bail rather than subscribing to it. if (service.state !== ChatSpeechToTextState.Idle) { this.stop(); return;