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
2 changes: 1 addition & 1 deletion src/vs/sessions/SESSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ focus a slot: part.onDidFocusSession → view.setActive → updates active vis

The Agents-window chat surface also registers the workbench chat pre-submit handlers. These handlers can consume provider-specific client-side commands before the normal send path, while the actual send still routes through the sessions provider model.

The voice bridge can either submit a finalized transcription or prefill it for review. With hands-free mode disabled, stopping listening uses the prefill path so sending remains an explicit user action.
The voice bridge can either submit a finalized transcription or prefill it for review. With hands-free mode disabled, stopping listening uses the prefill path so sending remains an explicit user action. When connected Voice Mode moves from a created session into the new-session composer, the composer sentinel becomes the active voice target without discarding the listening turn; moving between created sessions still stops or submits the turn to prevent misrouting.

The part (interface `services/sessions/browser/sessionsPartService.ts`; concrete `browser/parts/sessionsPart.ts`) is a **passive renderer**: it injects neither the model nor the view, and only exposes `updateVisibleSessions(visible, active)`, `focusSession`, and `onDidFocusSession`. The view owns the reconcile autorun and focus and wires `part.onDidFocusSession → view.setActive`.

Expand Down
24 changes: 18 additions & 6 deletions src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,24 +200,27 @@ registerWorkbenchContribution2(SessionsVoiceBridgeContribution.ID, SessionsVoice
* response deferral, and buffered playback follow the visible session.
*
* Agents can render multiple chat widgets while DOM focus stays on the sessions
* list, so forward {@link ISessionsService.activeSession}. Draft composers report
* `undefined` to avoid reusing a stale session.
* list, so forward {@link ISessionsService.activeSession}. Draft composers use a
* sentinel resource so routing does not fall back to a stale chat widget.
*/
class SessionsVoiceActiveSessionContribution extends Disposable implements IWorkbenchContribution {
export class SessionsVoiceActiveSessionContribution extends Disposable implements IWorkbenchContribution {

static readonly ID = 'sessions.voiceActiveSession';

constructor(
@IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController,
@ISessionsService private readonly sessionsService: ISessionsService,
@INewChatVoiceTargetService private readonly newChatVoiceTargetService: INewChatVoiceTargetService,
) {
super();

this._register(autorun(reader => {
const active = this.sessionsService.activeSession.read(reader);
const resource = active?.isCreated.read(reader)
? active.activeChat.read(reader)?.resource
: undefined;
: this.newChatVoiceTargetService.activeComposer.read(reader)
? NEW_CHAT_VOICE_SENTINEL
: undefined;
this.voiceSessionController.setActiveSessionShown(resource);
}));
}
Expand All @@ -231,13 +234,14 @@ registerWorkbenchContribution2(SessionsVoiceActiveSessionContribution.ID, Sessio
* anything already dictated to the original session, or discard an empty turn,
* so voice mode doesn't keep recording against a newly focused session.
*/
class SessionsVoiceListeningContribution extends Disposable implements IWorkbenchContribution {
export class SessionsVoiceListeningContribution extends Disposable implements IWorkbenchContribution {

static readonly ID = 'sessions.voiceListening';

constructor(
@IVoiceSessionController voiceSessionController: IVoiceSessionController,
@ISessionsService sessionsService: ISessionsService,
@INewChatVoiceTargetService newChatVoiceTargetService: INewChatVoiceTargetService,
) {
super();

Expand All @@ -248,7 +252,11 @@ class SessionsVoiceListeningContribution extends Disposable implements IWorkbenc
const targetSession = voiceSessionController.targetSession.read(reader);
const turns = voiceSessionController.transcriptTurns.read(reader);
const activeSession = sessionsService.activeSession.read(reader);
const currentSession = activeSession?.activeChat.read(reader)?.resource;
const currentSession = activeSession?.isCreated.read(reader)
? activeSession.activeChat.read(reader)?.resource
: newChatVoiceTargetService.activeComposer.read(reader)
? NEW_CHAT_VOICE_SENTINEL
: undefined;

if (!connected) {
listeningSession = undefined;
Expand All @@ -264,6 +272,10 @@ class SessionsVoiceListeningContribution extends Disposable implements IWorkbenc
if (!listeningSession) {
listeningSession = targetSession ?? currentSession;
} else if (!targetSession && currentSession && !isEqual(currentSession, listeningSession)) {
if (isEqual(currentSession, NEW_CHAT_VOICE_SENTINEL)) {
listeningSession = currentSession;
return;
}
const dictationSession = listeningSession;
const activelyDictating = turns.some(t => t.speaker === 'user' && t.isPartial && t.text.trim().length > 0);
if (activelyDictating) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*---------------------------------------------------------------------------------------------
* 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 { Event } from '../../../../../base/common/event.js';
import { constObservable, observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { upcastPartial } from '../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { IVoiceSessionController } from '../../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js';
import { IChat } from '../../../../services/sessions/common/session.js';
import { SessionsVoiceActiveSessionContribution, SessionsVoiceListeningContribution } from '../../browser/voiceBridge.contribution.js';
import { INewChatVoiceComposer, INewChatVoiceTargetService, NEW_CHAT_VOICE_SENTINEL } from '../../browser/newChatVoice.js';

suite('SessionsVoiceActiveSessionContribution', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();

test('routes connected voice mode to the new-session composer', () => {
const chatResource = URI.parse('test:///chat');
const createdSession = upcastPartial<IActiveSession>({
isCreated: constObservable(true),
activeChat: constObservable(upcastPartial<IChat>({ resource: chatResource })),
});
const draftSession = upcastPartial<IActiveSession>({
isCreated: constObservable(false),
activeChat: constObservable(upcastPartial<IChat>({ resource: URI.parse('test:///draft-chat') })),
});
const activeSession = observableValue<IActiveSession | undefined>('activeSession', createdSession);
const activeComposer = observableValue<INewChatVoiceComposer | undefined>('activeComposer', {
onDidFocus: Event.None,
sendQuery: () => undefined,
prefillInput: () => undefined,
focus: () => undefined,
});
const shownResources: Array<string | undefined> = [];
disposables.add(new SessionsVoiceActiveSessionContribution(
upcastPartial<IVoiceSessionController>({
setActiveSessionShown: resource => shownResources.push(resource?.toString()),
}),
upcastPartial<ISessionsService>({ activeSession }),
upcastPartial<INewChatVoiceTargetService>({ activeComposer }),
));

activeSession.set(draftSession, undefined);
activeComposer.set(undefined, undefined);

assert.deepStrictEqual(shownResources, [
chatResource.toString(),
NEW_CHAT_VOICE_SENTINEL.toString(),
undefined,
]);
});

test('keeps listening when opening the new-session composer', () => {
const firstChatResource = URI.parse('test:///first-chat');
const secondChatResource = URI.parse('test:///second-chat');
const createdSession = upcastPartial<IActiveSession>({
isCreated: constObservable(true),
activeChat: constObservable(upcastPartial<IChat>({ resource: firstChatResource })),
});
const draftSession = upcastPartial<IActiveSession>({
isCreated: constObservable(false),
activeChat: constObservable(upcastPartial<IChat>({ resource: URI.parse('test:///draft-chat') })),
});
const secondCreatedSession = upcastPartial<IActiveSession>({
isCreated: constObservable(true),
activeChat: constObservable(upcastPartial<IChat>({ resource: secondChatResource })),
});
const activeSession = observableValue<IActiveSession | undefined>('activeSession', createdSession);
const activeComposer = observableValue<INewChatVoiceComposer | undefined>('activeComposer', {
onDidFocus: Event.None,
sendQuery: () => undefined,
prefillInput: () => undefined,
focus: () => undefined,
});
const listeningChanges: string[] = [];
disposables.add(new SessionsVoiceListeningContribution(
upcastPartial<IVoiceSessionController>({
isConnected: constObservable(true),
voiceState: constObservable<'listening'>('listening'),
targetSession: constObservable(undefined),
transcriptTurns: constObservable([]),
discardListening: () => listeningChanges.push('discard'),
finishListeningAndSubmitTo: resource => listeningChanges.push(`submit:${resource.toString()}`),
}),
upcastPartial<ISessionsService>({ activeSession }),
upcastPartial<INewChatVoiceTargetService>({ activeComposer }),
));

activeSession.set(draftSession, undefined);
activeSession.set(secondCreatedSession, undefined);

assert.deepStrictEqual(listeningChanges, ['discard']);
});
});