diff --git a/eslint.config.js b/eslint.config.js index 52eb95c5ff06e..0cf09d0b24f1b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -899,6 +899,7 @@ export default tseslint.config( ], 'verbs': [ 'accept', + 'archive', 'change', 'close', 'collapse', diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 65bcacd26e622..ddefdd8934b6b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -65,7 +65,7 @@ const _allApiProposals = { }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', - version: 3 + version: 4 }, chatStatusItem: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts', diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 791a22f677f79..7f8142003d50f 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -382,7 +382,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } - $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } @@ -491,6 +490,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat resource: uri, iconPath: session.iconPath, tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, + archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ccfd8731f6ef9..4d893851be1bf 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1529,6 +1529,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, + createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); + }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 260627104c1a1..65253dbe9cec9 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,12 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -29,6 +31,175 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; +// #region Chat Session Item Controller + +class ChatSessionItemImpl implements vscode.ChatSessionItem { + #label: string; + #iconPath?: vscode.IconPath; + #description?: string | vscode.MarkdownString; + #badge?: string | vscode.MarkdownString; + #status?: vscode.ChatSessionStatus; + #archived?: boolean; + #tooltip?: string | vscode.MarkdownString; + #timing?: { startTime: number; endTime?: number }; + #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; + #onChanged: () => void; + + readonly resource: vscode.Uri; + + constructor(resource: vscode.Uri, label: string, onChanged: () => void) { + this.resource = resource; + this.#label = label; + this.#onChanged = onChanged; + } + + get label(): string { + return this.#label; + } + + set label(value: string) { + if (this.#label !== value) { + this.#label = value; + this.#onChanged(); + } + } + + get iconPath(): vscode.IconPath | undefined { + return this.#iconPath; + } + + set iconPath(value: vscode.IconPath | undefined) { + if (this.#iconPath !== value) { + this.#iconPath = value; + this.#onChanged(); + } + } + + get description(): string | vscode.MarkdownString | undefined { + return this.#description; + } + + set description(value: string | vscode.MarkdownString | undefined) { + if (this.#description !== value) { + this.#description = value; + this.#onChanged(); + } + } + + get badge(): string | vscode.MarkdownString | undefined { + return this.#badge; + } + + set badge(value: string | vscode.MarkdownString | undefined) { + if (this.#badge !== value) { + this.#badge = value; + this.#onChanged(); + } + } + + get status(): vscode.ChatSessionStatus | undefined { + return this.#status; + } + + set status(value: vscode.ChatSessionStatus | undefined) { + if (this.#status !== value) { + this.#status = value; + this.#onChanged(); + } + } + + get archived(): boolean | undefined { + return this.#archived; + } + + set archived(value: boolean | undefined) { + if (this.#archived !== value) { + this.#archived = value; + this.#onChanged(); + } + } + + get tooltip(): string | vscode.MarkdownString | undefined { + return this.#tooltip; + } + + set tooltip(value: string | vscode.MarkdownString | undefined) { + if (this.#tooltip !== value) { + this.#tooltip = value; + this.#onChanged(); + } + } + + get timing(): { startTime: number; endTime?: number } | undefined { + return this.#timing; + } + + set timing(value: { startTime: number; endTime?: number } | undefined) { + if (this.#timing !== value) { + this.#timing = value; + this.#onChanged(); + } + } + + get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { + return this.#changes; + } + + set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { + if (this.#changes !== value) { + this.#changes = value; + this.#onChanged(); + } + } +} + +class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { + readonly #items = new ResourceMap(); + #onItemsChanged: () => void; + + constructor(onItemsChanged: () => void) { + this.#onItemsChanged = onItemsChanged; + } + + get size(): number { + return this.#items.size; + } + + replace(items: readonly vscode.ChatSessionItem[]): void { + this.#items.clear(); + for (const item of items) { + this.#items.set(item.resource, item); + } + this.#onItemsChanged(); + } + + forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { + for (const [_, item] of this.#items) { + callback.call(thisArg, item, this); + } + } + + add(item: vscode.ChatSessionItem): void { + this.#items.set(item.resource, item); + this.#onItemsChanged(); + } + + delete(resource: vscode.Uri): void { + this.#items.delete(resource); + this.#onItemsChanged(); + } + + get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { + return this.#items.get(resource); + } + + [Symbol.iterator](): Iterator { + return this.#items.entries(); + } +} + +// #endregion + class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -62,13 +233,20 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly extension: IExtensionDescription; readonly disposable: DisposableStore; }>(); + private readonly _chatSessionItemControllers = new Map(); + private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map(); - private _nextChatSessionItemProviderHandle = 0; + private _nextChatSessionItemControllerHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -137,6 +315,52 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } + + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { + const controllerHandle = this._nextChatSessionItemControllerHandle++; + const disposables = new DisposableStore(); + + // TODO: Currently not hooked up + const onDidArchiveChatSessionItem = disposables.add(new Emitter()); + + const collection = new ChatSessionItemCollectionImpl(() => { + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + + let isDisposed = false; + + const controller: vscode.ChatSessionItemController = { + id, + refreshHandler, + items: collection, + onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, + createChatSessionItem: (resource: vscode.Uri, label: string) => { + if (isDisposed) { + throw new Error('ChatSessionItemController has been disposed'); + } + + return new ChatSessionItemImpl(resource, label, () => { + // TODO: Optimize to only update the specific item + this._proxy.$onDidChangeChatSessionItems(controllerHandle); + }); + }, + dispose: () => { + isDisposed = true; + disposables.dispose(); + }, + }; + + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(controllerHandle, id); + + disposables.add(toDisposable(() => { + this._chatSessionItemControllers.delete(controllerHandle); + this._proxy.$unregisterChatSessionItemProvider(controllerHandle); + })); + + return controller; + } + registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { const handle = this._nextChatSessionContentProviderHandle++; const disposables = new DisposableStore(); @@ -181,13 +405,14 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } - private convertChatSessionItem(sessionType: string, sessionContent: vscode.ChatSessionItem): IChatSessionItem { + private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { return { resource: sessionContent.resource, label: sessionContent.label, description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, status: this.convertChatSessionStatus(sessionContent.status), + archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { startTime: sessionContent.timing?.startTime ?? 0, @@ -204,21 +429,35 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemProviders.get(handle); - if (!entry) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } + let items: vscode.ChatSessionItem[]; + + const controller = this._chatSessionItemControllers.get(handle); + if (controller) { + // Call the refresh handler to populate items + await controller.controller.refreshHandler(); + if (token.isCancellationRequested) { + return []; + } + + items = Array.from(controller.controller.items, x => x[1]); + } else { - const sessions = await entry.provider.provideChatSessionItems(token); - if (!sessions) { - return []; + const itemProvider = this._chatSessionItemProviders.get(handle); + if (!itemProvider) { + this._logService.error(`No provider registered for handle ${handle}`); + return []; + } + + items = await itemProvider.provider.provideChatSessionItems(token) ?? []; + if (token.isCancellationRequested) { + return []; + } } const response: IChatSessionItem[] = []; - for (const sessionContent of sessions) { + for (const sessionContent of items) { this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); + response.push(this.convertChatSessionItem(sessionContent)); } return response; } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 6e50c8e5a7b41..95352a55f2c33 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -62,6 +62,7 @@ export interface IChatSessionsExtensionPoint { readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; } + export interface IChatSessionItem { resource: URI; label: string; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 2ec68c1731e98..213feb92c0017 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 4 declare module 'vscode' { /** @@ -26,6 +26,25 @@ declare module 'vscode' { InProgress = 2 } + export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + + /** + * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + */ + export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; + } + /** * Provides a list of information about chat sessions. */ @@ -52,6 +71,86 @@ declare module 'vscode' { // #endregion } + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemController { + readonly id: string; + + /** + * Unregisters the controller, disposing of its associated chat session items. + */ + dispose(): void; + + /** + * Managed collection of chat session items + */ + readonly items: ChatSessionItemCollection; + + /** + * Creates a new managed chat session item that be added to the collection. + */ + createChatSessionItem(resource: Uri, label: string): ChatSessionItem; + + /** + * Handler called to refresh the collection of chat session items. + * + * This is also called on first load to get the initial set of items. + */ + refreshHandler: () => Thenable; + + /** + * Fired when an item is archived by the editor + * + * TODO: expose archive state on the item too? + */ + readonly onDidArchiveChatSessionItem: Event; + } + + /** + * A collection of chat session items. It provides operations for managing and iterating over the items. + */ + export interface ChatSessionItemCollection extends Iterable { + /** + * Gets the number of items in the collection. + */ + readonly size: number; + + /** + * Replaces the items stored by the collection. + * @param items Items to store. + */ + replace(items: readonly ChatSessionItem[]): void; + + /** + * Iterate over each entry in this collection. + * + * @param callback Function to execute for each entry. + * @param thisArg The `this` context used when invoking the handler function. + */ + forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; + + /** + * Adds the chat session item to the collection. If an item with the same resource URI already + * exists, it'll be replaced. + * @param item Item to add. + */ + add(item: ChatSessionItem): void; + + /** + * Removes a single chat session item from the collection. + * @param resource Item resource to delete. + */ + delete(resource: Uri): void; + + /** + * Efficiently gets a chat session item by resource, if it exists, in the collection. + * @param resource Item resource to get. + * @returns The found item or undefined if it does not exist. + */ + get(resource: Uri): ChatSessionItem | undefined; + } + export interface ChatSessionItem { /** * The resource associated with the chat session. @@ -90,6 +189,11 @@ declare module 'vscode' { */ tooltip?: string | MarkdownString; + /** + * Whether the chat session has been archived. + */ + archived?: boolean; + /** * The times at which session started and ended */ @@ -268,18 +372,6 @@ declare module 'vscode' { } export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. *