Path: blob/main/src/vs/sessions/browser/parts/chatCompositeBar.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/chatCompositeBar.css';6import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';7import { Emitter, Event } from '../../../base/common/event.js';8import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow, reset } from '../../../base/browser/dom.js';9import { autorun } from '../../../base/common/observable.js';10import { IThemeService } from '../../../platform/theme/common/themeService.js';11import { PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND } from '../../../workbench/common/theme.js';12import { agentsPanelBackground } from '../../common/theme.js';13import { Action } from '../../../base/common/actions.js';14import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js';15import { Codicon } from '../../../base/common/codicons.js';16import { ThemeIcon } from '../../../base/common/themables.js';17import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js';18import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';19import { localize } from '../../../nls.js';20import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js';21import { IChat, SessionStatus } from '../../services/sessions/common/session.js';22import { ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js';2324interface IChatTab {25readonly chat: IChat;26readonly element: HTMLElement;27}2829/**30* A composite bar that displays chats within the active agent session as tabs.31* Selecting a tab loads that chat in the chat view pane instead of switching view containers.32*33* The bar auto-hides when there is only one chat in the active session and shows when there are multiple.34*/35export class ChatCompositeBar extends Disposable {3637private readonly _container: HTMLElement;38private readonly _tabsContainer: HTMLElement;39private readonly _tabs: IChatTab[] = [];40private readonly _tabDisposables = this._register(new DisposableStore());4142private readonly _onDidChangeVisibility = this._register(new Emitter<boolean>());43readonly onDidChangeVisibility: Event<boolean> = this._onDidChangeVisibility.event;4445private _visible = false;4647get element(): HTMLElement {48return this._container;49}5051get visible(): boolean {52return this._visible;53}5455constructor(56@IThemeService private readonly _themeService: IThemeService,57@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,58@IContextMenuService private readonly _contextMenuService: IContextMenuService,59@IQuickInputService private readonly _quickInputService: IQuickInputService,60) {61super();6263this._container = $('.chat-composite-bar');64this._tabsContainer = $('.chat-composite-bar-tabs');65this._container.appendChild(this._tabsContainer);6667// Track active session changes68this._register(autorun(reader => {69const activeSession = this._sessionsManagementService.activeSession.read(reader);70if (!activeSession) {71this._rebuildTabs([], '', undefined);72return;73}7475const chats = activeSession.chats.read(reader);76const activeChatUri = activeSession.activeChat.read(reader)?.resource.toString() ?? '';77const mainChatUri = activeSession.mainChat.resource.toString();78this._rebuildTabs(chats, activeChatUri, mainChatUri);79}));8081// Scroll active tab into view on resize82const resizeObserver = this._register(new DisposableResizeObserver(() => this._revealActiveTab()));83this._register(resizeObserver.observe(this._tabsContainer));848586this._updateStyles();87this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles()));88}8990private _rebuildTabs(chats: readonly IChat[], activeChatId: string, mainChatId?: string): void {91this._tabDisposables.clear();92this._tabs.length = 0;93reset(this._tabsContainer);9495for (const chat of chats) {96this._createTab(chat, chat.resource.toString() === mainChatId);97}9899this._updateActiveTab(activeChatId);100this._updateVisibility();101}102103private _createTab(chat: IChat, isMainChat: boolean): void {104const tab = $('.chat-composite-bar-tab');105tab.tabIndex = 0;106tab.setAttribute('role', 'tab');107108const labelEl = $('.chat-composite-bar-tab-label');109this._tabDisposables.add(autorun(reader => {110const title = chat.title.read(reader);111labelEl.textContent = title;112}));113tab.appendChild(labelEl);114115// Track untitled state for styling (dirty dot + close button)116this._tabDisposables.add(autorun(reader => {117const status = chat.status.read(reader);118tab.classList.toggle('untitled', status === SessionStatus.Untitled);119}));120121// Remove action bar — only for non-main chats, visible on hover122if (!isMainChat) {123const closeAction = this._tabDisposables.add(new Action(124'chatCompositeBar.closeChat',125localize('closeChat', "Close"),126ThemeIcon.asClassName(Codicon.close),127true,128async () => {129const session = this._sessionsManagementService.activeSession.get();130if (session) {131await this._sessionsManagementService.deleteChat(session, chat.resource);132}133},134));135const actionBar = this._tabDisposables.add(new ActionBar(tab, { actionViewItemProvider: undefined }));136actionBar.push(closeAction, { icon: true, label: false });137actionBar.getContainer().classList.add('chat-composite-bar-tab-actions');138}139140const indicator = $('.chat-composite-bar-tab-indicator');141tab.appendChild(indicator);142143this._tabsContainer.appendChild(tab);144145this._tabDisposables.add(addDisposableListener(tab, EventType.CLICK, () => {146this._onTabClicked(chat);147}));148149this._tabDisposables.add(addDisposableListener(tab, EventType.KEY_DOWN, (e: KeyboardEvent) => {150if (e.key === 'Enter' || e.key === ' ') {151e.preventDefault();152this._onTabClicked(chat);153}154}));155156const renameAction = this._tabDisposables.add(new Action('sessionCompositeBar.renameChat', localize('renameChat', "Rename"), undefined, true, async () => {157const newTitle = await this._quickInputService.input({158value: chat.title.get(),159prompt: localize('renameChat.prompt', "Rename Chat"),160});161if (newTitle) {162const session = this._sessionsManagementService.activeSession.get();163if (session) {164await this._sessionsManagementService.renameChat(session, chat.resource, newTitle);165}166}167}));168169this._tabDisposables.add(addDisposableListener(tab, EventType.CONTEXT_MENU, (e: MouseEvent) => {170// No context menu for untitled chats171if (chat.status.get() === SessionStatus.Untitled) {172e.preventDefault();173return;174}175e.preventDefault();176e.stopPropagation();177const event = new StandardMouseEvent(getWindow(tab), e);178this._contextMenuService.showContextMenu({179getAnchor: () => event,180getActions: () => [181renameAction,182]183});184}));185186this._tabs.push({ chat: chat, element: tab });187}188189private _onTabClicked(chat: IChat): void {190const session = this._sessionsManagementService.activeSession.get();191if (session) {192this._sessionsManagementService.openChat(session, chat.resource);193}194}195196private _updateActiveTab(activeChatId: string): void {197for (const tab of this._tabs) {198const isActive = tab.chat.resource.toString() === activeChatId;199tab.element.classList.toggle('active', isActive);200tab.element.setAttribute('aria-selected', String(isActive));201if (isActive) {202tab.element.scrollIntoView({ block: 'nearest', inline: 'nearest' });203}204}205}206207private _revealActiveTab(): void {208const activeTab = this._tabs.find(t => t.element.classList.contains('active'));209activeTab?.element.scrollIntoView({ block: 'nearest', inline: 'nearest' });210}211212private _updateVisibility(): void {213// Show when there are multiple sessions, hide when there is only one (or none)214const wasVisible = this._visible;215this._visible = this._tabs.length > 1;216this._container.style.display = this._visible ? '' : 'none';217if (wasVisible !== this._visible) {218this._onDidChangeVisibility.fire(this._visible);219}220}221222private _updateStyles(): void {223const theme = this._themeService.getColorTheme();224225const bg = theme.getColor(agentsPanelBackground);226const activeFg = theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND);227const inactiveFg = theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND);228const activeBorder = theme.getColor(PANEL_ACTIVE_TITLE_BORDER);229230this._container.style.setProperty('--chat-bar-background', bg?.toString() ?? '');231this._container.style.setProperty('--chat-tab-active-foreground', activeFg?.toString() ?? '');232this._container.style.setProperty('--chat-tab-inactive-foreground', inactiveFg?.toString() ?? '');233this._container.style.setProperty('--chat-tab-active-border', activeBorder?.toString() ?? '');234}235}236237238