From 3fcd3e70e1f96222d0a044e2597c4653f64a177e Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 09:41:50 +0100 Subject: [PATCH 01/17] agent sessions - introduce sessions control for re-use --- .../agentSessions/agentSessionsControl.ts | 200 +++++++++++++++++ .../agentSessions/agentSessionsView.ts | 203 ++++-------------- .../agentSessions/agentSessionsViewer.ts | 10 +- 3 files changed, 249 insertions(+), 164 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts new file mode 100644 index 0000000000000..64b23556507d9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -0,0 +1,200 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../common/chatContextKeys.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; +import { $, append } from '../../../../../base/browser/dom.js'; +import { IAgentSession, IAgentSessionsModel, isLocalAgentSessionItem } from './agentSessionsModel.js'; +import { AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionsSorter, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { FuzzyScore } from '../../../../../base/common/filters.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { getSessionItemContextOverlay } from '../chatSessions/common.js'; +import { ACTION_ID_OPEN_CHAT } from '../actions/chatActions.js'; +import { IChatEditorOptions } from '../chatEditor.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ITreeContextMenuEvent } from '../../../../../base/browser/ui/tree/tree.js'; +import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; +import { getFlatActionBarActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IChatService } from '../../common/chatService.js'; +import { IChatWidgetService } from '../chat.js'; +import { TreeFindMode } from '../../../../../base/browser/ui/tree/abstractTree.js'; +import { SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; +import { IMarshalledChatSessionContext } from '../actions/chatSessionActions.js'; +import { distinct } from '../../../../../base/common/arrays.js'; +import { IAgentSessionsService } from './agentSessionsService.js'; + +export interface IAgentSessionsControlOptions { + readonly filter?: IAgentSessionsFilter; + readonly allowNewSessionFromEmptySpace?: boolean; +} + +export class AgentSessionsControl extends Disposable { + + private sessionsContainer: HTMLElement | undefined; + private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; + + private visible: boolean = true; + + constructor( + private readonly options: IAgentSessionsControlOptions, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ICommandService private readonly commandService: ICommandService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IChatService private readonly chatService: IChatService, + @IMenuService private readonly menuService: IMenuService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + ) { + super(); + } + + render(container: HTMLElement): void { + container.classList.add('agent-sessions-view'); + + this.createList(container); + } + + private createList(container: HTMLElement): void { + this.sessionsContainer = append(container, $('.agent-sessions-viewer')); + + const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, + 'AgentSessionsView', + this.sessionsContainer, + new AgentSessionsListDelegate(), + new AgentSessionsCompressionDelegate(), + [ + this.instantiationService.createInstance(AgentSessionRenderer) + ], + new AgentSessionsDataSource(this.options.filter), + { + accessibilityProvider: new AgentSessionsAccessibilityProvider(), + dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), + identityProvider: new AgentSessionsIdentityProvider(), + horizontalScrolling: false, + multipleSelectionSupport: false, + findWidgetEnabled: true, + defaultFindMode: TreeFindMode.Filter, + keyboardNavigationLabelProvider: new AgentSessionsKeyboardNavigationLabelProvider(), + sorter: this.instantiationService.createInstance(AgentSessionsSorter), + paddingBottom: this.options.allowNewSessionFromEmptySpace ? AgentSessionsListDelegate.ITEM_HEIGHT : undefined, + twistieAdditionalCssClass: () => 'force-no-twistie', + } + )) as WorkbenchCompressibleAsyncDataTree; + + const model = this.agentSessionsService.model; + + this._register(Event.any( + this.options.filter?.onDidChange ?? Event.None, + model.onDidChangeSessions + )(() => { + if (this.visible) { + list.updateChildren(); + } + })); + + list.setInput(model); + + // List Events + + this._register(list.onDidOpen(e => { + this.openAgentSession(e); + })); + + if (this.options.allowNewSessionFromEmptySpace) { + this._register(list.onMouseDblClick(({ element }) => { + if (element === null) { + this.commandService.executeCommand(ACTION_ID_OPEN_CHAT); + } + })); + } + + this._register(list.onContextMenu((e) => { + this.showContextMenu(e); + })); + } + + private async openAgentSession(e: IOpenEvent): Promise { + const session = e.element; + if (!session) { + return; + } + + let sessionOptions: IChatEditorOptions; + if (isLocalAgentSessionItem(session)) { + sessionOptions = {}; + } else { + sessionOptions = { title: { preferred: session.label } }; + } + + sessionOptions.ignoreInView = true; + + const options: IChatEditorOptions = { + preserveFocus: false, + ...sessionOptions, + ...e.editorOptions, + }; + + await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); // ensure provider is activated before trying to open + + const group = e.sideBySide ? SIDE_GROUP : undefined; + await this.chatWidgetService.openSession(session.resource, group, options); + } + + private async showContextMenu({ element: session, anchor }: ITreeContextMenuEvent): Promise { + if (!session) { + return; + } + + const provider = await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); + const contextOverlay = getSessionItemContextOverlay(session, provider, this.chatService, this.editorGroupsService); + contextOverlay.push([ChatContextKeys.isCombinedSessionViewer.key, true]); + const menu = this.menuService.createMenu(MenuId.ChatSessionsMenu, this.contextKeyService.createOverlay(contextOverlay)); + + const marshalledSession: IMarshalledChatSessionContext = { session, $mid: MarshalledId.ChatSessionContext }; + this.contextMenuService.showContextMenu({ + getActions: () => distinct(getFlatActionBarActions(menu.getActions({ arg: marshalledSession, shouldForwardArgs: true })), action => action.id), + getAnchor: () => anchor, + getActionsContext: () => marshalledSession, + }); + + menu.dispose(); + } + + openFind(): void { + this.sessionsList?.openFind(); + } + + refresh(): void { + this.agentSessionsService.model.resolve(undefined); + } + + setVisible(visible: boolean): void { + this.visible = visible; + + if (this.visible) { + this.sessionsList?.updateChildren(); + } + } + + layout(height: number, width: number): void { + this.sessionsList?.layout(height, width); + } + + focus(): void { + if (this.sessionsList?.getFocus().length) { + this.sessionsList.domFocus(); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts index e41c8fc7bc73f..2a469284e3fa4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts @@ -22,38 +22,24 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; -import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; import { $, append } from '../../../../../base/browser/dom.js'; -import { IAgentSession, IAgentSessionsModel, isLocalAgentSessionItem } from './agentSessionsModel.js'; -import { AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionsSorter } from './agentSessionsViewer.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; import { IAction, Separator, toAction } from '../../../../../base/common/actions.js'; -import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { getSessionItemContextOverlay, NEW_CHAT_SESSION_ACTION_ID } from '../chatSessions/common.js'; +import { NEW_CHAT_SESSION_ACTION_ID } from '../chatSessions/common.js'; import { ACTION_ID_OPEN_CHAT } from '../actions/chatActions.js'; import { IProgressService } from '../../../../../platform/progress/common/progress.js'; -import { IChatEditorOptions } from '../chatEditor.js'; -import { assertReturnsDefined } from '../../../../../base/common/types.js'; -import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { Event } from '../../../../../base/common/event.js'; import { MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { ITreeContextMenuEvent } from '../../../../../base/browser/ui/tree/tree.js'; -import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; -import { getActionBarActions, getFlatActionBarActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { IChatService } from '../../common/chatService.js'; -import { IChatWidgetService } from '../chat.js'; +import { getActionBarActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { AGENT_SESSIONS_VIEW_ID, AGENT_SESSIONS_VIEW_CONTAINER_ID, AgentSessionProviders } from './agentSessions.js'; -import { TreeFindMode } from '../../../../../base/browser/ui/tree/abstractTree.js'; -import { SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; -import { IMarshalledChatSessionContext } from '../actions/chatSessionActions.js'; -import { distinct } from '../../../../../base/common/arrays.js'; -import { IAgentSessionsService } from './agentSessionsService.js'; import { AgentSessionsFilter } from './agentSessionsFilter.js'; +import { AgentSessionsControl } from './agentSessionsControl.js'; +import { IAgentSessionsService } from './agentSessionsService.js'; export class AgentSessionsView extends ViewPane { @@ -71,104 +57,46 @@ export class AgentSessionsView extends ViewPane { @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ICommandService private readonly commandService: ICommandService, @IProgressService private readonly progressService: IProgressService, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, - @IChatService private readonly chatService: IChatService, @IMenuService private readonly menuService: IMenuService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, ) { super({ ...options, titleMenuId: MenuId.AgentSessionsTitle }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - } - - protected override renderBody(container: HTMLElement): void { - super.renderBody(container); - - container.classList.add('agent-sessions-view'); - - // New Session - if (!this.configurationService.getValue('chat.hideNewButtonInAgentSessionsView')) { - this.createNewSessionButton(container); - } - - // Sessions List - this.createList(container); this.registerListeners(); } private registerListeners(): void { - const list = assertReturnsDefined(this.list); - - this._register(this.onDidChangeBodyVisibility(visible => { - if (visible) { - this.list?.updateChildren(); - } - })); - - this._register(list.onDidOpen(e => { - this.openAgentSession(e); - })); - - this._register(list.onMouseDblClick(({ element }) => { - if (element === null) { - this.commandService.executeCommand(ACTION_ID_OPEN_CHAT); - } - })); + const sessionsModel = this.agentSessionsService.model; + const didResolveDisposable = this._register(new MutableDisposable()); + this._register(sessionsModel.onWillResolve(() => { + const didResolve = new DeferredPromise(); + didResolveDisposable.value = Event.once(sessionsModel.onDidResolve)(() => didResolve.complete()); - this._register(list.onContextMenu((e) => { - this.showContextMenu(e); + this.progressService.withProgress( + { + location: this.id, + title: localize('agentSessions.refreshing', 'Refreshing agent sessions...'), + delay: 500 + }, + () => didResolve.p + ); })); } - private async openAgentSession(e: IOpenEvent): Promise { - const session = e.element; - if (!session) { - return; - } - - let sessionOptions: IChatEditorOptions; - if (isLocalAgentSessionItem(session)) { - sessionOptions = {}; - } else { - sessionOptions = { title: { preferred: session.label } }; - } - - sessionOptions.ignoreInView = true; - - const options: IChatEditorOptions = { - preserveFocus: false, - ...sessionOptions, - ...e.editorOptions, - }; - - await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); // ensure provider is activated before trying to open + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); - const group = e.sideBySide ? SIDE_GROUP : undefined; - await this.chatWidgetService.openSession(session.resource, group, options); - } + container.classList.add('agent-sessions-view'); - private async showContextMenu({ element: session, anchor }: ITreeContextMenuEvent): Promise { - if (!session) { - return; + // New Session + if (!this.configurationService.getValue('chat.hideNewButtonInAgentSessionsView')) { + this.createNewSessionButton(container); } - const provider = await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); - const contextOverlay = getSessionItemContextOverlay(session, provider, this.chatService, this.editorGroupsService); - contextOverlay.push([ChatContextKeys.isCombinedSessionViewer.key, true]); - const menu = this.menuService.createMenu(MenuId.ChatSessionsMenu, this.contextKeyService.createOverlay(contextOverlay)); - - const marshalledSession: IMarshalledChatSessionContext = { session, $mid: MarshalledId.ChatSessionContext }; - this.contextMenuService.showContextMenu({ - getActions: () => distinct(getFlatActionBarActions(menu.getActions({ arg: marshalledSession, shouldForwardArgs: true })), action => action.id), - getAnchor: () => anchor, - getActionsContext: () => marshalledSession, - }); - - menu.dispose(); + // Sessions Control + this.createSessionsControl(container); } - //#endregion - //#region New Session Controls private newSessionContainer: HTMLElement | undefined; @@ -263,70 +191,25 @@ export class AgentSessionsView extends ViewPane { //#endregion - //#region Sessions List + //#region Sessions Control - private listContainer: HTMLElement | undefined; - private list: WorkbenchCompressibleAsyncDataTree | undefined; - private listFilter: AgentSessionsFilter | undefined; + private sessionsControl: AgentSessionsControl | undefined; - private createList(container: HTMLElement): void { - this.listFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + private createSessionsControl(container: HTMLElement): void { + const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { filterMenuId: MenuId.AgentSessionsFilterSubMenu, })); - this.listContainer = append(container, $('.agent-sessions-viewer')); - - this.list = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, - 'AgentSessionsView', - this.listContainer, - new AgentSessionsListDelegate(), - new AgentSessionsCompressionDelegate(), - [ - this.instantiationService.createInstance(AgentSessionRenderer) - ], - new AgentSessionsDataSource(this.listFilter), - { - accessibilityProvider: new AgentSessionsAccessibilityProvider(), - dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), - identityProvider: new AgentSessionsIdentityProvider(), - horizontalScrolling: false, - multipleSelectionSupport: false, - findWidgetEnabled: true, - defaultFindMode: TreeFindMode.Filter, - keyboardNavigationLabelProvider: new AgentSessionsKeyboardNavigationLabelProvider(), - sorter: this.instantiationService.createInstance(AgentSessionsSorter), - paddingBottom: AgentSessionsListDelegate.ITEM_HEIGHT, - twistieAdditionalCssClass: () => 'force-no-twistie', - } - )) as WorkbenchCompressibleAsyncDataTree; - - const model = this.agentSessionsService.model; - - this._register(Event.any( - this.listFilter.onDidChange, - model.onDidChangeSessions - )(() => { - if (this.isBodyVisible()) { - this.list?.updateChildren(); - } + this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, { + filter: sessionsFilter, + allowNewSessionFromEmptySpace: true, })); + this.sessionsControl.setVisible(this.isBodyVisible()); + this.sessionsControl.render(container); - const didResolveDisposable = this._register(new MutableDisposable()); - this._register(model.onWillResolve(() => { - const didResolve = new DeferredPromise(); - didResolveDisposable.value = Event.once(model.onDidResolve)(() => didResolve.complete()); - - this.progressService.withProgress( - { - location: this.id, - title: localize('agentSessions.refreshing', 'Refreshing agent sessions...'), - delay: 500 - }, - () => didResolve.p - ); + this._register(this.onDidChangeBodyVisibility(visible => { + this.sessionsControl?.setVisible(visible); })); - - this.list?.setInput(model); } //#endregion @@ -334,11 +217,11 @@ export class AgentSessionsView extends ViewPane { //#region Actions internal API openFind(): void { - this.list?.openFind(); + this.sessionsControl?.openFind(); } refresh(): void { - this.agentSessionsService.model.resolve(undefined); + this.sessionsControl?.refresh(); } //#endregion @@ -346,18 +229,16 @@ export class AgentSessionsView extends ViewPane { protected override layoutBody(height: number, width: number): void { super.layoutBody(height, width); - let treeHeight = height; - treeHeight -= this.newSessionContainer?.offsetHeight ?? 0; + let sessionsControlHeight = height; + sessionsControlHeight -= this.newSessionContainer?.offsetHeight ?? 0; - this.list?.layout(treeHeight, width); + this.sessionsControl?.layout(sessionsControlHeight, width); } override focus(): void { super.focus(); - if (this.list?.getFocus().length) { - this.list.domFocus(); - } + this.sessionsControl?.focus(); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index a474733dea250..7c54c4cabd6ae 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -42,6 +42,7 @@ import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { Event } from '../../../../../base/common/event.js'; interface IAgentSessionItemTemplate { readonly element: HTMLElement; @@ -316,14 +317,17 @@ export class AgentSessionsAccessibilityProvider implements IListAccessibilityPro } } -export interface IAgentSessionsDataFilter { +export interface IAgentSessionsFilter { + + readonly onDidChange: Event; + exclude(session: IAgentSession): boolean; } export class AgentSessionsDataSource implements IAsyncDataSource { constructor( - private readonly filter: IAgentSessionsDataFilter + private readonly filter: IAgentSessionsFilter | undefined ) { } hasChildren(element: IAgentSessionsModel | IAgentSession): boolean { @@ -335,7 +339,7 @@ export class AgentSessionsDataSource implements IAsyncDataSource !this.filter.exclude(session)); + return element.sessions.filter(session => !this.filter?.exclude(session)); } } From 504e1b8d4aafb43c8093232bc64119b847bd5140 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 11:10:20 +0100 Subject: [PATCH 02/17] agent sessions - first cut sessions control in chat view pane --- .../chat/browser/actions/chatActions.ts | 24 +++ .../agentSessions/agentSessionsControl.ts | 17 +- .../agentSessions/agentSessionsView.ts | 12 +- .../agentSessions/media/agentsessionsview.css | 5 - .../media/agentsessionsviewer.css | 3 + .../contrib/chat/browser/chat.contribution.ts | 6 + src/vs/workbench/contrib/chat/browser/chat.ts | 1 + .../browser/chatParticipant.contribution.ts | 12 +- .../contrib/chat/browser/chatSetup.ts | 7 +- .../contrib/chat/browser/chatViewPane.ts | 170 ++++++++++++------ .../chat/browser/media/chatViewPane.css | 13 ++ .../contrib/chat/common/constants.ts | 1 + 12 files changed, 183 insertions(+), 88 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/media/chatViewPane.css diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 23ae2f9e87540..2ea09fc0e1c12 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1826,3 +1826,27 @@ registerAction2(class EditToolApproval extends Action2 { confirmationService.manageConfirmationPreferences([...toolsService.getTools()], scope ? { defaultScope: scope } : undefined); } }); + +registerAction2(class ToggleChatHistoryVisibilityAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.toggleEmptyChatViewSessions', + title: localize2('chat.toggleEmptyChatViewSessions.label', "Show Agent Sessions"), + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + toggled: ContextKeyExpr.equals(`config${ChatConfiguration.EmptyChatViewSessionsEnabled}`, true), + menu: { + id: MenuId.ChatWelcomeContext, + group: '1_modify', + order: 1 + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const configurationService = accessor.get(IConfigurationService); + + const emptyChatViewSessionsEnabled = configurationService.getValue(ChatConfiguration.EmptyChatViewSessionsEnabled); + await configurationService.updateValue(ChatConfiguration.EmptyChatViewSessionsEnabled, !emptyChatViewSessionsEnabled); + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 64b23556507d9..518d164cff868 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -45,7 +45,8 @@ export class AgentSessionsControl extends Disposable { private visible: boolean = true; constructor( - private readonly options: IAgentSessionsControlOptions, + private readonly container: HTMLElement, + private readonly options: IAgentSessionsControlOptions | undefined, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -58,12 +59,8 @@ export class AgentSessionsControl extends Disposable { @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, ) { super(); - } - - render(container: HTMLElement): void { - container.classList.add('agent-sessions-view'); - this.createList(container); + this.createList(this.container); } private createList(container: HTMLElement): void { @@ -77,7 +74,7 @@ export class AgentSessionsControl extends Disposable { [ this.instantiationService.createInstance(AgentSessionRenderer) ], - new AgentSessionsDataSource(this.options.filter), + new AgentSessionsDataSource(this.options?.filter), { accessibilityProvider: new AgentSessionsAccessibilityProvider(), dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), @@ -88,7 +85,7 @@ export class AgentSessionsControl extends Disposable { defaultFindMode: TreeFindMode.Filter, keyboardNavigationLabelProvider: new AgentSessionsKeyboardNavigationLabelProvider(), sorter: this.instantiationService.createInstance(AgentSessionsSorter), - paddingBottom: this.options.allowNewSessionFromEmptySpace ? AgentSessionsListDelegate.ITEM_HEIGHT : undefined, + paddingBottom: this.options?.allowNewSessionFromEmptySpace ? AgentSessionsListDelegate.ITEM_HEIGHT : undefined, twistieAdditionalCssClass: () => 'force-no-twistie', } )) as WorkbenchCompressibleAsyncDataTree; @@ -96,7 +93,7 @@ export class AgentSessionsControl extends Disposable { const model = this.agentSessionsService.model; this._register(Event.any( - this.options.filter?.onDidChange ?? Event.None, + this.options?.filter?.onDidChange ?? Event.None, model.onDidChangeSessions )(() => { if (this.visible) { @@ -112,7 +109,7 @@ export class AgentSessionsControl extends Disposable { this.openAgentSession(e); })); - if (this.options.allowNewSessionFromEmptySpace) { + if (this.options?.allowNewSessionFromEmptySpace) { this._register(list.onMouseDblClick(({ element }) => { if (element === null) { this.commandService.executeCommand(ACTION_ID_OPEN_CHAT); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts index 2a469284e3fa4..d248e9531ae86 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsView.ts @@ -200,12 +200,14 @@ export class AgentSessionsView extends ViewPane { filterMenuId: MenuId.AgentSessionsFilterSubMenu, })); - this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, { - filter: sessionsFilter, - allowNewSessionFromEmptySpace: true, - })); + this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, + container, + { + filter: sessionsFilter, + allowNewSessionFromEmptySpace: true, + } + )); this.sessionsControl.setVisible(this.isBodyVisible()); - this.sessionsControl.render(container); this._register(this.onDidChangeBodyVisibility(visible => { this.sessionsControl?.setVisible(visible); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsview.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsview.css index ea942db25de52..4517808453a1a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsview.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsview.css @@ -8,11 +8,6 @@ display: flex; flex-direction: column; - .agent-sessions-viewer { - flex: 1 1 auto !important; - min-height: 0; - } - .agent-sessions-new-session-container { padding: 6px 12px; flex: 0 0 auto !important; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 6391cbfbb6f26..cc516af26170f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -5,6 +5,9 @@ .agent-sessions-viewer { + flex: 1 1 auto !important; + min-height: 0; + .monaco-list-row .force-no-twistie { display: none !important; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d7714ba2b7b87..f5f3652081193 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -358,6 +358,12 @@ configurationRegistry.registerConfiguration({ enum: ['inline', 'hover', 'input', 'none'], default: 'inline', }, + [ChatConfiguration.EmptyChatViewSessionsEnabled]: { + type: 'boolean', + default: product.quality !== 'stable', + description: nls.localize('chat.emptyState.history.enabled', "Show agent sessions on the empty chat state."), + tags: ['preview', 'experimental'] + }, [ChatConfiguration.NotifyWindowOnResponseReceived]: { type: 'boolean', default: true, diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index c9f806fa7395d..3dfa6300ef502 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -278,3 +278,4 @@ export interface IChatCodeBlockContextProviderService { } export const ChatViewId = `workbench.panel.chat.view.${CHAT_PROVIDER_ID}`; +export const ChatViewContainerId = 'workbench.panel.chat'; diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index a1c2acbdd727d..a0494482d5a4d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -29,17 +29,17 @@ import { IChatAgentData, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IRawChatParticipantContribution } from '../common/chatParticipantContribTypes.js'; import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; -import { ChatViewId } from './chat.js'; -import { CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js'; +import { ChatViewId, ChatViewContainerId } from './chat.js'; +import { ChatViewPane } from './chatViewPane.js'; // --- Chat Container & View Registration const chatViewContainer: ViewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ - id: CHAT_SIDEBAR_PANEL_ID, + id: ChatViewContainerId, title: localize2('chat.viewContainer.label', "Chat"), icon: Codicon.chatSparkle, - ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [CHAT_SIDEBAR_PANEL_ID, { mergeViewWithContainerWhenSingleView: true }]), - storageId: CHAT_SIDEBAR_PANEL_ID, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [ChatViewContainerId, { mergeViewWithContainerWhenSingleView: true }]), + storageId: ChatViewContainerId, hideIfEmpty: true, order: 1, }, ViewContainerLocation.AuxiliaryBar, { isDefault: true, doNotRegisterOpenCommand: true }); @@ -53,7 +53,7 @@ const chatViewDescriptor: IViewDescriptor = { canToggleVisibility: false, canMoveView: true, openCommandActionDescriptor: { - id: CHAT_SIDEBAR_PANEL_ID, + id: ChatViewContainerId, title: chatViewContainer.title, mnemonicTitle: localize({ key: 'miToggleChat', comment: ['&& denotes a mnemonic'] }, "&&Chat"), keybindings: { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup.ts b/src/vs/workbench/contrib/chat/browser/chatSetup.ts index f275faa82893b..6e8879e6a02de 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup.ts @@ -71,8 +71,7 @@ import { IChatRequestToolEntry } from '../common/chatVariableEntries.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; import { ILanguageModelsService } from '../common/languageModels.js'; import { CHAT_CATEGORY, CHAT_OPEN_ACTION_ID, CHAT_SETUP_ACTION_ID, CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from './actions/chatActions.js'; -import { ChatViewId, IChatWidgetService } from './chat.js'; -import { CHAT_SIDEBAR_PANEL_ID } from './chatViewPane.js'; +import { ChatViewId, ChatViewContainerId, IChatWidgetService } from './chat.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { chatViewsWelcomeRegistry } from './viewsWelcome/chatViewsWelcome.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; @@ -1673,7 +1672,7 @@ export class ChatTeardownContribution extends Disposable implements IWorkbenchCo const activeContainers = this.viewDescriptorService.getViewContainersByLocation(ViewContainerLocation.AuxiliaryBar).filter( container => this.viewDescriptorService.getViewContainerModel(container).activeViewDescriptors.length > 0 ); - const hasChatView = activeContainers.some(container => container.id === CHAT_SIDEBAR_PANEL_ID); + const hasChatView = activeContainers.some(container => container.id === ChatViewContainerId); const hasAgentSessionsView = activeContainers.some(container => container.id === AGENT_SESSIONS_VIEW_CONTAINER_ID); if ( (activeContainers.length === 0) || // chat view is already gone but we know it was there before @@ -1796,7 +1795,7 @@ class ChatSetupController extends Disposable { async setup(options: IChatSetupControllerOptions = {}): Promise { const watch = new StopWatch(false); const title = localize('setupChatProgress', "Getting chat ready..."); - const badge = this.activityService.showViewContainerActivity(CHAT_SIDEBAR_PANEL_ID, { + const badge = this.activityService.showViewContainerActivity(ChatViewContainerId, { badge: new ProgressBadge(() => title), }); diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index 6e47b2c279fc3..6badc1d8ec3dd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -3,9 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './media/chatViewPane.css'; import { $, getWindow } from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../base/common/marshallingIds.js'; import { autorun, IReader } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -35,26 +36,39 @@ import { CHAT_PROVIDER_ID } from '../common/chatParticipantContribTypes.js'; import { IChatModelReference, IChatService } from '../common/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../common/chatUri.js'; -import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; import { showCloseActiveChatNotification } from './agentSessions/chatCloseNotification.js'; import { ChatWidget } from './chatWidget.js'; import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js'; +import { AgentSessionsControl } from './agentSessions/agentSessionsControl.js'; +import { Event } from '../../../../base/common/event.js'; -interface IViewPaneState extends Partial { +interface IChatViewPaneState extends Partial { sessionId?: string; hasMigratedCurrentSession?: boolean; } -export const CHAT_SIDEBAR_PANEL_ID = 'workbench.panel.chat'; +type ChatViewPaneOpenedClassification = { + owner: 'sbatten'; + comment: 'Event fired when the chat view pane is opened'; +}; + export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { + private _widget!: ChatWidget; get widget(): ChatWidget { return this._widget; } private readonly modelRef = this._register(new MutableDisposable()); - private memento: Memento; - private readonly viewState: IViewPaneState; - private _restoringSession: Promise | undefined; + private readonly memento: Memento; + private readonly viewState: IChatViewPaneState; + + private sessionsContainer: HTMLElement | undefined; + private sessionsControl: AgentSessionsControl | undefined; + + private restoringSession: Promise | undefined; + + private lastDimensions: { height: number; width: number } | undefined; constructor( private readonly chatOptions: { location: ChatAgentLocation.Chat }, @@ -78,12 +92,22 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - // View state for the ViewPane is currently global per-provider basically, but some other strictly per-model state will require a separate memento. - this.memento = new Memento('interactive-session-view-' + CHAT_PROVIDER_ID, this.storageService); + // View state for the ViewPane is currently global per-provider basically, + // but some other strictly per-model state will require a separate memento. + this.memento = new Memento(`interactive-session-view-${CHAT_PROVIDER_ID}`, this.storageService); this.viewState = this.memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + // Location context key + ChatContextKeys.panelLocation.bindTo(contextKeyService).set(viewDescriptorService.getViewLocationById(options.id) ?? ViewContainerLocation.AuxiliaryBar); + + this.maybeMigrateCurrentSession(); + + this.registerListeners(); + } + + private maybeMigrateCurrentSession(): void { if (this.chatOptions.location === ChatAgentLocation.Chat && !this.viewState.hasMigratedCurrentSession) { - const editsMemento = new Memento('interactive-session-view-' + CHAT_PROVIDER_ID + `-edits`, this.storageService); + const editsMemento = new Memento(`interactive-session-view-${CHAT_PROVIDER_ID}-edits`, this.storageService); const lastEditsState = editsMemento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); if (lastEditsState.sessionId) { this.logService.trace(`ChatViewPane: last edits session was ${lastEditsState.sessionId}`); @@ -104,12 +128,14 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } } } + } + private registerListeners(): void { this._register(this.chatAgentService.onDidChangeAgents(() => { if (this.chatAgentService.getDefaultAgent(this.chatOptions?.location)) { - if (!this._widget?.viewModel && !this._restoringSession) { + if (!this._widget?.viewModel && !this.restoringSession) { const info = this.getTransferredOrPersistedSessionInfo(); - this._restoringSession = + this.restoringSession = (info.sessionId ? this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(info.sessionId)) : Promise.resolve(undefined)).then(async modelRef => { if (!this._widget) { // renderBody has not been called yet @@ -127,28 +153,38 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } await this.updateModel(modelRef); } finally { - this.widget.setVisible(wasVisible); + this._widget.setVisible(wasVisible); } }); - this._restoringSession.finally(() => this._restoringSession = undefined); + this.restoringSession.finally(() => this.restoringSession = undefined); } } this._onDidChangeViewWelcomeState.fire(); })); + } - // Location context key - ChatContextKeys.panelLocation.bindTo(contextKeyService).set(viewDescriptorService.getViewLocationById(options.id) ?? ViewContainerLocation.AuxiliaryBar); + private getTransferredOrPersistedSessionInfo(): { sessionId?: string; inputState?: IChatModelInputState; mode?: ChatModeKind } { + if (this.chatService.transferredSessionData?.location === this.chatOptions.location) { + const sessionId = this.chatService.transferredSessionData.sessionId; + return { + sessionId, + inputState: this.chatService.transferredSessionData.inputState, + }; + } + + return { sessionId: this.viewState.sessionId }; } override getActionsContext(): IChatViewTitleActionContext | undefined { - return this.widget?.viewModel ? { - sessionResource: this.widget.viewModel.sessionResource, + return this._widget?.viewModel ? { + sessionResource: this._widget.viewModel.sessionResource, $mid: MarshalledId.ChatViewContext } : undefined; } private async updateModel(modelRef?: IChatModelReference | undefined) { + // Check if we're disposing a model with an active request if (this.modelRef.value?.object.requestInProgress.get()) { this.instantiationService.invokeFunction(showCloseActiveChatNotification); @@ -179,39 +215,36 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { 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 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}`); - return !!shouldShow; - } - private getTransferredOrPersistedSessionInfo(): { sessionId?: string; inputState?: IChatModelInputState; mode?: ChatModeKind } { - if (this.chatService.transferredSessionData?.location === this.chatOptions.location) { - const sessionId = this.chatService.transferredSessionData.sessionId; - return { - sessionId, - inputState: this.chatService.transferredSessionData.inputState, - }; - } else { - return { sessionId: this.viewState.sessionId }; - } + return !!shouldShow; } - protected override async renderBody(parent: HTMLElement): Promise { + protected override renderBody(parent: HTMLElement): void { super.renderBody(parent); + this.telemetryService.publicLog2<{}, ChatViewPaneOpenedClassification>('chatViewPaneOpened'); + + this.createControls(parent); + } - type ChatViewPaneOpenedClassification = { - owner: 'sbatten'; - comment: 'Event fired when the chat view pane is opened'; - }; + private async createControls(parent: HTMLElement): Promise { + parent.classList.add('chat-viewpane'); - this.telemetryService.publicLog2<{}, ChatViewPaneOpenedClassification>('chatViewPaneOpened'); + // Sessions Control + this.createSessionsControl(parent); + // Welcome Control const welcomeController = this._register(this.instantiationService.createInstance(ChatViewWelcomeController, parent, this, this.chatOptions.location)); - const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); + + // Chat Widget const locationBasedColors = this.getLocationBasedColors(); - const editorOverflowNode = this.layoutService.getContainer(getWindow(parent)).appendChild($('.chat-editor-overflow.monaco-editor')); - this._register({ dispose: () => editorOverflowNode.remove() }); + const editorOverflowWidgetsDomNode = this.layoutService.getContainer(getWindow(parent)).appendChild($('.chat-editor-overflow.monaco-editor')); + this._register(toDisposable(() => editorOverflowWidgetsDomNode.remove())); + + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); this._widget = this._register(scopedInstantiationService.createInstance( ChatWidget, this.chatOptions.location, @@ -228,7 +261,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { referencesExpandedWhenEmptyResponse: false, progressMessageAtBottomOfResponse: mode => mode !== ChatModeKind.Ask, }, - editorOverflowWidgetsDomNode: editorOverflowNode, + editorOverflowWidgetsDomNode, enableImplicitContext: this.chatOptions.location === ChatAgentLocation.Chat, enableWorkingSet: 'explicit', supportsChangingModes: true, @@ -242,30 +275,38 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { })); this._widget.render(parent); - const updateWidgetVisibility = (r?: IReader) => { - this._widget.setVisible(this.isBodyVisible() && !welcomeController.isShowingWelcome.read(r)); - }; - this._register(this.onDidChangeBodyVisibility(() => { - updateWidgetVisibility(); - })); - this._register(autorun(r => { - updateWidgetVisibility(r); - })); + const updateWidgetVisibility = (r?: IReader) => this._widget.setVisible(this.isBodyVisible() && !welcomeController.isShowingWelcome.read(r)); + this._register(this.onDidChangeBodyVisibility(() => updateWidgetVisibility())); + this._register(autorun(r => updateWidgetVisibility(r))); const info = this.getTransferredOrPersistedSessionInfo(); const modelRef = info.sessionId ? await this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(info.sessionId)) : undefined; - if (modelRef && info.inputState) { modelRef.object.inputModel.setState(info.inputState); } + await this.updateModel(modelRef); } - acceptInput(query?: string): void { - this._widget.acceptInput(query); + private createSessionsControl(parent: HTMLElement): void { + const sessionsContainer = this.sessionsContainer = parent.appendChild($('.chat-viewpane-sessions-container')); + + this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, sessionsContainer, undefined)); + this.sessionsControl.setVisible(this.isBodyVisible()); + this._register(this.onDidChangeBodyVisibility(visible => this.sessionsControl?.setVisible(visible))); + + this._register(Event.runAndSubscribe(this.configurationService.onDidChangeConfiguration, e => { + if (!e || e.affectsConfiguration(ChatConfiguration.EmptyChatViewSessionsEnabled)) { + sessionsContainer.style.display = this.configurationService.getValue(ChatConfiguration.EmptyChatViewSessionsEnabled) ? '' : 'none'; + if (this.lastDimensions) { + this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); + } + } + })); } private async clear(): Promise { + // Grab the widget's latest view state because it will be loaded back into the widget this.updateViewState(); await this.updateModel(undefined); @@ -275,6 +316,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } async loadSession(sessionId: URI): Promise { + // Handle locking for contributed chat sessions // TODO: Is this logic still correct with sessions from different schemes? const local = LocalChatSessionUri.parseLocalSessionId(sessionId); @@ -283,7 +325,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const contributions = this.chatSessionsService.getAllChatSessionContributions(); const contribution = contributions.find((c: IChatSessionsExtensionPoint) => c.type === localChatSessionType); if (contribution) { - this.widget.lockToCodingAgent(contribution.name, contribution.displayName, contribution.type); + this._widget.lockToCodingAgent(contribution.name, contribution.displayName, contribution.type); } } @@ -297,17 +339,30 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { override focus(): void { super.focus(); + this._widget.focusInput(); } protected override layoutBody(height: number, width: number): void { super.layoutBody(height, width); - this._widget.layout(height, width); + + this.lastDimensions = { height, width }; + + let widgetHeight = height; + + // Sessions Control + const sessionsControlHeight = this.sessionsContainer?.offsetHeight ?? 0; + widgetHeight -= sessionsControlHeight; + this.sessionsControl?.layout(sessionsControlHeight, width); + + this._widget.layout(widgetHeight, width); } override saveState(): void { - // Don't do saveState when no widget, or no viewModel in which case the state has not yet been restored - - // in that case the default state would overwrite the real state + + // Don't do saveState when no widget, or no viewModel in which case + // the state has not yet been restored - in that case the default + // state would overwrite the real state if (this._widget?.viewModel) { this._widget.saveState(); @@ -322,8 +377,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const newViewState = viewState ?? this._widget.getViewState(); if (newViewState) { for (const [key, value] of Object.entries(newViewState)) { - // Assign all props to the memento so they get saved - (this.viewState as Record)[key] = value; + (this.viewState as Record)[key] = value; // Assign all props to the memento so they get saved } } } diff --git a/src/vs/workbench/contrib/chat/browser/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/media/chatViewPane.css new file mode 100644 index 0000000000000..4680ec999fa3c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/media/chatViewPane.css @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-viewpane { + display: flex; + flex-direction: column; + + .chat-viewpane-sessions-container { + height: calc(3 * 44px); /* TODO@bpasero revisit: show at most 3 sessions */ + } +} diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 6ac69cb726709..b3a9784f5e2fa 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -24,6 +24,7 @@ export enum ChatConfiguration { TodosShowWidget = 'chat.tools.todos.showWidget', ShowAgentSessionsViewDescription = 'chat.showAgentSessionsViewDescription', NotifyWindowOnResponseReceived = 'chat.notifyWindowOnResponseReceived', + EmptyChatViewSessionsEnabled = 'chat.emptyState.sessions.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', } From 4643503cd92027c5a38857398fd4e20bfffa0801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Tue, 25 Nov 2025 11:36:11 +0100 Subject: [PATCH 03/17] release insiders with rollout of 4 hours (#279331) --- build/azure-pipelines/common/releaseBuild.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/build/azure-pipelines/common/releaseBuild.ts b/build/azure-pipelines/common/releaseBuild.ts index 32ea596ff64a0..68ecf30df39ea 100644 --- a/build/azure-pipelines/common/releaseBuild.ts +++ b/build/azure-pipelines/common/releaseBuild.ts @@ -59,8 +59,15 @@ async function main(force: boolean): Promise { console.log(`Releasing build ${commit}...`); + let rolloutDurationMs = undefined; + + // If the build is insiders or exploration, start a rollout of 4 hours + if (quality === 'insiders') { + rolloutDurationMs = 4 * 60 * 60 * 1000; // 4 hours + } + const scripts = client.database('builds').container(quality).scripts; - await retry(() => scripts.storedProcedure('releaseBuild').execute('', [commit])); + await retry(() => scripts.storedProcedure('releaseBuild').execute('', [commit, rolloutDurationMs])); } const [, , force] = process.argv; From 8a789d058769be2308835e2cdf93b28cd3b8fb81 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Tue, 25 Nov 2025 12:09:52 +0100 Subject: [PATCH 04/17] Enables inline completion providers to set a list of models the user can chose from. (#279213) * Enables inline completion providers to set a list of models the user can chose from. * Fixes nls key * Fixes eslint --- src/vs/editor/common/languages.ts | 14 + .../components/gutterIndicatorMenu.ts | 15 + .../components/gutterIndicatorView.ts | 6 +- src/vs/monaco.d.ts | 13 + .../api/browser/mainThreadLanguageFeatures.ts | 364 +++++++++++------- .../workbench/api/common/extHost.protocol.ts | 29 +- .../api/common/extHostLanguageFeatures.ts | 44 ++- .../browser/chatStatus/chatStatusDashboard.ts | 61 ++- .../browser/chatStatus/media/chatStatus.css | 23 +- ...e.proposed.inlineCompletionsAdditions.d.ts | 16 + 10 files changed, 438 insertions(+), 147 deletions(-) diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 3223c3cced166..bb02e95317fb7 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -762,6 +762,16 @@ export interface InlineCompletionContext { readonly earliestShownDateTime: number; } +export interface IInlineCompletionModelInfo { + models: IInlineCompletionModel[]; + currentModelId: string; +} + +export interface IInlineCompletionModel { + name: string; + id: string; +} + export class SelectedSuggestionInfo { constructor( public readonly range: IRange, @@ -940,6 +950,10 @@ export interface InlineCompletionsProvider; + setModelId?(modelId: string): Promise; + toString?(): string; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts index 2e358ab22be44..77a3a7ff80a0a 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorMenu.ts @@ -89,6 +89,19 @@ export class GutterIndicatorMenuContent { commandArgs: c.command.arguments }))); + const showModelEnabled = false; + const modelOptions = showModelEnabled ? this._data.modelInfo?.models.map((m: { id: string; name: string }) => option({ + title: m.name, + icon: m.id === this._data.modelInfo?.currentModelId ? Codicon.check : Codicon.circle, + keybinding: constObservable(undefined), + isActive: activeElement.map(v => v === 'model_' + m.id), + onHoverChange: v => activeElement.set(v ? 'model_' + m.id : undefined, undefined), + onAction: () => { + this._close(true); + this._data.setModelId?.(m.id); + }, + })) ?? [] : []; + const toggleCollapsedMode = this._inlineEditsShowCollapsed.map(showCollapsed => showCollapsed ? option(createOptionArgs({ id: 'showExpanded', @@ -137,6 +150,8 @@ export class GutterIndicatorMenuContent { gotoAndAccept, reject, toggleCollapsedMode, + modelOptions.length ? separator() : undefined, + ...modelOptions, extensionCommands.length ? separator() : undefined, snooze, settings, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts index eac82c3ed5545..344f356453f97 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/components/gutterIndicatorView.ts @@ -29,7 +29,7 @@ import { getEditorBlendedColor, inlineEditIndicatorBackground, inlineEditIndicat import { mapOutFalsy, rectToProps } from '../utils/utils.js'; import { GutterIndicatorMenuContent } from './gutterIndicatorMenu.js'; import { assertNever } from '../../../../../../../base/common/assert.js'; -import { Command, InlineCompletionCommand } from '../../../../../../common/languages.js'; +import { Command, InlineCompletionCommand, IInlineCompletionModelInfo } from '../../../../../../common/languages.js'; import { InlineSuggestionItem } from '../../../model/inlineSuggestionItem.js'; import { localize } from '../../../../../../../nls.js'; import { InlineCompletionsModel } from '../../../model/inlineCompletionsModel.js'; @@ -48,6 +48,8 @@ export class InlineSuggestionGutterMenuData { suggestion.action, suggestion.source.provider.displayName ?? localize('inlineSuggestion', "Inline Suggestion"), suggestion.source.inlineSuggestions.commands ?? [], + suggestion.source.provider.modelInfo, + suggestion.source.provider.setModelId?.bind(suggestion.source.provider), ); } @@ -55,6 +57,8 @@ export class InlineSuggestionGutterMenuData { readonly action: Command | undefined, readonly displayName: string, readonly extensionCommands: InlineCompletionCommand[], + readonly modelInfo: IInlineCompletionModelInfo | undefined, + readonly setModelId: ((modelId: string) => Promise) | undefined, ) { } } diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 99a75fc342edb..915510afdccd1 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -7497,6 +7497,16 @@ declare namespace monaco.languages { readonly earliestShownDateTime: number; } + export interface IInlineCompletionModelInfo { + models: IInlineCompletionModel[]; + currentModelId: string; + } + + export interface IInlineCompletionModel { + name: string; + id: string; + } + export class SelectedSuggestionInfo { readonly range: IRange; readonly text: string; @@ -7641,6 +7651,9 @@ declare namespace monaco.languages { excludesGroupIds?: InlineCompletionProviderGroupId[]; displayName?: string; debounceDelayMs?: number; + modelInfo?: IInlineCompletionModelInfo; + onDidModelInfoChange?: IEvent; + setModelId?(modelId: string): Promise; toString?(): string; } diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 7f13640c1c7a9..4aa79c80b2ec9 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -33,7 +33,7 @@ import * as callh from '../../contrib/callHierarchy/common/callHierarchy.js'; import * as search from '../../contrib/search/common/search.js'; import * as typeh from '../../contrib/typeHierarchy/common/typeHierarchy.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, ExtHostLanguageFeaturesShape, HoverWithId, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IDocumentDropEditDto, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol.js'; +import { ExtHostContext, ExtHostLanguageFeaturesShape, HoverWithId, ICallHierarchyItemDto, ICodeActionDto, ICodeActionProviderMetadataDto, IdentifiableInlineCompletion, IdentifiableInlineCompletions, IDocumentDropEditDto, IDocumentDropEditProviderMetadata, IDocumentFilterDto, IIndentationRuleDto, IInlayHintDto, IInlineCompletionModelInfoDto, ILanguageConfigurationDto, ILanguageWordDefinitionDto, ILinkDto, ILocationDto, ILocationLinkDto, IOnEnterRuleDto, IPasteEditDto, IPasteEditProviderMetadataDto, IRegExpDto, ISignatureHelpProviderMetadataDto, ISuggestDataDto, ISuggestDataDtoField, ISuggestResultDtoField, ITypeHierarchyItemDto, IWorkspaceSymbolDto, MainContext, MainThreadLanguageFeaturesShape } from '../common/extHost.protocol.js'; import { InlineCompletionEndOfLifeReasonKind } from '../common/extHostTypes.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { DataChannelForwardingTelemetryService, forwardToChannelIf, isCopilotLikeExtension } from '../../../platform/dataChannel/browser/forwardingTelemetryService.js'; @@ -56,7 +56,6 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread @IUriIdentityService private readonly _uriIdentService: IUriIdentityService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IInlineCompletionsUnificationService private readonly _inlineCompletionsUnificationService: IInlineCompletionsUnificationService, - @IAiEditTelemetryService private readonly _aiEditTelemetryService: IAiEditTelemetryService, ) { super(); @@ -645,152 +644,56 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread this._registrations.set(handle, this._languageFeaturesService.completionProvider.register(selector, provider)); } - $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleEvents: boolean, extensionId: string, extensionVersion: string, groupId: string | undefined, yieldsToExtensionIds: string[], displayName: string | undefined, debounceDelayMs: number | undefined, excludesExtensionIds: string[], eventHandle: number | undefined): void { + $registerInlineCompletionsSupport( + handle: number, + selector: IDocumentFilterDto[], + supportsHandleEvents: boolean, + extensionId: string, + extensionVersion: string, + groupId: string | undefined, + yieldsToExtensionIds: string[], + displayName: string | undefined, + debounceDelayMs: number | undefined, + excludesExtensionIds: string[], + supportsOnDidChange: boolean, + supportsSetModelId: boolean, + initialModelInfo: IInlineCompletionModelInfoDto | undefined, + supportsOnDidChangeModelInfo: boolean, + ): void { const providerId = new languages.ProviderId(extensionId, extensionVersion, groupId); - const provider: languages.InlineCompletionsProvider = { - provideInlineCompletions: async (model: ITextModel, position: EditorPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise => { - const result = await this._proxy.$provideInlineCompletions(handle, model.uri, position, context, token); - return result; - }, - handleItemDidShow: async (completions: IdentifiableInlineCompletions, item: IdentifiableInlineCompletion, updatedInsertText: string, editDeltaInfo: EditDeltaInfo): Promise => { - if (item.suggestionId === undefined) { - item.suggestionId = this._aiEditTelemetryService.createSuggestionId({ - applyCodeBlockSuggestionId: undefined, - feature: 'inlineSuggestion', - source: providerId, - languageId: completions.languageId, - editDeltaInfo: editDeltaInfo, - modeId: undefined, - modelId: undefined, - presentation: item.isInlineEdit ? 'nextEditSuggestion' : 'inlineCompletion', - }); - } - - if (supportsHandleEvents) { - await this._proxy.$handleInlineCompletionDidShow(handle, completions.pid, item.idx, updatedInsertText); - } - }, - handlePartialAccept: async (completions, item, acceptedCharacters, info: languages.PartialAcceptInfo): Promise => { - if (supportsHandleEvents) { - await this._proxy.$handleInlineCompletionPartialAccept(handle, completions.pid, item.idx, acceptedCharacters, info); - } - }, - handleEndOfLifetime: async (completions, item, reason, lifetimeSummary) => { - - function mapReason(reason: languages.InlineCompletionEndOfLifeReason, f: (reason: T1) => T2): languages.InlineCompletionEndOfLifeReason { - if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Ignored) { - return { - ...reason, - supersededBy: reason.supersededBy ? f(reason.supersededBy) : undefined, - }; - } - return reason; - } - if (supportsHandleEvents) { - await this._proxy.$handleInlineCompletionEndOfLifetime(handle, completions.pid, item.idx, mapReason(reason, i => ({ pid: completions.pid, idx: i.idx }))); - } - - if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Accepted) { - if (item.suggestionId !== undefined) { - this._aiEditTelemetryService.handleCodeAccepted({ - suggestionId: item.suggestionId, - feature: 'inlineSuggestion', - source: providerId, - languageId: completions.languageId, - editDeltaInfo: EditDeltaInfo.tryCreate( - lifetimeSummary.lineCountModified, - lifetimeSummary.lineCountOriginal, - lifetimeSummary.characterCountModified, - lifetimeSummary.characterCountOriginal, - ), - modeId: undefined, - modelId: undefined, - presentation: item.isInlineEdit ? 'nextEditSuggestion' : 'inlineCompletion', - acceptanceMethod: 'accept', - applyCodeBlockSuggestionId: undefined, - }); - } - } - - const endOfLifeSummary: InlineCompletionEndOfLifeEvent = { - opportunityId: lifetimeSummary.requestUuid, - correlationId: lifetimeSummary.correlationId, - shown: lifetimeSummary.shown, - shownDuration: lifetimeSummary.shownDuration, - shownDurationUncollapsed: lifetimeSummary.shownDurationUncollapsed, - timeUntilShown: lifetimeSummary.timeUntilShown, - timeUntilProviderRequest: lifetimeSummary.timeUntilProviderRequest, - timeUntilProviderResponse: lifetimeSummary.timeUntilProviderResponse, - editorType: lifetimeSummary.editorType, - viewKind: lifetimeSummary.viewKind, - preceeded: lifetimeSummary.preceeded, - requestReason: lifetimeSummary.requestReason, - typingInterval: lifetimeSummary.typingInterval, - typingIntervalCharacterCount: lifetimeSummary.typingIntervalCharacterCount, - languageId: lifetimeSummary.languageId, - cursorColumnDistance: lifetimeSummary.cursorColumnDistance, - cursorLineDistance: lifetimeSummary.cursorLineDistance, - lineCountOriginal: lifetimeSummary.lineCountOriginal, - lineCountModified: lifetimeSummary.lineCountModified, - characterCountOriginal: lifetimeSummary.characterCountOriginal, - characterCountModified: lifetimeSummary.characterCountModified, - disjointReplacements: lifetimeSummary.disjointReplacements, - sameShapeReplacements: lifetimeSummary.sameShapeReplacements, - selectedSuggestionInfo: lifetimeSummary.selectedSuggestionInfo, - extensionId, - extensionVersion, - groupId, - availableProviders: lifetimeSummary.availableProviders, - partiallyAccepted: lifetimeSummary.partiallyAccepted, - partiallyAcceptedCountSinceOriginal: lifetimeSummary.partiallyAcceptedCountSinceOriginal, - partiallyAcceptedRatioSinceOriginal: lifetimeSummary.partiallyAcceptedRatioSinceOriginal, - partiallyAcceptedCharactersSinceOriginal: lifetimeSummary.partiallyAcceptedCharactersSinceOriginal, - superseded: reason.kind === InlineCompletionEndOfLifeReasonKind.Ignored && !!reason.supersededBy, - reason: reason.kind === InlineCompletionEndOfLifeReasonKind.Accepted ? 'accepted' - : reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected ? 'rejected' - : reason.kind === InlineCompletionEndOfLifeReasonKind.Ignored ? 'ignored' : undefined, - noSuggestionReason: undefined, - notShownReason: lifetimeSummary.notShownReason, - renameCreated: lifetimeSummary.renameCreated, - renameDuration: lifetimeSummary.renameDuration, - renameTimedOut: lifetimeSummary.renameTimedOut, - ...forwardToChannelIf(isCopilotLikeExtension(extensionId)), - }; - - const dataChannelForwardingTelemetryService = this._instantiationService.createInstance(DataChannelForwardingTelemetryService); - sendInlineCompletionsEndOfLifeTelemetry(dataChannelForwardingTelemetryService, endOfLifeSummary); - }, - disposeInlineCompletions: (completions: IdentifiableInlineCompletions, reason: languages.InlineCompletionsDisposeReason): void => { - this._proxy.$freeInlineCompletionsList(handle, completions.pid, reason); - }, - handleRejection: async (completions, item): Promise => { - if (supportsHandleEvents) { - await this._proxy.$handleInlineCompletionRejection(handle, completions.pid, item.idx); - } - }, - groupId: groupId ?? extensionId, + const provider = this._instantiationService.createInstance( + ExtensionBackedInlineCompletionsProvider, + handle, + groupId ?? extensionId, providerId, - yieldsToGroupIds: yieldsToExtensionIds, - excludesGroupIds: excludesExtensionIds, + yieldsToExtensionIds, + excludesExtensionIds, debounceDelayMs, displayName, - toString() { - return `InlineCompletionsProvider(${extensionId})`; - }, - }; - if (typeof eventHandle === 'number') { - const emitter = new Emitter(); - this._registrations.set(eventHandle, emitter); - provider.onDidChangeInlineCompletions = emitter.event; - } - this._registrations.set(handle, this._languageFeaturesService.inlineCompletionsProvider.register(selector, provider)); + initialModelInfo, + supportsHandleEvents, + supportsSetModelId, + supportsOnDidChange, + supportsOnDidChangeModelInfo, + selector, + this._proxy, + ); + + this._registrations.set(handle, provider); } $emitInlineCompletionsChange(handle: number): void { const obj = this._registrations.get(handle); - if (obj instanceof Emitter) { - obj.fire(undefined); + if (obj instanceof ExtensionBackedInlineCompletionsProvider) { + obj._emitDidChange(); + } + } + + $emitInlineCompletionModelInfoChange(handle: number, data: IInlineCompletionModelInfoDto | undefined): void { + const obj = this._registrations.get(handle); + if (obj instanceof ExtensionBackedInlineCompletionsProvider) { + obj._setModelInfo(data); } } @@ -1384,3 +1287,186 @@ export class MainThreadDocumentRangeSemanticTokensProvider implements languages. throw new Error(`Unexpected`); } } + +class ExtensionBackedInlineCompletionsProvider extends Disposable implements languages.InlineCompletionsProvider { + public readonly setModelId: ((modelId: string) => Promise) | undefined; + public readonly _onDidChangeEmitter = new Emitter(); + public readonly onDidChangeInlineCompletions: Event | undefined; + + public readonly _onDidChangeModelInfoEmitter = new Emitter(); + public readonly onDidChangeModelInfo: Event | undefined; + + constructor( + public readonly handle: number, + public readonly groupId: string, + public readonly providerId: languages.ProviderId, + public readonly yieldsToGroupIds: string[], + public readonly excludesGroupIds: string[], + public readonly debounceDelayMs: number | undefined, + public readonly displayName: string | undefined, + public modelInfo: languages.IInlineCompletionModelInfo | undefined, + private readonly _supportsHandleEvents: boolean, + private readonly _supportsSetModelId: boolean, + private readonly _supportsOnDidChange: boolean, + private readonly _supportsOnDidChangeModelInfo: boolean, + private readonly _selector: IDocumentFilterDto[], + private readonly _proxy: ExtHostLanguageFeaturesShape, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @IAiEditTelemetryService private readonly _aiEditTelemetryService: IAiEditTelemetryService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this.setModelId = this._supportsSetModelId ? async (modelId: string) => { + await this._proxy.$handleInlineCompletionSetCurrentModelId(this.handle, modelId); + } : undefined; + + this.onDidChangeInlineCompletions = this._supportsOnDidChange ? this._onDidChangeEmitter.event : undefined; + this.onDidChangeModelInfo = this._supportsOnDidChangeModelInfo ? this._onDidChangeModelInfoEmitter.event : undefined; + + this._register(this._languageFeaturesService.inlineCompletionsProvider.register(this._selector, this)); + } + + public _setModelInfo(newModelInfo: languages.IInlineCompletionModelInfo | undefined) { + this.modelInfo = newModelInfo; + if (this._supportsOnDidChangeModelInfo) { + this._onDidChangeModelInfoEmitter.fire(); + } + } + + public _emitDidChange() { + if (this._supportsOnDidChange) { + this._onDidChangeEmitter.fire(); + } + } + + public async provideInlineCompletions(model: ITextModel, position: EditorPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise { + const result = await this._proxy.$provideInlineCompletions(this.handle, model.uri, position, context, token); + return result; + } + + public async handleItemDidShow(completions: IdentifiableInlineCompletions, item: IdentifiableInlineCompletion, updatedInsertText: string, editDeltaInfo: EditDeltaInfo): Promise { + if (item.suggestionId === undefined) { + item.suggestionId = this._aiEditTelemetryService.createSuggestionId({ + applyCodeBlockSuggestionId: undefined, + feature: 'inlineSuggestion', + source: this.providerId, + languageId: completions.languageId, + editDeltaInfo: editDeltaInfo, + modeId: undefined, + modelId: undefined, + presentation: item.isInlineEdit ? 'nextEditSuggestion' : 'inlineCompletion', + }); + } + + if (this._supportsHandleEvents) { + await this._proxy.$handleInlineCompletionDidShow(this.handle, completions.pid, item.idx, updatedInsertText); + } + } + + public async handlePartialAccept(completions: IdentifiableInlineCompletions, item: IdentifiableInlineCompletion, acceptedCharacters: number, info: languages.PartialAcceptInfo): Promise { + if (this._supportsHandleEvents) { + await this._proxy.$handleInlineCompletionPartialAccept(this.handle, completions.pid, item.idx, acceptedCharacters, info); + } + } + + public async handleEndOfLifetime(completions: IdentifiableInlineCompletions, item: IdentifiableInlineCompletion, reason: languages.InlineCompletionEndOfLifeReason, lifetimeSummary: languages.LifetimeSummary): Promise { + function mapReason(reason: languages.InlineCompletionEndOfLifeReason, f: (reason: T1) => T2): languages.InlineCompletionEndOfLifeReason { + if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Ignored) { + return { + ...reason, + supersededBy: reason.supersededBy ? f(reason.supersededBy) : undefined, + }; + } + return reason; + } + + if (this._supportsHandleEvents) { + await this._proxy.$handleInlineCompletionEndOfLifetime(this.handle, completions.pid, item.idx, mapReason(reason, i => ({ pid: completions.pid, idx: i.idx }))); + } + + if (reason.kind === languages.InlineCompletionEndOfLifeReasonKind.Accepted) { + if (item.suggestionId !== undefined) { + this._aiEditTelemetryService.handleCodeAccepted({ + suggestionId: item.suggestionId, + feature: 'inlineSuggestion', + source: this.providerId, + languageId: completions.languageId, + editDeltaInfo: EditDeltaInfo.tryCreate( + lifetimeSummary.lineCountModified, + lifetimeSummary.lineCountOriginal, + lifetimeSummary.characterCountModified, + lifetimeSummary.characterCountOriginal, + ), + modeId: undefined, + modelId: undefined, + presentation: item.isInlineEdit ? 'nextEditSuggestion' : 'inlineCompletion', + acceptanceMethod: 'accept', + applyCodeBlockSuggestionId: undefined, + }); + } + } + + const endOfLifeSummary: InlineCompletionEndOfLifeEvent = { + opportunityId: lifetimeSummary.requestUuid, + correlationId: lifetimeSummary.correlationId, + shown: lifetimeSummary.shown, + shownDuration: lifetimeSummary.shownDuration, + shownDurationUncollapsed: lifetimeSummary.shownDurationUncollapsed, + timeUntilShown: lifetimeSummary.timeUntilShown, + timeUntilProviderRequest: lifetimeSummary.timeUntilProviderRequest, + timeUntilProviderResponse: lifetimeSummary.timeUntilProviderResponse, + editorType: lifetimeSummary.editorType, + viewKind: lifetimeSummary.viewKind, + preceeded: lifetimeSummary.preceeded, + requestReason: lifetimeSummary.requestReason, + typingInterval: lifetimeSummary.typingInterval, + typingIntervalCharacterCount: lifetimeSummary.typingIntervalCharacterCount, + languageId: lifetimeSummary.languageId, + cursorColumnDistance: lifetimeSummary.cursorColumnDistance, + cursorLineDistance: lifetimeSummary.cursorLineDistance, + lineCountOriginal: lifetimeSummary.lineCountOriginal, + lineCountModified: lifetimeSummary.lineCountModified, + characterCountOriginal: lifetimeSummary.characterCountOriginal, + characterCountModified: lifetimeSummary.characterCountModified, + disjointReplacements: lifetimeSummary.disjointReplacements, + sameShapeReplacements: lifetimeSummary.sameShapeReplacements, + selectedSuggestionInfo: lifetimeSummary.selectedSuggestionInfo, + extensionId: this.providerId.extensionId!, + extensionVersion: this.providerId.extensionVersion!, + groupId: this.groupId, + availableProviders: lifetimeSummary.availableProviders, + partiallyAccepted: lifetimeSummary.partiallyAccepted, + partiallyAcceptedCountSinceOriginal: lifetimeSummary.partiallyAcceptedCountSinceOriginal, + partiallyAcceptedRatioSinceOriginal: lifetimeSummary.partiallyAcceptedRatioSinceOriginal, + partiallyAcceptedCharactersSinceOriginal: lifetimeSummary.partiallyAcceptedCharactersSinceOriginal, + superseded: reason.kind === InlineCompletionEndOfLifeReasonKind.Ignored && !!reason.supersededBy, + reason: reason.kind === InlineCompletionEndOfLifeReasonKind.Accepted ? 'accepted' + : reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected ? 'rejected' + : reason.kind === InlineCompletionEndOfLifeReasonKind.Ignored ? 'ignored' : undefined, + noSuggestionReason: undefined, + notShownReason: lifetimeSummary.notShownReason, + renameCreated: lifetimeSummary.renameCreated, + renameDuration: lifetimeSummary.renameDuration, + renameTimedOut: lifetimeSummary.renameTimedOut, + ...forwardToChannelIf(isCopilotLikeExtension(this.providerId.extensionId!)), + }; + + const dataChannelForwardingTelemetryService = this._instantiationService.createInstance(DataChannelForwardingTelemetryService); + sendInlineCompletionsEndOfLifeTelemetry(dataChannelForwardingTelemetryService, endOfLifeSummary); + } + + public disposeInlineCompletions(completions: IdentifiableInlineCompletions, reason: languages.InlineCompletionsDisposeReason): void { + this._proxy.$freeInlineCompletionsList(this.handle, completions.pid, reason); + } + + public async handleRejection(completions: IdentifiableInlineCompletions, item: IdentifiableInlineCompletion): Promise { + if (this._supportsHandleEvents) { + await this._proxy.$handleInlineCompletionRejection(this.handle, completions.pid, item.idx); + } + } + + override toString() { + return `InlineCompletionsProvider(${this.providerId.toString()})`; + } +} diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 04ed392a6e4c7..e0307629ded5c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -479,6 +479,16 @@ export interface IdentifiableInlineCompletion extends languages.InlineCompletion suggestionId: EditSuggestionId | undefined; } +export interface IInlineCompletionModelDto { + readonly id: string; + readonly name: string; +} + +export interface IInlineCompletionModelInfoDto { + readonly models: IInlineCompletionModelDto[]; + readonly currentModelId: string; +} + export interface MainThreadLanguageFeaturesShape extends IDisposable { $unregister(handle: number): void; $registerDocumentSymbolProvider(handle: number, selector: IDocumentFilterDto[], label: string): void; @@ -509,8 +519,24 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $registerDocumentRangeSemanticTokensProvider(handle: number, selector: IDocumentFilterDto[], legend: languages.SemanticTokensLegend, eventHandle: number | undefined): void; $emitDocumentRangeSemanticTokensEvent(eventHandle: number): void; $registerCompletionsProvider(handle: number, selector: IDocumentFilterDto[], triggerCharacters: string[], supportsResolveDetails: boolean, extensionId: ExtensionIdentifier): void; - $registerInlineCompletionsSupport(handle: number, selector: IDocumentFilterDto[], supportsHandleDidShowCompletionItem: boolean, extensionId: string, extensionVersion: string, yieldToId: string | undefined, yieldsToExtensionIds: string[], displayName: string | undefined, debounceDelayMs: number | undefined, excludesExtensionIds: string[], eventHandle: number | undefined): void; + $registerInlineCompletionsSupport( + handle: number, + selector: IDocumentFilterDto[], + supportsHandleEvents: boolean, + extensionId: string, + extensionVersion: string, + groupId: string | undefined, + yieldsToExtensionIds: string[], + displayName: string | undefined, + debounceDelayMs: number | undefined, + excludesExtensionIds: string[], + supportsSetModelId: boolean, + supportsOnDidChange: boolean, + initialModelInfo: IInlineCompletionModelInfoDto | undefined, + supportsOnDidChangeModelInfo: boolean, + ): void; $emitInlineCompletionsChange(handle: number): void; + $emitInlineCompletionModelInfoChange(handle: number, data: IInlineCompletionModelInfoDto | undefined): void; $registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void; $registerInlayHintsProvider(handle: number, selector: IDocumentFilterDto[], supportsResolve: boolean, eventHandle: number | undefined, displayName: string | undefined): void; $emitInlayHintsEvent(eventHandle: number): void; @@ -2458,6 +2484,7 @@ export interface ExtHostLanguageFeaturesShape { $handleInlineCompletionRejection(handle: number, pid: number, idx: number): void; $freeInlineCompletionsList(handle: number, pid: number, reason: languages.InlineCompletionsDisposeReason): void; $acceptInlineCompletionsUnificationState(state: IInlineCompletionsUnificationState): void; + $handleInlineCompletionSetCurrentModelId(handle: number, modelId: string): void; $provideSignatureHelp(handle: number, resource: UriComponents, position: IPosition, context: languages.SignatureHelpContext, token: CancellationToken): Promise; $releaseSignatureHelp(handle: number, id: number): void; $provideInlayHints(handle: number, resource: UriComponents, range: IRange, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 88b8ead1941b1..4e7b67fd89a78 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -1363,11 +1363,33 @@ class InlineCompletionAdapter { ); } + public get supportsSetModelId(): boolean { + return isProposedApiEnabled(this._extension, 'inlineCompletionsAdditions') + && typeof this._provider.setCurrentModelId === 'function'; + } + private readonly languageTriggerKindToVSCodeTriggerKind: Record = { [languages.InlineCompletionTriggerKind.Automatic]: InlineCompletionTriggerKind.Automatic, [languages.InlineCompletionTriggerKind.Explicit]: InlineCompletionTriggerKind.Invoke, }; + public get modelInfo(): extHostProtocol.IInlineCompletionModelInfoDto | undefined { + if (!this._isAdditionsProposedApiEnabled) { + return undefined; + } + return this._provider.modelInfo ? { + models: this._provider.modelInfo.models, + currentModelId: this._provider.modelInfo.currentModelId + } : undefined; + } + + setCurrentModelId(modelId: string): void { + if (!this._isAdditionsProposedApiEnabled) { + return; + } + this._provider.setCurrentModelId?.(modelId); + } + async provideInlineCompletions(resource: URI, position: IPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise { const doc = this._documents.getDocument(resource); const pos = typeConvert.Position.to(position); @@ -2589,16 +2611,21 @@ export class ExtHostLanguageFeatures extends CoreDisposable implements extHostPr // --- ghost text registerInlineCompletionsProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.InlineCompletionItemProvider, metadata: vscode.InlineCompletionItemProviderMetadata | undefined): vscode.Disposable { - const eventHandle = typeof provider.onDidChange === 'function' && isProposedApiEnabled(extension, 'inlineCompletionsAdditions') ? this._nextHandle() : undefined; const adapter = new InlineCompletionAdapter(extension, this._documents, provider, this._commands.converter); const handle = this._addNewAdapter(adapter, extension); let result = this._createDisposable(handle); - if (eventHandle !== undefined) { - const subscription = provider.onDidChange!(_ => this._proxy.$emitInlineCompletionsChange(eventHandle)); + const supportsOnDidChange = isProposedApiEnabled(extension, 'inlineCompletionsAdditions') && typeof provider.onDidChange === 'function'; + if (supportsOnDidChange) { + const subscription = provider.onDidChange!(_ => this._proxy.$emitInlineCompletionsChange(handle)); result = Disposable.from(result, subscription); } + const supportsOnDidChangeModelInfo = isProposedApiEnabled(extension, 'inlineCompletionsAdditions') && typeof provider.onDidChangeModelInfo === 'function'; + if (supportsOnDidChangeModelInfo) { + const subscription = provider.onDidChangeModelInfo!(_ => this._proxy.$emitInlineCompletionModelInfoChange(handle, adapter.modelInfo)); + result = Disposable.from(result, subscription); + } this._proxy.$registerInlineCompletionsSupport( handle, this._transformDocumentSelector(selector, extension), @@ -2610,7 +2637,10 @@ export class ExtHostLanguageFeatures extends CoreDisposable implements extHostPr metadata?.displayName, metadata?.debounceDelayMs, metadata?.excludes?.map(extId => ExtensionIdentifier.toKey(extId)) || [], - eventHandle, + supportsOnDidChange, + adapter.supportsSetModelId, + adapter.modelInfo, + supportsOnDidChangeModelInfo, ); return result; } @@ -2652,6 +2682,12 @@ export class ExtHostLanguageFeatures extends CoreDisposable implements extHostPr this._onDidChangeInlineCompletionsUnificationState.fire(); } + $handleInlineCompletionSetCurrentModelId(handle: number, modelId: string): void { + this._withAdapter(handle, InlineCompletionAdapter, async adapter => { + adapter.setCurrentModelId(modelId); + }, undefined, undefined); + } + // --- parameter hints registerSignatureHelpProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.SignatureHelpProvider, metadataOrTriggerChars: string[] | vscode.SignatureHelpProviderMetadata): vscode.Disposable { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index d50afb925299a..acc5ee098d530 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -23,6 +23,9 @@ import { URI } from '../../../../../base/common/uri.js'; import { IInlineCompletionsService } from '../../../../../editor/browser/services/inlineCompletionsService.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { ITextResourceConfigurationService } from '../../../../../editor/common/services/textResourceConfiguration.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import * as languages from '../../../../../editor/common/languages.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -135,7 +138,9 @@ export class ChatStatusDashboard extends DomWidget { @ITextResourceConfigurationService private readonly textResourceConfigurationService: ITextResourceConfigurationService, @IInlineCompletionsService private readonly inlineCompletionsService: IInlineCompletionsService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IQuickInputService private readonly quickInputService: IQuickInputService ) { super(); @@ -289,6 +294,35 @@ export class ChatStatusDashboard extends DomWidget { this.createSettings(this.element, this._store); } + // Model Selection + { + const providers = this.languageFeaturesService.inlineCompletionsProvider.allNoModel(); + const provider = providers.find(p => p.modelInfo && p.modelInfo.models.length > 0); + + if (provider) { + const modelInfo = provider.modelInfo!; + const currentModel = modelInfo.models.find(m => m.id === modelInfo.currentModelId); + + if (currentModel) { + const modelContainer = this.element.appendChild($('div.model-selection')); + + modelContainer.appendChild($('span.model-text', undefined, localize('modelLabel', "Model: {0}", currentModel.name))); + + const actionBar = modelContainer.appendChild($('div.model-action-bar')); + const toolbar = this._store.add(new ActionBar(actionBar, { hoverDelegate: nativeHoverDelegate })); + toolbar.push([toAction({ + id: 'workbench.action.selectInlineCompletionsModel', + label: localize('selectModel', "Select Model"), + tooltip: localize('selectModel', "Select Model"), + class: ThemeIcon.asClassName(Codicon.gear), + run: async () => { + await this.showModelPicker(provider); + } + })], { icon: true, label: false }); + } + } + } + // Completions Snooze if (this.canUseChat()) { const snooze = append(this.element, $('div.snooze-completions')); @@ -698,4 +732,29 @@ export class ChatStatusDashboard extends DomWidget { updateIntervalTimer(); })); } + + private async showModelPicker(provider: languages.InlineCompletionsProvider): Promise { + if (!provider.modelInfo || !provider.setModelId) { + return; + } + + const modelInfo = provider.modelInfo; + const items: IQuickPickItem[] = modelInfo.models.map(model => ({ + id: model.id, + label: model.name, + description: model.id === modelInfo.currentModelId ? localize('currentModel.description', "Currently selected") : undefined, + picked: model.id === modelInfo.currentModelId + })); + + const selected = await this.quickInputService.pick(items, { + placeHolder: localize('selectModelFor', "Select a model for {0}", provider.displayName || 'inline completions'), + canPickMany: false + }); + + if (selected && selected.id && selected.id !== modelInfo.currentModelId) { + await provider.setModelId(selected.id); + } + + this.hoverService.hideHover(true); + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index 14fdd0522b355..72fc11a967a8b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css @@ -128,10 +128,31 @@ color: var(--vscode-disabledForeground); } +/* Model Selection */ + +.chat-status-bar-entry-tooltip .model-selection { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 0 0 0; +} + +.chat-status-bar-entry-tooltip .model-selection .model-text { + flex: 1; +} + +.chat-status-bar-entry-tooltip .model-selection .model-action-bar { + margin-left: auto; +} + +.chat-status-bar-entry-tooltip .model-selection .model-action-bar .codicon { + color: var(--vscode-descriptionForeground); +} + /* Snoozing */ .chat-status-bar-entry-tooltip .snooze-completions { - margin-top: 6px; + margin-top: 1px; display: flex; flex-direction: row; flex-wrap: nowrap; diff --git a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts index a2fc913767c4c..88328b41cb047 100644 --- a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts @@ -135,6 +135,12 @@ declare module 'vscode' { readonly onDidChange?: Event; + readonly modelInfo?: InlineCompletionModelInfo; + readonly onDidChangeModelInfo?: Event; + // eslint-disable-next-line local/vscode-dts-provider-naming + setCurrentModelId?(modelId: string): Thenable; + + // #region Deprecated methods /** @@ -155,6 +161,16 @@ declare module 'vscode' { // #endregion } + export interface InlineCompletionModelInfo { + readonly models: InlineCompletionModel[]; + readonly currentModelId: string; + } + + export interface InlineCompletionModel { + readonly id: string; + readonly name: string; + } + export enum InlineCompletionEndOfLifeReasonKind { Accepted = 0, Rejected = 1, From da1aae6cde2efd723f379fac6d20881b2434f637 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 12:37:06 +0100 Subject: [PATCH 05/17] agent sessions - update session control visibility based on chat widget state --- .../chat/browser/actions/chatActions.ts | 2 +- .../contrib/chat/browser/chatViewPane.ts | 67 +++++++++++++------ .../contrib/chat/browser/chatWidget.ts | 11 ++- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 2ea09fc0e1c12..087eb5121a6f1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1834,7 +1834,7 @@ registerAction2(class ToggleChatHistoryVisibilityAction extends Action2 { title: localize2('chat.toggleEmptyChatViewSessions.label', "Show Agent Sessions"), category: CHAT_CATEGORY, precondition: ChatContextKeys.enabled, - toggled: ContextKeyExpr.equals(`config${ChatConfiguration.EmptyChatViewSessionsEnabled}`, true), + toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.EmptyChatViewSessionsEnabled}`, true), menu: { id: MenuId.ChatWelcomeContext, group: '1_modify', diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index 6badc1d8ec3dd..ce8571e4c2e8b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -41,7 +41,6 @@ import { showCloseActiveChatNotification } from './agentSessions/chatCloseNotifi import { ChatWidget } from './chatWidget.js'; import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js'; import { AgentSessionsControl } from './agentSessions/agentSessionsControl.js'; -import { Event } from '../../../../base/common/event.js'; interface IChatViewPaneState extends Partial { sessionId?: string; @@ -227,9 +226,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.telemetryService.publicLog2<{}, ChatViewPaneOpenedClassification>('chatViewPaneOpened'); this.createControls(parent); + + this.applyModel(); } - private async createControls(parent: HTMLElement): Promise { + private createControls(parent: HTMLElement): void { parent.classList.add('chat-viewpane'); // Sessions Control @@ -239,6 +240,45 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const welcomeController = this._register(this.instantiationService.createInstance(ChatViewWelcomeController, parent, this, this.chatOptions.location)); // Chat Widget + this.createChatWidget(parent, welcomeController); + + // Sessions control visibility is impacted by chat widget empty state + this._register(this._widget.onDidChangeEmptyState(() => this.updateSessionsControlVisibility(true))); + } + + private createSessionsControl(parent: HTMLElement): void { + this.sessionsContainer = parent.appendChild($('.chat-viewpane-sessions-container')); + this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsContainer, undefined)); + + this.updateSessionsControlVisibility(false); + + this._register(this.onDidChangeBodyVisibility(() => this.updateSessionsControlVisibility(true))); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.EmptyChatViewSessionsEnabled)) { + this.updateSessionsControlVisibility(true); + } + })); + } + + private updateSessionsControlVisibility(fromEvent: boolean): void { + if (!this.sessionsContainer || !this.sessionsControl) { + return; + } + + const sessionsControlVisible = + this.configurationService.getValue(ChatConfiguration.EmptyChatViewSessionsEnabled) && // enabled in settings + this.isBodyVisible() && // view expanded + (!this._widget || this._widget?.isEmpty()); // chat widget empty + + this.sessionsContainer.style.display = sessionsControlVisible ? '' : 'none'; + this.sessionsControl.setVisible(sessionsControlVisible); + + if (fromEvent && this.lastDimensions) { + this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); + } + } + + private createChatWidget(parent: HTMLElement, welcomeController: ChatViewWelcomeController): void { const locationBasedColors = this.getLocationBasedColors(); const editorOverflowWidgetsDomNode = this.layoutService.getContainer(getWindow(parent)).appendChild($('.chat-editor-overflow.monaco-editor')); @@ -275,10 +315,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { })); this._widget.render(parent); - const updateWidgetVisibility = (r?: IReader) => this._widget.setVisible(this.isBodyVisible() && !welcomeController.isShowingWelcome.read(r)); + const updateWidgetVisibility = (reader?: IReader) => this._widget.setVisible(this.isBodyVisible() && !welcomeController.isShowingWelcome.read(reader)); this._register(this.onDidChangeBodyVisibility(() => updateWidgetVisibility())); - this._register(autorun(r => updateWidgetVisibility(r))); + this._register(autorun(reader => updateWidgetVisibility(reader))); + } + private async applyModel(): Promise { const info = this.getTransferredOrPersistedSessionInfo(); const modelRef = info.sessionId ? await this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(info.sessionId)) : undefined; if (modelRef && info.inputState) { @@ -288,23 +330,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { await this.updateModel(modelRef); } - private createSessionsControl(parent: HTMLElement): void { - const sessionsContainer = this.sessionsContainer = parent.appendChild($('.chat-viewpane-sessions-container')); - - this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, sessionsContainer, undefined)); - this.sessionsControl.setVisible(this.isBodyVisible()); - this._register(this.onDidChangeBodyVisibility(visible => this.sessionsControl?.setVisible(visible))); - - this._register(Event.runAndSubscribe(this.configurationService.onDidChangeConfiguration, e => { - if (!e || e.affectsConfiguration(ChatConfiguration.EmptyChatViewSessionsEnabled)) { - sessionsContainer.style.display = this.configurationService.getValue(ChatConfiguration.EmptyChatViewSessionsEnabled) ? '' : 'none'; - if (this.lastDimensions) { - this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); - } - } - })); - } - private async clear(): Promise { // Grab the widget's latest view state because it will be loaded back into the widget diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 5521189bd64ea..ec2fad8a8d290 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -210,6 +210,9 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly _onDidChangeContentHeight = new Emitter(); readonly onDidChangeContentHeight: Event = this._onDidChangeContentHeight.event; + private _onDidChangeEmptyState = this._register(new Emitter()); + readonly onDidChangeEmptyState = this._onDidChangeEmptyState.event; + contribs: ReadonlyArray = []; private listContainer!: HTMLElement; @@ -849,7 +852,6 @@ export class ChatWidget extends Disposable implements IChatWidget { /** * Updates the DOM visibility of welcome view and chat list immediately - * @internal */ private updateChatViewVisibility(): void { if (!this.viewModel) { @@ -859,6 +861,12 @@ export class ChatWidget extends Disposable implements IChatWidget { const numItems = this.viewModel.getItems().length; dom.setVisibility(numItems === 0, this.welcomeMessageContainer); dom.setVisibility(numItems !== 0, this.listContainer); + + this._onDidChangeEmptyState.fire(); + } + + isEmpty(): boolean { + return (this.viewModel?.getItems().length ?? 0) === 0; } /** @@ -866,7 +874,6 @@ export class ChatWidget extends Disposable implements IChatWidget { * * Note: Do not call this method directly. Instead, use `this._welcomeRenderScheduler.schedule()` * to ensure proper debouncing and avoid potential cyclic calls - * @internal */ private renderWelcomeViewContentIfNeeded() { if (this.viewOptions.renderStyle === 'compact' || this.viewOptions.renderStyle === 'minimal' || this.lifecycleService.willShutdown) { From 012ad67dc98c83351003a17c3834cb51e10cd62b Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 25 Nov 2025 12:43:52 +0100 Subject: [PATCH 06/17] fix rename suggestions --- .../inlineCompletions/browser/model/renameSymbolProcessor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts index 6ceb1ff242f7b..4b7366c93b54a 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts @@ -61,7 +61,7 @@ export class RenameSymbolProcessor extends Disposable { const { oldName, newName, position } = edits.renames; let timedOut = false; const loc = await raceTimeout(prepareRename(this._languageFeaturesService.renameProvider, textModel, position), 1000, () => { timedOut = true; }); - const renamePossible = loc !== undefined && !loc.rejectReason; + const renamePossible = loc !== undefined && !loc.rejectReason && loc.text === oldName; suggestItem.setRenameProcessingInfo({ createdRename: renamePossible, duration: Date.now() - start, timedOut }); From f38467b140d54e0649c0ed4a2944f5097169c713 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 12:45:09 +0100 Subject: [PATCH 07/17] agent sessions - fix exception calling layout in chat widget --- src/vs/workbench/contrib/chat/browser/chatWidget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index ec2fad8a8d290..8f501d2f22f1d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -2300,7 +2300,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listContainer.style.removeProperty('--chat-current-response-min-height'); } else { this.listContainer.style.setProperty('--chat-current-response-min-height', contentHeight * .75 + 'px'); - if (heightUpdated && lastItem && this.visible) { + if (heightUpdated && lastItem && this.visible && this.tree.hasElement(lastItem)) { this.tree.updateElementHeight(lastItem, undefined); } } From ed69a865f3a8046a53b1db4f1bbd4c6f0b38ba57 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 12:52:17 +0100 Subject: [PATCH 08/17] agent sessions - update CODENOTIFY --- .github/CODENOTIFY | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index ae6288844a366..8bccb6e398de6 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -115,6 +115,7 @@ src/vs/workbench/contrib/chat/browser/chatSetup.ts @bpasero src/vs/workbench/contrib/chat/browser/chatStatus.ts @bpasero src/vs/workbench/contrib/chat/browser/chatInputPart.ts @bpasero src/vs/workbench/contrib/chat/browser/chatWidget.ts @bpasero +src/vs/workbench/contrib/chat/browser/chatViewPane.ts @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 From 60cce46c3373d80fa47438a01366ff53229ee0e6 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 12:53:07 +0100 Subject: [PATCH 09/17] agent sessions - update CODENOTIFY --- .github/CODENOTIFY | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index 8bccb6e398de6..6f42a3635a366 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -112,7 +112,7 @@ src/vs/workbench/contrib/authentication/** @TylerLeonhardt src/vs/workbench/contrib/files/** @bpasero src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @roblourens src/vs/workbench/contrib/chat/browser/chatSetup.ts @bpasero -src/vs/workbench/contrib/chat/browser/chatStatus.ts @bpasero +src/vs/workbench/contrib/chat/browser/chatStatus/** @bpasero src/vs/workbench/contrib/chat/browser/chatInputPart.ts @bpasero src/vs/workbench/contrib/chat/browser/chatWidget.ts @bpasero src/vs/workbench/contrib/chat/browser/chatViewPane.ts @bpasero From 4f321d9ee8c61bcb302667f3c48f84aaf406e5ad Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 25 Nov 2025 11:56:32 +0000 Subject: [PATCH 10/17] Use toPromptFileVariableEntry to create prompt file variable (#279334) * Use toPromptFileVariableEntry to create prompt file variable * Use toPromptFileVariableEntry to create prompt file variable --- .../contrib/chat/browser/actions/chatContinueInAction.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index 67ee6c4defb98..cc35c6ca64cbd 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -39,7 +39,7 @@ import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProv import { IChatWidgetService } from '../chat.js'; import { CHAT_SETUP_ACTION_ID } from './chatActions.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { IChatRequestVariableEntry } from '../../common/chatVariableEntries.js'; +import { PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/chatVariableEntries.js'; export const enum ActionLocation { ChatWidget = 'chatWidget', @@ -315,12 +315,7 @@ class CreateRemoteAgentJobFromEditorAction { return; } await editorService2.openEditor({ resource: sessionResource }, undefined); - const attachedContext: IChatRequestVariableEntry[] = [{ - kind: 'file', - id: 'editor.uri', - name: basename(uri), - value: uri - }]; + const attachedContext = [toPromptFileVariableEntry(uri, PromptFileVariableKind.PromptFile, undefined, false, [])]; await chatService.sendRequest(sessionResource, `Implement this.`, { agentIdSilent: continuationTargetType, attachedContext From afe34566a123776d6bd640ba1f53ddcfda9ad8e2 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 15:19:38 +0100 Subject: [PATCH 11/17] Local agent sessions provider cleanup (#279359) (#279363) * Local agent sessions provider cleanup (#279359) * add tests --- .../chatCloseNotification.ts | 24 +- .../localAgentSessionsProvider.ts} | 228 ++--- .../contrib/chat/browser/chat.contribution.ts | 4 +- .../contrib/chat/browser/chatEditorInput.ts | 2 +- .../chatSessions/view/sessionsViewPane.ts | 4 +- .../contrib/chat/browser/chatViewPane.ts | 2 +- .../localAgentSessionsProvider.test.ts | 780 ++++++++++++++++++ .../test/common/mockChatSessionsService.ts | 5 +- 8 files changed, 927 insertions(+), 122 deletions(-) rename src/vs/workbench/contrib/chat/browser/{agentSessions => actions}/chatCloseNotification.ts (59%) rename src/vs/workbench/contrib/chat/browser/{chatSessions/localChatSessionsProvider.ts => agentSessions/localAgentSessionsProvider.ts} (50%) create mode 100644 src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/chatCloseNotification.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCloseNotification.ts similarity index 59% rename from src/vs/workbench/contrib/chat/browser/agentSessions/chatCloseNotification.ts rename to src/vs/workbench/contrib/chat/browser/actions/chatCloseNotification.ts index 90f6339190a15..bc416f371ca1b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/chatCloseNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCloseNotification.ts @@ -4,23 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from '../../../../../nls.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { INotificationService, NeverShowAgainScope, Severity } from '../../../../../platform/notification/common/notification.js'; -import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { AGENT_SESSIONS_VIEW_ID } from './agentSessions.js'; - -const STORAGE_KEY = 'chat.closeWithActiveResponse.doNotShowAgain2'; +import { LEGACY_AGENT_SESSIONS_VIEW_ID } from '../../common/constants.js'; +import { AGENT_SESSIONS_VIEW_ID } from '../agentSessions/agentSessions.js'; /** * Shows a notification when closing a chat with an active response, informing the user * that the chat will continue running in the background. The notification includes a button * to open the Agent Sessions view and a "Don't Show Again" option. */ -export function showCloseActiveChatNotification( - accessor: ServicesAccessor -): void { +export function showCloseActiveChatNotification(accessor: ServicesAccessor): void { const notificationService = accessor.get(INotificationService); - const viewsService = accessor.get(IViewsService); + const configurationService = accessor.get(IConfigurationService); + const commandService = accessor.get(ICommandService); notificationService.prompt( Severity.Info, @@ -29,13 +28,18 @@ export function showCloseActiveChatNotification( { label: nls.localize('chat.openAgentSessions', "Open Agent Sessions"), run: async () => { - await viewsService.openView(AGENT_SESSIONS_VIEW_ID, true); + // TODO@bpasero remove this check once settled + if (configurationService.getValue('chat.agentSessionsViewLocation') === 'single-view') { + commandService.executeCommand(AGENT_SESSIONS_VIEW_ID); + } else { + commandService.executeCommand(LEGACY_AGENT_SESSIONS_VIEW_ID); + } } } ], { neverShowAgain: { - id: STORAGE_KEY, + id: 'chat.closeWithActiveResponse.doNotShowAgain', scope: NeverShowAgainScope.APPLICATION } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts similarity index 50% rename from src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index cc5c336dc03c4..0e30aa5f30f33 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/localChatSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -2,31 +2,33 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from '../../../../../base/common/arrays.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; -import { localize } from '../../../../../nls.js'; +import { truncate } from '../../../../../base/common/strings.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ModifiedFileEntryState } from '../../common/chatEditingService.js'; import { IChatModel } from '../../common/chatModel.js'; -import { IChatService } from '../../common/chatService.js'; +import { IChatDetail, IChatService } from '../../common/chatService.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../common/constants.js'; -import { IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; -import { ChatSessionItemWithProvider } from './common.js'; +import { ChatViewId, IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; +import { ChatSessionItemWithProvider } from '../chatSessions/common.js'; + +export class LocalAgentsSessionsProvider extends Disposable implements IChatSessionItemProvider, IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.localAgentsSessionsProvider'; -export class LocalChatSessionsProvider extends Disposable implements IChatSessionItemProvider, IWorkbenchContribution { - static readonly ID = 'workbench.contrib.localChatSessionsProvider'; - static readonly CHAT_WIDGET_VIEW_ID = 'workbench.panel.chat.view.copilot'; readonly chatSessionType = localChatSessionType; private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; + readonly onDidChange = this._onDidChange.event; readonly _onDidChangeChatSessionItems = this._register(new Emitter()); - public get onDidChangeChatSessionItems() { return this._onDidChangeChatSessionItems.event; } + readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; constructor( @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @@ -37,50 +39,52 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio this._register(this.chatSessionsService.registerChatSessionItemProvider(this)); - this.registerWidgetListeners(); - - this._register(this.chatService.onDidDisposeSession(() => { - this._onDidChange.fire(); - })); - - // Listen for global session items changes for our session type - this._register(this.chatSessionsService.onDidChangeSessionItems((sessionType) => { - if (sessionType === this.chatSessionType) { - this._onDidChange.fire(); - } - })); + this.registerListeners(); } - private registerWidgetListeners(): void { + private registerListeners(): void { + // Listen for new chat widgets being added/removed this._register(this.chatWidgetService.onDidAddWidget(widget => { - // Only fire for chat view instance - if (widget.location === ChatAgentLocation.Chat && + if ( + widget.location === ChatAgentLocation.Chat && // Only fire for chat view instance isIChatViewViewContext(widget.viewContext) && - widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID) { + widget.viewContext.viewId === ChatViewId + ) { this._onDidChange.fire(); - this._registerWidgetModelListeners(widget); + + this.registerWidgetModelListeners(widget); } })); // Check for existing chat widgets and register listeners - const existingWidgets = this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat) - .filter(widget => isIChatViewViewContext(widget.viewContext) && widget.viewContext.viewId === LocalChatSessionsProvider.CHAT_WIDGET_VIEW_ID); + this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat) + .filter(widget => isIChatViewViewContext(widget.viewContext) && widget.viewContext.viewId === ChatViewId) + .forEach(widget => this.registerWidgetModelListeners(widget)); - existingWidgets.forEach(widget => { - this._registerWidgetModelListeners(widget); - }); + this._register(this.chatService.onDidDisposeSession(() => { + this._onDidChange.fire(); + })); + + // Listen for global session items changes for our session type + this._register(this.chatSessionsService.onDidChangeSessionItems(sessionType => { + if (sessionType === this.chatSessionType) { + this._onDidChange.fire(); + } + })); } - private _registerWidgetModelListeners(widget: IChatWidget): void { + private registerWidgetModelListeners(widget: IChatWidget): void { const register = () => { this.registerModelTitleListener(widget); + if (widget.viewModel) { this.chatSessionsService.registerModelProgressListener(widget.viewModel.model, () => { this._onDidChangeChatSessionItems.fire(); }); } }; + // Listen for view model changes on this widget this._register(widget.onDidChangeViewModel(() => { register(); @@ -93,8 +97,10 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio private registerModelTitleListener(widget: IChatWidget): void { const model = widget.viewModel?.model; if (model) { + // Listen for model changes, specifically for title changes via setCustomTitle - this._register(model.onDidChange((e) => { + this._register(model.onDidChange(e => { + // Fire change events for all title-related changes to refresh the tree if (!e || e.kind === 'setCustomTitle') { this._onDidChange.fire(); @@ -106,62 +112,45 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio private modelToStatus(model: IChatModel): ChatSessionStatus | undefined { if (model.requestInProgress.get()) { return ChatSessionStatus.InProgress; - } else { - const requests = model.getRequests(); - if (requests.length > 0) { - // Check if the last request was completed successfully or failed - const lastRequest = requests[requests.length - 1]; - if (lastRequest?.response) { - if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails) { - return ChatSessionStatus.Failed; - } else if (lastRequest.response.isComplete) { - return ChatSessionStatus.Completed; - } else { - return ChatSessionStatus.InProgress; - } + } + + const requests = model.getRequests(); + if (requests.length > 0) { + + // Check if the last request was completed successfully or failed + const lastRequest = requests[requests.length - 1]; + if (lastRequest?.response) { + if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails) { + return ChatSessionStatus.Failed; + } else if (lastRequest.response.isComplete) { + return ChatSessionStatus.Completed; + } else { + return ChatSessionStatus.InProgress; } } } - return; + + return undefined; } async provideChatSessionItems(token: CancellationToken): Promise { const sessions: ChatSessionItemWithProvider[] = []; const sessionsByResource = new ResourceSet(); - this.chatService.getLiveSessionItems().forEach(sessionDetail => { - let status: ChatSessionStatus | undefined; - let startTime: number | undefined; - let endTime: number | undefined; - let description: string | undefined; - const model = this.chatService.getSession(sessionDetail.sessionResource); - if (model) { - status = this.modelToStatus(model); - startTime = model.timestamp; - description = this.chatSessionsService.getSessionDescription(model); - const lastResponse = model.getRequests().at(-1)?.response; - if (lastResponse) { - endTime = lastResponse.completedAt ?? lastResponse.timestamp; - } + + for (const sessionDetail of this.chatService.getLiveSessionItems()) { + const editorSession = this.toChatSessionItem(sessionDetail); + if (!editorSession) { + continue; } - const statistics = model ? this.getSessionStatistics(model) : undefined; - const editorSession: ChatSessionItemWithProvider = { - resource: sessionDetail.sessionResource, - label: sessionDetail.title, - iconPath: Codicon.chatSparkle, - status, - provider: this, - timing: { - startTime: startTime ?? Date.now(), // TODO@osortega this is not so good - endTime - }, - statistics, - description: description || localize('chat.localSessionDescription.finished', "Finished"), - }; + sessionsByResource.add(sessionDetail.sessionResource); sessions.push(editorSession); - }); - const history = await this.getHistoryItems(); - sessions.push(...history.filter(h => !sessionsByResource.has(h.resource))); + } + + if (!token.isCancellationRequested) { + const history = await this.getHistoryItems(); + sessions.push(...history.filter(h => !sessionsByResource.has(h.resource))); + } return sessions; } @@ -169,46 +158,77 @@ export class LocalChatSessionsProvider extends Disposable implements IChatSessio private async getHistoryItems(): Promise { try { const allHistory = await this.chatService.getHistorySessionItems(); - const historyItems = allHistory.map((historyDetail): ChatSessionItemWithProvider => { - const model = this.chatService.getSession(historyDetail.sessionResource); - const statistics = model ? this.getSessionStatistics(model) : undefined; - return { - resource: historyDetail.sessionResource, - label: historyDetail.title, - iconPath: Codicon.chatSparkle, - provider: this, - timing: { - startTime: historyDetail.lastMessageDate ?? Date.now() - }, - archived: true, - statistics - }; - }); - return historyItems; - + return coalesce(allHistory.map(history => this.toChatSessionItem(history))); } catch (error) { return []; } } + private toChatSessionItem(chat: IChatDetail): ChatSessionItemWithProvider | undefined { + const model = this.chatService.getSession(chat.sessionResource); + + let description: string | undefined; + let startTime: number | undefined; + let endTime: number | undefined; + if (model) { + if (!model.hasRequests) { + return undefined; // ignore sessions without requests + } + + const lastResponse = model.getRequests().at(-1)?.response; + + description = this.chatSessionsService.getSessionDescription(model); + if (!description) { + const responseValue = lastResponse?.response.toString(); + if (responseValue) { + description = truncate(responseValue.replace(/\r?\n/g, ' '), 100); + } + } + + startTime = model.timestamp; + if (lastResponse) { + endTime = lastResponse.completedAt ?? lastResponse.timestamp; + } + } else { + startTime = chat.lastMessageDate; + } + + return { + resource: chat.sessionResource, + provider: this, + label: chat.title, + description, + status: model ? this.modelToStatus(model) : undefined, + iconPath: Codicon.chatSparkle, + timing: { + startTime, + endTime + }, + statistics: model ? this.getSessionStatistics(model) : undefined + }; + } + private getSessionStatistics(chatModel: IChatModel) { let linesAdded = 0; let linesRemoved = 0; - const modifiedFiles = new ResourceSet(); + const files = new ResourceSet(); + const currentEdits = chatModel.editingSession?.entries.get(); if (currentEdits) { - const uncommittedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified); - uncommittedEdits.forEach(edit => { + const uncommittedEdits = currentEdits.filter(edit => edit.state.get() === ModifiedFileEntryState.Modified); + for (const edit of uncommittedEdits) { linesAdded += edit.linesAdded?.get() ?? 0; linesRemoved += edit.linesRemoved?.get() ?? 0; - modifiedFiles.add(edit.modifiedURI); - }); + files.add(edit.modifiedURI); + } } - if (modifiedFiles.size === 0) { - return; + + if (files.size === 0) { + return undefined; } + return { - files: modifiedFiles.size, + files: files.size, insertions: linesAdded, deletions: linesRemoved, }; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f5f3652081193..b9233cc2a7121 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -111,7 +111,7 @@ import { ChatPasteProvidersFeature } from './chatPasteProviders.js'; import { QuickChatService } from './chatQuick.js'; import { ChatResponseAccessibleView } from './chatResponseAccessibleView.js'; import { ChatTerminalOutputAccessibleView } from './chatTerminalOutputAccessibleView.js'; -import { LocalChatSessionsProvider } from './chatSessions/localChatSessionsProvider.js'; +import { LocalAgentsSessionsProvider } from './agentSessions/localAgentSessionsProvider.js'; import { ChatSessionsView, ChatSessionsViewContrib } from './chatSessions/view/chatSessionsView.js'; import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup.js'; import { ChatStatusBarEntry } from './chatStatus/chatStatusEntry.js'; @@ -1145,7 +1145,7 @@ registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribu registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatResponseResourceFileSystemProvider.ID, ChatResponseResourceFileSystemProvider, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(LocalChatSessionsProvider.ID, LocalChatSessionsProvider, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(LocalAgentsSessionsProvider.ID, LocalAgentsSessionsProvider, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatSessionsViewContrib.ID, ChatSessionsViewContrib, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatSessionsView.ID, ChatSessionsView, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts index f2b87fa1f742d..48c076c99c81c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts @@ -24,7 +24,7 @@ import { IChatSessionsService, localChatSessionType } from '../common/chatSessio import { LocalChatSessionUri } from '../common/chatUri.js'; import { ChatAgentLocation, ChatEditorTitleMaxLength } from '../common/constants.js'; import { IClearEditingSessionConfirmationOptions } from './actions/chatActions.js'; -import { showCloseActiveChatNotification } from './agentSessions/chatCloseNotification.js'; +import { showCloseActiveChatNotification } from './actions/chatCloseNotification.js'; import type { IChatEditorOptions } from './chatEditor.js'; const ChatEditorIcon = registerIcon('chat-editor-label-icon', Codicon.chatSparkle, nls.localize('chatEditorLabelIcon', 'Icon of the chat editor label.')); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts index b091b7a684259..f19d43dbdce54 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/view/sessionsViewPane.ts @@ -45,7 +45,7 @@ import { IChatWidgetService } from '../../chat.js'; import { IChatEditorOptions } from '../../chatEditor.js'; import { ChatSessionTracker } from '../chatSessionTracker.js'; import { ChatSessionItemWithProvider, getSessionItemContextOverlay, NEW_CHAT_SESSION_ACTION_ID } from '../common.js'; -import { LocalChatSessionsProvider } from '../localChatSessionsProvider.js'; +import { LocalAgentsSessionsProvider } from '../../agentSessions/localAgentSessionsProvider.js'; import { ArchivedSessionItems, GettingStartedDelegate, GettingStartedRenderer, IGettingStartedItem, SessionsDataSource, SessionsDelegate, SessionsRenderer } from './sessionsTreeRenderer.js'; // Identity provider for session items @@ -105,7 +105,7 @@ export class SessionsViewPane extends ViewPane { this.minimumBodySize = 44; // Listen for changes in the provider if it's a LocalChatSessionsProvider - if (provider instanceof LocalChatSessionsProvider) { + if (provider instanceof LocalAgentsSessionsProvider) { this._register(provider.onDidChange(() => { if (this.tree && this.isBodyVisible()) { this.refreshTreeWithProgress(); diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index ce8571e4c2e8b..6acc49cb3c8df 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -37,7 +37,7 @@ import { IChatModelReference, IChatService } from '../common/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../common/chatUri.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; -import { showCloseActiveChatNotification } from './agentSessions/chatCloseNotification.js'; +import { showCloseActiveChatNotification } from './actions/chatCloseNotification.js'; import { ChatWidget } from './chatWidget.js'; import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js'; import { AgentSessionsControl } from './agentSessions/agentSessionsControl.js'; diff --git a/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts new file mode 100644 index 0000000000000..afb3accda0e18 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts @@ -0,0 +1,780 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { IChatWidget, IChatWidgetService } from '../../browser/chat.js'; +import { LocalAgentsSessionsProvider } from '../../browser/agentSessions/localAgentSessionsProvider.js'; +import { IChatDetail, IChatService } from '../../common/chatService.js'; +import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; +import { ChatAgentLocation } from '../../common/constants.js'; +import { MockChatSessionsService } from '../common/mockChatSessionsService.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../common/chatModel.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { LocalChatSessionUri } from '../../common/chatUri.js'; +import { ModifiedFileEntryState } from '../../common/chatEditingService.js'; + +class MockChatWidgetService implements IChatWidgetService { + private readonly _onDidAddWidget = new Emitter(); + readonly onDidAddWidget = this._onDidAddWidget.event; + + readonly _serviceBrand: undefined; + readonly lastFocusedWidget: IChatWidget | undefined; + + private widgets: IChatWidget[] = []; + + fireDidAddWidget(widget: IChatWidget): void { + this._onDidAddWidget.fire(widget); + } + + addWidget(widget: IChatWidget): void { + this.widgets.push(widget); + } + + getWidgetByInputUri(_uri: URI): IChatWidget | undefined { + return undefined; + } + + getWidgetBySessionResource(_sessionResource: URI): IChatWidget | undefined { + return undefined; + } + + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray { + return this.widgets.filter(w => w.location === location); + } + + revealWidget(_preserveFocus?: boolean): Promise { + return Promise.resolve(undefined); + } + + reveal(_widget: IChatWidget, _preserveFocus?: boolean): Promise { + return Promise.resolve(true); + } + + getAllWidgets(): ReadonlyArray { + return this.widgets; + } + + openSession(_sessionResource: URI): Promise { + throw new Error('Method not implemented.'); + } + + register(_newWidget: IChatWidget): { dispose: () => void } { + return { dispose: () => { } }; + } +} + +class MockChatService implements IChatService { + requestInProgressObs = observableValue('name', false); + edits2Enabled: boolean = false; + _serviceBrand: undefined; + editingSessions = []; + transferredSessionData = undefined; + readonly onDidSubmitRequest = Event.None; + + private sessions = new Map(); + private liveSessionItems: IChatDetail[] = []; + private historySessionItems: IChatDetail[] = []; + + private readonly _onDidDisposeSession = new Emitter<{ sessionResource: URI; reason: 'cleared' }>(); + readonly onDidDisposeSession = this._onDidDisposeSession.event; + + fireDidDisposeSession(sessionResource: URI): void { + this._onDidDisposeSession.fire({ sessionResource, reason: 'cleared' }); + } + + setLiveSessionItems(items: IChatDetail[]): void { + this.liveSessionItems = items; + } + + setHistorySessionItems(items: IChatDetail[]): void { + this.historySessionItems = items; + } + + addSession(sessionResource: URI, session: IChatModel): void { + this.sessions.set(sessionResource.toString(), session); + } + + isEnabled(_location: ChatAgentLocation): boolean { + return true; + } + + hasSessions(): boolean { + return this.sessions.size > 0; + } + + getProviderInfos() { + return []; + } + + startSession(_location: ChatAgentLocation, _token: CancellationToken): any { + throw new Error('Method not implemented.'); + } + + getSession(sessionResource: URI): IChatModel | undefined { + return this.sessions.get(sessionResource.toString()); + } + + getOrRestoreSession(_sessionResource: URI): Promise { + throw new Error('Method not implemented.'); + } + + getPersistedSessionTitle(_sessionResource: URI): string | undefined { + return undefined; + } + + loadSessionFromContent(_data: any): any { + throw new Error('Method not implemented.'); + } + + loadSessionForResource(_resource: URI, _position: ChatAgentLocation, _token: CancellationToken): Promise { + throw new Error('Method not implemented.'); + } + + getActiveSessionReference(_sessionResource: URI): any { + return undefined; + } + + setTitle(_sessionResource: URI, _title: string): void { } + + appendProgress(_request: IChatRequestModel, _progress: any): void { } + + sendRequest(_sessionResource: URI, _message: string): Promise { + throw new Error('Method not implemented.'); + } + + resendRequest(_request: IChatRequestModel, _options?: any): Promise { + throw new Error('Method not implemented.'); + } + + adoptRequest(_sessionResource: URI, _request: IChatRequestModel): Promise { + throw new Error('Method not implemented.'); + } + + removeRequest(_sessionResource: URI, _requestId: string): Promise { + throw new Error('Method not implemented.'); + } + + cancelCurrentRequestForSession(_sessionResource: URI): void { } + + addCompleteRequest(): void { } + + async getLocalSessionHistory(): Promise { + return this.historySessionItems; + } + + async clearAllHistoryEntries(): Promise { } + + async removeHistoryEntry(_resource: URI): Promise { } + + readonly onDidPerformUserAction = Event.None; + + notifyUserAction(_event: any): void { } + + transferChatSession(): void { } + + setChatSessionTitle(): void { } + + isEditingLocation(_location: ChatAgentLocation): boolean { + return false; + } + + getChatStorageFolder(): URI { + return URI.file('/tmp'); + } + + logChatIndex(): void { } + + isPersistedSessionEmpty(_sessionResource: URI): boolean { + return false; + } + + activateDefaultAgent(_location: ChatAgentLocation): Promise { + return Promise.resolve(); + } + + getChatSessionFromInternalUri(_sessionResource: URI): any { + return undefined; + } + + getLiveSessionItems(): IChatDetail[] { + return this.liveSessionItems; + } + + async getHistorySessionItems(): Promise { + return this.historySessionItems; + } + + waitForModelDisposals(): Promise { + return Promise.resolve(); + } +} + +function createMockChatModel(options: { + sessionResource: URI; + hasRequests?: boolean; + requestInProgress?: boolean; + timestamp?: number; + lastResponseComplete?: boolean; + lastResponseCanceled?: boolean; + lastResponseHasError?: boolean; + lastResponseTimestamp?: number; + lastResponseCompletedAt?: number; + customTitle?: string; + editingSession?: { + entries: Array<{ + state: ModifiedFileEntryState; + linesAdded: number; + linesRemoved: number; + modifiedURI: URI; + }>; + }; +}): IChatModel { + const requests: IChatRequestModel[] = []; + + if (options.hasRequests !== false) { + const mockResponse: Partial = { + isComplete: options.lastResponseComplete ?? true, + isCanceled: options.lastResponseCanceled ?? false, + result: options.lastResponseHasError ? { errorDetails: { message: 'error' } } : undefined, + timestamp: options.lastResponseTimestamp ?? Date.now(), + completedAt: options.lastResponseCompletedAt, + response: { + value: [], + getMarkdown: () => '', + toString: () => options.customTitle ? '' : 'Test response content' + } + }; + + requests.push({ + id: 'request-1', + response: mockResponse as IChatResponseModel + } as IChatRequestModel); + } + + const editingSessionEntries = options.editingSession?.entries.map(entry => ({ + state: observableValue('state', entry.state), + linesAdded: observableValue('linesAdded', entry.linesAdded), + linesRemoved: observableValue('linesRemoved', entry.linesRemoved), + modifiedURI: entry.modifiedURI + })); + + const mockEditingSession = options.editingSession ? { + entries: observableValue('entries', editingSessionEntries ?? []) + } : undefined; + + const _onDidChange = new Emitter<{ kind: string } | undefined>(); + + return { + sessionResource: options.sessionResource, + hasRequests: options.hasRequests !== false, + timestamp: options.timestamp ?? Date.now(), + requestInProgress: observableValue('requestInProgress', options.requestInProgress ?? false), + getRequests: () => requests, + onDidChange: _onDidChange.event, + editingSession: mockEditingSession, + setCustomTitle: (_title: string) => { + _onDidChange.fire({ kind: 'setCustomTitle' }); + } + } as unknown as IChatModel; +} + +suite('LocalAgentsSessionsProvider', () => { + const disposables = new DisposableStore(); + let mockChatWidgetService: MockChatWidgetService; + let mockChatService: MockChatService; + let mockChatSessionsService: MockChatSessionsService; + let instantiationService: TestInstantiationService; + + setup(() => { + mockChatWidgetService = new MockChatWidgetService(); + mockChatService = new MockChatService(); + mockChatSessionsService = new MockChatSessionsService(); + instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); + instantiationService.stub(IChatWidgetService, mockChatWidgetService); + instantiationService.stub(IChatService, mockChatService); + instantiationService.stub(IChatSessionsService, mockChatSessionsService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createProvider(): LocalAgentsSessionsProvider { + return disposables.add(instantiationService.createInstance(LocalAgentsSessionsProvider)); + } + + test('should have correct session type', () => { + const provider = createProvider(); + assert.strictEqual(provider.chatSessionType, localChatSessionType); + }); + + test('should register itself with chat sessions service', () => { + const provider = createProvider(); + + const providers = mockChatSessionsService.getAllChatSessionItemProviders(); + assert.strictEqual(providers.length, 1); + assert.strictEqual(providers[0], provider); + }); + + test('should provide empty sessions when no live or history sessions', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + mockChatService.setLiveSessionItems([]); + mockChatService.setHistorySessionItems([]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 0); + }); + }); + + test('should provide live session items', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('test-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + timestamp: Date.now() + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Test Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].label, 'Test Session'); + assert.strictEqual(sessions[0].resource.toString(), sessionResource.toString()); + }); + }); + + test('should ignore sessions without requests', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('empty-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: false + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Empty Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 0); + }); + }); + + test('should provide history session items', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('history-session'); + + mockChatService.setLiveSessionItems([]); + mockChatService.setHistorySessionItems([{ + sessionResource, + title: 'History Session', + lastMessageDate: Date.now() - 10000, + isActive: false + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].label, 'History Session'); + }); + }); + + test('should not duplicate sessions in history and live', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('duplicate-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Live Session', + lastMessageDate: Date.now(), + isActive: true + }]); + mockChatService.setHistorySessionItems([{ + sessionResource, + title: 'History Session', + lastMessageDate: Date.now() - 10000, + isActive: false + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].label, 'Live Session'); + }); + }); + + suite('Session Status', () => { + test('should return InProgress status when request in progress', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('in-progress-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'In Progress Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.InProgress); + }); + }); + + test('should return Completed status when last response is complete', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('completed-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: false, + lastResponseComplete: true, + lastResponseCanceled: false, + lastResponseHasError: false + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Completed Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); + }); + }); + + test('should return Failed status when last response was canceled', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('canceled-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: false, + lastResponseComplete: false, + lastResponseCanceled: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Canceled Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); + }); + }); + + test('should return Failed status when last response has error', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('error-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + requestInProgress: false, + lastResponseComplete: true, + lastResponseHasError: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Error Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); + }); + }); + }); + + suite('Session Statistics', () => { + test('should return statistics for sessions with modified entries', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('stats-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + editingSession: { + entries: [ + { + state: ModifiedFileEntryState.Modified, + linesAdded: 10, + linesRemoved: 5, + modifiedURI: URI.file('/test/file1.ts') + }, + { + state: ModifiedFileEntryState.Modified, + linesAdded: 20, + linesRemoved: 3, + modifiedURI: URI.file('/test/file2.ts') + } + ] + } + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Stats Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.ok(sessions[0].statistics); + assert.strictEqual(sessions[0].statistics?.files, 2); + assert.strictEqual(sessions[0].statistics?.insertions, 30); + assert.strictEqual(sessions[0].statistics?.deletions, 8); + }); + }); + + test('should not return statistics for sessions without modified entries', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('no-stats-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + editingSession: { + entries: [ + { + state: ModifiedFileEntryState.Accepted, + linesAdded: 10, + linesRemoved: 5, + modifiedURI: URI.file('/test/file1.ts') + } + ] + } + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'No Stats Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].statistics, undefined); + }); + }); + }); + + suite('Session Timing', () => { + test('should use model timestamp for startTime when model exists', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('timing-session'); + const modelTimestamp = Date.now() - 5000; + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + timestamp: modelTimestamp + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Timing Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); + }); + }); + + test('should use lastMessageDate for startTime when model does not exist', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('history-timing'); + const lastMessageDate = Date.now() - 10000; + + mockChatService.setLiveSessionItems([]); + mockChatService.setHistorySessionItems([{ + sessionResource, + title: 'History Timing Session', + lastMessageDate, + isActive: false + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); + }); + }); + + test('should set endTime from last response completedAt', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('endtime-session'); + const completedAt = Date.now() - 1000; + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true, + lastResponseComplete: true, + lastResponseCompletedAt: completedAt + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'EndTime Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].timing.endTime, completedAt); + }); + }); + }); + + suite('Session Icon', () => { + test('should use Codicon.chatSparkle as icon', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + const sessionResource = LocalChatSessionUri.forSession('icon-session'); + const mockModel = createMockChatModel({ + sessionResource, + hasRequests: true + }); + + mockChatService.addSession(sessionResource, mockModel); + mockChatService.setLiveSessionItems([{ + sessionResource, + title: 'Icon Session', + lastMessageDate: Date.now(), + isActive: true + }]); + + const sessions = await provider.provideChatSessionItems(CancellationToken.None); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].iconPath, Codicon.chatSparkle); + }); + }); + }); + + suite('Events', () => { + test('should fire onDidChange when session is disposed', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + let changeEventFired = false; + disposables.add(provider.onDidChange(() => { + changeEventFired = true; + })); + + const sessionResource = LocalChatSessionUri.forSession('disposed-session'); + mockChatService.fireDidDisposeSession(sessionResource); + + assert.strictEqual(changeEventFired, true); + }); + }); + + test('should fire onDidChange when session items change for local type', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + let changeEventFired = false; + disposables.add(provider.onDidChange(() => { + changeEventFired = true; + })); + + mockChatSessionsService.notifySessionItemsChanged(localChatSessionType); + + assert.strictEqual(changeEventFired, true); + }); + }); + + test('should not fire onDidChange when session items change for other types', async () => { + return runWithFakedTimers({}, async () => { + const provider = createProvider(); + + let changeEventFired = false; + disposables.add(provider.onDidChange(() => { + changeEventFired = true; + })); + + mockChatSessionsService.notifySessionItemsChanged('other-type'); + + assert.strictEqual(changeEventFired, false); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index c5cfb9806cbd1..7bdf86b9cd618 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -218,9 +218,10 @@ export class MockChatSessionsService implements IChatSessionsService { } registerModelProgressListener(model: IChatModel, callback: () => void): void { - throw new Error('Method not implemented.'); + // No-op implementation for testing } + getSessionDescription(chatModel: IChatModel): string | undefined { - throw new Error('Method not implemented.'); + return undefined; } } From ffc052c9132702e0db6ded331a2d284dcaf4c852 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:29:41 +0000 Subject: [PATCH 12/17] Bump actions/checkout from 5 to 6 (#279152) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> --- .github/workflows/copilot-setup-steps.yml | 2 +- .github/workflows/monaco-editor.yml | 2 +- .github/workflows/pr-darwin-test.yml | 2 +- .github/workflows/pr-linux-cli-test.yml | 2 +- .github/workflows/pr-linux-test.yml | 2 +- .github/workflows/pr-node-modules.yml | 8 ++++---- .github/workflows/pr-win32-test.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/telemetry.yml | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 0024456b4dfbd..52beb803984a0 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -26,7 +26,7 @@ jobs: # If you do not check out your code, Copilot will do this for you. steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/monaco-editor.yml b/.github/workflows/monaco-editor.yml index f574aab1c7ef2..99aea9933faad 100644 --- a/.github/workflows/monaco-editor.yml +++ b/.github/workflows/monaco-editor.yml @@ -19,7 +19,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index 8faf2dd99cf51..dd8f9d909d422 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -24,7 +24,7 @@ jobs: VSCODE_ARCH: arm64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/pr-linux-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml index 1b9a52a821e72..003e1344fb6c7 100644 --- a/.github/workflows/pr-linux-cli-test.yml +++ b/.github/workflows/pr-linux-cli-test.yml @@ -16,7 +16,7 @@ jobs: RUSTUP_TOOLCHAIN: ${{ inputs.rustup_toolchain }} steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust run: | diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 694c456b5a320..7e69b3d2481df 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -24,7 +24,7 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index ce99efd7a97fb..cae9abdb7f879 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -13,7 +13,7 @@ jobs: runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 @@ -92,7 +92,7 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 @@ -164,7 +164,7 @@ jobs: VSCODE_ARCH: arm64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 @@ -225,7 +225,7 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index 99c2c70b15836..7314a74519c37 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -24,7 +24,7 @@ jobs: VSCODE_ARCH: x64 steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b0b2ed66321d2..1267fa95a7bfb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -21,7 +21,7 @@ jobs: runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] steps: - name: Checkout microsoft/vscode - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/telemetry.yml b/.github/workflows/telemetry.yml index cb7d81e551f08..e30d3cc8da36e 100644 --- a/.github/workflows/telemetry.yml +++ b/.github/workflows/telemetry.yml @@ -7,7 +7,7 @@ jobs: runs-on: 'ubuntu-latest' steps: - - uses: 'actions/checkout@v5' + - uses: 'actions/checkout@v6' with: persist-credentials: false From d8b476e88d74061aad50de7aa487ce6c826c661c Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 25 Nov 2025 15:11:24 +0100 Subject: [PATCH 13/17] Change to warning --- src/vs/workbench/api/common/extHostMcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 6645be3fc8b0e..e9fc82ac139c1 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -438,7 +438,7 @@ export class McpHTTPHandle extends Disposable { scopesChallenge ??= resourceMetadata.scopes_supported; resource = resourceMetadata; } catch (e) { - this._log(LogLevel.Debug, `Could not fetch resource metadata: ${String(e)}`); + this._log(LogLevel.Warning, `Could not fetch resource metadata: ${String(e)}`); } const baseUrl = new URL(originalResponse.url).origin; From 44c4dafb466868a71a1bd0264450249ae707ad7d Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 25 Nov 2025 15:51:20 +0100 Subject: [PATCH 14/17] rename edit source --- .../browser/model/renameSymbolProcessor.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts index 4b7366c93b54a..119761600a9b7 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts @@ -17,6 +17,7 @@ import { StandardTokenType } from '../../../../common/encodedTokenAttributes.js' import { Command, InlineCompletionHintStyle } from '../../../../common/languages.js'; import { ITextModel } from '../../../../common/model.js'; import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; +import { EditSources, TextModelEditSource } from '../../../../common/textModelEditSource.js'; import { prepareRename, rename } from '../../../rename/browser/rename.js'; import { renameSymbolCommandId } from '../controller/commandIds.js'; import { InlineSuggestHint, InlineSuggestionItem } from './inlineSuggestionItem.js'; @@ -33,16 +34,12 @@ export class RenameSymbolProcessor extends Disposable { @IBulkEditService bulkEditService: IBulkEditService, ) { super(); - this._register(CommandsRegistry.registerCommand(renameSymbolCommandId, async (_: ServicesAccessor, textModel: ITextModel, position: Position, newName: string) => { - try { - const result = await rename(this._languageFeaturesService.renameProvider, textModel, position, newName); - if (result.rejectReason) { - return; - } - bulkEditService.apply(result); - } catch (error) { - // The actual rename failed we should log this. + this._register(CommandsRegistry.registerCommand(renameSymbolCommandId, async (_: ServicesAccessor, textModel: ITextModel, position: Position, newName: string, source: TextModelEditSource) => { + const result = await rename(this._languageFeaturesService.renameProvider, textModel, position, newName); + if (result.rejectReason) { + return; } + bulkEditService.apply(result, { reason: source }); })); } @@ -69,12 +66,18 @@ export class RenameSymbolProcessor extends Disposable { return suggestItem; } + const source = EditSources.inlineCompletionAccept({ + nes: suggestItem.isInlineEdit, + requestUuid: suggestItem.requestUuid, + providerId: suggestItem.source.provider.providerId, + languageId: textModel.getLanguageId(), + }); const hintRange = edits.renames.edits[0].replacements[0].range; const label = localize('renameSymbol', "Rename '{0}' to '{1}'", oldName, newName); const command: Command = { id: renameSymbolCommandId, title: label, - arguments: [textModel, position, newName], + arguments: [textModel, position, newName, source], }; const hint = InlineSuggestHint.create({ range: hintRange, content: label, style: InlineCompletionHintStyle.Code }); return InlineSuggestionItem.create(suggestItem.withRename(command, hint), textModel); From 6ea77b3ff442a24c71f421ee9b99e2470dc46cd5 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 16:15:36 +0100 Subject: [PATCH 15/17] agent sessions - show a link to open all sessions (#279366) --- .../contrib/chat/browser/chatViewPane.ts | 40 +++++++++++++++---- .../chat/browser/media/chatViewPane.css | 15 ++++++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index 6acc49cb3c8df..a19b1b5d5b5ed 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatViewPane.css'; -import { $, getWindow } from '../../../../base/browser/dom.js'; +import { $, append, getWindow } from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../base/common/marshallingIds.js'; @@ -36,11 +36,15 @@ import { CHAT_PROVIDER_ID } from '../common/chatParticipantContribTypes.js'; import { IChatModelReference, IChatService } from '../common/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../common/chatUri.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, LEGACY_AGENT_SESSIONS_VIEW_ID } from '../common/constants.js'; +import { AGENT_SESSIONS_VIEW_ID } from './agentSessions/agentSessions.js'; import { showCloseActiveChatNotification } from './actions/chatCloseNotification.js'; import { ChatWidget } from './chatWidget.js'; +import { Link } from '../../../../platform/opener/browser/link.js'; +import { localize } from '../../../../nls.js'; import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js'; import { AgentSessionsControl } from './agentSessions/agentSessionsControl.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; interface IChatViewPaneState extends Partial { sessionId?: string; @@ -64,6 +68,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private sessionsContainer: HTMLElement | undefined; private sessionsControl: AgentSessionsControl | undefined; + private sessionsLinkContainer: HTMLElement | undefined; private restoringSession: Promise | undefined; @@ -88,6 +93,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @ILayoutService private readonly layoutService: ILayoutService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ITelemetryService private readonly telemetryService: ITelemetryService, + @ICommandService private readonly commandService: ICommandService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -247,9 +253,24 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } private createSessionsControl(parent: HTMLElement): void { - this.sessionsContainer = parent.appendChild($('.chat-viewpane-sessions-container')); + + // Sessions Control + this.sessionsContainer = parent.appendChild($('.agent-sessions-container')); this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsContainer, undefined)); + // Link to Sessions View + this.sessionsLinkContainer = append(this.sessionsContainer, $('.agent-sessions-link-container')); + this._register(this.instantiationService.createInstance(Link, this.sessionsLinkContainer, { label: localize('openAgentSessionsView', "Show All Sessions"), href: '', }, { + opener: () => { + // TODO@bpasero remove this check once settled + if (this.configurationService.getValue('chat.agentSessionsViewLocation') === 'single-view') { + this.commandService.executeCommand(AGENT_SESSIONS_VIEW_ID); + } else { + this.commandService.executeCommand(LEGACY_AGENT_SESSIONS_VIEW_ID); + } + } + })); + this.updateSessionsControlVisibility(false); this._register(this.onDidChangeBodyVisibility(() => this.updateSessionsControlVisibility(true))); @@ -373,14 +394,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.lastDimensions = { height, width }; - let widgetHeight = height; + let remainingHeight = height; // Sessions Control - const sessionsControlHeight = this.sessionsContainer?.offsetHeight ?? 0; - widgetHeight -= sessionsControlHeight; - this.sessionsControl?.layout(sessionsControlHeight, width); + const sessionsContainerHeight = this.sessionsContainer?.offsetHeight ?? 0; + remainingHeight -= sessionsContainerHeight; - this._widget.layout(widgetHeight, width); + const sessionsLinkHeight = this.sessionsLinkContainer?.offsetHeight ?? 0; + this.sessionsControl?.layout(sessionsContainerHeight - sessionsLinkHeight, width); + + // Chat Widget + this._widget.layout(remainingHeight, width); } override saveState(): void { diff --git a/src/vs/workbench/contrib/chat/browser/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/media/chatViewPane.css index 4680ec999fa3c..2cb43aff3f0f1 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatViewPane.css @@ -7,7 +7,18 @@ display: flex; flex-direction: column; - .chat-viewpane-sessions-container { - height: calc(3 * 44px); /* TODO@bpasero revisit: show at most 3 sessions */ + .agent-sessions-container { + display: flex; + flex-direction: column; + + .agent-sessions-viewer { + height: calc(3 * 44px); /* TODO@bpasero revisit: show at most 3 sessions */ + } + + .agent-sessions-link-container { + padding: 4px 12px; + font-size: 12px; + text-align: center; + } } } From 65f1fcffe147086c2fa7757827d4dea7d7f9ee9f Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 25 Nov 2025 16:43:40 +0100 Subject: [PATCH 16/17] chat - bring "Show History" back if sessions view is disabled in chat view (#279376) --- .../contrib/chat/browser/actions/chatActions.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 087eb5121a6f1..b9be1b1605336 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -512,7 +512,19 @@ export function registerChatActions() { menu: [ { id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', ChatViewId), + when: ContextKeyExpr.and( + ContextKeyExpr.equals('view', ChatViewId), + ContextKeyExpr.equals(`config.${ChatConfiguration.EmptyChatViewSessionsEnabled}`, false) + ), + group: 'navigation', + order: 2 + }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('view', ChatViewId), + ContextKeyExpr.equals(`config.${ChatConfiguration.EmptyChatViewSessionsEnabled}`, true) + ), group: '2_history', order: 1 }, From baefac95dd575d801508a1660e95031882c82294 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 25 Nov 2025 08:20:22 -0800 Subject: [PATCH 17/17] Change Delegate to --> Continue in (#279386) change Delegate to --> Continue in --- .../contrib/chat/browser/actions/chatContinueInAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index cc35c6ca64cbd..42a390af431c7 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -182,7 +182,7 @@ export class ChatContinueInSessionActionItem extends ActionWidgetDropdownActionV if (this.location === ActionLocation.Editor) { const view = h('span.action-widget-delegate-label', [ h('span', { className: ThemeIcon.asClassName(Codicon.forward) }), - h('span', [localize('delegate', "Delegate to...")]) + h('span', [localize('continueInEllipsis', "Continue in...")]) ]); element.appendChild(view.root); return null;