Skip to content
95 changes: 68 additions & 27 deletions src/vs/sessions/browser/parts/chatCompositeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ import { ISessionsProvidersService } from '../../services/sessions/browser/sessi
import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js';
import { ICommandService } from '../../../platform/commands/common/commands.js';
import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js';
import { MenuItemAction } from '../../../platform/actions/common/actions.js';
import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js';
import { SessionActivatingActionRunner } from '../sessionActionRunner.js';
import { ISessionsService } from '../../services/sessions/browser/sessionsService.js';
import { getSessionConversationStatusAriaLabel } from '../sessionConversationGroups.js';

interface IChatTab {
readonly chat: IChat;
Expand All @@ -57,7 +62,7 @@ export interface IChatCompositeBarDelegate {
/**
* The session whose chats are partitioned across groups. The bar reads it for
* the contributed tab menus (whose actions act on `{ session, chat }`), chat
* capabilities, rename/delete, and the trailing "New Chat" gating.
* drag data, and rename/delete operations.
*/
readonly session: IActiveSession;

Expand All @@ -73,6 +78,9 @@ export interface IChatCompositeBarDelegate {
/** Whether the tab strip should be shown. */
readonly visible: IObservable<boolean>;

/** Whether this single group's tab row replaces the session header and shows its actions. */
readonly showSessionActions: IObservable<boolean>;

/** Activate (show + focus) the given chat within this group. */
openChat(resource: URI): void;

Expand Down Expand Up @@ -100,15 +108,20 @@ export class ChatCompositeBar extends Disposable {
private readonly _tabsRow: HTMLElement;
private readonly _tabsContainer: HTMLElement;
private readonly _tabsScrollbar: ScrollableElement;
private readonly _newChatAction: Action;
private readonly _newChatContainer: HTMLElement;
private readonly _sessionActionsContainer: HTMLElement;
private readonly _sessionToolbar: MenuWorkbenchToolBar;
private readonly _metaRow: HTMLElement;
private readonly _metaToolbar: MenuWorkbenchToolBar;
private readonly _tabs: IChatTab[] = [];
private readonly _tabDisposables = this._register(new DisposableStore());

private readonly _groupDisposables = this._register(new MutableDisposable<DisposableStore>());
private readonly _editingDisposables = this._register(new MutableDisposable<DisposableStore>());
private _editingTab: IChatTab | undefined;
private _delegate: IChatCompositeBarDelegate | undefined;
private readonly _newChatAction: Action;
private readonly _newChatContainer: HTMLElement;
private _showSessionActions = false;

private readonly _onDidChangeVisibility = this._register(new Emitter<boolean>());
readonly onDidChangeVisibility: Event<boolean> = this._onDidChangeVisibility.event;
Expand Down Expand Up @@ -139,6 +152,7 @@ export class ChatCompositeBar extends Disposable {
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
@ICommandService private readonly _commandService: ICommandService,
@ISessionsService sessionsService: ISessionsService,
) {
super();

Expand All @@ -159,6 +173,43 @@ export class ChatCompositeBar extends Disposable {
}));
this._tabsRow.appendChild(this._tabsScrollbar.getDomNode());

this._newChatAction = this._register(new Action(
'sessions.chatCompositeBar.addChat',
localize('chatCompositeBar.addChat', "New Chat in This Session"),
ThemeIcon.asClassName(Codicon.add),
true,
async () => this._delegate?.newChat(),
));
const newChatActionBar = this._register(new ActionBar(this._tabsRow));
newChatActionBar.push(this._newChatAction, { icon: true, label: false });
this._newChatContainer = newChatActionBar.getContainer();
this._newChatContainer.classList.add('chat-composite-bar-new-chat');

this._sessionActionsContainer = $('.session-chat-tabs-actions');
this._tabsRow.appendChild(this._sessionActionsContainer);
const sessionToolbarContainer = $('.chat-composite-bar-toolbar');
this._sessionActionsContainer.appendChild(sessionToolbarContainer);
this._sessionToolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, sessionToolbarContainer, Menus.SessionBarToolbar, {
hiddenItemStrategy: HiddenItemStrategy.Ignore,
menuOptions: { shouldForwardArgs: true },
highlightToggledItems: true,
}));

this._metaRow = $('.chat-composite-bar-meta-row');
this._container.appendChild(this._metaRow);
const metaToolbarContainer = $('.chat-composite-bar-meta-toolbar');
this._metaRow.appendChild(metaToolbarContainer);
const metaActionRunner = this._register(new SessionActivatingActionRunner(() => this._delegate?.session, sessionsService));
this._metaToolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, metaToolbarContainer, Menus.SessionHeaderMeta, {
hiddenItemStrategy: HiddenItemStrategy.Ignore,
menuOptions: { shouldForwardArgs: true },
actionRunner: metaActionRunner,
actionViewItemProvider: (action, options) => action instanceof MenuItemAction
? this._instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options)
: undefined,
}));
this._register(this._metaToolbar.onDidChangeMenuItems(() => this._updateMetaRowVisibility()));

const preventMiddleButtonDefault = (e: MouseEvent) => {
if (e.button === 1 && !this._isInTabInput(e)) {
e.preventDefault();
Expand All @@ -170,21 +221,6 @@ export class ChatCompositeBar extends Disposable {
this._register(addDisposableGenericMouseUpListener(this._tabsContainer, preventMiddleButtonDefault));
}

// "New Chat" button pinned at the end of the tab strip. Starting a new chat
// is offered here while the tabs are shown; when the session has a single
// chat the session header toolbar offers it instead.
const newChatAction = this._newChatAction = this._register(new Action(
'chatCompositeBar.addChat',
localize('chatCompositeBar.addChat', "New Chat"),
ThemeIcon.asClassName(Codicon.add),
true,
async () => this._delegate?.newChat(),
));
const newChatActionBar = this._register(new ActionBar(this._tabsRow, { actionViewItemProvider: undefined }));
newChatActionBar.push(newChatAction, { icon: true, label: false });
this._newChatContainer = newChatActionBar.getContainer();
this._newChatContainer.classList.add('chat-composite-bar-new-chat');

// Keep the visual scrollbar in sync with native scrolling inside the tabs container
this._register(addDisposableListener(this._tabsContainer, EventType.SCROLL, () => {
this._tabsScrollbar.setScrollPosition({ scrollLeft: this._tabsContainer.scrollLeft });
Expand Down Expand Up @@ -225,6 +261,8 @@ export class ChatCompositeBar extends Disposable {
}

this._delegate = delegate;
this._sessionToolbar.context = delegate?.session;
this._metaToolbar.context = delegate?.session;

const store = new DisposableStore();
this._groupDisposables.value = store;
Expand All @@ -242,21 +280,22 @@ export class ChatCompositeBar extends Disposable {
const activeChatUri = delegate.activeChatResource.read(reader);
const mainChatUri = delegate.mainChatResource.read(reader);
this._rebuildTabs(chats, activeChatUri, mainChatUri);

// The trailing "New Chat" action only applies to sessions that support
// user-created peer chats. Subagent (read-only) tabs can surface in
// sessions without that capability, so gate the action on the
// capability rather than on tab-strip visibility.
const supportsMultipleChats = delegate.session.capabilities.read(reader).supportsMultipleChats;
this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats);
// Archived sessions are read-only, so disable the trailing New Chat
// action (mirrors the header action's SessionIsArchivedContext gating).
this._newChatAction.enabled = supportsMultipleChats && !delegate.session.isArchived.read(reader);
const isQuickChat = delegate.session.isQuickChat?.read(reader) ?? false;
this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats || isQuickChat);
this._newChatAction.enabled = supportsMultipleChats && !isQuickChat && !delegate.session.isArchived.read(reader);
this._showSessionActions = delegate.showSessionActions.read(reader);
this._sessionActionsContainer.classList.toggle('hidden', !this._showSessionActions);
this._updateMetaRowVisibility();

this._setVisible(delegate.visible.read(reader));
}));
}

private _updateMetaRowVisibility(): void {
this._metaRow.style.display = this._showSessionActions && !this._metaToolbar.isEmpty() ? '' : 'none';
}

setAriaLabel(label: string): void {
this._tabsContainer.setAttribute('aria-label', label);
}
Expand Down Expand Up @@ -301,7 +340,9 @@ export class ChatCompositeBar extends Disposable {
const labelEl = $('.chat-composite-bar-tab-label.modern-ui-editor-tab-label');
this._tabDisposables.add(autorun(reader => {
const title = chat.title.read(reader);
const status = chat.status.read(reader);
labelEl.textContent = title;
tab.setAttribute('aria-label', localize('chatTabAriaLabel', "{0}, {1}", title, getSessionConversationStatusAriaLabel(status)));
}));

// Lock icon shown for read-only (non-interactive) chats.
Expand Down
4 changes: 4 additions & 0 deletions src/vs/sessions/browser/parts/chatGroupView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export interface IChatGroupContext {
/** Whether the group's tab strip should be shown. */
readonly tabsVisible: IObservable<boolean>;

/** Whether this group's tab row replaces the session header and shows its actions. */
readonly showSessionActions: IObservable<boolean>;

/** Activate (show + focus) the given chat within this group. */
openChat(resource: URI): void;

Expand Down Expand Up @@ -170,6 +173,7 @@ export class ChatGroupView extends Disposable implements ISerializableView {
activeChatResource: context.activeChatResource,
mainChatResource: context.mainChatResource,
visible: context.tabsVisible,
showSessionActions: context.showSessionActions,
openChat: resource => context.openChat(resource),
newChat: () => context.newChat(),
onTabDragStart: resource => context.onTabDragStart(resource),
Expand Down
2 changes: 2 additions & 0 deletions src/vs/sessions/browser/parts/chatGroupsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export class ChatGroupsView extends Themable {
}
return session.shouldShowChatTabs.read(reader);
});
const showSessionActions = derived(reader => this._groupCount.read(reader) === 1 && tabsVisible.read(reader));

const view = store.add(this._instantiationService.createInstance(ChatGroupView));
const entry: IGroupEntry = { id, view, resourceIds, activeResourceId, chats, tabsVisible };
Expand All @@ -295,6 +296,7 @@ export class ChatGroupsView extends Themable {
activeChatResource: activeResourceId,
mainChatResource: this._mainChatResource!,
tabsVisible,
showSessionActions,
openChat: resource => this._openChat(entry, resource),
newChat: () => this._newChat(entry).catch(onUnexpectedError),
onTabDragStart: () => { },
Expand Down
Loading
Loading