Chat view: introduce session title and back button (fix #277537) (#278874)

This commit is contained in:
Hawk Ticehurst 2025-12-03 06:27:51 -05:00 committed by GitHub
parent f69c21c5ff
commit 302c914060
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 321 additions and 30 deletions

3
.github/CODENOTIFY vendored
View File

@ -104,6 +104,9 @@ src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @roblourens
src/vs/workbench/contrib/chat/browser/chatSetup/** @bpasero
src/vs/workbench/contrib/chat/browser/chatStatus/** @bpasero
src/vs/workbench/contrib/chat/browser/chatViewPane.ts @bpasero
src/vs/workbench/contrib/chat/browser/media/chatViewPane.css @bpasero
src/vs/workbench/contrib/chat/browser/chatViewTitleControl.ts @bpasero
src/vs/workbench/contrib/chat/browser/media/chatViewTitleControl.css @bpasero
src/vs/workbench/contrib/chat/browser/chatManagement/chatUsageWidget.ts @bpasero
src/vs/workbench/contrib/chat/browser/chatManagement/media/chatUsageWidget.css @bpasero
src/vs/workbench/contrib/chat/browser/agentSessions/** @bpasero

View File

@ -281,6 +281,7 @@ export class MenuId {
static readonly ChatSessionsMenu = new MenuId('ChatSessionsMenu');
static readonly ChatSessionsCreateSubMenu = new MenuId('ChatSessionsCreateSubMenu');
static readonly ChatRecentSessionsToolbar = new MenuId('ChatRecentSessionsToolbar');
static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar');
static readonly ChatConfirmationMenu = new MenuId('ChatConfirmationMenu');
static readonly ChatEditorInlineExecute = new MenuId('ChatEditorInputExecute');
static readonly ChatEditorInlineInputSide = new MenuId('ChatEditorInputSide');

View File

@ -1836,8 +1836,6 @@ registerAction2(class ToggleChatViewRecentSessionsAction extends Action2 {
super({
id: 'workbench.action.chat.toggleChatViewRecentSessions',
title: localize2('chat.toggleChatViewRecentSessions.label', "Show Recent Sessions"),
category: CHAT_CATEGORY,
precondition: ChatContextKeys.enabled,
toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewRecentSessionsEnabled}`, true),
menu: {
id: MenuId.ChatWelcomeContext,
@ -1855,3 +1853,26 @@ registerAction2(class ToggleChatViewRecentSessionsAction extends Action2 {
await configurationService.updateValue(ChatConfiguration.ChatViewRecentSessionsEnabled, !chatViewRecentSessionsEnabled);
}
});
registerAction2(class ToggleChatViewTitleAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.toggleChatViewTitle',
title: localize2('chat.toggleChatViewTitle.label', "Show Chat Title"),
toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewTitleEnabled}`, true),
menu: {
id: MenuId.ChatWelcomeContext,
group: '1_modify',
order: 2,
when: ChatContextKeys.inChatEditor.negate()
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const configurationService = accessor.get(IConfigurationService);
const chatViewTitleEnabled = configurationService.getValue<boolean>(ChatConfiguration.ChatViewTitleEnabled);
await configurationService.updateValue(ChatConfiguration.ChatViewTitleEnabled, !chatViewTitleEnabled);
}
});

View File

@ -160,6 +160,15 @@ export function registerNewChatActions() {
});
CommandsRegistry.registerCommandAlias(ACTION_ID_NEW_EDIT_SESSION, ACTION_ID_NEW_CHAT);
MenuRegistry.appendMenuItem(MenuId.ChatViewSessionTitleToolbar, {
command: {
id: ACTION_ID_NEW_CHAT,
title: localize2('chat.goBack', "Go Back"),
icon: Codicon.arrowLeft,
},
group: 'navigation'
});
registerAction2(class UndoChatEditInteractionAction extends EditingSessionAction {
constructor() {
super({

View File

@ -373,6 +373,15 @@ configurationRegistry.registerConfiguration({
mode: 'auto'
}
},
[ChatConfiguration.ChatViewTitleEnabled]: { // TODO@bpasero decide on a default
type: 'boolean',
default: product.quality !== 'stable',
description: nls.localize('chat.viewTitle.enabled', "Show the title of the chat above the chat in the chat view."),
tags: ['preview', 'experimental'],
experiment: {
mode: 'auto'
}
},
[ChatConfiguration.NotifyWindowOnResponseReceived]: {
type: 'boolean',
default: true,

View File

@ -64,7 +64,7 @@ const chatViewDescriptor: IViewDescriptor = {
},
order: 1
},
ctorDescriptor: new SyncDescriptor(ChatViewPane, [{ location: ChatAgentLocation.Chat }]),
ctorDescriptor: new SyncDescriptor(ChatViewPane),
when: ContextKeyExpr.or(
ContextKeyExpr.or(
ChatContextKeys.Setup.hidden,

View File

@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/chatViewPane.css';
import { $, addDisposableListener, append, EventHelper, EventType, getWindow, setVisibility } from '../../../../base/browser/dom.js';
import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
@ -46,7 +47,7 @@ import { showCloseActiveChatNotification } from './actions/chatCloseNotification
import { AgentSessionsControl } from './agentSessions/agentSessionsControl.js';
import { AgentSessionsListDelegate } from './agentSessions/agentSessionsViewer.js';
import { ChatWidget } from './chatWidget.js';
import './media/chatViewPane.css';
import { ChatViewTitleControl } from './chatViewTitleControl.js';
import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js';
interface IChatViewPaneState extends Partial<IChatModelInputState> {
@ -65,8 +66,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
private _widget!: ChatWidget;
get widget(): ChatWidget { return this._widget; }
private readonly modelRef = this._register(new MutableDisposable<IChatModelReference>());
private readonly memento: Memento<IChatViewPaneState>;
private readonly viewState: IChatViewPaneState;
@ -77,14 +76,16 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
private sessionsControl: AgentSessionsControl | undefined;
private sessionsCount: number = 0;
private welcomeController: ChatViewWelcomeController | undefined;
private titleControl: ChatViewTitleControl | undefined;
private restoringSession: Promise<void> | undefined;
private welcomeController: ChatViewWelcomeController | undefined;
private lastDimensions: { height: number; width: number } | undefined;
private restoringSession: Promise<void> | undefined;
private readonly modelRef = this._register(new MutableDisposable<IChatModelReference>());
constructor(
private readonly chatOptions: { location: ChatAgentLocation.Chat },
options: IViewPaneOptions,
@IKeybindingService keybindingService: IKeybindingService,
@IContextMenuService contextMenuService: IContextMenuService,
@ -125,7 +126,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
private registerListeners(): void {
this._register(this.chatAgentService.onDidChangeAgents(() => {
if (this.chatAgentService.getDefaultAgent(this.chatOptions?.location)) {
if (this.chatAgentService.getDefaultAgent(ChatAgentLocation.Chat)) {
if (!this._widget?.viewModel && !this.restoringSession) {
const info = this.getTransferredOrPersistedSessionInfo();
this.restoringSession =
@ -144,11 +145,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
if (info.inputState && modelRef) {
modelRef.object.inputModel.setState(info.inputState);
}
await this.updateModel(modelRef);
await this.showModel(modelRef);
} finally {
this._widget.setVisible(wasVisible);
}
});
this.restoringSession.finally(() => this.restoringSession = undefined);
}
}
@ -158,7 +161,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
}
private getTransferredOrPersistedSessionInfo(): { sessionId?: string; inputState?: IChatModelInputState; mode?: ChatModeKind } {
if (this.chatService.transferredSessionData?.location === this.chatOptions.location) {
if (this.chatService.transferredSessionData?.location === ChatAgentLocation.Chat) {
const sessionId = this.chatService.transferredSessionData.sessionId;
return {
sessionId,
@ -176,7 +179,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
} : undefined;
}
private async updateModel(modelRef?: IChatModelReference | undefined) {
private async showModel(modelRef?: IChatModelReference | undefined): Promise<IChatModel | undefined> {
// Check if we're disposing a model with an active request
if (this.modelRef.value?.object.requestInProgress.get()) {
@ -186,20 +189,24 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.modelRef.value = undefined;
const ref = modelRef ?? (this.chatService.transferredSessionData?.sessionId && this.chatService.transferredSessionData?.location === this.chatOptions.location
const ref = modelRef ?? (this.chatService.transferredSessionData?.sessionId && this.chatService.transferredSessionData?.location === ChatAgentLocation.Chat
? await this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(this.chatService.transferredSessionData.sessionId))
: this.chatService.startSession(this.chatOptions.location));
: this.chatService.startSession(ChatAgentLocation.Chat));
if (!ref) {
throw new Error('Could not start chat session');
}
this.modelRef.value = ref;
const model = ref.object;
// Update widget lock state based on session type
await this.updateWidgetLockState(model.sessionResource);
this.viewState.sessionId = model.sessionId;
this.viewState.sessionId = model.sessionId; // remember as model to restore in view state
this._widget.setModel(model);
// Update title control
this.titleControl?.update(model);
// Update the toolbar context with new sessionId
this.updateActions();
@ -208,11 +215,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
override shouldShowWelcome(): boolean {
const noPersistedSessions = !this.chatService.hasSessions();
const hasCoreAgent = this.chatAgentService.getAgents().some(agent => agent.isCore && agent.locations.includes(this.chatOptions.location));
const hasDefaultAgent = this.chatAgentService.getDefaultAgent(this.chatOptions.location) !== undefined; // only false when Hide AI Features has run and unregistered the setup agents
const hasCoreAgent = this.chatAgentService.getAgents().some(agent => agent.isCore && agent.locations.includes(ChatAgentLocation.Chat));
const hasDefaultAgent = this.chatAgentService.getDefaultAgent(ChatAgentLocation.Chat) !== undefined; // only false when Hide AI Features has run and unregistered the setup agents
const shouldShow = !hasCoreAgent && (!hasDefaultAgent || !this._widget?.viewModel && noPersistedSessions);
this.logService.trace(`ChatViewPane#shouldShowWelcome(${this.chatOptions.location}) = ${shouldShow}: hasCoreAgent=${hasCoreAgent} hasDefaultAgent=${hasDefaultAgent} || noViewModel=${!this._widget?.viewModel} && noPersistedSessions=${noPersistedSessions}`);
this.logService.trace(`ChatViewPane#shouldShowWelcome() = ${shouldShow}: hasCoreAgent=${hasCoreAgent} hasDefaultAgent=${hasDefaultAgent} || noViewModel=${!this._widget?.viewModel} && noPersistedSessions=${noPersistedSessions}`);
return !!shouldShow;
}
@ -226,6 +233,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.viewPaneContainer.classList.add('chat-viewpane');
this.createControls(parent);
this.setupContextMenu(parent);
this.applyModel();
@ -236,8 +244,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
// Sessions Control
this.createSessionsControl(parent);
// Title Control
this.createTitleControl(parent);
// Welcome Control
this.welcomeController = this._register(this.instantiationService.createInstance(ChatViewWelcomeController, parent, this, this.chatOptions.location));
this.welcomeController = this._register(this.instantiationService.createInstance(ChatViewWelcomeController, parent, this, ChatAgentLocation.Chat));
// Chat Widget
this.createChatWidget(parent);
@ -314,9 +325,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
const newSessionsContainerVisible =
this.configurationService.getValue<boolean>(ChatConfiguration.ChatViewRecentSessionsEnabled) && // enabled in settings
(!this._widget || this._widget?.isEmpty()) && // chat widget empty
!this.welcomeController?.isShowingWelcome.get() && // welcome not showing
this.sessionsCount > 0; // has sessions
(!this._widget || this._widget?.isEmpty()) && // chat widget empty
!this.welcomeController?.isShowingWelcome.get() && // welcome not showing
this.sessionsCount > 0; // has sessions
this.viewPaneContainer.classList.toggle('has-sessions-control', newSessionsContainerVisible);
@ -329,6 +340,21 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
};
}
private createTitleControl(parent: HTMLElement): void {
this.titleControl = this._register(this.instantiationService.createInstance(ChatViewTitleControl,
parent,
{
updateTitle: title => this.updateTitle(title)
}
));
this._register(this.titleControl.onDidChangeHeight(() => {
if (this.lastDimensions) {
this.layoutBody(this.lastDimensions.height, this.lastDimensions.width);
}
}));
}
private createChatWidget(parent: HTMLElement): void {
const locationBasedColors = this.getLocationBasedColors();
@ -338,11 +364,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService])));
this._widget = this._register(scopedInstantiationService.createInstance(
ChatWidget,
this.chatOptions.location,
ChatAgentLocation.Chat,
{ viewId: this.id },
{
autoScroll: mode => mode !== ChatModeKind.Ask,
renderFollowups: this.chatOptions.location === ChatAgentLocation.Chat,
renderFollowups: true,
supportsFileReferences: true,
clear: () => this.clear(),
rendererOptions: {
@ -353,7 +379,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
progressMessageAtBottomOfResponse: mode => mode !== ChatModeKind.Ask,
},
editorOverflowWidgetsDomNode,
enableImplicitContext: this.chatOptions.location === ChatAgentLocation.Chat,
enableImplicitContext: true,
enableWorkingSet: 'explicit',
supportsChangingModes: true,
},
@ -390,28 +416,27 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
modelRef.object.inputModel.setState(info.inputState);
}
await this.updateModel(modelRef);
await this.showModel(modelRef);
}
private async clear(): Promise<void> {
// Grab the widget's latest view state because it will be loaded back into the widget
this.updateViewState();
await this.updateModel(undefined);
await this.showModel(undefined);
// Update the toolbar context with new sessionId
this.updateActions();
}
async loadSession(sessionId: URI): Promise<IChatModel | undefined> {
const sessionType = getChatSessionType(sessionId);
if (sessionType !== localChatSessionType) {
await this.chatSessionsService.canResolveChatSession(sessionId);
}
const newModelRef = await this.chatService.loadSessionForResource(sessionId, ChatAgentLocation.Chat, CancellationToken.None);
return this.updateModel(newModelRef);
return this.showModel(newModelRef);
}
focusInput(): void {
@ -440,6 +465,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
remainingHeight -= this.sessionsContainer.offsetHeight;
}
// Title Control
remainingHeight -= this.titleControl?.getHeight() ?? 0;
// Chat Widget
this._widget.layout(remainingHeight, width);
}
@ -494,4 +522,15 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this._widget.unlockFromCodingAgent();
}
}
override get singleViewPaneContainerTitle(): string | undefined {
if (this.titleControl) {
const titleControlTitle = this.titleControl.getSingleViewPaneContainerTitle();
if (titleControlTitle) {
return titleControlTitle;
}
}
return super.singleViewPaneContainerTitle;
}
}

View File

@ -0,0 +1,180 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/chatViewTitleControl.css';
import { h } from '../../../../base/browser/dom.js';
import { Emitter } from '../../../../base/common/event.js';
import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { localize } from '../../../../nls.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IViewDescriptorService, IViewContainerModel } from '../../../common/views.js';
import { ActivityBarPosition, LayoutSettings } from '../../../services/layout/browser/layoutService.js';
import { IChatModel } from '../common/chatModel.js';
import { ChatViewId } from './chat.js';
import { ChatConfiguration } from '../common/constants.js';
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { MenuId } from '../../../../platform/actions/common/actions.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
export interface IChatViewTitleDelegate {
updateTitle(title: string): void;
}
export class ChatViewTitleControl extends Disposable {
private static readonly DEFAULT_TITLE = localize('chat', "Chat Session");
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
readonly onDidChangeHeight = this._onDidChangeHeight.event;
private get viewContainerModel(): IViewContainerModel | undefined {
const viewContainer = this.viewDescriptorService.getViewContainerByViewId(ChatViewId);
if (viewContainer) {
return this.viewDescriptorService.getViewContainerModel(viewContainer);
}
return undefined;
}
private title: string | undefined = undefined;
private titleContainer: HTMLElement | undefined;
private titleLabel: HTMLElement | undefined;
private model: IChatModel | undefined;
private modelDisposables = this._register(new MutableDisposable());
private lastKnownHeight = 0;
constructor(
private readonly container: HTMLElement,
private readonly delegate: IChatViewTitleDelegate,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this.render(this.container);
this.registerListeners();
}
private registerListeners(): void {
// Update when views change in container
if (this.viewContainerModel) {
this._register(this.viewContainerModel.onDidAddVisibleViewDescriptors(() => this.doUpdate()));
this._register(this.viewContainerModel.onDidRemoveVisibleViewDescriptors(() => this.doUpdate()));
}
// Update on configuration changes
this._register(this.configurationService.onDidChangeConfiguration(e => {
if (
e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_LOCATION) ||
e.affectsConfiguration(ChatConfiguration.ChatViewTitleEnabled)
) {
this.doUpdate();
}
}));
}
private render(parent: HTMLElement): void {
const elements = h('div.chat-view-title-container', [
h('div.chat-view-title-toolbar@toolbar'),
h('span.chat-view-title-label@label'),
]);
this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, elements.toolbar, MenuId.ChatViewSessionTitleToolbar, {}));
this.titleContainer = elements.root;
this.titleLabel = elements.label;
parent.appendChild(this.titleContainer);
}
update(model: IChatModel | undefined): void {
this.model = model;
this.modelDisposables.value = model?.onDidChange(e => {
if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') {
this.doUpdate();
}
});
this.doUpdate();
}
private doUpdate(): void {
this.title = this.model?.title;
this.delegate.updateTitle(this.getTitleWithPrefix());
this.updateTitle(this.title ?? ChatViewTitleControl.DEFAULT_TITLE);
}
private updateTitle(title: string): void {
if (!this.titleContainer || !this.titleLabel) {
return;
}
this.titleContainer.classList.toggle('visible', this.shouldRender());
this.titleLabel.textContent = title;
const currentHeight = this.getHeight();
if (currentHeight !== this.lastKnownHeight) {
this.lastKnownHeight = currentHeight;
this._onDidChangeHeight.fire();
}
}
private shouldRender(): boolean {
if (!this.isEnabled()) {
return false; // title hidden via setting
}
if (this.viewContainerModel && this.viewContainerModel.visibleViewDescriptors.length > 1) {
return false; // multiple views visible, chat view shows a title already
}
if (this.configurationService.getValue<ActivityBarPosition>(LayoutSettings.ACTIVITY_BAR_LOCATION) !== ActivityBarPosition.DEFAULT) {
return false; // activity bar not in default location, view title shown already
}
return !!this.model?.title;
}
private isEnabled(): boolean {
return this.configurationService.getValue<boolean>(ChatConfiguration.ChatViewTitleEnabled) === true;
}
getSingleViewPaneContainerTitle(): string | undefined {
if (
!this.isEnabled() || // title disabled
this.shouldRender() // title is rendered in the view, do not repeat
) {
return undefined;
}
return this.getTitleWithPrefix();
}
private getTitleWithPrefix(): string {
if (this.title) {
return localize('chatTitleWithPrefixCustom', "Chat: {0}", this.title);
}
return ChatViewTitleControl.DEFAULT_TITLE;
}
getHeight(): number {
if (!this.titleContainer || this.titleContainer.style.display === 'none') {
return 0;
}
return this.titleContainer.offsetHeight;
}
}

View File

@ -0,0 +1,28 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.chat-viewpane {
.chat-view-title-container {
display: none;
padding: 8px 16px;
border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border);
align-items: center;
.chat-view-title-label {
text-transform: uppercase;
font-size: 12px;
color: var(--vscode-descriptionForeground);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.chat-view-title-container.visible {
display: flex;
gap: 4px;
}
}

View File

@ -25,6 +25,7 @@ export enum ChatConfiguration {
ShowAgentSessionsViewDescription = 'chat.showAgentSessionsViewDescription',
NotifyWindowOnResponseReceived = 'chat.notifyWindowOnResponseReceived',
ChatViewRecentSessionsEnabled = 'chat.recentSessions.enabled',
ChatViewTitleEnabled = 'chat.viewTitle.enabled',
SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled',
ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress',
RestoreLastPanelSession = 'chat.restoreLastPanelSession',