Path: blob/main/src/vs/workbench/contrib/chat/browser/chatSessions.contribution.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../base/common/cancellation.js';6import { Codicon } from '../../../../base/common/codicons.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { MarkdownString } from '../../../../base/common/htmlContent.js';9import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';10import { truncate } from '../../../../base/common/strings.js';11import { localize, localize2 } from '../../../../nls.js';12import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';13import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';14import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';15import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';16import { ILogService } from '../../../../platform/log/common/log.js';17import { IEditableData } from '../../../common/views.js';18import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';19import { IEditorService } from '../../../services/editor/common/editorService.js';20import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';21import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js';22import { IChatWidgetService } from '../browser/chat.js';23import { ChatEditorInput } from '../browser/chatEditorInput.js';24import { IChatAgentData, IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../common/chatAgents.js';25import { ChatContextKeys } from '../common/chatContextKeys.js';26import { IChatProgress, IChatService } from '../common/chatService.js';27import { ChatSession, ChatSessionStatus, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionsExtensionPoint, IChatSessionsService } from '../common/chatSessionsService.js';28import { ChatSessionUri } from '../common/chatUri.js';29import { ChatAgentLocation, ChatModeKind } from '../common/constants.js';30import { CHAT_CATEGORY } from './actions/chatActions.js';31import { IChatEditorOptions } from './chatEditor.js';32import { VIEWLET_ID } from './chatSessions.js';3334const CODING_AGENT_DOCS = 'https://code.visualstudio.com/docs/copilot/copilot-coding-agent';3536const extensionPoint = ExtensionsRegistry.registerExtensionPoint<IChatSessionsExtensionPoint[]>({37extensionPoint: 'chatSessions',38jsonSchema: {39description: localize('chatSessionsExtPoint', 'Contributes chat session integrations to the chat widget.'),40type: 'array',41items: {42type: 'object',43properties: {44type: {45description: localize('chatSessionsExtPoint.chatSessionType', 'Unique identifier for the type of chat session.'),46type: 'string',47},48name: {49description: localize('chatSessionsExtPoint.name', 'Name shown in the chat widget. (eg: @agent)'),50type: 'string',51},52displayName: {53description: localize('chatSessionsExtPoint.displayName', 'A longer name for this item which is used for display in menus.'),54type: 'string',55},56description: {57description: localize('chatSessionsExtPoint.description', 'Description of the chat session for use in menus and tooltips.'),58type: 'string'59},60when: {61description: localize('chatSessionsExtPoint.when', 'Condition which must be true to show this item.'),62type: 'string'63},64capabilities: {65description: localize('chatSessionsExtPoint.capabilities', 'Optional capabilities for this chat session.'),66type: 'object',67properties: {68supportsFileAttachments: {69description: localize('chatSessionsExtPoint.supportsFileAttachments', 'Whether this chat session supports attaching files or file references.'),70type: 'boolean'71},72supportsToolAttachments: {73description: localize('chatSessionsExtPoint.supportsToolAttachments', 'Whether this chat session supports attaching tools or tool references.'),74type: 'boolean'75}76}77}78},79required: ['type', 'name', 'displayName', 'description'],80}81},82activationEventsGenerator: (contribs, results) => {83for (const contrib of contribs) {84results.push(`onChatSession:${contrib.type}`);85}86}87});8889class ContributedChatSessionData implements IDisposable {90private readonly _disposableStore: DisposableStore;9192constructor(93readonly session: ChatSession,94readonly chatSessionType: string,95readonly id: string,96private readonly onWillDispose: (session: ChatSession, chatSessionType: string, id: string) => void97) {98this._disposableStore = new DisposableStore();99this._disposableStore.add(this.session.onWillDispose(() => {100this.onWillDispose(this.session, this.chatSessionType, this.id);101}));102}103104dispose(): void {105this._disposableStore.dispose();106}107}108109110export class ChatSessionsService extends Disposable implements IChatSessionsService {111readonly _serviceBrand: undefined;112private readonly _itemsProviders: Map<string, IChatSessionItemProvider> = new Map();113114private readonly _onDidChangeItemsProviders = this._register(new Emitter<IChatSessionItemProvider>());115readonly onDidChangeItemsProviders: Event<IChatSessionItemProvider> = this._onDidChangeItemsProviders.event;116private readonly _contentProviders: Map<string, IChatSessionContentProvider> = new Map();117private readonly _contributions: Map<string, IChatSessionsExtensionPoint> = new Map();118private readonly _disposableStores: Map<string, DisposableStore> = new Map();119private readonly _contextKeys = new Set<string>();120private readonly _onDidChangeSessionItems = this._register(new Emitter<string>());121readonly onDidChangeSessionItems: Event<string> = this._onDidChangeSessionItems.event;122private readonly _onDidChangeAvailability = this._register(new Emitter<void>());123readonly onDidChangeAvailability: Event<void> = this._onDidChangeAvailability.event;124private readonly _onDidChangeInProgress = this._register(new Emitter<void>());125public get onDidChangeInProgress() { return this._onDidChangeInProgress.event; }126private readonly inProgressMap: Map<string, number> = new Map();127128constructor(129@ILogService private readonly _logService: ILogService,130@IInstantiationService private readonly _instantiationService: IInstantiationService,131@IChatAgentService private readonly _chatAgentService: IChatAgentService,132@IExtensionService private readonly _extensionService: IExtensionService,133@IContextKeyService private readonly _contextKeyService: IContextKeyService,134) {135super();136this._register(extensionPoint.setHandler(extensions => {137for (const ext of extensions) {138if (!isProposedApiEnabled(ext.description, 'chatSessionsProvider')) {139continue;140}141if (!Array.isArray(ext.value)) {142continue;143}144for (const contribution of ext.value) {145const c: IChatSessionsExtensionPoint = {146type: contribution.type,147name: contribution.name,148displayName: contribution.displayName,149description: contribution.description,150when: contribution.when,151capabilities: contribution.capabilities,152extensionDescription: ext.description,153};154this._register(this.registerContribution(c));155}156}157}));158159// Listen for context changes and re-evaluate contributions160this._register(Event.filter(this._contextKeyService.onDidChangeContext, e => e.affectsSome(this._contextKeys))(() => {161this._evaluateAvailability();162}));163164this._register(this.onDidChangeSessionItems(chatSessionType => {165this.updateInProgressStatus(chatSessionType).catch(error => {166this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error);167});168}));169}170171public reportInProgress(chatSessionType: string, count: number): void {172let displayName: string | undefined;173174if (chatSessionType === 'local') {175displayName = 'Local Chat Sessions';176} else {177displayName = this._contributions.get(chatSessionType)?.displayName;178}179180if (displayName) {181this.inProgressMap.set(displayName, count);182}183this._onDidChangeInProgress.fire();184}185186public getInProgress(): { displayName: string; count: number }[] {187return Array.from(this.inProgressMap.entries()).map(([displayName, count]) => ({ displayName, count }));188}189190private async updateInProgressStatus(chatSessionType: string): Promise<void> {191try {192const items = await this.provideChatSessionItems(chatSessionType, CancellationToken.None);193const inProgress = items.filter(item => item.status === ChatSessionStatus.InProgress);194this.reportInProgress(chatSessionType, inProgress.length);195} catch (error) {196this._logService.warn(`Failed to update in-progress status for chat session type '${chatSessionType}':`, error);197}198}199200private registerContribution(contribution: IChatSessionsExtensionPoint): IDisposable {201if (this._contributions.has(contribution.type)) {202this._logService.warn(`Chat session contribution with id '${contribution.type}' is already registered.`);203return { dispose: () => { } };204}205206// Track context keys from the when condition207if (contribution.when) {208const whenExpr = ContextKeyExpr.deserialize(contribution.when);209if (whenExpr) {210for (const key of whenExpr.keys()) {211this._contextKeys.add(key);212}213}214}215216this._contributions.set(contribution.type, contribution);217this._evaluateAvailability();218219return {220dispose: () => {221this._contributions.delete(contribution.type);222const store = this._disposableStores.get(contribution.type);223if (store) {224store.dispose();225this._disposableStores.delete(contribution.type);226}227}228};229}230231private _isContributionAvailable(contribution: IChatSessionsExtensionPoint): boolean {232if (!contribution.when) {233return true;234}235const whenExpr = ContextKeyExpr.deserialize(contribution.when);236return !whenExpr || this._contextKeyService.contextMatchesRules(whenExpr);237}238239private _registerMenuItems(contribution: IChatSessionsExtensionPoint): IDisposable {240return MenuRegistry.appendMenuItem(MenuId.ViewTitle, {241command: {242id: `workbench.action.chat.openNewSessionEditor.${contribution.type}`,243title: localize('interactiveSession.openNewSessionEditor', "New {0} Chat Editor", contribution.displayName),244icon: Codicon.plus,245},246group: 'navigation',247order: 1,248when: ContextKeyExpr.and(249ContextKeyExpr.equals('view', `${VIEWLET_ID}.${contribution.type}`)250),251});252}253254private _registerCommands(contribution: IChatSessionsExtensionPoint): IDisposable {255return registerAction2(class OpenNewChatSessionEditorAction extends Action2 {256constructor() {257super({258id: `workbench.action.chat.openNewSessionEditor.${contribution.type}`,259title: localize2('interactiveSession.openNewSessionEditor', "New {0} Chat Editor", contribution.displayName),260category: CHAT_CATEGORY,261icon: Codicon.plus,262f1: true, // Show in command palette263precondition: ChatContextKeys.enabled264});265}266267async run(accessor: ServicesAccessor) {268const editorService = accessor.get(IEditorService);269const logService = accessor.get(ILogService);270271const { type } = contribution;272273try {274const options: IChatEditorOptions = {275override: ChatEditorInput.EditorID,276pinned: true,277};278await editorService.openEditor({279resource: ChatEditorInput.getNewEditorUri().with({ query: `chatSessionType=${type}` }),280options,281});282} catch (e) {283logService.error(`Failed to open new '${type}' chat session editor`, e);284}285}286});287}288289private _evaluateAvailability(): void {290let hasChanges = false;291for (const contribution of this._contributions.values()) {292const isCurrentlyRegistered = this._disposableStores.has(contribution.type);293const shouldBeRegistered = this._isContributionAvailable(contribution);294if (isCurrentlyRegistered && !shouldBeRegistered) {295// Disable the contribution by disposing its disposable store296const store = this._disposableStores.get(contribution.type);297if (store) {298store.dispose();299this._disposableStores.delete(contribution.type);300}301// Also dispose any cached sessions for this contribution302this._disposeSessionsForContribution(contribution.type);303hasChanges = true;304} else if (!isCurrentlyRegistered && shouldBeRegistered) {305// Enable the contribution by registering it306this._enableContribution(contribution);307hasChanges = true;308}309}310if (hasChanges) {311this._onDidChangeAvailability.fire();312for (const provider of this._itemsProviders.values()) {313this._onDidChangeItemsProviders.fire(provider);314}315for (const contribution of this._contributions.values()) {316this._onDidChangeSessionItems.fire(contribution.type);317}318}319}320321private _enableContribution(contribution: IChatSessionsExtensionPoint): void {322const disposableStore = new DisposableStore();323this._disposableStores.set(contribution.type, disposableStore);324325disposableStore.add(this._registerDynamicAgent(contribution));326disposableStore.add(this._registerCommands(contribution));327disposableStore.add(this._registerMenuItems(contribution));328}329330private _disposeSessionsForContribution(contributionId: string): void {331// Find and dispose all sessions that belong to this contribution332const sessionsToDispose: string[] = [];333for (const [sessionKey, sessionData] of this._sessions) {334if (sessionData.chatSessionType === contributionId) {335sessionsToDispose.push(sessionKey);336}337}338339if (sessionsToDispose.length > 0) {340this._logService.info(`Disposing ${sessionsToDispose.length} cached sessions for contribution '${contributionId}' due to when clause change`);341}342343for (const sessionKey of sessionsToDispose) {344const sessionData = this._sessions.get(sessionKey);345if (sessionData) {346sessionData.dispose(); // This will call _onWillDisposeSession and clean up347}348}349}350351private _registerDynamicAgent(contribution: IChatSessionsExtensionPoint): IDisposable {352const { type: id, name, displayName, description, extensionDescription } = contribution;353const { identifier: extensionId, name: extensionName, displayName: extensionDisplayName, publisher: extensionPublisherId } = extensionDescription;354const agentData: IChatAgentData = {355id,356name,357fullName: displayName,358description: description,359isDefault: false,360isCore: false,361isDynamic: true,362slashCommands: [],363locations: [ChatAgentLocation.Panel],364modes: [ChatModeKind.Agent, ChatModeKind.Ask], // TODO: These are no longer respected365disambiguation: [],366metadata: {367themeIcon: Codicon.sendToRemoteAgent,368isSticky: false,369},370capabilities: contribution.capabilities,371extensionId,372extensionVersion: extensionDescription.version,373extensionDisplayName: extensionDisplayName || extensionName,374extensionPublisherId,375};376377const agentImpl = this._instantiationService.createInstance(CodingAgentChatImplementation, contribution);378const disposable = this._chatAgentService.registerDynamicAgent(agentData, agentImpl);379return disposable;380}381382getAllChatSessionContributions(): IChatSessionsExtensionPoint[] {383return Array.from(this._contributions.values()).filter(contribution =>384this._isContributionAvailable(contribution)385);386}387388getAllChatSessionItemProviders(): IChatSessionItemProvider[] {389return [...this._itemsProviders.values()].filter(provider => {390// Check if the provider's corresponding contribution is available391const contribution = this._contributions.get(provider.chatSessionType);392return !contribution || this._isContributionAvailable(contribution);393});394}395396async canResolveItemProvider(chatViewType: string) {397await this._extensionService.whenInstalledExtensionsRegistered();398const contribution = this._contributions.get(chatViewType);399if (contribution && !this._isContributionAvailable(contribution)) {400return false;401}402403if (this._itemsProviders.has(chatViewType)) {404return true;405}406407await this._extensionService.activateByEvent(`onChatSession:${chatViewType}`);408409return this._itemsProviders.has(chatViewType);410}411412async canResolveContentProvider(chatViewType: string) {413await this._extensionService.whenInstalledExtensionsRegistered();414const contribution = this._contributions.get(chatViewType);415if (contribution && !this._isContributionAvailable(contribution)) {416return false;417}418419if (this._contentProviders.has(chatViewType)) {420return true;421}422423await this._extensionService.activateByEvent(`onChatSession:${chatViewType}`);424425return this._contentProviders.has(chatViewType);426}427428public async provideChatSessionItems(chatSessionType: string, token: CancellationToken): Promise<IChatSessionItem[]> {429if (!(await this.canResolveItemProvider(chatSessionType))) {430throw Error(`Can not find provider for ${chatSessionType}`);431}432433const provider = this._itemsProviders.get(chatSessionType);434435if (provider?.provideChatSessionItems) {436const sessions = await provider.provideChatSessionItems(token);437return sessions;438}439440return [];441}442443public registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable {444const chatSessionType = provider.chatSessionType;445this._itemsProviders.set(chatSessionType, provider);446this._onDidChangeItemsProviders.fire(provider);447448const disposables = new DisposableStore();449disposables.add(provider.onDidChangeChatSessionItems(() => {450this._onDidChangeSessionItems.fire(chatSessionType);451}));452453this.updateInProgressStatus(chatSessionType).catch(error => {454this._logService.warn(`Failed to update initial progress status for '${chatSessionType}':`, error);455});456457return {458dispose: () => {459disposables.dispose();460461const provider = this._itemsProviders.get(chatSessionType);462if (provider) {463this._itemsProviders.delete(chatSessionType);464this._onDidChangeItemsProviders.fire(provider);465}466}467};468}469470registerChatSessionContentProvider(chatSessionType: string, provider: IChatSessionContentProvider): IDisposable {471this._contentProviders.set(chatSessionType, provider);472return {473dispose: () => {474this._contentProviders.delete(chatSessionType);475476// Remove all sessions that were created by this provider477for (const [key, session] of this._sessions) {478if (session.chatSessionType === chatSessionType) {479session.dispose();480this._sessions.delete(key);481}482}483}484};485}486487private readonly _sessions = new Map<string, ContributedChatSessionData>();488489// Editable session support490private readonly _editableSessions = new Map<string, IEditableData>();491492/**493* Creates a new chat session by delegating to the appropriate provider494* @param chatSessionType The type of chat session provider to use495* @param options Options for the new session including the request496* @param token A cancellation token497* @returns A session ID for the newly created session498*/499public async provideNewChatSessionItem(chatSessionType: string, options: {500request: IChatAgentRequest;501prompt?: string;502history?: any[];503metadata?: any;504}, token: CancellationToken): Promise<IChatSessionItem> {505if (!(await this.canResolveItemProvider(chatSessionType))) {506throw Error(`Cannot find provider for ${chatSessionType}`);507}508509const provider = this._itemsProviders.get(chatSessionType);510if (!provider?.provideNewChatSessionItem) {511throw Error(`Provider for ${chatSessionType} does not support creating sessions`);512}513const chatSessionItem = await provider.provideNewChatSessionItem(options, token);514this._onDidChangeSessionItems.fire(chatSessionType);515return chatSessionItem;516}517518public async provideChatSessionContent(chatSessionType: string, id: string, token: CancellationToken): Promise<ChatSession> {519if (!(await this.canResolveContentProvider(chatSessionType))) {520throw Error(`Can not find provider for ${chatSessionType}`);521}522523const provider = this._contentProviders.get(chatSessionType);524if (!provider) {525throw Error(`Can not find provider for ${chatSessionType}`);526}527528const sessionKey = `${chatSessionType}_${id}`;529const existingSessionData = this._sessions.get(sessionKey);530if (existingSessionData) {531return existingSessionData.session;532}533534const session = await provider.provideChatSessionContent(id, token);535const sessionData = new ContributedChatSessionData(session, chatSessionType, id, this._onWillDisposeSession.bind(this));536537this._sessions.set(sessionKey, sessionData);538539return session;540}541542private _onWillDisposeSession(session: ChatSession, chatSessionType: string, id: string): void {543const sessionKey = `${chatSessionType}_${id}`;544this._sessions.delete(sessionKey);545}546547// Implementation of editable session methods548public async setEditableSession(sessionId: string, data: IEditableData | null): Promise<void> {549if (!data) {550this._editableSessions.delete(sessionId);551} else {552this._editableSessions.set(sessionId, data);553}554// Trigger refresh of the session views that might need to update their rendering555this._onDidChangeSessionItems.fire('local');556}557558public getEditableData(sessionId: string): IEditableData | undefined {559return this._editableSessions.get(sessionId);560}561562public isEditable(sessionId: string): boolean {563return this._editableSessions.has(sessionId);564}565566public notifySessionItemsChanged(chatSessionType: string): void {567this._onDidChangeSessionItems.fire(chatSessionType);568}569}570571registerSingleton(IChatSessionsService, ChatSessionsService, InstantiationType.Delayed);572573/**574* Implementation for individual remote coding agent chat functionality575*/576class CodingAgentChatImplementation extends Disposable implements IChatAgentImplementation {577578constructor(579private readonly chatSession: IChatSessionsExtensionPoint,580@IChatService private readonly chatService: IChatService,581@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,582@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,583@IChatSessionsService private readonly chatSessionService: IChatSessionsService,584@IEditorService private readonly editorService: IEditorService,585@ILogService private readonly logService: ILogService,586) {587super();588}589590async invoke(request: IChatAgentRequest, progress: (progress: IChatProgress[]) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise<IChatAgentResult> {591const widget = this.chatWidgetService.getWidgetBySessionId(request.sessionId);592593if (!widget) {594return {};595}596597let chatSession: ChatSession | undefined;598599// Find the first editor that matches the chat session600for (const group of this.editorGroupService.groups) {601if (chatSession) {602break;603}604605for (const editor of group.editors) {606if (editor instanceof ChatEditorInput) {607try {608const chatModel = await this.chatService.loadSessionForResource(editor.resource, request.location, CancellationToken.None);609if (chatModel?.sessionId === request.sessionId) {610// this is the model611const identifier = ChatSessionUri.parse(editor.resource);612613if (identifier) {614chatSession = await this.chatSessionService.provideChatSessionContent(this.chatSession.type, identifier.sessionId, token);615}616break;617}618} catch (error) {619// might not be us620}621}622}623}624625if (chatSession?.requestHandler) {626await chatSession.requestHandler(request, progress, history, token); // TODO: Revisit this function's signature in relation to its extension API (eg: 'history' is not strongly typed here)627} else {628try {629const chatSessionItem = await this.chatSessionService.provideNewChatSessionItem(630this.chatSession.type,631{632request,633prompt: request.message,634history,635},636token,637);638const options: IChatEditorOptions = {639pinned: true,640preferredTitle: truncate(chatSessionItem.label, 30),641};642643// Prefetch the chat session content to make the subsequent editor swap quick644await this.chatSessionService.provideChatSessionContent(645this.chatSession.type,646chatSessionItem.id,647token,648);649650const activeGroup = this.editorGroupService.activeGroup;651const currentEditor = activeGroup?.activeEditor;652if (currentEditor instanceof ChatEditorInput) {653await this.editorService.replaceEditors([{654editor: currentEditor,655replacement: {656resource: ChatSessionUri.forSession(this.chatSession.type, chatSessionItem.id),657options,658}659}], activeGroup);660} else {661// Fallback: open in new editor if we couldn't find the current one662await this.editorService.openEditor({663resource: ChatSessionUri.forSession(this.chatSession.type, chatSessionItem.id),664options,665});666progress([{667kind: 'markdownContent',668content: new MarkdownString(localize('continueInNewChat', 'Continue **{0}** in a new chat editor', truncate(chatSessionItem.label, 30))),669}]);670}671} catch (error) {672// NOTE: May end up here if extension does not support 'provideNewChatSessionItem' or that API usage throws673this.logService.error(`Failed to create new chat session for type '${this.chatSession.type}'`, error);674const content =675this.chatSession.type === 'copilot-swe-agent' // TODO: Use contributed error messages676? new MarkdownString(localize('chatSessionNotFoundCopilot', "Failed to create chat session. Use `#copilotCodingAgent` to begin a new [coding agent session]({0}).", CODING_AGENT_DOCS))677: new MarkdownString(localize('chatSessionNotFoundGeneric', "Failed to create chat session. Please try again later."));678progress([{679kind: 'markdownContent',680content,681}]);682}683}684685return {};686}687}688689690