Skip to content

Commit 264a406

Browse files
ulugbeknaCopilot
andauthored
sessions: feat: close chat tabs on middle click (#329377)
sessions: fix: close chat tabs on middle click Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e2356cdb-d06e-4d0f-9d9e-42d8bdc36166
1 parent c479457 commit 264a406

5 files changed

Lines changed: 255 additions & 2 deletions

File tree

src/vs/sessions/SESSIONS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ A second producer sits **outside** the protocol: `IAgentHostAdapterOptions.readO
180180
- `applyChatCatalog` (`baseAgentHostSessionsProvider.ts`) surfaces a non-default chat as a peer when the session supports multiple chats (`copilotcli`) **or** the chat is a subagent (`origin.kind === Tool`). So subagent chats exist in the peer-chat catalog even in single-chat session types (e.g. `claude`), while ordinary user/fork/side-chat peers still require the usual session support.
181181
- `VisibleSession` keeps tool-origin chats out of `visibleChatTabs` until the user explicitly opens one (for example from the transcript pill or the **Conversations** menu). `chatCompositeBar` renders whatever is in `visibleChatTabs`, so user-created peers such as side chats behave like ordinary tabs while subagents stay hidden/read-only by default. The trailing **New Chat** action remains gated to `capabilities.supportsMultipleChats`, so single-chat sessions that merely host a subagent don't expose chat creation.
182182

183+
Non-main chat tabs close from their close button, the active-chat close keybinding, or a middle click anywhere on the tab. Closing a committed chat hides it until it is reopened from the **Chats** menu; closing an untitled draft deletes it.
184+
183185
Subagent chats **persist** in the session catalog after the subagent completes (completion only marks the chat's turn complete; the chat is removed only when the whole session is disposed), so the read-only tab stays reviewable for the lifetime of the session.
184186

185187
**Opening a subagent chat from the transcript.** `ChatSubagentContentPart` and `OpenSubagentChatActionViewItem` provide one shared rich pill in both windows. The Agents window opens the surfaced peer chat; regular chat editors use the default-enabled `chat.subagents.useRichRendering` setting to open the child in a read-only editor instead of rendering its full activity inline. Editor-hosted children show the shared **This chat is read-only** banner above the transcript.

src/vs/sessions/browser/parts/chatCompositeBar.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import './media/chatCompositeBar.css';
77
import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
88
import { URI } from '../../../base/common/uri.js';
99
import { Emitter, Event } from '../../../base/common/event.js';
10-
import { $, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, reset } from '../../../base/browser/dom.js';
10+
import { $, addDisposableGenericMouseDownListener, addDisposableGenericMouseUpListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventHelper, EventType, getWindow, isHTMLElement, reset } from '../../../base/browser/dom.js';
1111
import { ScrollableElement } from '../../../base/browser/ui/scrollbar/scrollableElement.js';
1212
import { ScrollbarVisibility } from '../../../base/common/scrollable.js';
1313
import { autorun } from '../../../base/common/observable.js';
14+
import { isLinux } from '../../../base/common/platform.js';
1415
import { IThemeService } from '../../../platform/theme/common/themeService.js';
1516
import { Action } from '../../../base/common/actions.js';
1617
import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js';
@@ -38,6 +39,8 @@ import { applyDragImage } from '../../../base/browser/ui/dnd/dnd.js';
3839
import { clearChatReferenceDragData, fillChatReferenceDragData } from '../dnd.js';
3940
import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js';
4041
import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js';
42+
import { ICommandService } from '../../../platform/commands/common/commands.js';
43+
import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js';
4144

4245
interface IChatTab {
4346
readonly chat: IChat;
@@ -100,6 +103,7 @@ export class ChatCompositeBar extends Disposable {
100103
@IHoverService private readonly _hoverService: IHoverService,
101104
@IInstantiationService private readonly _instantiationService: IInstantiationService,
102105
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
106+
@ICommandService private readonly _commandService: ICommandService,
103107
) {
104108
super();
105109

@@ -120,6 +124,17 @@ export class ChatCompositeBar extends Disposable {
120124
}));
121125
this._tabsRow.appendChild(this._tabsScrollbar.getDomNode());
122126

127+
const preventMiddleButtonDefault = (e: MouseEvent) => {
128+
if (e.button === 1 && !this._isInTabInput(e)) {
129+
e.preventDefault();
130+
}
131+
};
132+
this._register(addDisposableGenericMouseDownListener(this._tabsContainer, preventMiddleButtonDefault));
133+
// Prevent Linux primary-selection paste after the middle-button release (https://github.com/microsoft/vscode/issues/201696).
134+
if (isLinux) {
135+
this._register(addDisposableGenericMouseUpListener(this._tabsContainer, preventMiddleButtonDefault));
136+
}
137+
123138
// "New Chat" button pinned at the end of the tab strip. Starting a new chat
124139
// is offered here while the tabs are shown; when the session has a single
125140
// chat the session header toolbar offers it instead.
@@ -338,6 +353,23 @@ export class ChatCompositeBar extends Disposable {
338353
this._onTabClicked(chat);
339354
}));
340355

356+
this._tabDisposables.add(addDisposableListener(tab, EventType.AUXCLICK, e => {
357+
if (e.button !== 1) {
358+
return;
359+
}
360+
if (this._isInTabInput(e)) {
361+
return;
362+
}
363+
364+
EventHelper.stop(e, true);
365+
if (isMainChat || !session) {
366+
return;
367+
}
368+
369+
this._cancelTabEditing();
370+
void this._commandService.executeCommand(CLOSE_CHAT_COMMAND_ID, { session, chat }).catch(onUnexpectedError);
371+
}));
372+
341373
// Make the tab a drag source that offers a chat reference, so it can be
342374
// dropped into an agent-host chat input to insert an inline `#chat:` ref.
343375
tab.draggable = true;
@@ -442,6 +474,10 @@ export class ChatCompositeBar extends Disposable {
442474
}
443475
}
444476

477+
private _isInTabInput(event: MouseEvent): boolean {
478+
return isHTMLElement(event.target) && !!event.target.closest('.chat-composite-bar-tab-input-container');
479+
}
480+
445481
/**
446482
* Resolves the opaque backend chat URI for a chat tab so a dragged `#chat:`
447483
* reference can carry it. Reaches the owning agent-host provider by id and

src/vs/sessions/common/sessionCommands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@ export const UNARCHIVE_SESSION_COMMAND_ID = 'sessionsViewPane.unarchiveSession';
1515

1616
/** Renames a session. Registered in `sessionsViewActions.ts`. */
1717
export const RENAME_SESSION_COMMAND_ID = 'sessionsViewPane.renameSession';
18+
19+
/** Closes a chat tab. Registered in `sessionsActions.ts`. */
20+
export const CLOSE_CHAT_COMMAND_ID = 'sessions.chatCompositeBar.closeChat';

src/vs/sessions/contrib/sessions/browser/sessionsActions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Menus } from '../../../browser/menus.js';
2929
import { SessionsCategories } from '../../../common/categories.js';
3030
import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext } from '../../../common/contextkeys.js';
3131
import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js';
32+
import { CLOSE_CHAT_COMMAND_ID } from '../../../common/sessionCommands.js';
3233
import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
3334
import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
3435
import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js';
@@ -526,7 +527,7 @@ export interface IChatTabContext {
526527
registerAction2(class CloseChatAction extends Action2 {
527528
constructor() {
528529
super({
529-
id: 'sessions.chatCompositeBar.closeChat',
530+
id: CLOSE_CHAT_COMMAND_ID,
530531
title: localize2('closeActiveChat', "Close Chat"),
531532
icon: Codicon.close,
532533
// Hidden from the palette: closing a specific chat is contextual (the
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import assert from 'assert';
7+
import { addDisposableListener, EventType } from '../../../base/browser/dom.js';
8+
import { mainWindow } from '../../../base/browser/window.js';
9+
import { Event } from '../../../base/common/event.js';
10+
import { DisposableStore } from '../../../base/common/lifecycle.js';
11+
import { constObservable, IObservable } from '../../../base/common/observable.js';
12+
import { isLinux } from '../../../base/common/platform.js';
13+
import { URI } from '../../../base/common/uri.js';
14+
import { mock } from '../../../base/test/common/mock.js';
15+
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js';
16+
import { ICommandService } from '../../../platform/commands/common/commands.js';
17+
import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js';
18+
import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js';
19+
import { ChatCompositeBar } from '../../browser/parts/chatCompositeBar.js';
20+
import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js';
21+
import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js';
22+
import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js';
23+
import { ISessionsService } from '../../services/sessions/browser/sessionsService.js';
24+
import { ChatInteractivity, IChat, ISession, ISessionCapabilities, SessionStatus } from '../../services/sessions/common/session.js';
25+
import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js';
26+
27+
class TestCommandService extends mock<ICommandService>() {
28+
readonly calls: { readonly commandId: string; readonly args: readonly unknown[] }[] = [];
29+
30+
override async executeCommand<T = unknown>(commandId: string, ...args: unknown[]): Promise<T | undefined> {
31+
this.calls.push({ commandId, args });
32+
return undefined;
33+
}
34+
}
35+
36+
class TestSessionsService extends mock<ISessionsService>() {
37+
readonly openedChats: URI[] = [];
38+
39+
override async openChat(_session: ISession, chatUri: URI): Promise<void> {
40+
this.openedChats.push(chatUri);
41+
}
42+
}
43+
44+
function createChat(id: string, title: string, status: SessionStatus = SessionStatus.Completed): IChat {
45+
const resource = URI.parse(`test-chat://${id}`);
46+
return new class extends mock<IChat>() {
47+
override readonly resource = resource;
48+
override readonly title: IObservable<string> = constObservable(title);
49+
override readonly status: IObservable<SessionStatus> = constObservable(status);
50+
override readonly isRead: IObservable<boolean> = constObservable(true);
51+
override readonly interactivity: IObservable<ChatInteractivity> = constObservable(ChatInteractivity.Full);
52+
}();
53+
}
54+
55+
function createSession(chats: readonly IChat[], activeChat: IChat): IActiveSession {
56+
const resource = URI.parse('test-session://session');
57+
return new class extends mock<IActiveSession>() {
58+
override readonly sessionId = 'session';
59+
override readonly resource = resource;
60+
override readonly providerId = 'test';
61+
override readonly chats: IObservable<readonly IChat[]> = constObservable(chats);
62+
override readonly openChats: IObservable<readonly IChat[]> = constObservable(chats);
63+
override readonly closedChats: IObservable<readonly IChat[]> = constObservable([]);
64+
override readonly visibleChatTabs: IObservable<readonly IChat[]> = constObservable(chats);
65+
override readonly shouldShowChatTabs: IObservable<boolean> = constObservable(true);
66+
override readonly mainChat: IObservable<IChat> = constObservable(chats[0]);
67+
override readonly activeChat: IObservable<IChat> = constObservable(activeChat);
68+
override readonly capabilities: IObservable<ISessionCapabilities> = constObservable({ supportsMultipleChats: true });
69+
override readonly isCreated: IObservable<boolean> = constObservable(true);
70+
override readonly isArchived: IObservable<boolean> = constObservable(false);
71+
}();
72+
}
73+
74+
interface IChatCompositeBarHarness {
75+
readonly store: DisposableStore;
76+
readonly instantiationService: TestInstantiationService;
77+
readonly commandService: TestCommandService;
78+
readonly sessionsService: TestSessionsService;
79+
readonly bar: ChatCompositeBar;
80+
readonly session: IActiveSession;
81+
readonly tabs: readonly HTMLElement[];
82+
}
83+
84+
function createHarness(disposables: Pick<DisposableStore, 'add'>): IChatCompositeBarHarness {
85+
const store = disposables.add(new DisposableStore());
86+
const instantiationService = workbenchInstantiationService(undefined, store);
87+
const commandService = new TestCommandService();
88+
const sessionsService = new TestSessionsService();
89+
const mainChat = createChat('main', 'Main Chat');
90+
const secondaryChat = createChat('secondary', 'Secondary Chat');
91+
const session = createSession([mainChat, secondaryChat], mainChat);
92+
93+
instantiationService.stub(ICommandService, commandService);
94+
instantiationService.stub(ISessionsService, sessionsService);
95+
instantiationService.stub(ISessionsManagementService, new class extends mock<ISessionsManagementService>() {
96+
override readonly onDidChangeSessions = Event.None;
97+
}());
98+
instantiationService.stub(ISessionsPartService, new class extends mock<ISessionsPartService>() { });
99+
instantiationService.stub(ISessionsProvidersService, new class extends mock<ISessionsProvidersService>() {
100+
override readonly onDidChangeProviders = Event.None;
101+
override getProvider() { return undefined; }
102+
}());
103+
104+
const bar = store.add(instantiationService.createInstance(ChatCompositeBar));
105+
bar.setSession(session);
106+
const container = mainWindow.document.createElement('div');
107+
container.appendChild(bar.element);
108+
const tabs = Array.from(bar.element.querySelectorAll<HTMLElement>('.chat-composite-bar-tab'));
109+
110+
return { store, instantiationService, commandService, sessionsService, bar, session, tabs };
111+
}
112+
113+
suite('Sessions - ChatCompositeBar', () => {
114+
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
115+
116+
test('middle-click closes the targeted inactive non-main chat', () => {
117+
const { store, commandService, sessionsService, bar, session, tabs } = createHarness(disposables);
118+
let bubbled = 0;
119+
store.add(addDisposableListener(bar.element, EventType.AUXCLICK, () => bubbled++));
120+
const event = new MouseEvent(EventType.AUXCLICK, { bubbles: true, button: 1, cancelable: true });
121+
122+
const dispatchResult = tabs[1].dispatchEvent(event);
123+
124+
assert.deepStrictEqual({
125+
commandCalls: commandService.calls,
126+
openedChats: sessionsService.openedChats,
127+
defaultPrevented: event.defaultPrevented,
128+
dispatchResult,
129+
bubbled,
130+
}, {
131+
commandCalls: [{
132+
commandId: CLOSE_CHAT_COMMAND_ID,
133+
args: [{ session, chat: session.visibleChatTabs.get()[1] }],
134+
}],
135+
openedChats: [],
136+
defaultPrevented: true,
137+
dispatchResult: false,
138+
bubbled: 0,
139+
});
140+
});
141+
142+
test('middle-click does not close the main chat and other auxiliary clicks are ignored', () => {
143+
const { store, commandService, bar, tabs } = createHarness(disposables);
144+
let bubbled = 0;
145+
store.add(addDisposableListener(bar.element, EventType.AUXCLICK, () => bubbled++));
146+
const mainMiddleClick = new MouseEvent(EventType.AUXCLICK, { bubbles: true, button: 1, cancelable: true });
147+
const secondaryRightClick = new MouseEvent(EventType.AUXCLICK, { bubbles: true, button: 2, cancelable: true });
148+
149+
tabs[0].dispatchEvent(mainMiddleClick);
150+
tabs[1].dispatchEvent(secondaryRightClick);
151+
152+
assert.deepStrictEqual({
153+
commandCalls: commandService.calls,
154+
mainDefaultPrevented: mainMiddleClick.defaultPrevented,
155+
secondaryDefaultPrevented: secondaryRightClick.defaultPrevented,
156+
bubbled,
157+
}, {
158+
commandCalls: [],
159+
mainDefaultPrevented: true,
160+
secondaryDefaultPrevented: false,
161+
bubbled: 1,
162+
});
163+
});
164+
165+
test('prevents native middle-button behavior on the scrollable tab container', () => {
166+
const { bar } = createHarness(disposables);
167+
const tabsContainer = bar.element.querySelector<HTMLElement>('.chat-composite-bar-tabs')!;
168+
const middleMouseDown = new MouseEvent(EventType.MOUSE_DOWN, { bubbles: true, button: 1, cancelable: true });
169+
const leftMouseDown = new MouseEvent(EventType.MOUSE_DOWN, { bubbles: true, button: 0, cancelable: true });
170+
const middleMouseUp = new MouseEvent(EventType.MOUSE_UP, { bubbles: true, button: 1, cancelable: true });
171+
172+
tabsContainer.dispatchEvent(middleMouseDown);
173+
tabsContainer.dispatchEvent(leftMouseDown);
174+
tabsContainer.dispatchEvent(middleMouseUp);
175+
176+
assert.deepStrictEqual({
177+
middleMouseDown: middleMouseDown.defaultPrevented,
178+
leftMouseDown: leftMouseDown.defaultPrevented,
179+
middleMouseUp: middleMouseUp.defaultPrevented,
180+
}, {
181+
middleMouseDown: true,
182+
leftMouseDown: false,
183+
middleMouseUp: isLinux,
184+
});
185+
});
186+
187+
test('middle-click in the rename input does not close the chat', () => {
188+
const { commandService, tabs } = createHarness(disposables);
189+
tabs[1].dispatchEvent(new MouseEvent(EventType.DBLCLICK, { bubbles: true, button: 0, cancelable: true }));
190+
const input = tabs[1].querySelector<HTMLInputElement>('.chat-composite-bar-tab-input input')!;
191+
const mouseDownEvent = new MouseEvent(EventType.MOUSE_DOWN, { bubbles: true, button: 1, cancelable: true });
192+
const mouseUpEvent = new MouseEvent(EventType.MOUSE_UP, { bubbles: true, button: 1, cancelable: true });
193+
const auxClickEvent = new MouseEvent(EventType.AUXCLICK, { bubbles: true, button: 1, cancelable: true });
194+
195+
input.dispatchEvent(mouseDownEvent);
196+
input.dispatchEvent(mouseUpEvent);
197+
input.dispatchEvent(auxClickEvent);
198+
199+
assert.deepStrictEqual({
200+
commandCalls: commandService.calls,
201+
mouseDownDefaultPrevented: mouseDownEvent.defaultPrevented,
202+
mouseUpDefaultPrevented: mouseUpEvent.defaultPrevented,
203+
auxClickDefaultPrevented: auxClickEvent.defaultPrevented,
204+
}, {
205+
commandCalls: [],
206+
mouseDownDefaultPrevented: false,
207+
mouseUpDefaultPrevented: false,
208+
auxClickDefaultPrevented: false,
209+
});
210+
});
211+
});

0 commit comments

Comments
 (0)