Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/browser/parts/chatCompositeBar.ts
13394 views
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 './media/chatCompositeBar.css';
7
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
8
import { Emitter, Event } from '../../../base/common/event.js';
9
import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow, reset } from '../../../base/browser/dom.js';
10
import { autorun } from '../../../base/common/observable.js';
11
import { IThemeService } from '../../../platform/theme/common/themeService.js';
12
import { PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND } from '../../../workbench/common/theme.js';
13
import { agentsPanelBackground } from '../../common/theme.js';
14
import { Action } from '../../../base/common/actions.js';
15
import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js';
16
import { Codicon } from '../../../base/common/codicons.js';
17
import { ThemeIcon } from '../../../base/common/themables.js';
18
import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js';
19
import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';
20
import { localize } from '../../../nls.js';
21
import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js';
22
import { IChat, SessionStatus } from '../../services/sessions/common/session.js';
23
import { ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js';
24
25
interface IChatTab {
26
readonly chat: IChat;
27
readonly element: HTMLElement;
28
}
29
30
/**
31
* A composite bar that displays chats within the active agent session as tabs.
32
* Selecting a tab loads that chat in the chat view pane instead of switching view containers.
33
*
34
* The bar auto-hides when there is only one chat in the active session and shows when there are multiple.
35
*/
36
export class ChatCompositeBar extends Disposable {
37
38
private readonly _container: HTMLElement;
39
private readonly _tabsContainer: HTMLElement;
40
private readonly _tabs: IChatTab[] = [];
41
private readonly _tabDisposables = this._register(new DisposableStore());
42
43
private readonly _onDidChangeVisibility = this._register(new Emitter<boolean>());
44
readonly onDidChangeVisibility: Event<boolean> = this._onDidChangeVisibility.event;
45
46
private _visible = false;
47
48
get element(): HTMLElement {
49
return this._container;
50
}
51
52
get visible(): boolean {
53
return this._visible;
54
}
55
56
constructor(
57
@IThemeService private readonly _themeService: IThemeService,
58
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
59
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
60
@IQuickInputService private readonly _quickInputService: IQuickInputService,
61
) {
62
super();
63
64
this._container = $('.chat-composite-bar');
65
this._tabsContainer = $('.chat-composite-bar-tabs');
66
this._container.appendChild(this._tabsContainer);
67
68
// Track active session changes
69
this._register(autorun(reader => {
70
const activeSession = this._sessionsManagementService.activeSession.read(reader);
71
if (!activeSession) {
72
this._rebuildTabs([], '', undefined);
73
return;
74
}
75
76
const chats = activeSession.chats.read(reader);
77
const activeChatUri = activeSession.activeChat.read(reader)?.resource.toString() ?? '';
78
const mainChatUri = activeSession.mainChat.resource.toString();
79
this._rebuildTabs(chats, activeChatUri, mainChatUri);
80
}));
81
82
// Scroll active tab into view on resize
83
const resizeObserver = this._register(new DisposableResizeObserver(() => this._revealActiveTab()));
84
this._register(resizeObserver.observe(this._tabsContainer));
85
86
87
this._updateStyles();
88
this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles()));
89
}
90
91
private _rebuildTabs(chats: readonly IChat[], activeChatId: string, mainChatId?: string): void {
92
this._tabDisposables.clear();
93
this._tabs.length = 0;
94
reset(this._tabsContainer);
95
96
for (const chat of chats) {
97
this._createTab(chat, chat.resource.toString() === mainChatId);
98
}
99
100
this._updateActiveTab(activeChatId);
101
this._updateVisibility();
102
}
103
104
private _createTab(chat: IChat, isMainChat: boolean): void {
105
const tab = $('.chat-composite-bar-tab');
106
tab.tabIndex = 0;
107
tab.setAttribute('role', 'tab');
108
109
const labelEl = $('.chat-composite-bar-tab-label');
110
this._tabDisposables.add(autorun(reader => {
111
const title = chat.title.read(reader);
112
labelEl.textContent = title;
113
}));
114
tab.appendChild(labelEl);
115
116
// Track untitled state for styling (dirty dot + close button)
117
this._tabDisposables.add(autorun(reader => {
118
const status = chat.status.read(reader);
119
tab.classList.toggle('untitled', status === SessionStatus.Untitled);
120
}));
121
122
// Remove action bar — only for non-main chats, visible on hover
123
if (!isMainChat) {
124
const closeAction = this._tabDisposables.add(new Action(
125
'chatCompositeBar.closeChat',
126
localize('closeChat', "Close"),
127
ThemeIcon.asClassName(Codicon.close),
128
true,
129
async () => {
130
const session = this._sessionsManagementService.activeSession.get();
131
if (session) {
132
await this._sessionsManagementService.deleteChat(session, chat.resource);
133
}
134
},
135
));
136
const actionBar = this._tabDisposables.add(new ActionBar(tab, { actionViewItemProvider: undefined }));
137
actionBar.push(closeAction, { icon: true, label: false });
138
actionBar.getContainer().classList.add('chat-composite-bar-tab-actions');
139
}
140
141
const indicator = $('.chat-composite-bar-tab-indicator');
142
tab.appendChild(indicator);
143
144
this._tabsContainer.appendChild(tab);
145
146
this._tabDisposables.add(addDisposableListener(tab, EventType.CLICK, () => {
147
this._onTabClicked(chat);
148
}));
149
150
this._tabDisposables.add(addDisposableListener(tab, EventType.KEY_DOWN, (e: KeyboardEvent) => {
151
if (e.key === 'Enter' || e.key === ' ') {
152
e.preventDefault();
153
this._onTabClicked(chat);
154
}
155
}));
156
157
const renameAction = this._tabDisposables.add(new Action('sessionCompositeBar.renameChat', localize('renameChat', "Rename"), undefined, true, async () => {
158
const newTitle = await this._quickInputService.input({
159
value: chat.title.get(),
160
prompt: localize('renameChat.prompt', "Rename Chat"),
161
});
162
if (newTitle) {
163
const session = this._sessionsManagementService.activeSession.get();
164
if (session) {
165
await this._sessionsManagementService.renameChat(session, chat.resource, newTitle);
166
}
167
}
168
}));
169
170
this._tabDisposables.add(addDisposableListener(tab, EventType.CONTEXT_MENU, (e: MouseEvent) => {
171
// No context menu for untitled chats
172
if (chat.status.get() === SessionStatus.Untitled) {
173
e.preventDefault();
174
return;
175
}
176
e.preventDefault();
177
e.stopPropagation();
178
const event = new StandardMouseEvent(getWindow(tab), e);
179
this._contextMenuService.showContextMenu({
180
getAnchor: () => event,
181
getActions: () => [
182
renameAction,
183
]
184
});
185
}));
186
187
this._tabs.push({ chat: chat, element: tab });
188
}
189
190
private _onTabClicked(chat: IChat): void {
191
const session = this._sessionsManagementService.activeSession.get();
192
if (session) {
193
this._sessionsManagementService.openChat(session, chat.resource);
194
}
195
}
196
197
private _updateActiveTab(activeChatId: string): void {
198
for (const tab of this._tabs) {
199
const isActive = tab.chat.resource.toString() === activeChatId;
200
tab.element.classList.toggle('active', isActive);
201
tab.element.setAttribute('aria-selected', String(isActive));
202
if (isActive) {
203
tab.element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
204
}
205
}
206
}
207
208
private _revealActiveTab(): void {
209
const activeTab = this._tabs.find(t => t.element.classList.contains('active'));
210
activeTab?.element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
211
}
212
213
private _updateVisibility(): void {
214
// Show when there are multiple sessions, hide when there is only one (or none)
215
const wasVisible = this._visible;
216
this._visible = this._tabs.length > 1;
217
this._container.style.display = this._visible ? '' : 'none';
218
if (wasVisible !== this._visible) {
219
this._onDidChangeVisibility.fire(this._visible);
220
}
221
}
222
223
private _updateStyles(): void {
224
const theme = this._themeService.getColorTheme();
225
226
const bg = theme.getColor(agentsPanelBackground);
227
const activeFg = theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND);
228
const inactiveFg = theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND);
229
const activeBorder = theme.getColor(PANEL_ACTIVE_TITLE_BORDER);
230
231
this._container.style.setProperty('--chat-bar-background', bg?.toString() ?? '');
232
this._container.style.setProperty('--chat-tab-active-foreground', activeFg?.toString() ?? '');
233
this._container.style.setProperty('--chat-tab-inactive-foreground', inactiveFg?.toString() ?? '');
234
this._container.style.setProperty('--chat-tab-active-border', activeBorder?.toString() ?? '');
235
}
236
}
237
238