From 0fc0d95042163c728905f4c3e32a767f0295fec0 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 27 Jul 2026 13:24:17 -0400 Subject: [PATCH 1/2] Fix chat placeholder rotation during dictation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/speechToText/dictationSession.ts | 20 +++++------- .../input/chatInputPlaceholderRotation.ts | 14 +++++++-- .../chatInputPlaceholderRotation.test.ts | 31 +++++++++++++++++++ .../test/browser/dictationSession.test.ts | 18 +++++++++-- 4 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatInputPlaceholderRotation.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts index cba42089948de..6ea77ed57e8e3 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts @@ -362,9 +362,9 @@ export async function startDictation(service: IChatSpeechToTextService, editor: // prepared on first use (downloading/loading, which can take a while), show // a "Preparing…/Downloading… X%" placeholder instead so the user knows why // dictation has not started yet rather than staring at an idle editor. The - // placeholder must not appear during microphone acquisition. It remains - // visible until transcript text is inserted, and is restored to its - // previous value when the session ends. + // previous placeholder must not appear during microphone acquisition. The + // dictation placeholder remains visible until transcript text is inserted, + // and the previous value is restored when the session ends. const previousPlaceholder = editor.getOption(EditorOption.placeholder); const listeningPlaceholder = localize('chatStt.listening', "Listening…"); // The placeholder we last applied (listening or a preparing label), so we @@ -377,15 +377,10 @@ export async function startDictation(service: IChatSpeechToTextService, editor: const recording = service.state === ChatSpeechToTextState.Recording; const desired = recording ? (service.isPreparingModel ? getDictationPreparingLabel(service) : listeningPlaceholder) - : undefined; - if (desired !== undefined) { - if (appliedPlaceholder !== desired) { - editor.updateOptions({ placeholder: desired }); - appliedPlaceholder = desired; - } - } else if (appliedPlaceholder !== undefined) { - editor.updateOptions({ placeholder: previousPlaceholder }); - appliedPlaceholder = undefined; + : ''; + if (appliedPlaceholder !== desired) { + editor.updateOptions({ placeholder: desired }); + appliedPlaceholder = desired; } }; disposables.add(toDisposable(() => { @@ -427,6 +422,7 @@ export async function startDictation(service: IChatSpeechToTextService, editor: // and local transcription running against a dead editor. disposables.add(editor.onDidDispose(() => cancelDictation())); _active = { service, editor, inserter, disposables, logService, surface }; + applyPlaceholder(); try { await service.start(window, surface); } catch { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPlaceholderRotation.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPlaceholderRotation.ts index 43f99690f4e84..6564465e0a7cb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPlaceholderRotation.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPlaceholderRotation.ts @@ -63,17 +63,27 @@ export function installRotatingChatPlaceholder(editor: ICodeEditor, options?: IR const store = new DisposableStore(); const placeholderContribution = PlaceholderTextContribution.get(editor); - placeholderContribution?.setAnimateTransitions(true); store.add(toDisposable(() => PlaceholderTextContribution.get(editor)?.setAnimateTransitions(false))); const currentPlaceholder = editor.getOption(EditorOption.placeholder); + let expectedPlaceholder = currentPlaceholder; let index = placeholders.indexOf(currentPlaceholder); if (index === -1) { index = Math.floor(Math.random() * placeholders.length); } store.add(disposableWindowInterval(getWindow(editor.getDomNode()), () => { + if (editor.getOption(EditorOption.placeholder) !== expectedPlaceholder) { + return; + } + index = (index + 1) % placeholders.length; - editor.updateOptions({ placeholder: placeholders[index] }); + expectedPlaceholder = placeholders[index]; + placeholderContribution?.setAnimateTransitions(true); + try { + editor.updateOptions({ placeholder: expectedPlaceholder }); + } finally { + placeholderContribution?.setAnimateTransitions(false); + } }, intervalMs)); return store; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatInputPlaceholderRotation.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatInputPlaceholderRotation.test.ts new file mode 100644 index 0000000000000..c8dabe89c0d0d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatInputPlaceholderRotation.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; +import { createTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { installRotatingChatPlaceholder } from '../../browser/widget/input/chatInputPlaceholderRotation.js'; + +suite('ChatInputPlaceholderRotation', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('does not overwrite a placeholder set by another feature', async () => { + const model = store.add(createTextModel('')); + const editor = store.add(createTestCodeEditor(model, { placeholder: 'First prompt' })); + store.add(installRotatingChatPlaceholder(editor, { + placeholders: ['First prompt', 'Second prompt'], + intervalMs: 5, + })); + + editor.updateOptions({ placeholder: 'Listening\u2026' }); + await timeout(20); + + assert.strictEqual(editor.getOption(EditorOption.placeholder), 'Listening\u2026'); + }); +}); 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 b7b0976b50248..7950801149fd8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { mainWindow } from '../../../../../base/browser/window.js'; import { Emitter } from '../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { createTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; @@ -23,6 +24,7 @@ suite('DictationSession', () => { const onDidUpdateTranscript = store.add(new Emitter()); const onDidChangeState = store.add(new Emitter()); let state = ChatSpeechToTextState.Idle; + let placeholderDuringStart: string | undefined; const service: IChatSpeechToTextService = { _serviceBrand: undefined, onDidUpdateTranscript: onDidUpdateTranscript.event, @@ -35,6 +37,7 @@ suite('DictationSession', () => { get modelDownloadProgress() { return undefined; }, get currentBackend() { return 'mai' as const; }, async start() { + placeholderDuringStart = editor.getOption(EditorOption.placeholder); state = ChatSpeechToTextState.Recording; onDidChangeState.fire(state); }, @@ -47,13 +50,24 @@ suite('DictationSession', () => { logDictationAccuracy() { }, }; const model = store.add(createTextModel('')); - const editor = store.add(createTestCodeEditor(model)); + const editor = store.add(createTestCodeEditor(model, { placeholder: 'Ask a question' })); await startDictation(service, editor, mainWindow, new NullLogService()); + const activePlaceholder = editor.getOption(EditorOption.placeholder); onDidUpdateTranscript.fire({ text: transcript, finalizedText: '' }); editor.executeEdits('test', [{ range: new Range(1, 1, 1, transcript.length + 1), text: '' }]); await stopDictation(); - assert.strictEqual(editor.getValue(), ''); + assert.deepStrictEqual({ + placeholderDuringStart, + activePlaceholder, + placeholderAfterStop: editor.getOption(EditorOption.placeholder), + value: editor.getValue(), + }, { + placeholderDuringStart: '', + activePlaceholder: 'Listening\u2026', + placeholderAfterStop: 'Ask a question', + value: '', + }); }); }); From 0a8782402bed748e1dd80a1289ecff5ddafe6c57 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 27 Jul 2026 13:32:57 -0400 Subject: [PATCH 2/2] Keep chat placeholder during dictation startup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/speechToText/dictationSession.ts | 19 ++++++++------ .../test/browser/dictationSession.test.ts | 25 +++++++++++++------ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts index 6ea77ed57e8e3..1bfe708fe1cf3 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts @@ -362,9 +362,8 @@ export async function startDictation(service: IChatSpeechToTextService, editor: // prepared on first use (downloading/loading, which can take a while), show // a "Preparing…/Downloading… X%" placeholder instead so the user knows why // dictation has not started yet rather than staring at an idle editor. The - // previous placeholder must not appear during microphone acquisition. The - // dictation placeholder remains visible until transcript text is inserted, - // and the previous value is restored when the session ends. + // previous placeholder remains visible during microphone acquisition, then + // is restored when the session ends. const previousPlaceholder = editor.getOption(EditorOption.placeholder); const listeningPlaceholder = localize('chatStt.listening', "Listening…"); // The placeholder we last applied (listening or a preparing label), so we @@ -377,10 +376,15 @@ export async function startDictation(service: IChatSpeechToTextService, editor: const recording = service.state === ChatSpeechToTextState.Recording; const desired = recording ? (service.isPreparingModel ? getDictationPreparingLabel(service) : listeningPlaceholder) - : ''; - if (appliedPlaceholder !== desired) { - editor.updateOptions({ placeholder: desired }); - appliedPlaceholder = desired; + : undefined; + if (desired !== undefined) { + if (appliedPlaceholder !== desired) { + editor.updateOptions({ placeholder: desired }); + appliedPlaceholder = desired; + } + } else if (appliedPlaceholder !== undefined) { + editor.updateOptions({ placeholder: previousPlaceholder }); + appliedPlaceholder = undefined; } }; disposables.add(toDisposable(() => { @@ -422,7 +426,6 @@ export async function startDictation(service: IChatSpeechToTextService, editor: // and local transcription running against a dead editor. disposables.add(editor.onDidDispose(() => cancelDictation())); _active = { service, editor, inserter, disposables, logService, surface }; - applyPlaceholder(); try { await service.start(window, surface); } catch { 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 7950801149fd8..87a2c9854eea2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/dictationSession.test.ts @@ -23,21 +23,25 @@ suite('DictationSession', () => { const transcript = 'hello world'; const onDidUpdateTranscript = store.add(new Emitter()); const onDidChangeState = store.add(new Emitter()); + const onDidChangePreparingModel = store.add(new Emitter()); let state = ChatSpeechToTextState.Idle; + let isPreparingModel = false; let placeholderDuringStart: string | undefined; const service: IChatSpeechToTextService = { _serviceBrand: undefined, onDidUpdateTranscript: onDidUpdateTranscript.event, onDidChangeState: onDidChangeState.event, - onDidChangePreparingModel: store.add(new Emitter()).event, + onDidChangePreparingModel: onDidChangePreparingModel.event, onDidChangeModelDownloadProgress: store.add(new Emitter()).event, get state() { return state; }, get isConfigured() { return true; }, - get isPreparingModel() { return false; }, - get modelDownloadProgress() { return undefined; }, - get currentBackend() { return 'mai' as const; }, + get isPreparingModel() { return isPreparingModel; }, + get modelDownloadProgress() { return 0.5; }, + get currentBackend() { return 'local' as const; }, async start() { placeholderDuringStart = editor.getOption(EditorOption.placeholder); + isPreparingModel = true; + onDidChangePreparingModel.fire(true); state = ChatSpeechToTextState.Recording; onDidChangeState.fire(state); }, @@ -53,19 +57,24 @@ suite('DictationSession', () => { const editor = store.add(createTestCodeEditor(model, { placeholder: 'Ask a question' })); await startDictation(service, editor, mainWindow, new NullLogService()); - const activePlaceholder = editor.getOption(EditorOption.placeholder); + const downloadingPlaceholder = editor.getOption(EditorOption.placeholder); + isPreparingModel = false; + onDidChangePreparingModel.fire(false); + const listeningPlaceholder = editor.getOption(EditorOption.placeholder); onDidUpdateTranscript.fire({ text: transcript, finalizedText: '' }); editor.executeEdits('test', [{ range: new Range(1, 1, 1, transcript.length + 1), text: '' }]); await stopDictation(); assert.deepStrictEqual({ placeholderDuringStart, - activePlaceholder, + downloadingPlaceholder, + listeningPlaceholder, placeholderAfterStop: editor.getOption(EditorOption.placeholder), value: editor.getValue(), }, { - placeholderDuringStart: '', - activePlaceholder: 'Listening\u2026', + placeholderDuringStart: 'Ask a question', + downloadingPlaceholder: 'Downloading speech-to-text model\u2026 50%', + listeningPlaceholder: 'Listening\u2026', placeholderAfterStop: 'Ask a question', value: '', });