From 32f2033ad9739875b706782439c1b1ad31655f84 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 20:34:29 +0200 Subject: [PATCH 1/9] sessions: move chat actions to overflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/chatCompositeBar.ts | 87 ++++++--- .../sessions/browser/parts/chatGroupView.ts | 4 + .../sessions/browser/parts/chatGroupsView.ts | 2 + .../browser/parts/media/chatCompositeBar.css | 113 ++++------- .../sessionConversationsActionViewItem.ts | 138 ------------- .../sessions/browser/parts/sessionHeader.ts | 54 ++--- src/vs/sessions/browser/parts/sessionView.ts | 8 + .../browser/sessionConversationGroups.ts | 30 +-- src/vs/sessions/browser/sessionWorkspace.ts | 2 +- src/vs/sessions/common/contextkeys.ts | 2 +- .../browser/media/sessionsTitleBarWidget.css | 36 +--- .../sessions/browser/sessions.contribution.ts | 6 +- .../sessions/browser/sessionsActions.ts | 184 +++++------------- .../browser/sessionsTitleBarWidget.ts | 93 +++------ .../browser/views/sessionsViewActions.ts | 12 +- .../test/browser/sessionsActions.test.ts | 89 +++++++++ .../sessions/common/sessionContextKeys.ts | 3 +- .../test/browser/chatCompositeBar.test.ts | 1 + .../test/browser/chatGroupsView.test.ts | 48 +++-- .../browser/sessionConversationGroups.test.ts | 139 +------------ .../test/browser/sessionHeader.test.ts | 26 ++- .../sessions/chatCompositeBar.fixture.ts | 2 + 22 files changed, 364 insertions(+), 715 deletions(-) delete mode 100644 src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts create mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 13c6658ff7216..bb53c28ce57db 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -39,6 +39,10 @@ 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'; interface IChatTab { readonly chat: IChat; @@ -57,7 +61,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; @@ -73,6 +77,9 @@ export interface IChatCompositeBarDelegate { /** Whether the tab strip should be shown. */ readonly visible: IObservable; + /** Whether this single group's tab row replaces the session header and shows its actions. */ + readonly showSessionActions: IObservable; + /** Activate (show + focus) the given chat within this group. */ openChat(resource: URI): void; @@ -100,6 +107,12 @@ 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()); @@ -107,8 +120,7 @@ export class ChatCompositeBar extends Disposable { private readonly _editingDisposables = this._register(new MutableDisposable()); 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()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -139,6 +151,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(); @@ -159,6 +172,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(); @@ -170,21 +220,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 }); @@ -225,6 +260,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; @@ -242,21 +279,21 @@ 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); + 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); } diff --git a/src/vs/sessions/browser/parts/chatGroupView.ts b/src/vs/sessions/browser/parts/chatGroupView.ts index 500573c644963..d7307f3edb6a2 100644 --- a/src/vs/sessions/browser/parts/chatGroupView.ts +++ b/src/vs/sessions/browser/parts/chatGroupView.ts @@ -46,6 +46,9 @@ export interface IChatGroupContext { /** Whether the group's tab strip should be shown. */ readonly tabsVisible: IObservable; + /** Whether this group's tab row replaces the session header and shows its actions. */ + readonly showSessionActions: IObservable; + /** Activate (show + focus) the given chat within this group. */ openChat(resource: URI): void; @@ -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), diff --git a/src/vs/sessions/browser/parts/chatGroupsView.ts b/src/vs/sessions/browser/parts/chatGroupsView.ts index 5ef6dcd2a0c7f..eb10414482596 100644 --- a/src/vs/sessions/browser/parts/chatGroupsView.ts +++ b/src/vs/sessions/browser/parts/chatGroupsView.ts @@ -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 }; @@ -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: () => { }, diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 93943c6187a03..f022082d14b28 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -12,20 +12,15 @@ overflow: hidden; } -/* Header host: title row + meta row, with the top padding for the whole bar area */ +/* Header host: title row + meta row. */ .chat-composite-bar.session-header-bar { - padding: 6px 10px 0; + padding: 0 var(--vscode-spacing-size100); box-sizing: border-box; } -/* Tabs host: the chat tab strip, shown only when the session has multiple chats. - It lives in the same centered session-view content host as the header. - Symmetric 10px gutter on both sides, matching the small, even inset editor - tabs use — the tab strip no longer tries to align its first tab under the - header's status-icon column, since that produced a much larger left gutter - than the shared modern-tab convention. */ +/* Tabs host: the chat tab strip, shown only when the session has multiple chats. */ .chat-composite-bar.session-chat-tabs-bar { - padding: 0 10px; + padding: 0 var(--vscode-spacing-size100); box-sizing: border-box; container-type: inline-size; @@ -41,8 +36,7 @@ flex-direction: row; align-items: flex-start; gap: 6px; - padding-bottom: 6px; - border-bottom: 1px solid color-mix(in srgb, var(--session-view-foreground) 12%, transparent); + border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); } /* Main column stacks the title row and the meta row */ @@ -59,7 +53,7 @@ display: flex; align-items: center; gap: 6px; - height: 26px; + height: 34px; } /* Status icon column — sits beside the main column, centered on the title line. @@ -70,7 +64,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - height: 26px; + height: 34px; font-size: var(--vscode-codiconFontSize, 16px); color: var(--session-view-foreground); } @@ -81,7 +75,7 @@ overflow: hidden; display: flex; align-items: center; - font-weight: var(--vscode-agents-fontWeight-semiBold, 600); + font-weight: var(--vscode-agents-fontWeight-regular, 400); font-size: var(--vscode-agents-fontSize-heading3, 13px); color: var(--chat-tab-active-foreground, var(--session-view-foreground)); border-radius: var(--vscode-cornerRadius-small); @@ -100,45 +94,6 @@ white-space: nowrap; } -.chat-composite-bar-workspace-meta { - display: inline-flex; - align-items: center; - gap: var(--vscode-spacing-size40); - flex: 0 1 auto; - min-width: 0; - max-width: 40%; - color: var(--vscode-descriptionForeground); - font-size: var(--vscode-agents-fontSize-label1); - font-weight: var(--vscode-agents-fontWeight-regular); - white-space: nowrap; -} - -.chat-composite-bar-workspace-meta.hidden { - display: none; -} - -/* Compact glyph at the compact size. The compound selector outranks the base - `.codicon` font shorthand; the clamped box keeps combined glyphs (wider - advance) tight against the label, and the padding optically centers it. */ -.monaco-workbench .chat-composite-bar-workspace-meta-icon.codicon[class*='codicon-'] { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--vscode-codiconFontSize-compact); - height: var(--vscode-codiconFontSize-compact); - margin: 0; - padding: 3px 1px 0 2px; - font-size: var(--vscode-codiconFontSize-compact); - flex-shrink: 0; -} - -.chat-composite-bar-workspace-meta-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - /* Hover feedback: only when the title can actually be renamed and we aren't currently editing it. */ .chat-composite-bar-session-title.editable { @@ -227,9 +182,14 @@ display: flex; align-items: center; height: 35px; + box-sizing: border-box; overflow: hidden; } +.chat-groups-view.single-group .chat-composite-bar-tabs-row { + border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); +} + /* The ScrollableElement wrapper holding the tabs is the shrinkable flex item */ .chat-composite-bar-tabs-row > .monaco-scrollable-element { flex: 0 1 auto; @@ -237,35 +197,23 @@ height: 100%; } -.chat-composite-bar-tabs { +.chat-composite-bar-new-chat { display: flex; align-items: center; - height: 100%; - min-height: calc(var(--vscode-spacing-size240) + var(--vscode-spacing-size40) * 2); -} - -/* "New Chat" button pinned at the end of the tab strip, after the Conversations menu. */ -.chat-composite-bar-tabs-row > .chat-composite-bar-new-chat { flex-shrink: 0; - display: flex; - align-items: center; - margin-left: 4px; } -.chat-composite-bar-tabs-row > .chat-composite-bar-new-chat.hidden { +.chat-composite-bar-new-chat.hidden { display: none; } -/* Include the tab-row owner to outrank `.monaco-action-bar .action-item .codicon`, - * which otherwise resets this button's width and height from 26px to 16px. */ -.chat-composite-bar-tabs-row > .chat-composite-bar-new-chat .action-item .action-label { - box-sizing: border-box; - width: 26px; - height: 26px; - padding: 0; +.chat-composite-bar-new-chat .action-item .action-label { display: flex; align-items: center; justify-content: center; + width: var(--editor-group-tab-height, var(--vscode-spacing-size240)); + height: var(--editor-group-tab-height, var(--vscode-spacing-size240)); + padding: 0; border-radius: var(--vscode-cornerRadius-small); color: var(--chat-tab-inactive-foreground, currentColor); } @@ -276,8 +224,27 @@ } .chat-composite-bar-new-chat .action-item .action-label:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.session-chat-tabs-actions { + display: flex; + align-items: center; + margin-left: auto; + padding-right: var(--vscode-spacing-size40); + flex-shrink: 0; +} + +.session-chat-tabs-actions.hidden { + display: none; +} + +.chat-composite-bar-tabs { + display: flex; + align-items: center; + height: 100%; + min-height: calc(var(--vscode-spacing-size240) + var(--vscode-spacing-size40) * 2); } .chat-composite-bar-toolbar { diff --git a/src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts b/src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts deleted file mode 100644 index 73b02e784f152..0000000000000 --- a/src/vs/sessions/browser/parts/sessionConversationsActionViewItem.ts +++ /dev/null @@ -1,138 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IAction } from '../../../base/common/actions.js'; -import { IDisposable } from '../../../base/common/lifecycle.js'; -import { Codicon } from '../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; -import { localize } from '../../../nls.js'; -import { ActionWidgetDropdownActionViewItem } from '../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; -import { IMenuService, MenuItemAction, SubmenuItemAction } from '../../../platform/actions/common/actions.js'; -import { IActionWidgetService } from '../../../platform/actionWidget/browser/actionWidget.js'; -import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider } from '../../../platform/actionWidget/browser/actionWidgetDropdown.js'; -import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; -import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; -import { getSelectedSessionConversationActionId, getSessionConversationActionId, getSessionConversationStatusAriaLabel, getSessionConversationStatusDescription, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../sessionConversationGroups.js'; -import { Menus } from '../menus.js'; -import { ISessionContext } from '../../services/sessions/browser/sessionContext.js'; -import { ISessionsListModelService } from '../../services/sessions/browser/sessionsListModelService.js'; - -export interface ISessionConversationActionMetadata { - readonly description?: string; - readonly ariaDescription: string; - readonly icon: ThemeIcon; -} - -const sessionConversationGroups = [ - { id: SESSION_CONVERSATION_CHATS_GROUP, label: localize('sessionConversationGroup.chats', "Chats"), showHeader: false, order: 1 }, - { id: SESSION_CONVERSATION_SUBAGENTS_GROUP, label: localize('sessionConversationGroup.subagents', "Subagents"), showHeader: true, order: 2 }, -] as const; - -export function toSessionConversationDropdownActions( - menuActions: readonly (readonly [string, readonly IAction[]])[], - actionMetadata: ReadonlyMap = new Map(), -): IActionWidgetDropdownAction[] { - const groupsById = new Map(sessionConversationGroups.map(group => [group.id, group])); - const actionsByGroup = new Map(); - - for (const [groupId, actions] of menuActions) { - const group = groupsById.get(groupId); - const dropdownActions = actions.map(action => { - const metadata = actionMetadata.get(action.id); - return { - id: action.id, - label: action.label, - tooltip: action.tooltip, - description: metadata?.description, - ariaDescription: metadata?.ariaDescription, - icon: metadata?.icon, - class: action.class, - enabled: action.enabled, - category: { - label: group?.label ?? '', - order: group?.order ?? Number.MAX_SAFE_INTEGER, - showHeader: group?.showHeader ?? false, - }, - run: () => action.run(), - } satisfies IActionWidgetDropdownAction; - }); - actionsByGroup.set(groupId, dropdownActions); - } - - const chatActions = actionsByGroup.get(SESSION_CONVERSATION_CHATS_GROUP) ?? []; - const subagentActions = actionsByGroup.get(SESSION_CONVERSATION_SUBAGENTS_GROUP) ?? []; - if (chatActions.length === 1) { - return subagentActions; - } - - return sessionConversationGroups.flatMap(group => actionsByGroup.get(group.id) ?? []); -} - -/** Renders the scoped Conversations menu with the Sessions workbench Action Widget dropdown. */ -export class SessionConversationsActionViewItem extends ActionWidgetDropdownActionViewItem { - - constructor( - action: SubmenuItemAction, - @IActionWidgetService actionWidgetService: IActionWidgetService, - @IKeybindingService keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, - @IMenuService menuService: IMenuService, - @ISessionContext sessionContext: ISessionContext, - @ISessionsListModelService sessionsListModelService: ISessionsListModelService, - @ITelemetryService telemetryService: ITelemetryService, - ) { - const menu = menuService.createMenu(Menus.SessionConversations, contextKeyService); - const getSelectedChatActionId = () => { - const session = sessionContext.session.get(); - const activeChat = session?.activeChat.get(); - if (!session || !activeChat) { - return undefined; - } - return getSelectedSessionConversationActionId(session.sessionId, activeChat); - }; - const actionProvider: IActionWidgetDropdownActionProvider = { - getActions: () => { - const session = sessionContext.session.get(); - const actionMetadata = new Map(); - if (session) { - for (const chat of session.chats.get()) { - const actionId = getSessionConversationActionId(session.sessionId, chat.resource); - const status = chat.status.get(); - actionMetadata.set(actionId, { - description: getSessionConversationStatusDescription(status), - ariaDescription: getSessionConversationStatusAriaLabel(status), - icon: sessionsListModelService.getStatusIcon(status, chat.isRead.get(), chat.isArchived.get()), - }); - } - } - return toSessionConversationDropdownActions( - menu.getActions().map(([group, actions]) => [group, actions.filter(action => action instanceof MenuItemAction)] as const), - actionMetadata, - ); - }, - }; - - super(action, { - actionProvider, - getInitialFocusActionId: getSelectedChatActionId, - listOptions: { - hideDefaultKeybindingTooltip: true, - }, - reporter: { id: 'SessionConversations' }, - }, actionWidgetService, keybindingService, contextKeyService, telemetryService); - this._register(menu); - } - - protected override renderLabel(element: HTMLElement): IDisposable | null { - element.classList.add(...ThemeIcon.asClassNameArray(Codicon.commentDiscussion)); - return super.renderLabel(element); - } - - protected override setAriaLabelAttributes(element: HTMLElement): void { - super.setAriaLabelAttributes(element); - element.setAttribute('aria-label', this.action.label); - } -} diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index 3baf192e26a74..fcde5fa5b2272 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -6,7 +6,7 @@ import './media/chatCompositeBar.css'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent, reset } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent } from '../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; @@ -32,9 +32,6 @@ import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js' import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; -import { getSessionWorkspaceDisplayInfo } from '../sessionWorkspace.js'; -import { ThemeIcon } from '../../../base/common/themables.js'; -import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; /** @@ -52,7 +49,6 @@ export class SessionHeader extends Disposable { private readonly _iconEl: HTMLElement; private readonly _titleEl: HTMLElement; private readonly _titleTextEl: HTMLElement; - private readonly _workspaceMetaEl: HTMLElement; private readonly _metaRow: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; private readonly _metaToolbar: MenuWorkbenchToolBar; @@ -62,6 +58,8 @@ export class SessionHeader extends Disposable { private readonly _editingDisposables = this._register(new MutableDisposable()); private _renameInput: HTMLInputElement | undefined; private _session: IActiveSession | undefined; + private _sessionIsCreated = false; + private _requestedVisible = true; // dragstart's own target is always the draggable container, so this tracks the // preceding pointerdown's target to know where the gesture actually began. @@ -79,7 +77,6 @@ export class SessionHeader extends Disposable { private readonly _metaActionsSignal: IObservable; private readonly _showMetadataInChatInput: IObservable; - private readonly _workspaceHover = this._register(new MutableDisposable()); private readonly _statusIcon: SessionStatusIcon; @@ -103,7 +100,6 @@ export class SessionHeader extends Disposable { @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, @IConfigurationService configurationService: IConfigurationService, - @IHoverService private readonly _hoverService: IHoverService, ) { super(); @@ -135,9 +131,6 @@ export class SessionHeader extends Disposable { this._titleTextEl = $('span.chat-composite-bar-session-title-text'); this._titleEl.appendChild(this._titleTextEl); - this._workspaceMetaEl = $('.chat-composite-bar-workspace-meta'); - titleRow.appendChild(this._workspaceMetaEl); - // Click the title to start an inline rename. Click is preferred over // mousedown so that initiating a drag from the title doesn't also // flip into edit mode. @@ -155,9 +148,6 @@ export class SessionHeader extends Disposable { hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { shouldForwardArgs: true }, highlightToggledItems: true, - // Render every group in the primary slot with a separator between groups - // so the actions stay visually grouped. - toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, })); this._metaRow = $('.chat-composite-bar-meta-row'); @@ -200,7 +190,7 @@ export class SessionHeader extends Disposable { })); this._register(heightObserver.observe(this._container)); - this._setVisible(false); + this._applyVisibility(false); this._updateStyles(); this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles())); @@ -293,7 +283,8 @@ export class SessionHeader extends Disposable { this._sessionDisposables.value = store; if (!session) { - this._setVisible(false); + this._sessionIsCreated = false; + this._updateVisibility(); return; } @@ -302,10 +293,23 @@ export class SessionHeader extends Disposable { })); store.add(autorun(reader => { - this._setVisible(session.isCreated.read(reader)); + this._sessionIsCreated = session.isCreated.read(reader); + this._updateVisibility(); })); } + setVisible(visible: boolean): void { + if (this._requestedVisible === visible) { + return; + } + this._requestedVisible = visible; + this._updateVisibility(); + } + + private _updateVisibility(): void { + this._applyVisibility(this._sessionIsCreated && this._requestedVisible); + } + private _updateHeader(session: IActiveSession, reader: IReader): void { // Session icon — the SessionStatusIcon widget owns the rendering (spinner vs. // codicon, cross-fade, reduced-motion); here we just feed it the latest state. @@ -321,21 +325,6 @@ export class SessionHeader extends Disposable { this._titleTextEl.textContent = session.title.read(reader) || getUntitledSessionTitle(isQuickChat); this._titleEl.classList.toggle('editable', this._isTitleEditable()); const showMetadataInChatInput = this._showMetadataInChatInput.read(reader); - const workspaceInfo = showMetadataInChatInput && !isQuickChat ? getSessionWorkspaceDisplayInfo(session, reader) : undefined; - this._workspaceMetaEl.classList.toggle('hidden', !workspaceInfo); - this._workspaceHover.clear(); - if (workspaceInfo) { - const label = $('span.chat-composite-bar-workspace-meta-label', undefined, workspaceInfo.label); - reset( - this._workspaceMetaEl, - $('span.chat-composite-bar-workspace-meta-separator', { 'aria-hidden': 'true' }, '·'), - $(`span.chat-composite-bar-workspace-meta-icon${ThemeIcon.asCSSSelector(workspaceInfo.icon)}`, { 'aria-hidden': 'true' }), - label, - ); - this._workspaceHover.value = this._hoverService.setupDelayedHover(label, { content: workspaceInfo.label }); - } else { - reset(this._workspaceMetaEl); - } // Meta row: contributed action pills (workspace folder · diff stats · pull request). // Reading the signal re-runs this on menu changes. @@ -346,7 +335,7 @@ export class SessionHeader extends Disposable { this._onDidChangeHeight.fire(); } - private _setVisible(visible: boolean): void { + private _applyVisibility(visible: boolean): void { const wasVisible = this._visible; this._visible = visible; this._container.style.display = this._visible ? '' : 'none'; @@ -503,7 +492,6 @@ export class SessionViewFloatingToolbar extends Disposable { hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { shouldForwardArgs: true }, highlightToggledItems: true, - toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, })); this._setVisible(false); diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 38a35ed78a374..ff65256a864c9 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -148,6 +148,14 @@ export class SessionView extends Disposable implements ISerializableView { this.element.classList.toggle('grid-layout', isGridLayout); this._layoutChildren(); })); + + this._register(autorun(reader => { + const session = this._sessionObs.read(reader); + const tabsReplaceHeader = this._groupsView.groupCount.read(reader) === 1 + && (session?.isCreated.read(reader) ?? false) + && (session?.shouldShowChatTabs.read(reader) ?? false); + this._header.setVisible(!tabsReplaceHeader); + })); } openSession(session: IActiveSession | undefined, options: ISessionViewOptions): void { diff --git a/src/vs/sessions/browser/sessionConversationGroups.ts b/src/vs/sessions/browser/sessionConversationGroups.ts index b17af138724b9..824867cc802fe 100644 --- a/src/vs/sessions/browser/sessionConversationGroups.ts +++ b/src/vs/sessions/browser/sessionConversationGroups.ts @@ -6,8 +6,7 @@ import { hash } from '../../base/common/hash.js'; import { IExtUri } from '../../base/common/resources.js'; import { URI } from '../../base/common/uri.js'; -import { localize } from '../../nls.js'; -import { ChatOriginKind, IChat, SessionStatus } from '../services/sessions/common/session.js'; +import { ChatOriginKind, IChat } from '../services/sessions/common/session.js'; export const SESSION_CONVERSATION_CHATS_GROUP = '1_chats'; export const SESSION_CONVERSATION_SUBAGENTS_GROUP = '2_subagents'; @@ -16,33 +15,6 @@ export function getSessionConversationActionId(sessionId: string, chatResource: return `sessions.openChat.${sessionId}.${hash(chatResource.toString())}`; } -export function getSelectedSessionConversationActionId(sessionId: string, activeChat: IChat): string { - return getSessionConversationActionId(sessionId, activeChat.resource); -} - -export function getSessionConversationStatusLabel(status: SessionStatus): string { - switch (status) { - case SessionStatus.Untitled: - return localize('sessionConversationStatus.new', "New"); - case SessionStatus.InProgress: - return localize('sessionConversationStatus.inProgress', "In Progress"); - case SessionStatus.NeedsInput: - return localize('sessionConversationStatus.needsInput', "Input Needed"); - case SessionStatus.Completed: - return localize('sessionConversationStatus.completed', "Completed"); - case SessionStatus.Error: - return localize('sessionConversationStatus.failed', "Failed"); - } -} - -export function getSessionConversationStatusAriaLabel(status: SessionStatus): string { - return localize('sessionConversationStatus.ariaLabel', "State: {0}", getSessionConversationStatusLabel(status)); -} - -export function getSessionConversationStatusDescription(status: SessionStatus): string | undefined { - return status === SessionStatus.Completed ? undefined : getSessionConversationStatusLabel(status); -} - /** Returns the contributed menu group for a chat in the scoped session. */ export function getSessionConversationGroupId(chat: IChat, activeChat: IChat, extUri: IExtUri): string | undefined { if (chat.origin?.kind === ChatOriginKind.Tool) { diff --git a/src/vs/sessions/browser/sessionWorkspace.ts b/src/vs/sessions/browser/sessionWorkspace.ts index a23cc17e24c50..d32c7e77415fa 100644 --- a/src/vs/sessions/browser/sessionWorkspace.ts +++ b/src/vs/sessions/browser/sessionWorkspace.ts @@ -16,7 +16,7 @@ export interface ISessionWorkspaceDisplayInfo { readonly worktreePending: boolean; } -/** Returns the workspace presentation shared by the session header and Files pill. */ +/** Returns the workspace presentation shared by the command center and Files pill. */ export function getSessionWorkspaceDisplayInfo(session: ISession | undefined, reader: IReader): ISessionWorkspaceDisplayInfo | undefined { const workspace = session?.workspace.read(reader); if (!workspace?.label) { diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 157073097f5ac..eb15ba98562ae 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -32,7 +32,7 @@ export const SessionSupportsForkContext = new RawContextKey('sessionSup export const SessionSupportsSideChatContext = new RawContextKey('sessionSupportsSideChat', false, localize('sessionSupportsSideChat', "Whether the session view's session supports creating a side chat from a turn (via /btw)")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Chats dropdown visibility")); export const SessionActiveChatHasSubagentsContext = new RawContextKey('sessionActiveChatHasSubagents', false, localize('sessionActiveChatHasSubagents', "Whether the active chat has subagents, which are shown in the Chats dropdown")); -export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip. Used to hide the header New Chat button, which the tab strip then offers instead")); +export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat (the tabs shown in the strip, including in-composer drafts). Used to scope chat-to-chat navigation (next/previous chat, the Ctrl+Tab chat switcher)")); export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed (hidden) from the tab strip, i.e. it is not the main chat. Includes read-only subagent chats. Used to scope the close-chat keybinding so it closes the tab instead of the session")); export const SessionActiveChatIsDeletableContext = new RawContextKey('sessionActiveChatIsDeletable', false, localize('sessionActiveChatIsDeletable', "Whether the session's active chat can be permanently deleted from the tab strip, i.e. it is a real, user-created non-main chat (not the main chat and not a tool-spawned subagent chat, which are transient children). Used to scope the delete-chat keybinding")); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 557d711fc050a..310b7b1fecaa1 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -46,7 +46,7 @@ /* Session pill - clickable area for session picker, fills the command center box */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill { display: flex; - justify-content: space-around; + justify-content: flex-start; align-items: center; flex: 1 1 auto; padding: 0 8px; @@ -64,7 +64,7 @@ outline-offset: -1px; } -/* Center group: icon + label + folder */ +/* Center group: workspace icon and folder */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-center { display: flex; align-items: center; @@ -74,16 +74,8 @@ cursor: pointer; overflow: hidden; } -/* Codicons use the base 16px token rather than an unsupported intermediate size. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-icon { - display: flex; - align-items: center; - flex-shrink: 0; - font-size: var(--vscode-codiconFontSize); -} - -/* Session title - primary label in the command center box. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-title { +/* Workspace name shown beside its icon. */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-workspace { flex: 0 1 auto; min-width: 0; overflow: hidden; @@ -91,27 +83,17 @@ white-space: nowrap; } -/* Workspace name - secondary, dimmed label shown after the session title. - It must not be cropped: when space is tight the session title truncates - first while the workspace name stays fully visible. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-workspace { - flex: 0 0 auto; - white-space: nowrap; +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-workspace-icon { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: var(--vscode-codiconFontSize-compact); } .command-center > .monaco-toolbar > .monaco-action-bar > .actions-container > .action-item:not(.disabled) > .action-label { color: var(--vscode-icon-foreground); } -/* Separator between the session title and the workspace name. */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-separator { - flex-shrink: 0; - - &::before { - content: '\00B7'; - } -} - /* Sidebar toggle unread badge */ .agent-sessions-workbench .action-item.sidebar-toggle-action { position: relative; diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 6a0bd7123b316..38a00abdd3d8a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -13,7 +13,7 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { SessionsTelemetryContribution } from './sessionsTelemetry.contribution.js'; -import { NewSessionActionViewItemContribution, SessionConversationsActionViewItemContribution, SessionConversationsMenuContribution, SessionNewChatActionViewItemContribution } from './sessionsActions.js'; +import { NewSessionActionViewItemContribution, SessionConversationActionsContribution } from './sessionsActions.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import { AutomationsCustomViewContribution } from './views/automationsView.js'; import './views/sessionsViewActions.js'; @@ -75,9 +75,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis registerWorkbenchContribution2(AutomationsCustomViewContribution.ID, AutomationsCustomViewContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(NewSessionActionViewItemContribution.ID, NewSessionActionViewItemContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(SessionConversationsActionViewItemContribution.ID, SessionConversationsActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsMouseNavigationContribution.ID, SessionsMouseNavigationContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTelemetryContribution.ID, SessionsTelemetryContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SessionsWindowNotifier.ID, SessionsWindowNotifier, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(SessionConversationsMenuContribution.ID, SessionConversationsMenuContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(SessionNewChatActionViewItemContribution.ID, SessionNewChatActionViewItemContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(SessionConversationActionsContribution.ID, SessionConversationActionsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 7f02a91e7567d..687703ed498ab 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -11,11 +11,11 @@ import { autorun, IReader, observableSignalFromEvent } from '../../../../base/co import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; -import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { InputFocusedContext } from '../../../../platform/contextkey/common/contextkeys.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; @@ -26,17 +26,16 @@ import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/l import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { CLOSE_CHAT_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; -import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { $, append, EventHelper, ModifierKeyEmitter, reset } from '../../../../base/browser/dom.js'; -import { BaseActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; @@ -54,7 +53,6 @@ import { logSessionsInteraction, SessionsInteractionSource } from '../../../comm import { NEW_SESSION_ACTION_ID } from '../../chat/common/constants.js'; import { groupSessionsForPicker } from './sessionsPicker.js'; import { getSessionConversationActionId, getSessionConversationGroupId } from '../../../browser/sessionConversationGroups.js'; -import { SessionConversationsActionViewItem } from '../../../browser/parts/sessionConversationsActionViewItem.js'; import './media/newSessionActionViewItem.css'; // -- Show Sessions Picker -- @@ -532,15 +530,14 @@ registerAction2(class CloseAllSessionsAction extends Action2 { // session-level commands when the tab strip is not shown. const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; -// "New Chat" starts a new chat. Hidden once the session has more than one open -// chat, since the chat tab strip then offers New Chat at the end of the tabs. +// "New Chat in This Session" starts a new chat from the session header's overflow menu. const ADD_CHAT_TO_SESSION_ACTION_ID = 'sessions.chatCompositeBar.addChat'; registerAction2(class AddChatToSessionAction extends Action2 { constructor() { super({ id: ADD_CHAT_TO_SESSION_ACTION_ID, - title: localize2('chatCompositeBar.addChat', "New Chat"), + title: localize2('chatCompositeBar.addChat', "New Chat in This Session"), icon: Codicon.add, keybinding: { weight: CHAT_TAB_KEYBINDING_WEIGHT, @@ -552,9 +549,9 @@ registerAction2(class AddChatToSessionAction extends Action2 { }, menu: { id: Menus.SessionBarToolbar, - group: 'navigation', - order: 0, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionShouldShowChatTabsContext.negate()), + group: 'secondary/2_chats', + order: 20, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), }, }); } @@ -1265,125 +1262,13 @@ export class NewSessionActionViewItemContribution extends Disposable implements } /** - * Renders the "New Chat" action in the session header as the compact pill, matching the - * "New" session pill in the sessions list header / titlebar. + * Populates the Chats submenu for each visible session. Actions are scoped per + * session via {@link SessionIdContext} and re-registered whenever visible + * sessions or their chats change. */ -class NewChatActionViewItem extends CompactButtonActionViewItem { - - protected override get commandId(): string { - return ADD_CHAT_TO_SESSION_ACTION_ID; - } - - protected override get label(): string { - return localize('chatCompositeBar.addChat.compact', "New Chat"); - } - - protected override get showKeybindingHint(): boolean { - return false; - } - - protected override getHoverContent(keybindingLabel: string | undefined): string { - return keybindingLabel - ? localize('newChatButtonTitle', "New Chat ({0})", keybindingLabel) - : localize('newChatButtonTitleWithoutKeybinding', "New Chat"); - } - - protected override getAriaLabel(keybindingAriaLabel: string | undefined): string { - return keybindingAriaLabel - ? localize('newChatButtonAriaLabel', "New Chat ({0})", keybindingAriaLabel) - : localize('newChatButtonAriaLabelWithoutKeybinding', "New Chat"); - } -} - -export class SessionNewChatActionViewItemContribution extends Disposable implements IWorkbenchContribution { +export class SessionConversationActionsContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.sessions.newChatActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - - // Fire once after registering so a header toolbar that was already built - // (e.g. for a session restored before this contribution runs) re-renders and - // picks up this factory; otherwise New Chat stays icon-only until its menu - // next changes. - const onDidRegister = this._register(new Emitter()); - this._register(actionViewItemService.register(Menus.SessionBarToolbar, ADD_CHAT_TO_SESSION_ACTION_ID, (action, _options, instantiationService) => { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(NewChatActionViewItem, action); - }, onDidRegister.event)); - onDidRegister.fire(); - } -} - -export class SessionConversationsActionViewItemContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.sessions.conversationsActionViewItem'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - ) { - super(); - const provider = (action: IAction, _options: IActionViewItemOptions, instantiationService: IInstantiationService) => { - if (!(action instanceof SubmenuItemAction)) { - return undefined; - } - return instantiationService.createInstance(SessionConversationsActionViewItem, action); - }; - this._register(actionViewItemService.register(Menus.SessionHeaderMeta, Menus.SessionConversations, provider)); - this._register(actionViewItemService.register(Menus.SessionBarToolbar, Menus.SessionConversations, provider)); - } -} - -// The "Chats" toolbar entry is backed by a submenu whose groups are rendered by -// the Sessions workbench as an Action Widget dropdown. Selecting an entry opens -// or focuses that chat. -// -// It renders after the metadata pills by default, or after New Chat in the title -// toolbar when session metadata is configured to appear above the input. -MenuRegistry.appendMenuItem(Menus.SessionHeaderMeta, { - submenu: Menus.SessionConversations, - title: localize2('chatCompositeBar.conversations', "Chats"), - icon: Codicon.commentDiscussion, - group: 'navigation', - order: 100, - when: ContextKeyExpr.and( - SessionIsCreatedContext, - SessionIsArchivedContext.negate(), - ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), - ContextKeyExpr.notEquals(`config.${SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING}`, true), - ), -}); - -MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { - submenu: Menus.SessionConversations, - title: localize2('chatCompositeBar.conversations', "Chats"), - icon: Codicon.commentDiscussion, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and( - SessionIsCreatedContext, - SessionIsArchivedContext.negate(), - ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), - ContextKeyExpr.equals(`config.${SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING}`, true), - ), -}); - -/** - * Populates the {@link Menus.SessionConversations} menu for every visible - * session. {@link Menus.SessionBarToolbar} is rendered once per session view - * (header/floating toolbar) against that view's scoped context key service, so - * the menu items are scoped per session via {@link SessionIdContext}: each - * session's per-chat navigation actions only render in (and act on) their own - * session's toolbar. The actions are (re)registered whenever the set of visible - * sessions or their chat lists change. - */ -export class SessionConversationsMenuContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.sessions.conversationsMenu'; + static readonly ID = 'workbench.contrib.sessions.conversationActions'; constructor( @ISessionsService private readonly _sessionsService: ISessionsService, @@ -1393,13 +1278,13 @@ export class SessionConversationsMenuContribution extends Disposable implements this._register(autorun(reader => { for (const session of this._sessionsService.visibleSessions.read(reader)) { if (session) { - reader.store.add(this._registerSessionConversations(session, reader)); + reader.store.add(this._registerSessionConversationActions(session, reader)); } } })); } - private _registerSessionConversations(session: IActiveSession, reader: IReader): IDisposable { + private _registerSessionConversationActions(session: IActiveSession, reader: IReader): IDisposable { const store = new DisposableStore(); const that = this; const extUri = this._uriIdentityService.extUri; @@ -1408,6 +1293,12 @@ export class SessionConversationsMenuContribution extends Disposable implements // per session view against its own scoped context key service, where // `sessionId` resolves to that view's session. const scopedToSession = ContextKeyExpr.equals(SessionIdContext.key, session.sessionId); + const conversationsVisible = ContextKeyExpr.and( + scopedToSession, + SessionIsCreatedContext, + SessionIsArchivedContext.negate(), + ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), + ); const allChats = session.chats.read(reader); const activeChat = session.activeChat.read(reader); @@ -1423,7 +1314,7 @@ export class SessionConversationsMenuContribution extends Disposable implements super({ id: getSessionConversationActionId(session.sessionId, chatResource), title, - menu: { id: Menus.SessionConversations, group, order, when: scopedToSession }, + menu: { id: Menus.SessionConversations, group, order, when: conversationsVisible }, }); } override async run(accessor: ServicesAccessor, forwardedSession?: IActiveSession): Promise { @@ -1464,20 +1355,33 @@ export class SessionConversationsMenuContribution extends Disposable implements } } +MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { + submenu: Menus.SessionConversations, + title: localize2('chatCompositeBar.conversations', "Chats"), + icon: Codicon.commentDiscussion, + group: 'secondary/2_chats', + order: 10, + when: ContextKeyExpr.and( + SessionIsCreatedContext, + SessionIsArchivedContext.negate(), + ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext), + ), +}); + registerAction2(class TogglePinSessionAction extends Action2 { constructor() { super({ id: 'sessions.chatCompositeBar.togglePin', - title: localize2('chatCompositeBar.pin', "Pin Session"), + title: localize2('chatCompositeBar.pin', "Pin Session View"), icon: Codicon.pin, toggled: { condition: SessionIsStickyContext, icon: Codicon.pinned, - title: localize('chatCompositeBar.unpin', "Unpin Session"), + title: localize('chatCompositeBar.unpin', "Unpin Session View"), }, menu: { id: Menus.SessionBarToolbar, - group: '1_session', + group: 'secondary/3_pin', order: 10, when: ContextKeyExpr.and(SessionIsCreatedContext, SessionIsArchivedContext.negate()), }, @@ -1532,12 +1436,12 @@ registerAction2(class CloseSessionAction extends Action2 { constructor() { super({ id: 'sessions.chatCompositeBar.close', - title: localize2('chatCompositeBar.close', "Close"), + title: localize2('chatCompositeBar.close', "Close Session View"), icon: Codicon.close, menu: [{ id: Menus.SessionBarToolbar, when: ContextKeyExpr.or(SessionIsCreatedContext, MultipleSessionsVisibleContext), - group: '1_session', + group: 'secondary/3_pin', order: 30, }, { id: Menus.SessionHeaderContext, @@ -1561,17 +1465,17 @@ registerAction2(class ToggleMaximizeSessionViewAction extends Action2 { constructor() { super({ id: 'sessions.chatCompositeBar.toggleMaximize', - title: localize2('chatCompositeBar.maximize', "Maximize Session"), + title: localize2('chatCompositeBar.maximize', "Maximize Session View"), icon: Codicon.screenFull, toggled: { condition: SessionIsMaximizedContext, icon: Codicon.screenNormal, - title: localize('chatCompositeBar.unmaximize', "Restore Session"), + title: localize('chatCompositeBar.unmaximize', "Restore Session View"), }, menu: { id: Menus.SessionBarToolbar, when: MultipleSessionsVisibleContext, - group: '1_session', + group: 'secondary/3_pin', order: 20, }, }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index af7da5966f8b9..e48e265909d99 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -32,7 +32,6 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { getUntitledSessionTitle } from '../../../services/sessions/common/session.js'; import { BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; import { BlockedSessionsList, IBlockedSessionsHeaderActionContext, registerBlockedSessionsItemActions } from './blockedSessionsList.js'; import { BlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; @@ -40,6 +39,8 @@ import { SessionActionFeedback } from './sessionActionFeedback.js'; import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessionsIndicatorModel.js'; import { openSessionToTheSide } from './views/sessionsView.js'; +import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; /** * Internal command behind the blocked-sessions dropdown header's "Show All @@ -127,9 +128,8 @@ const BLOCKED_DROPDOWN_MAX_WIDTH_RATIO = 0.9; * Sessions Title Bar Widget - renders the active chat session * in the command center of the agent sessions workbench. * - * Shows the current chat session as a clickable pill with: - * - Kind icon at the beginning (provider type icon) - * - Repository folder name and active branch/worktree name when available + * Shows the current chat session as a clickable pill with its workspace icon + * and folder name when available. * * When at least one session is blocked (needs input or has failing CI checks), * the widget instead adopts an orange "N sessions require input" state and reveals those sessions as a @@ -157,6 +157,8 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { /** Guard to prevent re-entrant rendering */ private _isRendering = false; + private _workspaceInfo: ISessionWorkspaceDisplayInfo | undefined; + private _isQuickChat = false; /** Model behind the "N sessions require input" indicator (blocked-session set, blink, labels). */ private readonly _blockedIndicator: BlockedSessionsIndicatorModel; @@ -188,6 +190,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IHoverService private readonly hoverService: IHoverService, ) { super(undefined, action, options); @@ -215,11 +218,8 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // Re-render when the active session's title, workspace, or quick-chat kind changes this._register(autorun(reader => { const sessionData = this.sessionsService.activeSession.read(reader); - if (sessionData) { - sessionData.title.read(reader); - sessionData.workspace.read(reader); - sessionData.isQuickChat?.read(reader); - } + this._workspaceInfo = getSessionWorkspaceDisplayInfo(sessionData, reader); + this._isQuickChat = sessionData?.isQuickChat?.read(reader) ?? false; this._lastRenderState = undefined; this._render(); })); @@ -308,10 +308,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } else if (showRequiresInput) { renderState = `blocked|${blockedCount}|${requiresInputKind ?? 'mixed'}`; } else { - const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); - const workspaceLabel = this._getRepositoryLabel(); - renderState = `normal|${icon?.id ?? ''}|${sessionTitle ?? ''}|${workspaceLabel ?? ''}`; + renderState = `normal|${this._workspaceInfo?.icon.id ?? ''}|${this._workspaceInfo?.label ?? ''}|${this._isQuickChat}`; } // Skip re-render if state hasn't changed @@ -362,43 +359,32 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } /** - * Render the active-session pill: icon + title + workspace. Clicking opens the + * Render the active-session pill: workspace icon + folder. Clicking opens the * sessions picker. */ private _renderActiveSession(): void { const container = this._container!; container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); - const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); - const workspaceLabel = this._getRepositoryLabel(); + const workspaceInfo = this._workspaceInfo; - // Session pill: icon + title + workspace together + // Session pill: workspace icon + label const sessionPill = $('div.agent-sessions-titlebar-pill'); - // Center group: icon + title + workspace name + // Center group: workspace icon and name const centerGroup = $('div.agent-sessions-titlebar-center'); - // Kind icon at the beginning - if (icon) { - const iconEl = $('div.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); - centerGroup.appendChild(iconEl); - } - - // Session title shown next to the icon - if (sessionTitle) { - const titleEl = $('div.agent-sessions-titlebar-title'); - titleEl.textContent = sessionTitle; - centerGroup.appendChild(titleEl); - } - - // Workspace name shown after the session title - if (workspaceLabel) { - const separatorEl = $('div.agent-sessions-titlebar-separator'); - centerGroup.appendChild(separatorEl); + if (workspaceInfo) { + const workspaceIconEl = $(`div.agent-sessions-titlebar-workspace-icon${ThemeIcon.asCSSSelector(workspaceInfo.icon)}`, { 'aria-hidden': 'true' }); + centerGroup.appendChild(workspaceIconEl); const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = workspaceLabel; + workspaceEl.textContent = workspaceInfo.label; + centerGroup.appendChild(workspaceEl); + this._dynamicDisposables.add(this.hoverService.setupDelayedHover(workspaceEl, { content: workspaceInfo.label })); + } else if (this._isQuickChat) { + const workspaceEl = $('div.agent-sessions-titlebar-workspace'); + workspaceEl.textContent = localize('quickChat', "Quick Chat"); centerGroup.appendChild(workspaceEl); } @@ -685,39 +671,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this.sessionsService.openSession(resource, { preserveFocus }).catch(onUnexpectedError); } - /** - * Get the icon for the active session's type. - */ - private _getActiveSessionIcon(): ThemeIcon | undefined { - const sessionData = this.sessionsService.activeSession.get(); - if (sessionData) { - return sessionData.icon; - } - return undefined; - } - - /** - * Get the display title for the active session. - */ - private _getSessionTitle(): string | undefined { - const sessionData = this.sessionsService.activeSession.get(); - return sessionData?.title.get()?.trim() || undefined; - } - - /** - * Get the repository label for the active session. - */ - private _getRepositoryLabel(): string | undefined { - const sessionData = this.sessionsService.activeSession.get(); - if (sessionData) { - const workspace = sessionData.workspace.get(); - if (workspace) { - return workspace.label; - } - } - return undefined; - } - private _showSessionsPicker(): void { this.commandService.executeCommand(SHOW_SESSIONS_PICKER_COMMAND_ID); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index e3d5e8e40a77e..b22a8d4c953c1 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -880,8 +880,8 @@ abstract class BaseArchiveSessionAction extends Action2 { when: ContextKeyExpr.equals(SessionIsArchivedContext.key, false), }, { id: Menus.SessionBarToolbar, - group: '1_session', - order: 5, + group: 'secondary/1_session', + order: 30, when: ContextKeyExpr.and(SessionIsCreatedContext, ContextKeyExpr.equals(SessionIsArchivedContext.key, false)), }] }); @@ -929,7 +929,7 @@ abstract class BaseUnarchiveSessionAction extends Action2 { when: ContextKeyExpr.equals(SessionIsArchivedContext.key, true), }, { id: Menus.SessionBarToolbar, - group: 'navigation', + group: 'secondary/1_session', order: 5, when: ContextKeyExpr.equals(SessionIsArchivedContext.key, true), }] @@ -969,11 +969,17 @@ registerAction2(class RenameSessionAction extends Action2 { super({ id: RENAME_SESSION_COMMAND_ID, title: localize2('renameSession', "Rename..."), + icon: Codicon.edit, menu: [{ id: SessionItemContextMenuId, group: '1_edit', order: 1, when: SessionSupportsRenameContext, + }, { + id: Menus.SessionBarToolbar, + group: 'secondary/1_session', + order: 20, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsRenameContext, SessionIsArchivedContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts new file mode 100644 index 0000000000000..936885a474166 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, isISubmenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { Menus } from '../../../../browser/menus.js'; + +import '../../browser/sessionsActions.js'; +import '../../browser/views/sessionsViewActions.js'; + +suite('Sessions - Actions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('contributes New Chat to the session header overflow', () => { + const action = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessions.chatCompositeBar.addChat'); + + assert.deepStrictEqual({ + title: action && (typeof action.command.title === 'string' ? action.command.title : action.command.title.value), + group: action?.group, + order: action?.order, + when: action?.when?.serialize(), + }, { + title: 'New Chat in This Session', + group: 'secondary/2_chats', + order: 20, + when: 'sessionIsCreated && sessionSupportsMultipleChats && !sessionIsArchived', + }); + }); + + test('groups session management actions before creation and close', () => { + const actions = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .filter(item => item.command.id === 'sessions.chatCompositeBar.togglePin' || item.command.id === 'sessionsViewPane.renameSession' || item.command.id === 'sessions.chatCompositeBar.addChat' || item.command.id === 'sessions.chatCompositeBar.close') + .sort((a, b) => (a.group ?? '').localeCompare(b.group ?? '') || (a.order ?? 0) - (b.order ?? 0)) + .map(item => ({ id: item.command.id, group: item.group })); + + assert.deepStrictEqual(actions, [ + { id: 'sessionsViewPane.renameSession', group: 'secondary/1_session' }, + { id: 'sessions.chatCompositeBar.addChat', group: 'secondary/2_chats' }, + { id: 'sessions.chatCompositeBar.togglePin', group: 'secondary/3_pin' }, + { id: 'sessions.chatCompositeBar.close', group: 'secondary/3_pin' }, + ]); + }); + + test('contributes Chats before New Chat in the same overflow group', () => { + const chats = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isISubmenuItem) + .find(item => item.submenu === Menus.SessionConversations); + + assert.deepStrictEqual({ + group: chats?.group, + order: chats?.order, + }, { + group: 'secondary/2_chats', + order: 10, + }); + }); + + test('distinguishes pinning the session view from pinning the session in the list', () => { + const pin = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessions.chatCompositeBar.togglePin'); + + assert.strictEqual(pin && (typeof pin.command.title === 'string' ? pin.command.title : pin.command.title.value), 'Pin Session View'); + }); + + test('groups session view actions with consistent titles', () => { + const actions = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) + .filter(isIMenuItem) + .filter(item => ['sessions.chatCompositeBar.togglePin', 'sessions.chatCompositeBar.toggleMaximize', 'sessions.chatCompositeBar.close'].includes(item.command.id)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map(item => ({ + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + group: item.group, + })); + + assert.deepStrictEqual(actions, [ + { title: 'Pin Session View', group: 'secondary/3_pin' }, + { title: 'Maximize Session View', group: 'secondary/3_pin' }, + { title: 'Close Session View', group: 'secondary/3_pin' }, + ]); + }); +}); diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 870429bba093c..76285864761a2 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -196,8 +196,7 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, keys.hasMultipleCommittedChats.set(committedChatCount > 1); // The tab strip is shown when the session has more than one chat (counting - // closed chats) or its single remaining chat's title diverged from the - // session title; the header then hides its own New Chat button. + // closed chats) or its single remaining chat's title diverged from the session title. keys.shouldShowChatTabs.set(session?.shouldShowChatTabs.read(reader) ?? false); // More than one open chat tab (incl. drafts): scopes chat-to-chat navigation diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index eaffe9687f348..5808916cb7231 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -109,6 +109,7 @@ function createHarness(disposables: Pick): IChatComposit activeChatResource: constObservable(session.activeChat.get().resource.toString()), mainChatResource: constObservable(session.mainChat.get().resource.toString()), visible: session.shouldShowChatTabs, + showSessionActions: session.shouldShowChatTabs, openChat: resource => { sessionsService.openChat(session, resource); }, newChat: () => { }, }; diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 1638a2f14a5f0..268525be789a9 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -5,7 +5,6 @@ import assert from 'assert'; import { mainWindow } from '../../../base/browser/window.js'; -import { DeferredPromise } from '../../../base/common/async.js'; import { Event } from '../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; @@ -116,7 +115,6 @@ class TestActiveSession extends mock() { class TestSessionsService extends mock() { override readonly activeSession = observableValue(this, undefined); - newChatGate: Promise | undefined; override async openChat(session: ISession, chatUri: URI): Promise { if (!(session instanceof TestActiveSession)) { @@ -137,7 +135,6 @@ class TestSessionsService extends mock() { if (!(session instanceof TestActiveSession)) { return; } - await this.newChatGate; const chat = createChat(`new-${session.allChats.get().length}`, SessionStatus.Untitled); session.allChats.set([...session.allChats.get(), chat], undefined); session.visibleChatTabs.set([...session.visibleChatTabs.get(), chat], undefined); @@ -402,23 +399,9 @@ suite('Sessions - ChatGroupsView', () => { }); }); - test('new chat action focuses its group composer', async () => { + test('new chat from the tab bar is assigned to its group', async () => { const { view } = createHarness(disposables); const main = createChat('main'); - const session = new TestActiveSession([main]); - view.setSession(session, options); - const group = view.element.querySelector('.chat-group-view')!; - - group.querySelector('.chat-composite-bar-new-chat .action-label')!.click(); - await Promise.resolve(); - await Promise.resolve(); - - assert.strictEqual(group.contains(mainWindow.document.activeElement), true); - }); - - test('new chat remains assigned to the group where creation started', async () => { - const { sessionsService, view } = createHarness(disposables); - const main = createChat('main'); const secondary = createChat('secondary'); const session = new TestActiveSession([main, secondary]); view.setSession(session, options); @@ -426,13 +409,8 @@ suite('Sessions - ChatGroupsView', () => { view.focusAdjacentGroup('previous'); const groups = Array.from(view.element.querySelectorAll('.chat-group-view')); const mainGroup = groups.find(group => group.querySelector('.chat-composite-bar-tab')?.dataset.chatResource === main.resource.toString())!; - const gate = new DeferredPromise(); - sessionsService.newChatGate = gate.p; mainGroup.querySelector('.chat-composite-bar-new-chat .action-label')!.click(); - view.focusAdjacentGroup('next'); - gate.complete(); - await gate.p; await Promise.resolve(); await Promise.resolve(); @@ -440,11 +418,31 @@ suite('Sessions - ChatGroupsView', () => { assert.deepStrictEqual({ mainGroupTabs: Array.from(mainGroup.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource), secondaryGroupTabs: Array.from(groups.find(group => group !== mainGroup)!.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource), - focusInMainGroup: mainGroup.contains(mainWindow.document.activeElement), }, { mainGroupTabs: [main.resource.toString(), newChat.resource.toString()], secondaryGroupTabs: [secondary.resource.toString()], - focusInMainGroup: true, }); }); + + test('shows session actions in a single tab row and hides them for split groups', () => { + const { view } = createHarness(disposables); + const main = createChat('main'); + const secondary = createChat('secondary'); + const session = new TestActiveSession([main, secondary]); + view.setSession(session, options); + + const singleGroupActions = view.element.querySelector('.session-chat-tabs-actions'); + const singleGroupHidden = singleGroupActions?.classList.contains('hidden'); + view.splitChatToSide(secondary.resource); + const splitGroupActions = Array.from(view.element.querySelectorAll('.session-chat-tabs-actions')); + + assert.deepStrictEqual({ + singleGroupHidden, + splitGroupsHidden: splitGroupActions.map(actions => actions.classList.contains('hidden')), + }, { + singleGroupHidden: false, + splitGroupsHidden: [true, true], + }); + }); + }); diff --git a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts index 95b3fb8c724c3..39f339682d47e 100644 --- a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts +++ b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts @@ -4,15 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Codicon } from '../../../base/common/codicons.js'; -import { toAction } from '../../../base/common/actions.js'; import { extUri } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; -import { ISessionConversationActionMetadata, toSessionConversationDropdownActions } from '../../browser/parts/sessionConversationsActionViewItem.js'; -import { getSelectedSessionConversationActionId, getSessionConversationActionId, getSessionConversationGroupId, getSessionConversationStatusAriaLabel, getSessionConversationStatusDescription, getSessionConversationStatusLabel, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../../browser/sessionConversationGroups.js'; -import { ChatOriginKind, IChat, IChatOrigin, SessionStatus } from '../../services/sessions/common/session.js'; +import { getSessionConversationGroupId, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../../browser/sessionConversationGroups.js'; +import { ChatOriginKind, IChat, IChatOrigin } from '../../services/sessions/common/session.js'; function createChat(id: string, origin?: IChatOrigin): IChat { return new class extends mock() { @@ -39,136 +36,4 @@ suite('Sessions - Session conversation groups', () => { ]); }); - test('selects the active chat or subagent directly', () => { - const parentChat = createChat('parent'); - const activeSubagent = createChat('active-subagent', { kind: ChatOriginKind.Tool, parentChat: parentChat.resource }); - const activeSideChat = createChat('active-side-chat', { kind: ChatOriginKind.SideChat, parentChat: parentChat.resource }); - - assert.deepStrictEqual({ - subagent: getSelectedSessionConversationActionId('session', activeSubagent), - sideChat: getSelectedSessionConversationActionId('session', activeSideChat), - }, { - subagent: getSessionConversationActionId('session', activeSubagent.resource), - sideChat: getSessionConversationActionId('session', activeSideChat.resource), - }); - }); - - test('adapts flat chat and subagent groups with state', async () => { - let runCount = 0; - const firstChatAction = toAction({ - id: getSessionConversationActionId('session', URI.parse('test-chat:/parent-1')), - label: 'First Chat', - enabled: false, - run: () => runCount++, - }); - const secondChatAction = toAction({ - id: getSessionConversationActionId('session', URI.parse('test-chat:/parent-2')), - label: 'Second Chat', - run: () => runCount++, - }); - const firstSubagentAction = toAction({ - id: 'test.subagent.1', - label: 'Research', - run: () => runCount++, - }); - const metadata = new Map([ - [firstChatAction.id, { description: 'In Progress', ariaDescription: 'State: In Progress', icon: Codicon.sessionInProgress }], - [firstSubagentAction.id, { description: 'Completed', ariaDescription: 'State: Completed', icon: Codicon.circleSmallFilled }], - ]); - const actions = toSessionConversationDropdownActions([ - [SESSION_CONVERSATION_CHATS_GROUP, [firstChatAction, secondChatAction]], - [SESSION_CONVERSATION_SUBAGENTS_GROUP, [firstSubagentAction]], - ], metadata); - - await actions[0].run(); - - assert.deepStrictEqual({ - actions: actions.map(action => ({ - label: action.label, - description: action.description, - ariaDescription: action.ariaDescription, - category: action.category, - })), - runCount, - }, { - actions: [ - { - label: 'First Chat', - description: 'In Progress', - ariaDescription: 'State: In Progress', - category: { label: 'Chats', order: 1, showHeader: false }, - }, - { - label: 'Second Chat', - description: undefined, - ariaDescription: undefined, - category: { label: 'Chats', order: 1, showHeader: false }, - }, - { - label: 'Research', - description: 'Completed', - ariaDescription: 'State: Completed', - category: { label: 'Subagents', order: 2, showHeader: true }, - }, - ], - runCount: 1, - }); - }); - - test('shows only subagents when there is one first-level chat', () => { - const chatAction = toAction({ - id: getSessionConversationActionId('session', URI.parse('test-chat:/parent')), - label: 'Only Chat', - run: () => { }, - }); - const subagentAction = toAction({ id: 'test.subagent', label: 'Research', run: () => { } }); - - const actions = toSessionConversationDropdownActions([ - [SESSION_CONVERSATION_CHATS_GROUP, [chatAction]], - [SESSION_CONVERSATION_SUBAGENTS_GROUP, [subagentAction]], - ]); - - assert.deepStrictEqual(actions.map(action => ({ - label: action.label, - category: action.category?.label, - showHeader: action.category?.showHeader, - })), [ - { label: 'Research', category: 'Subagents', showHeader: true }, - ]); - }); - - test('localizes every conversation state', () => { - assert.deepStrictEqual([ - SessionStatus.Untitled, - SessionStatus.InProgress, - SessionStatus.NeedsInput, - SessionStatus.Completed, - SessionStatus.Error, - ].map(status => ({ - label: getSessionConversationStatusLabel(status), - ariaLabel: getSessionConversationStatusAriaLabel(status), - })), [ - { label: 'New', ariaLabel: 'State: New' }, - { label: 'In Progress', ariaLabel: 'State: In Progress' }, - { label: 'Input Needed', ariaLabel: 'State: Input Needed' }, - { label: 'Completed', ariaLabel: 'State: Completed' }, - { label: 'Failed', ariaLabel: 'State: Failed' }, - ]); - }); - - test('keeps completed state visually quiet but accessible', () => { - assert.deepStrictEqual([ - SessionStatus.Untitled, - SessionStatus.InProgress, - SessionStatus.NeedsInput, - SessionStatus.Completed, - SessionStatus.Error, - ].map(status => getSessionConversationStatusDescription(status)), [ - 'New', - 'In Progress', - 'Input Needed', - undefined, - 'Failed', - ]); - }); }); diff --git a/src/vs/sessions/test/browser/sessionHeader.test.ts b/src/vs/sessions/test/browser/sessionHeader.test.ts index 059d8c2bfe3f9..601b50d6adae5 100644 --- a/src/vs/sessions/test/browser/sessionHeader.test.ts +++ b/src/vs/sessions/test/browser/sessionHeader.test.ts @@ -123,7 +123,23 @@ suite('Sessions - SessionHeader', () => { assert.strictEqual(dragEvent.defaultPrevented, false); }); - test('shows read-only workspace metadata beside the title and hides the second row when configured', () => { + test('hides the header while it is replaced by the single-group tabs row', () => { + const { header } = createHarness(disposables); + + header.setVisible(false); + const hiddenDisplay = header.element.style.display; + header.setVisible(true); + + assert.deepStrictEqual({ + hiddenDisplay, + restoredDisplay: header.element.style.display, + }, { + hiddenDisplay: 'none', + restoredDisplay: '', + }); + }); + + test('does not show workspace metadata beside the title and hides the second row when configured', () => { const root = URI.file('C:\\Code\\vscode'); const workspace: ISessionWorkspace = { uri: root, @@ -144,14 +160,10 @@ suite('Sessions - SessionHeader', () => { const metaRow = header.element.querySelector('.chat-composite-bar-meta-row'); assert.deepStrictEqual({ - workspaceText: workspaceMeta?.textContent, - workspaceHidden: workspaceMeta?.classList.contains('hidden'), - workspaceFocusable: workspaceMeta?.tabIndex, + workspaceMeta, metaRowDisplay: metaRow?.style.display, }, { - workspaceText: '·vscode', - workspaceHidden: false, - workspaceFocusable: -1, + workspaceMeta: null, metaRowDisplay: 'none', }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts index 93cc4d932a3c5..5f11c34aaa24a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts @@ -67,6 +67,7 @@ function createMockDelegate(session: IActiveSession, chats: readonly IChat[], ac activeChatResource: observableValue('activeChatResource', activeChat.resource.toString()), mainChatResource: observableValue('mainChatResource', chats[0].resource.toString()), visible: session.shouldShowChatTabs, + showSessionActions: session.shouldShowChatTabs, openChat: () => { }, newChat: () => { }, }; @@ -98,6 +99,7 @@ function renderBar(ctx: ComponentFixtureContext, chats: readonly IChat[], active container.style.width = '360px'; container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + container.classList.add('chat-groups-view', 'single-group'); const session = createMockSession(chats, activeChat, sessionTitle); const bar = disposableStore.add(instantiationService.createInstance(ChatCompositeBar)); From 0aeca2fda5c6e3d44286ad43d1116b93fcd714f6 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 20:39:20 +0200 Subject: [PATCH 2/9] sessions: align command center workspace label Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/media/sessionsTitleBarWidget.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 310b7b1fecaa1..0b37589a225e2 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -68,7 +68,8 @@ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-center { display: flex; align-items: center; - gap: 6px; + gap: 0; + height: 100%; min-width: 0; justify-content: flex-start; cursor: pointer; @@ -76,7 +77,10 @@ } /* Workspace name shown beside its icon. */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-workspace { + display: flex; + align-items: center; flex: 0 1 auto; + height: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -87,6 +91,7 @@ display: flex; align-items: center; flex-shrink: 0; + height: 100%; font-size: var(--vscode-codiconFontSize-compact); } From 2d4f528e4e6b9259babe1ddfeb891053e987f6b4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 20:49:01 +0200 Subject: [PATCH 3/9] sessions: address chat header review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/chatCompositeBar.ts | 3 +++ .../browser/parts/media/chatCompositeBar.css | 4 ++++ .../browser/sessionConversationGroups.ts | 22 +++++++++++++++++- .../test/browser/chatCompositeBar.test.ts | 5 ++-- .../test/browser/chatGroupsView.test.ts | 14 +++++++++-- .../browser/sessionConversationGroups.test.ts | 23 +++++++++++++++++-- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index bb53c28ce57db..660ebed8880a0 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -43,6 +43,7 @@ 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; @@ -338,7 +339,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. diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index f022082d14b28..80f240ee0a10f 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -190,6 +190,10 @@ border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--session-view-foreground, var(--chat-tab-active-foreground)) 12%, transparent); } +:is(.hc-black, .hc-light) .chat-groups-view.single-group .chat-composite-bar-tabs-row { + border-bottom-color: var(--vscode-contrastBorder); +} + /* The ScrollableElement wrapper holding the tabs is the shrinkable flex item */ .chat-composite-bar-tabs-row > .monaco-scrollable-element { flex: 0 1 auto; diff --git a/src/vs/sessions/browser/sessionConversationGroups.ts b/src/vs/sessions/browser/sessionConversationGroups.ts index 824867cc802fe..4c16c3881a715 100644 --- a/src/vs/sessions/browser/sessionConversationGroups.ts +++ b/src/vs/sessions/browser/sessionConversationGroups.ts @@ -6,7 +6,8 @@ import { hash } from '../../base/common/hash.js'; import { IExtUri } from '../../base/common/resources.js'; import { URI } from '../../base/common/uri.js'; -import { ChatOriginKind, IChat } from '../services/sessions/common/session.js'; +import { localize } from '../../nls.js'; +import { ChatOriginKind, IChat, SessionStatus } from '../services/sessions/common/session.js'; export const SESSION_CONVERSATION_CHATS_GROUP = '1_chats'; export const SESSION_CONVERSATION_SUBAGENTS_GROUP = '2_subagents'; @@ -15,6 +16,25 @@ export function getSessionConversationActionId(sessionId: string, chatResource: return `sessions.openChat.${sessionId}.${hash(chatResource.toString())}`; } +export function getSessionConversationStatusLabel(status: SessionStatus): string { + switch (status) { + case SessionStatus.Untitled: + return localize('sessionConversationStatus.new', "New"); + case SessionStatus.InProgress: + return localize('sessionConversationStatus.inProgress', "In Progress"); + case SessionStatus.NeedsInput: + return localize('sessionConversationStatus.needsInput', "Input Needed"); + case SessionStatus.Completed: + return localize('sessionConversationStatus.completed', "Completed"); + case SessionStatus.Error: + return localize('sessionConversationStatus.failed', "Failed"); + } +} + +export function getSessionConversationStatusAriaLabel(status: SessionStatus): string { + return localize('sessionConversationStatus.ariaLabel', "State: {0}", getSessionConversationStatusLabel(status)); +} + /** Returns the contributed menu group for a chat in the scoped session. */ export function getSessionConversationGroupId(chat: IChat, activeChat: IChat, extUri: IExtUri): string | undefined { if (chat.origin?.kind === ChatOriginKind.Tool) { diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index 5808916cb7231..63043e03c24b1 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -133,11 +133,12 @@ suite('Sessions - ChatCompositeBar', () => { hasFill: tab.querySelector(':scope > .chat-composite-bar-tab-fill.modern-ui-editor-tab-fill') !== null, hasLabel: tab.querySelector(':scope > .chat-composite-bar-tab-label.modern-ui-editor-tab-label') !== null, hasActions: tab.querySelector(':scope > .chat-composite-bar-tab-actions') !== null, + ariaLabel: tab.getAttribute('aria-label'), })), }, { tabs: [ - { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: false }, - { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: true }, + { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: false, ariaLabel: 'Main Chat, State: Completed' }, + { hasSharedPresentation: true, hasFill: true, hasLabel: true, hasActions: true, ariaLabel: 'Secondary Chat, State: Completed' }, ], }); }); diff --git a/src/vs/sessions/test/browser/chatGroupsView.test.ts b/src/vs/sessions/test/browser/chatGroupsView.test.ts index 268525be789a9..e73a2960f8826 100644 --- a/src/vs/sessions/test/browser/chatGroupsView.test.ts +++ b/src/vs/sessions/test/browser/chatGroupsView.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { mainWindow } from '../../../base/browser/window.js'; +import { DeferredPromise } from '../../../base/common/async.js'; import { Event } from '../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; @@ -115,6 +116,7 @@ class TestActiveSession extends mock() { class TestSessionsService extends mock() { override readonly activeSession = observableValue(this, undefined); + newChatGate: Promise | undefined; override async openChat(session: ISession, chatUri: URI): Promise { if (!(session instanceof TestActiveSession)) { @@ -135,6 +137,7 @@ class TestSessionsService extends mock() { if (!(session instanceof TestActiveSession)) { return; } + await this.newChatGate; const chat = createChat(`new-${session.allChats.get().length}`, SessionStatus.Untitled); session.allChats.set([...session.allChats.get(), chat], undefined); session.visibleChatTabs.set([...session.visibleChatTabs.get(), chat], undefined); @@ -399,8 +402,8 @@ suite('Sessions - ChatGroupsView', () => { }); }); - test('new chat from the tab bar is assigned to its group', async () => { - const { view } = createHarness(disposables); + test('new chat remains assigned to the group where creation started', async () => { + const { sessionsService, view } = createHarness(disposables); const main = createChat('main'); const secondary = createChat('secondary'); const session = new TestActiveSession([main, secondary]); @@ -409,8 +412,13 @@ suite('Sessions - ChatGroupsView', () => { view.focusAdjacentGroup('previous'); const groups = Array.from(view.element.querySelectorAll('.chat-group-view')); const mainGroup = groups.find(group => group.querySelector('.chat-composite-bar-tab')?.dataset.chatResource === main.resource.toString())!; + const gate = new DeferredPromise(); + sessionsService.newChatGate = gate.p; mainGroup.querySelector('.chat-composite-bar-new-chat .action-label')!.click(); + view.focusAdjacentGroup('next'); + gate.complete(); + await gate.p; await Promise.resolve(); await Promise.resolve(); @@ -418,9 +426,11 @@ suite('Sessions - ChatGroupsView', () => { assert.deepStrictEqual({ mainGroupTabs: Array.from(mainGroup.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource), secondaryGroupTabs: Array.from(groups.find(group => group !== mainGroup)!.querySelectorAll('.chat-composite-bar-tab')).map(tab => tab.dataset.chatResource), + focusInMainGroup: mainGroup.contains(mainWindow.document.activeElement), }, { mainGroupTabs: [main.resource.toString(), newChat.resource.toString()], secondaryGroupTabs: [secondary.resource.toString()], + focusInMainGroup: true, }); }); diff --git a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts index 39f339682d47e..a194d0f41a70b 100644 --- a/src/vs/sessions/test/browser/sessionConversationGroups.test.ts +++ b/src/vs/sessions/test/browser/sessionConversationGroups.test.ts @@ -8,8 +8,8 @@ import { extUri } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { mock } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; -import { getSessionConversationGroupId, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../../browser/sessionConversationGroups.js'; -import { ChatOriginKind, IChat, IChatOrigin } from '../../services/sessions/common/session.js'; +import { getSessionConversationGroupId, getSessionConversationStatusAriaLabel, getSessionConversationStatusLabel, SESSION_CONVERSATION_CHATS_GROUP, SESSION_CONVERSATION_SUBAGENTS_GROUP } from '../../browser/sessionConversationGroups.js'; +import { ChatOriginKind, IChat, IChatOrigin, SessionStatus } from '../../services/sessions/common/session.js'; function createChat(id: string, origin?: IChatOrigin): IChat { return new class extends mock() { @@ -36,4 +36,23 @@ suite('Sessions - Session conversation groups', () => { ]); }); + test('localizes every conversation state for accessibility', () => { + assert.deepStrictEqual([ + SessionStatus.Untitled, + SessionStatus.InProgress, + SessionStatus.NeedsInput, + SessionStatus.Completed, + SessionStatus.Error, + ].map(status => ({ + label: getSessionConversationStatusLabel(status), + ariaLabel: getSessionConversationStatusAriaLabel(status), + })), [ + { label: 'New', ariaLabel: 'State: New' }, + { label: 'In Progress', ariaLabel: 'State: In Progress' }, + { label: 'Input Needed', ariaLabel: 'State: Input Needed' }, + { label: 'Completed', ariaLabel: 'State: Completed' }, + { label: 'Failed', ariaLabel: 'State: Failed' }, + ]); + }); + }); From 789a88db3302726a8180f5626134aba9c426f54e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 21:24:36 +0200 Subject: [PATCH 4/9] sessions: label workspace-less command center Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsTitleBarWidget.ts | 5 ++++- .../sessionsTitleBarWidget.fixture.ts | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index e48e265909d99..1b4e4d4b85ab8 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -383,8 +383,11 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { centerGroup.appendChild(workspaceEl); this._dynamicDisposables.add(this.hoverService.setupDelayedHover(workspaceEl, { content: workspaceInfo.label })); } else if (this._isQuickChat) { + const workspaceIconEl = $(`div.agent-sessions-titlebar-workspace-icon${ThemeIcon.asCSSSelector(Codicon.commentDiscussion)}`, { 'aria-hidden': 'true' }); + centerGroup.appendChild(workspaceIconEl); + const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = localize('quickChat', "Quick Chat"); + workspaceEl.textContent = localize('workspaceless', "Workspaceless"); centerGroup.appendChild(workspaceEl); } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index 1748c2b79272f..739f7dcb9009a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -36,15 +36,19 @@ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, // Mock helpers // ============================================================================ -function createMockActiveSession(title: string, workspaceLabel: string): IActiveSession { - const workspace = new class extends mock() { - override readonly label = workspaceLabel; - }(); +function createMockActiveSession(title: string, workspaceLabel?: string): IActiveSession { + let workspace: ISessionWorkspace | undefined; + if (workspaceLabel) { + const label = workspaceLabel; + workspace = new class extends mock() { + override readonly label = label; + }(); + } return new class extends mock() { override readonly icon = Codicon.copilot; override readonly title: IObservable = constObservable(title); override readonly workspace: IObservable = constObservable(workspace); - override readonly isQuickChat: IObservable = constObservable(false); + override readonly isQuickChat: IObservable = constObservable(workspace === undefined); }(); } @@ -185,13 +189,19 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo export default defineThemedFixtureGroup({ path: 'sessions/' }, { - // Default: shows the active session pill (icon + title + workspace). + // Default: shows the active session workspace. SessionsTitleBar_ActiveSession: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), }), }), + SessionsTitleBar_Workspaceless: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Quick chat'), + }), + }), + // Requires-input: generic orange state (a mix, or unclassified needs-input). SessionsTitleBar_RequiresInput: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { From c5164338787d1d613e1a6cdaf37d9598c3e2625b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 21:28:14 +0200 Subject: [PATCH 5/9] sessions: label sessions without workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts | 2 +- .../sessions/sessionsTitleBarWidget.fixture.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 1b4e4d4b85ab8..94dcfe9da6c03 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -387,7 +387,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { centerGroup.appendChild(workspaceIconEl); const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = localize('workspaceless', "Workspaceless"); + workspaceEl.textContent = localize('noWorkspace', "No workspace"); centerGroup.appendChild(workspaceEl); } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index 739f7dcb9009a..fe6ab7e739b61 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -196,7 +196,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { }), }), - SessionsTitleBar_Workspaceless: defineComponentFixture({ + SessionsTitleBar_NoWorkspace: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { activeSession: createMockActiveSession('Quick chat'), }), From 48a39411942076e244ddd888d01021ad5baa9576 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 22:21:38 +0200 Subject: [PATCH 6/9] test: provide complete session workspace fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/sessionsTitleBarWidget.fixture.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index fe6ab7e739b61..d75e7f56d75d6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -42,6 +42,8 @@ function createMockActiveSession(title: string, workspaceLabel?: string): IActiv const label = workspaceLabel; workspace = new class extends mock() { override readonly label = label; + override readonly folders = []; + override readonly isVirtualWorkspace = false; }(); } return new class extends mock() { From df32fa1e5e00ffa5109cfcb49ae3361fdd164f52 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 22:25:51 +0200 Subject: [PATCH 7/9] sessions: add new chat to session item menu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/sessionsActions.ts | 18 +++++++++++------- .../test/browser/sessionsActions.test.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 687703ed498ab..2467fd79c5e1b 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -547,26 +547,30 @@ registerAction2(class AddChatToSessionAction extends Action2 { when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), primary: KeyMod.CtrlCmd | KeyCode.KeyT, }, - menu: { + menu: [{ id: Menus.SessionBarToolbar, group: 'secondary/2_chats', order: 20, when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), - }, + }, { + id: Menus.SessionItemContextMenu, + group: '1_edit', + order: 0, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), + }], }); } - override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise { + override async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { const sessionsService = accessor.get(ISessionsService); const sessionsPartService = accessor.get(ISessionsPartService); - // From the menu: session is forwarded as context. From the keybinding: - // fall back to the active session. - const target = session ?? sessionsService.activeSession.get(); + const target = Array.isArray(context) ? context[0] : context ?? sessionsService.activeSession.get(); if (!target) { return; } await sessionsService.openNewChatInSession(target); - sessionsPartService.focusSession(target); + const activeSession = sessionsService.activeSession.get(); + sessionsPartService.focusSession(activeSession?.sessionId === target.sessionId ? activeSession : undefined); } }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index 936885a474166..9344b7098e7db 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -33,6 +33,24 @@ suite('Sessions - Actions', () => { }); }); + test('contributes New Chat to the session list item menu', () => { + const action = MenuRegistry.getMenuItems(Menus.SessionItemContextMenu) + .filter(isIMenuItem) + .find(item => item.command.id === 'sessions.chatCompositeBar.addChat'); + + assert.deepStrictEqual({ + title: action && (typeof action.command.title === 'string' ? action.command.title : action.command.title.value), + group: action?.group, + order: action?.order, + when: action?.when?.serialize(), + }, { + title: 'New Chat in This Session', + group: '1_edit', + order: 0, + when: 'sessionIsCreated && sessionSupportsMultipleChats && !sessionIsArchived', + }); + }); + test('groups session management actions before creation and close', () => { const actions = MenuRegistry.getMenuItems(Menus.SessionBarToolbar) .filter(isIMenuItem) From d117ea59b1bdfa94f660461bfde66362d09d463a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 22:27:25 +0200 Subject: [PATCH 8/9] sessions: separate new chat list action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/sessions/browser/sessionsActions.ts | 2 +- .../contrib/sessions/test/browser/sessionsActions.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 2467fd79c5e1b..db46f5a7db730 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -554,7 +554,7 @@ registerAction2(class AddChatToSessionAction extends Action2 { when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), }, { id: Menus.SessionItemContextMenu, - group: '1_edit', + group: '1_newChat', order: 0, when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), }], diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index 9344b7098e7db..036efd667d1e3 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -45,7 +45,7 @@ suite('Sessions - Actions', () => { when: action?.when?.serialize(), }, { title: 'New Chat in This Session', - group: '1_edit', + group: '1_newChat', order: 0, when: 'sessionIsCreated && sessionSupportsMultipleChats && !sessionIsArchived', }); From 8b0e16283039848b1de732b00b9e19813722da19 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 20 Aug 2026 22:31:04 +0200 Subject: [PATCH 9/9] sessions: disable side chats without workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/parts/chatCompositeBar.ts | 5 +++-- .../contrib/sessions/browser/sessionsActions.ts | 11 +++++++---- .../sessions/test/browser/sessionsActions.test.ts | 4 ++-- .../sessions/test/browser/chatCompositeBar.test.ts | 13 ++++++++++--- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 660ebed8880a0..d128c870b9354 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -281,8 +281,9 @@ export class ChatCompositeBar extends Disposable { const mainChatUri = delegate.mainChatResource.read(reader); this._rebuildTabs(chats, activeChatUri, mainChatUri); const supportsMultipleChats = delegate.session.capabilities.read(reader).supportsMultipleChats; - this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats); - 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(); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index db46f5a7db730..45b14b6cb78be 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -26,7 +26,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/l import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext, SessionsTitleBarNewSessionEnabledContext, SessionsEditorScopeContext, SessionsHasClosedItemContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { CLOSE_CHAT_COMMAND_ID, FOCUS_NEXT_CHAT_GROUP_COMMAND_ID, FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID, MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID, MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID, SPLIT_CHAT_GROUP_DOWN_COMMAND_ID, SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID } from '../../../common/sessionCommands.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -544,19 +544,19 @@ registerAction2(class AddChatToSessionAction extends Action2 { // Like Cmd/Ctrl+T in a browser — opens a new chat tab within the // active session. Scoped so it does not steal the shortcut outside // the agents window or when the session does not support multiple chats. - when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionIsCreatedContext, SessionSupportsMultipleChatsContext, IsQuickChatSessionContext.negate(), SessionIsArchivedContext.negate()), primary: KeyMod.CtrlCmd | KeyCode.KeyT, }, menu: [{ id: Menus.SessionBarToolbar, group: 'secondary/2_chats', order: 20, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, IsQuickChatSessionContext.negate(), SessionIsArchivedContext.negate()), }, { id: Menus.SessionItemContextMenu, group: '1_newChat', order: 0, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, IsQuickChatSessionContext.negate(), SessionIsArchivedContext.negate()), }], }); } @@ -568,6 +568,9 @@ registerAction2(class AddChatToSessionAction extends Action2 { if (!target) { return; } + if (target.isQuickChat?.get()) { + return; + } await sessionsService.openNewChatInSession(target); const activeSession = sessionsService.activeSession.get(); sessionsPartService.focusSession(activeSession?.sessionId === target.sessionId ? activeSession : undefined); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts index 036efd667d1e3..5ded37d19d0d9 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsActions.test.ts @@ -29,7 +29,7 @@ suite('Sessions - Actions', () => { title: 'New Chat in This Session', group: 'secondary/2_chats', order: 20, - when: 'sessionIsCreated && sessionSupportsMultipleChats && !sessionIsArchived', + when: 'sessionIsCreated && sessionSupportsMultipleChats && !isQuickChatSession && !sessionIsArchived', }); }); @@ -47,7 +47,7 @@ suite('Sessions - Actions', () => { title: 'New Chat in This Session', group: '1_newChat', order: 0, - when: 'sessionIsCreated && sessionSupportsMultipleChats && !sessionIsArchived', + when: 'sessionIsCreated && sessionSupportsMultipleChats && !isQuickChatSession && !sessionIsArchived', }); }); diff --git a/src/vs/sessions/test/browser/chatCompositeBar.test.ts b/src/vs/sessions/test/browser/chatCompositeBar.test.ts index 63043e03c24b1..d0e63cafca2e7 100644 --- a/src/vs/sessions/test/browser/chatCompositeBar.test.ts +++ b/src/vs/sessions/test/browser/chatCompositeBar.test.ts @@ -53,7 +53,7 @@ function createChat(id: string, title: string, status: SessionStatus = SessionSt }(); } -function createSession(chats: readonly IChat[], activeChat: IChat): IActiveSession { +function createSession(chats: readonly IChat[], activeChat: IChat, isQuickChat = false): IActiveSession { const resource = URI.parse('test-session://session'); return new class extends mock() { override readonly sessionId = 'session'; @@ -69,6 +69,7 @@ function createSession(chats: readonly IChat[], activeChat: IChat): IActiveSessi override readonly capabilities: IObservable = constObservable({ supportsMultipleChats: true }); override readonly isCreated: IObservable = constObservable(true); override readonly isArchived: IObservable = constObservable(false); + override readonly isQuickChat: IObservable = constObservable(isQuickChat); }(); } @@ -82,14 +83,14 @@ interface IChatCompositeBarHarness { readonly tabs: readonly HTMLElement[]; } -function createHarness(disposables: Pick): IChatCompositeBarHarness { +function createHarness(disposables: Pick, isQuickChat = false): IChatCompositeBarHarness { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const commandService = new TestCommandService(); const sessionsService = new TestSessionsService(); const mainChat = createChat('main', 'Main Chat'); const secondaryChat = createChat('secondary', 'Secondary Chat'); - const session = createSession([mainChat, secondaryChat], mainChat); + const session = createSession([mainChat, secondaryChat], mainChat, isQuickChat); instantiationService.stub(ICommandService, commandService); instantiationService.stub(ISessionsService, sessionsService); @@ -143,6 +144,12 @@ suite('Sessions - ChatCompositeBar', () => { }); }); + test('hides New Chat for workspace-less sessions', () => { + const { bar } = createHarness(disposables, true); + + assert.strictEqual(bar.element.querySelector('.chat-composite-bar-new-chat')?.classList.contains('hidden'), true); + }); + test('middle-click closes the targeted inactive non-main chat', () => { const { store, commandService, sessionsService, bar, session, tabs } = createHarness(disposables); let bubbled = 0;