Skip to content

Commit f48bd0e

Browse files
ulugbeknaCopilot
andcommitted
sessions: fix: preserve side-question ownership and promotion focus
Reserve source-scoped presentation ownership before side-chat creation. Cancel superseded fallback navigation and promotion, keep older sends recoverable without taking focus, and focus the exact promoted full-chat destination. Release pending ownership and cover creation, close, promotion, selection-input, and multi-group focus races. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f48399f6-8524-4cb1-b187-d7f3eb899620
1 parent 07a718e commit f48bd0e

11 files changed

Lines changed: 586 additions & 156 deletions

‎src/vs/sessions/contrib/chat/browser/btwSlashCommand.contribution.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,16 @@ export class BtwSlashCommandContribution extends Disposable implements IWorkbenc
8181
}
8282
const selection = captureSideChatSelection(chatWidgetService.getWidgetBySessionResource(chat.resource));
8383

84-
let sideChat;
84+
let prepared;
8585
try {
86-
sideChat = await sessionsManagementService.createSideChatInSession(session, chat.resource, sourceTurn.id, selection);
86+
prepared = await sideChatOrchestrationService.createAndPresent(session, chat, sourceTurn.id, remainder, selection);
8787
} catch (err) {
8888
logService.error('[btw] Failed to create side chat', err);
8989
notificationService.error(localize('btw.createFailed', "The side chat could not be created."));
9090
return;
9191
}
9292

9393
try {
94-
const prepared = await sideChatOrchestrationService.prepare(session, chat, sideChat, remainder);
9594
await prepared.send({ query: remainder, attachedContext: options?.attachedContext });
9695
} catch (err) {
9796
logService.error('[btw] Failed to send side chat request', err);

‎src/vs/sessions/contrib/chat/browser/responseSelectionSideChatController.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ export class ResponseSelectionSideChatController extends Disposable {
344344
* it — outside interactions like Escape or selection invalidation must not
345345
* race the in-flight create/open/send.
346346
*/
347-
private _dismiss(force = false): void {
347+
private _dismiss(force = false, preserveFocus = false): void {
348348
if (!force && this._input.isBusy) {
349349
return;
350350
}
@@ -359,7 +359,7 @@ export class ResponseSelectionSideChatController extends Disposable {
359359
this._input.setBusy(false);
360360
this._input.hide();
361361
this._input.clearInput();
362-
if (hadFocus) {
362+
if (hadFocus && !preserveFocus) {
363363
// Hiding the focused input would otherwise leave focus stranded on
364364
// the body; return it to the transcript it was invoked from.
365365
this._widget.focusResponseItem(true);
@@ -391,16 +391,16 @@ export class ResponseSelectionSideChatController extends Disposable {
391391
}
392392

393393
private async _createAndSendSideChat(session: ISession, sourceChat: IChat, turnId: string, selectedText: string, query: string, generation: number): Promise<void> {
394-
let presentedTransiently = false;
394+
let presentation: SideChatPresentation | undefined;
395395
try {
396396
const prepared = await this._sideChatOrchestrationService.createAndPresent(session, sourceChat, turnId, query, { text: selectedText });
397-
presentedTransiently = prepared.presentation === SideChatPresentation.Transient;
398-
if (presentedTransiently && this._generation === generation) {
399-
this._dismissAfterTransientPresentation();
397+
presentation = prepared.presentation;
398+
if (presentation !== SideChatPresentation.Full && this._generation === generation) {
399+
this._dismissAfterBackgroundPresentation(presentation === SideChatPresentation.Superseded);
400400
}
401401

402402
await prepared.send({ query });
403-
if (!presentedTransiently && this._generation === generation) {
403+
if (presentation === SideChatPresentation.Full && this._generation === generation) {
404404
this._input.setBusy(false);
405405
this._dismiss();
406406
}
@@ -409,7 +409,7 @@ export class ResponseSelectionSideChatController extends Disposable {
409409
if (this._generation !== generation) {
410410
return;
411411
}
412-
if (presentedTransiently) {
412+
if (presentation === SideChatPresentation.Transient || presentation === SideChatPresentation.Superseded) {
413413
this._notificationService.error(localize('sessions.selectionSideChat.sendFailed', "The side question could not be answered."));
414414
return;
415415
}
@@ -422,10 +422,10 @@ export class ResponseSelectionSideChatController extends Disposable {
422422
}
423423
}
424424

425-
private _dismissAfterTransientPresentation(): void {
425+
private _dismissAfterBackgroundPresentation(preserveFocus: boolean): void {
426426
dom.getWindow(this._widget.domNode).getSelection()?.removeAllRanges();
427427
this._input.setBusy(false);
428-
this._dismiss();
428+
this._dismiss(false, preserveFocus);
429429
this._autoScrollHold.clear();
430430
}
431431
}

‎src/vs/sessions/contrib/chat/browser/sideChatOrchestration.ts‎

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import { ISessionsPartService } from '../../../services/sessions/browser/session
1010
import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
1111
import { IChat, ISession, ISideChatSelection } from '../../../services/sessions/common/session.js';
1212
import { ISendRequestOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
13-
import { ITransientSideChatService } from './transientSideChatService.js';
13+
import { ITransientSideChatService, TransientSideChatPresentationResult } from './transientSideChatService.js';
1414

1515
export const ISideChatOrchestrationService = createDecorator<ISideChatOrchestrationService>('sideChatOrchestrationService');
1616

1717
export const enum SideChatPresentation {
1818
Full = 'full',
1919
Transient = 'transient',
20+
Superseded = 'superseded',
2021
}
2122

2223
export interface IPreparedSideChat {
@@ -27,7 +28,6 @@ export interface IPreparedSideChat {
2728

2829
export interface ISideChatOrchestrationService {
2930
readonly _serviceBrand: undefined;
30-
prepare(session: ISession, sourceChat: IChat, sideChat: IChat, question: string): Promise<IPreparedSideChat>;
3131
createAndPresent(session: ISession, sourceChat: IChat, turnId: string, question: string, selection?: ISideChatSelection): Promise<IPreparedSideChat>;
3232
}
3333

@@ -42,34 +42,41 @@ export class SideChatOrchestrationService implements ISideChatOrchestrationServi
4242
) { }
4343

4444
async createAndPresent(session: ISession, sourceChat: IChat, turnId: string, question: string, selection?: ISideChatSelection): Promise<IPreparedSideChat> {
45-
const sideChat = await this.sessionsManagementService.createSideChatInSession(session, sourceChat.resource, turnId, selection);
46-
return this.prepare(session, sourceChat, sideChat, question);
47-
}
48-
49-
async prepare(session: ISession, sourceChat: IChat, sideChat: IChat, question: string): Promise<IPreparedSideChat> {
50-
const presentation = await this.transientSideChatService.show(session, sourceChat, sideChat, question)
51-
? SideChatPresentation.Transient
52-
: SideChatPresentation.Full;
53-
if (presentation === SideChatPresentation.Full) {
54-
await this.sessionsService.openChat(session, sideChat.resource);
55-
const activeSession = this.sessionsService.activeSession.get();
56-
if (activeSession?.sessionId !== session.sessionId || !isEqual(activeSession.activeChat.get().resource, sideChat.resource)) {
57-
throw new Error(`Side chat '${sideChat.resource.toString()}' did not open`);
45+
const pendingPresentation = this.transientSideChatService.beginPresentation(sourceChat);
46+
try {
47+
const sideChat = await this.sessionsManagementService.createSideChatInSession(session, sourceChat.resource, turnId, selection);
48+
const result = await pendingPresentation.show(session, sideChat, question);
49+
let presentation = pendingPresentation.token.isCancellationRequested || result === TransientSideChatPresentationResult.Superseded
50+
? SideChatPresentation.Superseded
51+
: result === TransientSideChatPresentationResult.Shown ? SideChatPresentation.Transient : SideChatPresentation.Full;
52+
if (presentation === SideChatPresentation.Full) {
53+
await this.sessionsService.openChat(session, sideChat.resource, { token: pendingPresentation.token });
54+
if (pendingPresentation.token.isCancellationRequested) {
55+
await this.sessionsService.closeChat(session, sideChat, { skipHistory: true });
56+
presentation = SideChatPresentation.Superseded;
57+
} else {
58+
const activeSession = this.sessionsService.activeSession.get();
59+
if (activeSession?.sessionId !== session.sessionId || !isEqual(activeSession.activeChat.get().resource, sideChat.resource)) {
60+
throw new Error(`Side chat '${sideChat.resource.toString()}' did not open`);
61+
}
62+
this.sessionsPartService.getSessionView(session.sessionId)?.splitChatToSide(sideChat.resource);
63+
}
5864
}
59-
this.sessionsPartService.getSessionView(session.sessionId)?.splitChatToSide(sideChat.resource);
65+
return {
66+
sideChat,
67+
presentation,
68+
send: requestOptions => this._send(session, sideChat, presentation, requestOptions),
69+
};
70+
} finally {
71+
pendingPresentation.dispose();
6072
}
61-
return {
62-
sideChat,
63-
presentation,
64-
send: requestOptions => this._send(session, sideChat, presentation, requestOptions),
65-
};
6673
}
6774

6875
private async _send(session: ISession, sideChat: IChat, presentation: SideChatPresentation, requestOptions: ISendRequestOptions): Promise<ChatSideChatSendResult> {
6976
try {
7077
await this.sessionsManagementService.sendRequest(session, sideChat, {
7178
...requestOptions,
72-
preserveActiveChat: presentation === SideChatPresentation.Transient,
79+
preserveActiveChat: presentation !== SideChatPresentation.Full,
7380
});
7481
return { kind: ChatSideChatSendResultKind.Sent };
7582
} catch (error) {

‎src/vs/sessions/contrib/chat/browser/transientSideChatService.ts‎

Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
6+
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
7+
import { Disposable, DisposableResourceMap, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
78
import { autorun, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js';
89
import { isEqual } from '../../../../base/common/resources.js';
910
import { ResourceMap } from '../../../../base/common/map.js';
@@ -12,6 +13,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common
1213
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
1314
import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
1415
import { ChatInteractivity, IChat, ISession } from '../../../services/sessions/common/session.js';
16+
import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js';
1517
import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
1618

1719
export const AGENT_SESSIONS_TRANSIENT_SIDE_CHAT_SETTING = 'chat.agentSessions.transientSideChat';
@@ -34,11 +36,22 @@ export interface IResolvedTransientSideChatState extends ITransientSideChatState
3436

3537
export const ITransientSideChatService = createDecorator<ITransientSideChatService>('transientSideChatService');
3638

39+
export const enum TransientSideChatPresentationResult {
40+
Shown = 'shown',
41+
Unavailable = 'unavailable',
42+
Superseded = 'superseded',
43+
}
44+
45+
export interface ITransientSideChatPresentation extends IDisposable {
46+
readonly token: CancellationToken;
47+
show(session: ISession, sideChat: IChat, question: string): Promise<TransientSideChatPresentationResult>;
48+
}
49+
3750
export interface ITransientSideChatService {
3851
readonly _serviceBrand: undefined;
3952
readonly states: IObservable<readonly ITransientSideChatState[]>;
4053
registerHost(sourceChat: URI): IDisposable;
41-
show(session: ISession, sourceChat: IChat, sideChat: IChat, question: string): Promise<boolean>;
54+
beginPresentation(sourceChat: IChat): ITransientSideChatPresentation;
4255
resolveState(state: ITransientSideChatState, reader?: IReader): IResolvedTransientSideChatState | undefined;
4356
promote(sourceChat: URI): Promise<boolean>;
4457
dismiss(sourceChat: URI): void;
@@ -53,14 +66,14 @@ export class TransientSideChatService extends Disposable implements ITransientSi
5366
readonly states: IObservable<readonly ITransientSideChatState[]> = this._states;
5467

5568
private readonly _hosts = new ResourceMap<Set<object>>();
56-
private readonly _presentationIds = new ResourceMap<number>();
69+
private readonly _presentations = this._register(new DisposableResourceMap<CancellationTokenSource>());
5770
private readonly _catalogChanged: IObservable<void>;
58-
private _presentationIdPool = 0;
5971

6072
constructor(
6173
@ISessionsService private readonly sessionsService: ISessionsService,
6274
@ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
6375
@IConfigurationService private readonly configurationService: IConfigurationService,
76+
@ISessionsPartService private readonly sessionsPartService: ISessionsPartService,
6477
) {
6578
super();
6679
this._catalogChanged = observableSignalFromEvent(this, sessionsManagementService.onDidChangeSessions);
@@ -86,7 +99,7 @@ export class TransientSideChatService extends Disposable implements ITransientSi
8699
}));
87100
this._register(configurationService.onDidChangeConfiguration(event => {
88101
if (event.affectsConfiguration(AGENT_SESSIONS_TRANSIENT_SIDE_CHAT_SETTING) && !this._isEnabled()) {
89-
this._presentationIds.clear();
102+
this._cancelPresentations();
90103
this._states.set([], undefined);
91104
}
92105
}));
@@ -117,27 +130,36 @@ export class TransientSideChatService extends Disposable implements ITransientSi
117130
});
118131
}
119132

120-
async show(session: ISession, sourceChat: IChat, sideChat: IChat, question: string): Promise<boolean> {
121-
if (!this._isEnabled()) {
122-
return false;
123-
}
124-
if (!this._hasHost(sourceChat.resource)) {
125-
return false;
126-
}
127-
if (sourceChat.interactivity.get() !== ChatInteractivity.Full) {
128-
return false;
133+
beginPresentation(sourceChat: IChat): ITransientSideChatPresentation {
134+
this._presentations.get(sourceChat.resource)?.cancel();
135+
const source = new CancellationTokenSource();
136+
this._presentations.set(sourceChat.resource, source);
137+
const token = source.token;
138+
return Object.assign(toDisposable(() => {
139+
source.cancel();
140+
if (this._presentations.get(sourceChat.resource) === source) {
141+
this._presentations.deleteAndDispose(sourceChat.resource);
142+
}
143+
}), {
144+
token,
145+
show: (session: ISession, sideChat: IChat, question: string) => this._show(session, sourceChat, sideChat, question, token),
146+
});
147+
}
148+
149+
private async _show(session: ISession, sourceChat: IChat, sideChat: IChat, question: string, token: CancellationToken): Promise<TransientSideChatPresentationResult> {
150+
if (!token.isCancellationRequested && (!this._isEnabled()
151+
|| !this._hasHost(sourceChat.resource)
152+
|| sourceChat.interactivity.get() !== ChatInteractivity.Full)) {
153+
return TransientSideChatPresentationResult.Unavailable;
129154
}
130155

131-
const presentationId = ++this._presentationIdPool;
132-
this._presentationIds.set(sourceChat.resource, presentationId);
156+
// Superseded creations remain recoverable without taking over visible navigation.
133157
await this.sessionsService.closeChat(session, sideChat, { skipHistory: true });
134-
if (this._presentationIds.get(sourceChat.resource) !== presentationId
135-
|| !this._isEnabled()
136-
|| !this._hasHost(sourceChat.resource)) {
137-
if (this._presentationIds.get(sourceChat.resource) === presentationId) {
138-
this._presentationIds.delete(sourceChat.resource);
139-
}
140-
return false;
158+
if (token.isCancellationRequested) {
159+
return TransientSideChatPresentationResult.Superseded;
160+
}
161+
if (!this._isEnabled() || !this._hasHost(sourceChat.resource)) {
162+
return TransientSideChatPresentationResult.Unavailable;
141163
}
142164
const liveSession = this.sessionsManagementService.getSession(session.resource);
143165
const liveChats = liveSession?.chats.get();
@@ -147,8 +169,7 @@ export class TransientSideChatService extends Disposable implements ITransientSi
147169
|| !liveSourceChat
148170
|| liveSourceChat.interactivity.get() !== ChatInteractivity.Full
149171
|| !liveChats.some(chat => isEqual(chat.resource, sideChat.resource))) {
150-
this._presentationIds.delete(sourceChat.resource);
151-
return false;
172+
return TransientSideChatPresentationResult.Unavailable;
152173
}
153174
this._setState({
154175
sessionResource: liveSession.resource,
@@ -159,7 +180,7 @@ export class TransientSideChatService extends Disposable implements ITransientSi
159180
failed: false,
160181
replacedExisting: this._getState(sourceChat.resource) !== undefined,
161182
});
162-
return true;
183+
return TransientSideChatPresentationResult.Shown;
163184
}
164185

165186
private _isEnabled(): boolean {
@@ -193,27 +214,34 @@ export class TransientSideChatService extends Disposable implements ITransientSi
193214
throw new Error('The transient side chat is no longer available');
194215
}
195216

196-
this._setState({ ...state, promoting: true });
217+
const presentation = this.beginPresentation(resolved.sourceChat);
197218
try {
198-
await this.sessionsService.openChat(resolved.session, state.sideChatResource);
219+
this._setState({ ...state, promoting: true });
220+
await this.sessionsService.openChat(resolved.session, state.sideChatResource, { token: presentation.token });
221+
if (presentation.token.isCancellationRequested) {
222+
return false;
223+
}
199224
if (!this._isActiveChat(resolved.session, state.sideChatResource)) {
200225
throw new Error('The transient side chat did not open');
201226
}
227+
this.sessionsPartService.focusSession(this.sessionsService.activeSession.get());
202228
const current = this._getState(sourceChat);
203229
if (current && isEqual(current.sideChatResource, state.sideChatResource) && current.promoting) {
204230
this._remove(sourceChat);
205231
}
206232
return true;
207-
} catch (error) {
233+
} finally {
208234
const current = this._getState(sourceChat);
209235
if (current && isEqual(current.sideChatResource, state.sideChatResource) && current.promoting) {
210236
this._setState({ ...current, promoting: false });
211237
}
212-
throw error;
238+
presentation.dispose();
213239
}
214240
}
215241

216242
dismiss(sourceChat: URI): void {
243+
this._presentations.get(sourceChat)?.cancel();
244+
this._presentations.deleteAndDispose(sourceChat);
217245
this._remove(sourceChat);
218246
}
219247

@@ -259,9 +287,7 @@ export class TransientSideChatService extends Disposable implements ITransientSi
259287
const states = this._states.get();
260288
const next: ITransientSideChatState[] = [];
261289
for (const state of states) {
262-
if (predicate(state)) {
263-
this._presentationIds.delete(state.sourceChatResource);
264-
} else {
290+
if (!predicate(state)) {
265291
next.push(state);
266292
}
267293
}
@@ -279,4 +305,16 @@ export class TransientSideChatService extends Disposable implements ITransientSi
279305
return activeSession?.sessionId === session.sessionId && isEqual(activeSession.activeChat.get().resource, chatResource);
280306
}
281307

308+
private _cancelPresentations(): void {
309+
for (const source of this._presentations.values()) {
310+
source.cancel();
311+
}
312+
this._presentations.clearAndDisposeAll();
313+
}
314+
315+
override dispose(): void {
316+
this._cancelPresentations();
317+
super.dispose();
318+
}
319+
282320
}

‎src/vs/sessions/contrib/chat/browser/transientSideChatWidget.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,6 @@ export class TransientSideChatWidget extends Disposable {
701701
}
702702
try {
703703
if (await this._transientSideChatService.promote(source.chat.resource)) {
704-
this._mainWidget.focusInput();
705704
announceStatus(localize('transientSideChat.promotedStatus', "Opened side question as a full chat"));
706705
}
707706
} catch (error) {

0 commit comments

Comments
 (0)