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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; } };
Expand Down Expand Up @@ -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'],
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down