From 3f197a65831ce81ce2526f90e845caef0bb57f9e Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:18:02 -0800 Subject: [PATCH 01/17] Initial sketch of a controller based chat session item API For #276243 Explores moving the chat session item api to use a controller instead of a provider --- eslint.config.js | 1 + .../vscode.proposed.chatSessionsProvider.d.ts | 100 ++++++++++++++---- 2 files changed, 78 insertions(+), 23 deletions(-) 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/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 2ec68c1731e98..12c664326d66b 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -26,30 +26,96 @@ declare module 'vscode' { InProgress = 2 } + export namespace chat { + /** + * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. + */ + export function createChatSessionItemController(id: string): ChatSessionItemController; + } + /** * Provides a list of information about chat sessions. */ - export interface ChatSessionItemProvider { + export class 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? Or should this + */ + readonly onDidArchiveChatSessionItem: Event; + + /** + * Fired when an item is disposed by the editor + */ + readonly onDidDisposeChatSessionItem: Event; + } + + /** + * A collection of chat session items. It provides operations for managing and iterating over the items. + */ + export interface ChatSessionItemCollection extends Iterable { /** - * Event that the provider can fire to signal that chat sessions have changed. + * Gets the number of items in the collection. */ - readonly onDidChangeChatSessionItems: Event; + readonly size: number; /** - * Provides a list of chat sessions. + * Replaces the items stored by the collection. + * @param items Items to store. */ - // TODO: Do we need a flag to try auth if needed? - provideChatSessionItems(token: CancellationToken): ProviderResult; + replace(items: readonly ChatSessionItem[]): void; - // #region Unstable parts of API + /** + * 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; /** - * Event that the provider can fire to signal that the current (original) chat session should be replaced with a new (modified) chat session. - * The UI can use this information to gracefully migrate the user to the new session. + * 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. */ - readonly onDidCommitChatSessionItem: Event<{ original: ChatSessionItem /** untitled */; modified: ChatSessionItem /** newly created */ }>; + add(item: ChatSessionItem): void; - // #endregion + /** + * 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 { @@ -268,18 +334,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}. * From e45ac30b90b0de04d8cafda7ab6ad96922eaa5bd Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:46:29 -0800 Subject: [PATCH 02/17] Hookup basic proxy impl --- .../workbench/api/common/extHost.api.impl.ts | 5 +- .../api/common/extHostChatSessions.ts | 224 +++++++++++++++--- src/vs/workbench/api/common/extHostTypes.ts | 11 + .../vscode.proposed.chatSessionsProvider.d.ts | 6 +- 4 files changed, 211 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ccfd8731f6ef9..99e6187397493 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1525,9 +1525,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, - registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { + createChatSessionItemController: (id: string, refreshHandler: () => Thenable) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); + return extHostChatSessions.createChatSessionItemController(extension, id, refreshHandler); }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); @@ -1865,6 +1865,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection, ChatCopyKind: extHostTypes.ChatCopyKind, ChatSessionChangedFile: extHostTypes.ChatSessionChangedFile, + ChatSessionItemController: extHostTypes.ChatSessionItemController, ChatEditingSessionActionOutcome: extHostTypes.ChatEditingSessionActionOutcome, InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind, DebugStackFrame: extHostTypes.DebugStackFrame, diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 260627104c1a1..2c926f086e92e 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,148 @@ 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 { + readonly resource: vscode.Uri; + #label: string; + #iconPath?: vscode.IconPath; + #description?: string | vscode.MarkdownString; + #badge?: string | vscode.MarkdownString; + #status?: vscode.ChatSessionStatus; + #tooltip?: string | vscode.MarkdownString; + #timing?: { startTime: number; endTime?: number }; + #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; + #onChanged: () => void; + + 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) { + this.#label = value; + this.#onChanged(); + } + + get iconPath(): vscode.IconPath | undefined { + return this.#iconPath; + } + + set iconPath(value: vscode.IconPath | undefined) { + this.#iconPath = value; + this.#onChanged(); + } + + get description(): string | vscode.MarkdownString | undefined { + return this.#description; + } + + set description(value: string | vscode.MarkdownString | undefined) { + this.#description = value; + this.#onChanged(); + } + + get badge(): string | vscode.MarkdownString | undefined { + return this.#badge; + } + + set badge(value: string | vscode.MarkdownString | undefined) { + this.#badge = value; + this.#onChanged(); + } + + get status(): vscode.ChatSessionStatus | undefined { + return this.#status; + } + + set status(value: vscode.ChatSessionStatus | undefined) { + this.#status = value; + this.#onChanged(); + } + + get tooltip(): string | vscode.MarkdownString | undefined { + return this.#tooltip; + } + + set tooltip(value: string | vscode.MarkdownString | undefined) { + this.#tooltip = value; + this.#onChanged(); + } + + get timing(): { startTime: number; endTime?: number } | undefined { + return this.#timing; + } + + set timing(value: { startTime: number; endTime?: number } | undefined) { + 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) { + 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](): Generator { + for (const [uri, item] of this.#items) { + yield [uri, item] as const; + } + } +} + +// #endregion + class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -56,9 +200,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio private static _sessionHandlePool = 0; private readonly _proxy: Proxied; - private readonly _chatSessionItemProviders = new Map(); @@ -68,7 +212,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly capabilities?: vscode.ChatSessionCapabilities; readonly disposable: DisposableStore; }>(); - private _nextChatSessionItemProviderHandle = 0; + private _nextChatSessionItemControllerHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -111,30 +255,50 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - registerChatSessionItemProvider(extension: IExtensionDescription, chatSessionType: string, provider: vscode.ChatSessionItemProvider): vscode.Disposable { - const handle = this._nextChatSessionItemProviderHandle++; + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { + const handle = this._nextChatSessionItemControllerHandle++; const disposables = new DisposableStore(); - this._chatSessionItemProviders.set(handle, { provider, extension, disposable: disposables, sessionType: chatSessionType }); - this._proxy.$registerChatSessionItemProvider(handle, chatSessionType); - if (provider.onDidChangeChatSessionItems) { - disposables.add(provider.onDidChangeChatSessionItems(() => { - this._proxy.$onDidChangeChatSessionItems(handle); - })); - } - if (provider.onDidCommitChatSessionItem) { - disposables.add(provider.onDidCommitChatSessionItem((e) => { - const { original, modified } = e; - this._proxy.$onDidCommitChatSessionItem(handle, original.resource, modified.resource); - })); - } - return { + // TODO: Currently not hooked up + const onDidArchiveChatSessionItem = disposables.add(new Emitter()); + const onDidDisposeChatSessionItem = disposables.add(new Emitter()); + + const collection = new ChatSessionItemCollectionImpl(() => { + this._proxy.$onDidChangeChatSessionItems(handle); + }); + + let isDisposed = false; + + const controller: vscode.ChatSessionItemController = { + id, + refreshHandler, + items: collection, + onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, + onDidDisposeChatSessionItem: onDidDisposeChatSessionItem.event, + createChatSessionItem: (resource: vscode.Uri, label: string) => { + if (isDisposed) { + throw new Error('ChatSessionItemController has been disposed'); + } + + return new ChatSessionItemImpl(resource, label, () => { + this._proxy.$onDidChangeChatSessionItems(handle); + }); + }, dispose: () => { - this._chatSessionItemProviders.delete(handle); + isDisposed = true; disposables.dispose(); - this._proxy.$unregisterChatSessionItemProvider(handle); - } + }, }; + + this._chatSessionItemControllers.set(handle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(handle, id); + + disposables.add(toDisposable(() => { + this._chatSessionItemControllers.delete(handle); + this._proxy.$unregisterChatSessionItemProvider(handle); + })); + + return controller; } registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { @@ -204,19 +368,19 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemProviders.get(handle); + const entry = this._chatSessionItemControllers.get(handle); if (!entry) { - this._logService.error(`No provider registered for handle ${handle}`); + this._logService.error(`No controller registered for handle ${handle}`); return []; } - const sessions = await entry.provider.provideChatSessionItems(token); - if (!sessions) { - return []; - } + // Call the refresh handler to populate items + await entry.controller.refreshHandler(); + + const items = [...entry.controller.items]; 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)); } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 41cbfdd173858..0a718a8c547db 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3431,6 +3431,17 @@ export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } +// Stub class - actual implementation is in extHostChatSessions.ts +export class ChatSessionItemController { + readonly id!: string; + readonly items!: vscode.ChatSessionItemCollection; + refreshHandler!: () => Thenable; + readonly onDidArchiveChatSessionItem!: vscode.Event; + readonly onDidDisposeChatSessionItem!: vscode.Event; + createChatSessionItem(_resource: vscode.Uri, _label: string): vscode.ChatSessionItem { throw new Error('Stub'); } + dispose(): void { throw new Error('Stub'); } +} + export enum ChatResponseReferencePartStatusKind { Complete = 1, Partial = 2, diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 12c664326d66b..c44f0991d369a 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -30,7 +30,7 @@ declare module 'vscode' { /** * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. */ - export function createChatSessionItemController(id: string): ChatSessionItemController; + export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; } /** @@ -64,7 +64,7 @@ declare module 'vscode' { /** * Fired when an item is archived by the editor * - * TODO: expose archive state on the item too? Or should this + * TODO: expose archive state on the item too? */ readonly onDidArchiveChatSessionItem: Event; @@ -77,7 +77,7 @@ declare module 'vscode' { /** * A collection of chat session items. It provides operations for managing and iterating over the items. */ - export interface ChatSessionItemCollection extends Iterable { + export interface ChatSessionItemCollection extends Iterable { /** * Gets the number of items in the collection. */ From 25bdc74f90a7494e5438d7d332da263331c57020 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:44:43 -0800 Subject: [PATCH 03/17] Cleanup --- .../workbench/api/common/extHost.api.impl.ts | 5 +- .../api/common/extHostChatSessions.ts | 120 +++++++++++++----- src/vs/workbench/api/common/extHostTypes.ts | 11 -- .../vscode.proposed.chatSessionsProvider.d.ts | 45 ++++++- 4 files changed, 134 insertions(+), 47 deletions(-) diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 99e6187397493..ad4c3bf659fb2 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1525,6 +1525,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, + registerChatSessionItemProvider: (id: string, provider: vscode.ChatSessionItemProvider) => { + checkProposedApiEnabled(extension, 'chatSessionsProvider'); + return extHostChatSessions.registerChatSessionItemProvider(extension, id, provider); + }, createChatSessionItemController: (id: string, refreshHandler: () => Thenable) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.createChatSessionItemController(extension, id, refreshHandler); @@ -1865,7 +1869,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection, ChatCopyKind: extHostTypes.ChatCopyKind, ChatSessionChangedFile: extHostTypes.ChatSessionChangedFile, - ChatSessionItemController: extHostTypes.ChatSessionItemController, ChatEditingSessionActionOutcome: extHostTypes.ChatEditingSessionActionOutcome, InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind, DebugStackFrame: extHostTypes.DebugStackFrame, diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 2c926f086e92e..6ecdfc4d37580 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -56,8 +56,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set label(value: string) { - this.#label = value; - this.#onChanged(); + if (this.#label !== value) { + this.#label = value; + this.#onChanged(); + } } get iconPath(): vscode.IconPath | undefined { @@ -65,8 +67,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set iconPath(value: vscode.IconPath | undefined) { - this.#iconPath = value; - this.#onChanged(); + if (this.#iconPath !== value) { + this.#iconPath = value; + this.#onChanged(); + } } get description(): string | vscode.MarkdownString | undefined { @@ -74,8 +78,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set description(value: string | vscode.MarkdownString | undefined) { - this.#description = value; - this.#onChanged(); + if (this.#description !== value) { + this.#description = value; + this.#onChanged(); + } } get badge(): string | vscode.MarkdownString | undefined { @@ -83,8 +89,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set badge(value: string | vscode.MarkdownString | undefined) { - this.#badge = value; - this.#onChanged(); + if (this.#badge !== value) { + this.#badge = value; + this.#onChanged(); + } } get status(): vscode.ChatSessionStatus | undefined { @@ -92,8 +100,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set status(value: vscode.ChatSessionStatus | undefined) { - this.#status = value; - this.#onChanged(); + if (this.#status !== value) { + this.#status = value; + this.#onChanged(); + } } get tooltip(): string | vscode.MarkdownString | undefined { @@ -101,8 +111,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set tooltip(value: string | vscode.MarkdownString | undefined) { - this.#tooltip = value; - this.#onChanged(); + if (this.#tooltip !== value) { + this.#tooltip = value; + this.#onChanged(); + } } get timing(): { startTime: number; endTime?: number } | undefined { @@ -110,8 +122,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set timing(value: { startTime: number; endTime?: number } | undefined) { - this.#timing = value; - this.#onChanged(); + if (this.#timing !== value) { + this.#timing = value; + this.#onChanged(); + } } get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { @@ -119,8 +133,10 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { - this.#changes = value; - this.#onChanged(); + if (this.#changes !== value) { + this.#changes = value; + this.#onChanged(); + } } } @@ -200,12 +216,19 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio private static _sessionHandlePool = 0; private readonly _proxy: Proxied; + private readonly _chatSessionItemProviders = new Map(); private readonly _chatSessionItemControllers = new Map(); + private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map { + this._proxy.$onDidChangeChatSessionItems(handle); + })); + } + if (provider.onDidCommitChatSessionItem) { + disposables.add(provider.onDidCommitChatSessionItem((e) => { + const { original, modified } = e; + this._proxy.$onDidCommitChatSessionItem(handle, original.resource, modified.resource); + })); + } + return { + dispose: () => { + this._chatSessionItemProviders.delete(handle); + disposables.dispose(); + this._proxy.$unregisterChatSessionItemProvider(handle); + } + }; + } + + createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { const handle = this._nextChatSessionItemControllerHandle++; const disposables = new DisposableStore(); // TODO: Currently not hooked up const onDidArchiveChatSessionItem = disposables.add(new Emitter()); - const onDidDisposeChatSessionItem = disposables.add(new Emitter()); const collection = new ChatSessionItemCollectionImpl(() => { this._proxy.$onDidChangeChatSessionItems(handle); @@ -274,7 +323,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio refreshHandler, items: collection, onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, - onDidDisposeChatSessionItem: onDidDisposeChatSessionItem.event, createChatSessionItem: (resource: vscode.Uri, label: string) => { if (isDisposed) { throw new Error('ChatSessionItemController has been disposed'); @@ -345,7 +393,7 @@ 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, @@ -368,21 +416,35 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const entry = this._chatSessionItemControllers.get(handle); - if (!entry) { - this._logService.error(`No controller 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 []; + } - // Call the refresh handler to populate items - await entry.controller.refreshHandler(); + items = Array.from(controller.controller.items, x => x[1]); + } else { - const items = [...entry.controller.items]; + 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 items) { + 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/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 0a718a8c547db..41cbfdd173858 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3431,17 +3431,6 @@ export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } -// Stub class - actual implementation is in extHostChatSessions.ts -export class ChatSessionItemController { - readonly id!: string; - readonly items!: vscode.ChatSessionItemCollection; - refreshHandler!: () => Thenable; - readonly onDidArchiveChatSessionItem!: vscode.Event; - readonly onDidDisposeChatSessionItem!: vscode.Event; - createChatSessionItem(_resource: vscode.Uri, _label: string): vscode.ChatSessionItem { throw new Error('Stub'); } - dispose(): void { throw new Error('Stub'); } -} - export enum ChatResponseReferencePartStatusKind { Complete = 1, Partial = 2, diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index c44f0991d369a..b7e53e5d49b79 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -27,6 +27,18 @@ 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; + /** * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. */ @@ -36,7 +48,33 @@ declare module 'vscode' { /** * Provides a list of information about chat sessions. */ - export class ChatSessionItemController { + export interface ChatSessionItemProvider { + /** + * Event that the provider can fire to signal that chat sessions have changed. + */ + readonly onDidChangeChatSessionItems: Event; + + /** + * Provides a list of chat sessions. + */ + // TODO: Do we need a flag to try auth if needed? + provideChatSessionItems(token: CancellationToken): ProviderResult; + + // #region Unstable parts of API + + /** + * Event that the provider can fire to signal that the current (original) chat session should be replaced with a new (modified) chat session. + * The UI can use this information to gracefully migrate the user to the new session. + */ + readonly onDidCommitChatSessionItem: Event<{ original: ChatSessionItem /** untitled */; modified: ChatSessionItem /** newly created */ }>; + + // #endregion + } + + /** + * Provides a list of information about chat sessions. + */ + export interface ChatSessionItemController { readonly id: string; /** @@ -67,11 +105,6 @@ declare module 'vscode' { * TODO: expose archive state on the item too? */ readonly onDidArchiveChatSessionItem: Event; - - /** - * Fired when an item is disposed by the editor - */ - readonly onDidDisposeChatSessionItem: Event; } /** From 242baf74bc4c1b77a24f1d7149539965227c1a87 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:34:17 -0800 Subject: [PATCH 04/17] Add archived property --- .../api/browser/mainThreadChatSessions.ts | 3 +- .../api/common/extHostChatSessions.ts | 28 ++++++++++++++----- .../chat/common/chatSessionsService.ts | 1 + .../vscode.proposed.chatSessionsProvider.d.ts | 5 ++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 791a22f677f79..fa5e7997e6291 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(); } @@ -490,7 +489,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat changes: revive(session.changes), resource: uri, iconPath: session.iconPath, - tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, + tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 6ecdfc4d37580..9c751cd07a64c 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -40,6 +40,7 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #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 }; @@ -106,6 +107,17 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } } + 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; } @@ -306,14 +318,14 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { - const handle = this._nextChatSessionItemControllerHandle++; + 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(handle); + this._proxy.$onDidChangeChatSessionItems(controllerHandle); }); let isDisposed = false; @@ -329,7 +341,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } return new ChatSessionItemImpl(resource, label, () => { - this._proxy.$onDidChangeChatSessionItems(handle); + // TODO: Optimize to only update the specific item + this._proxy.$onDidChangeChatSessionItems(controllerHandle); }); }, dispose: () => { @@ -338,12 +351,12 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, }; - this._chatSessionItemControllers.set(handle, { controller, extension, disposable: disposables, sessionType: id }); - this._proxy.$registerChatSessionItemProvider(handle, id); + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); + this._proxy.$registerChatSessionItemProvider(controllerHandle, id); disposables.add(toDisposable(() => { - this._chatSessionItemControllers.delete(handle); - this._proxy.$unregisterChatSessionItemProvider(handle); + this._chatSessionItemControllers.delete(controllerHandle); + this._proxy.$unregisterChatSessionItemProvider(controllerHandle); })); return controller; @@ -400,6 +413,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio 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, 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 b7e53e5d49b79..bdef678b5aa0d 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -189,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 */ From 93ff336d5185459a117d520b3f735c75bb4a1887 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:39:35 -0800 Subject: [PATCH 05/17] Cleanup --- src/vs/workbench/api/browser/mainThreadChatSessions.ts | 3 ++- src/vs/workbench/api/common/extHost.api.impl.ts | 8 ++++---- src/vs/workbench/api/common/extHostChatSessions.ts | 9 ++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index fa5e7997e6291..7f8142003d50f 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -489,7 +489,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat changes: revive(session.changes), resource: uri, iconPath: session.iconPath, - tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, archived: session.archived, + 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 ad4c3bf659fb2..4d893851be1bf 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1525,13 +1525,13 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, - registerChatSessionItemProvider: (id: string, provider: vscode.ChatSessionItemProvider) => { + registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.registerChatSessionItemProvider(extension, id, provider); + return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, - createChatSessionItemController: (id: string, refreshHandler: () => Thenable) => { + createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.createChatSessionItemController(extension, id, refreshHandler); + return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 9c751cd07a64c..65253dbe9cec9 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -34,7 +34,6 @@ import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; // #region Chat Session Item Controller class ChatSessionItemImpl implements vscode.ChatSessionItem { - readonly resource: vscode.Uri; #label: string; #iconPath?: vscode.IconPath; #description?: string | vscode.MarkdownString; @@ -46,6 +45,8 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #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; @@ -192,10 +193,8 @@ class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection return this.#items.get(resource); } - *[Symbol.iterator](): Generator { - for (const [uri, item] of this.#items) { - yield [uri, item] as const; - } + [Symbol.iterator](): Iterator { + return this.#items.entries(); } } From bfd6eed65bfa65d77440864372e75679e0df1c17 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:41:16 -0800 Subject: [PATCH 06/17] Bump api version --- src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index bdef678b5aa0d..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' { /** From 49ee0d00694f3a90c57f79b9f5c51229fdfefaac Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:09:18 -0800 Subject: [PATCH 07/17] Also update proposal file --- src/vs/platform/extensions/common/extensionsApiProposals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From 4c7b7c7edfc049dd56776f15d40448f53203742a Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:37:54 -0800 Subject: [PATCH 08/17] Bump api notebook milestone --- .vscode/notebooks/api.github-issues | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/notebooks/api.github-issues b/.vscode/notebooks/api.github-issues index d466fa1b04b5d..aca29690dc229 100644 --- a/.vscode/notebooks/api.github-issues +++ b/.vscode/notebooks/api.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"October 2025\"" + "value": "$REPO=repo:microsoft/vscode\n$MILESTONE=milestone:\"January 2026\"" }, { "kind": 1, From 067cb03d18229c4cf3f142448f852dac4b7ebf33 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 14 Jan 2026 00:31:19 +0100 Subject: [PATCH 09/17] [json] add trustedDomains settings (#287639) * use trusted schemas * [json] add trustedDomains settings --- .../client/src/jsonClient.ts | 329 +++++++++++++----- .../client/src/languageStatus.ts | 91 ++++- .../client/src/utils/urlMatch.ts | 107 ++++++ .../json-language-features/package.json | 16 + .../json-language-features/package.nls.json | 4 +- .../server/package-lock.json | 8 +- .../server/package.json | 2 +- .../server/src/jsonServer.ts | 18 +- 8 files changed, 466 insertions(+), 109 deletions(-) create mode 100644 extensions/json-language-features/client/src/utils/urlMatch.ts diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index 6d832e6c1592e..95d0a131b7c18 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -7,9 +7,9 @@ export type JSONLanguageStatus = { schemas: string[] }; import { workspace, window, languages, commands, LogOutputChannel, ExtensionContext, extensions, Uri, ColorInformation, - Diagnostic, StatusBarAlignment, TextEditor, TextDocument, FormattingOptions, CancellationToken, FoldingRange, + Diagnostic, StatusBarAlignment, TextDocument, FormattingOptions, CancellationToken, FoldingRange, ProviderResult, TextEdit, Range, Position, Disposable, CompletionItem, CompletionList, CompletionContext, Hover, MarkdownString, FoldingContext, DocumentSymbol, SymbolInformation, l10n, - RelativePattern + RelativePattern, CodeAction, CodeActionKind, CodeActionContext } from 'vscode'; import { LanguageClientOptions, RequestType, NotificationType, FormattingOptions as LSPFormattingOptions, DocumentDiagnosticReportKind, @@ -20,8 +20,9 @@ import { import { hash } from './utils/hash'; -import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem } from './languageStatus'; +import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem, createSchemaLoadIssueItem, createSchemaLoadStatusItem } from './languageStatus'; import { getLanguageParticipants, LanguageParticipants } from './languageParticipants'; +import { matchesUrlPattern } from './utils/urlMatch'; namespace VSCodeContentRequest { export const type: RequestType = new RequestType('vscode/content'); @@ -42,6 +43,7 @@ namespace LanguageStatusRequest { namespace ValidateContentRequest { export const type: RequestType<{ schemaUri: string; content: string }, LSPDiagnostic[], any> = new RequestType('json/validateContent'); } + interface SortOptions extends LSPFormattingOptions { } @@ -110,6 +112,7 @@ export namespace SettingIds { export const enableKeepLines = 'json.format.keepLines'; export const enableValidation = 'json.validate.enable'; export const enableSchemaDownload = 'json.schemaDownload.enable'; + export const trustedDomains = 'json.schemaDownload.trustedDomains'; export const maxItemsComputed = 'json.maxItemsComputed'; export const editorFoldingMaximumRegions = 'editor.foldingMaximumRegions'; export const editorColorDecoratorsLimit = 'editor.colorDecoratorsLimit'; @@ -119,6 +122,17 @@ export namespace SettingIds { export const colorDecoratorsLimit = 'colorDecoratorsLimit'; } +export namespace CommandIds { + export const workbenchActionOpenSettings = 'workbench.action.openSettings'; + export const workbenchTrustManage = 'workbench.trust.manage'; + export const retryResolveSchemaCommandId = '_json.retryResolveSchema'; + export const configureTrustedDomainsCommandId = '_json.configureTrustedDomains'; + export const showAssociatedSchemaList = '_json.showAssociatedSchemaList'; + export const clearCacheCommandId = 'json.clearCache'; + export const validateCommandId = 'json.validate'; + export const sortCommandId = 'json.sort'; +} + export interface TelemetryReporter { sendTelemetryEvent(eventName: string, properties?: { [key: string]: string; @@ -143,6 +157,16 @@ export interface SchemaRequestService { clearCache?(): Promise; } +export enum SchemaRequestServiceErrors { + UntrustedWorkspaceError = 1, + UntrustedSchemaError = 2, + OpenTextDocumentAccessError = 3, + HTTPDisabledError = 4, + HTTPError = 5, + VSCodeAccessError = 6, + UntitledAccessError = 7, +} + export const languageServerDescription = l10n.t('JSON Language Server'); let resultLimit = 5000; @@ -191,6 +215,8 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const toDispose: Disposable[] = []; let rangeFormatting: Disposable | undefined = undefined; + let settingsCache: Settings | undefined = undefined; + let schemaAssociationsCache: Promise | undefined = undefined; const documentSelector = languageParticipants.documentSelector; @@ -200,14 +226,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(schemaResolutionErrorStatusBarItem); const fileSchemaErrors = new Map(); - let schemaDownloadEnabled = true; + let schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); + let trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); let isClientReady = false; const documentSymbolsLimitStatusbarItem = createLimitStatusItem((limit: number) => createDocumentSymbolsLimitItem(documentSelector, SettingIds.maxItemsComputed, limit)); toDispose.push(documentSymbolsLimitStatusbarItem); - toDispose.push(commands.registerCommand('json.clearCache', async () => { + const schemaLoadStatusItem = createSchemaLoadStatusItem((diagnostic: Diagnostic) => createSchemaLoadIssueItem(documentSelector, schemaDownloadEnabled, diagnostic)); + toDispose.push(schemaLoadStatusItem); + + toDispose.push(commands.registerCommand(CommandIds.clearCacheCommandId, async () => { if (isClientReady && runtime.schemaRequests.clearCache) { const cachedSchemas = await runtime.schemaRequests.clearCache(); await client.sendNotification(SchemaContentChangeNotification.type, cachedSchemas); @@ -215,12 +245,12 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP window.showInformationMessage(l10n.t('JSON schema cache cleared.')); })); - toDispose.push(commands.registerCommand('json.validate', async (schemaUri: Uri, content: string) => { + toDispose.push(commands.registerCommand(CommandIds.validateCommandId, async (schemaUri: Uri, content: string) => { const diagnostics: LSPDiagnostic[] = await client.sendRequest(ValidateContentRequest.type, { schemaUri: schemaUri.toString(), content }); return diagnostics.map(client.protocol2CodeConverter.asDiagnostic); })); - toDispose.push(commands.registerCommand('json.sort', async () => { + toDispose.push(commands.registerCommand(CommandIds.sortCommandId, async () => { if (isClientReady) { const textEditor = window.activeTextEditor; @@ -239,17 +269,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } })); - function filterSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { - const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); - if (schemaErrorIndex !== -1) { - const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; - fileSchemaErrors.set(uri.toString(), schemaResolveDiagnostic.message); - if (!schemaDownloadEnabled) { - diagnostics = diagnostics.filter(d => !isSchemaResolveError(d)); - } - if (window.activeTextEditor && window.activeTextEditor.document.uri.toString() === uri.toString()) { - schemaResolutionErrorStatusBarItem.show(); - } + function handleSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { + schemaLoadStatusItem.update(uri, diagnostics); + if (!schemaDownloadEnabled) { + return diagnostics.filter(d => !isSchemaResolveError(d)); } return diagnostics; } @@ -270,18 +293,18 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }, middleware: { workspace: { - didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() }) + didChangeConfiguration: () => client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) }) }, provideDiagnostics: async (uriOrDoc, previousResolutId, token, next) => { const diagnostics = await next(uriOrDoc, previousResolutId, token); if (diagnostics && diagnostics.kind === DocumentDiagnosticReportKind.Full) { const uri = uriOrDoc instanceof Uri ? uriOrDoc : uriOrDoc.uri; - diagnostics.items = filterSchemaErrorDiagnostics(uri, diagnostics.items); + diagnostics.items = handleSchemaErrorDiagnostics(uri, diagnostics.items); } return diagnostics; }, handleDiagnostics: (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => { - diagnostics = filterSchemaErrorDiagnostics(uri, diagnostics); + diagnostics = handleSchemaErrorDiagnostics(uri, diagnostics); next(uri, diagnostics); }, // testing the replace / insert mode @@ -373,7 +396,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const uri = Uri.parse(uriPath); const uriString = uri.toString(true); if (uri.scheme === 'untitled') { - throw new ResponseError(3, l10n.t('Unable to load {0}', uriString)); + throw new ResponseError(SchemaRequestServiceErrors.UntitledAccessError, l10n.t('Unable to load {0}', uriString)); } if (uri.scheme === 'vscode') { try { @@ -382,7 +405,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const content = await workspace.fs.readFile(uri); return new TextDecoder().decode(content); } catch (e) { - throw new ResponseError(5, e.toString(), e); + throw new ResponseError(SchemaRequestServiceErrors.VSCodeAccessError, e.toString(), e); } } else if (uri.scheme !== 'http' && uri.scheme !== 'https') { try { @@ -390,9 +413,15 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP schemaDocuments[uriString] = true; return document.getText(); } catch (e) { - throw new ResponseError(2, e.toString(), e); + throw new ResponseError(SchemaRequestServiceErrors.OpenTextDocumentAccessError, e.toString(), e); + } + } else if (schemaDownloadEnabled) { + if (!workspace.isTrusted) { + throw new ResponseError(SchemaRequestServiceErrors.UntrustedWorkspaceError, l10n.t('Downloading schemas is disabled in untrusted workspaces')); + } + if (!await isTrusted(uri)) { + throw new ResponseError(SchemaRequestServiceErrors.UntrustedSchemaError, l10n.t('Location {0} is untrusted', uriString)); } - } else if (schemaDownloadEnabled && workspace.isTrusted) { if (runtime.telemetry && uri.authority === 'schema.management.azure.com') { /* __GDPR__ "json.schema" : { @@ -406,13 +435,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP try { return await runtime.schemaRequests.getContent(uriString); } catch (e) { - throw new ResponseError(4, e.toString()); + throw new ResponseError(SchemaRequestServiceErrors.HTTPError, e.toString(), e); } } else { - if (!workspace.isTrusted) { - throw new ResponseError(1, l10n.t('Downloading schemas is disabled in untrusted workspaces')); - } - throw new ResponseError(1, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload)); + throw new ResponseError(SchemaRequestServiceErrors.HTTPDisabledError, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload)); } }); @@ -427,19 +453,6 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } return false; }; - const handleActiveEditorChange = (activeEditor?: TextEditor) => { - if (!activeEditor) { - return; - } - - const activeDocUri = activeEditor.document.uri.toString(); - - if (activeDocUri && fileSchemaErrors.has(activeDocUri)) { - schemaResolutionErrorStatusBarItem.show(); - } else { - schemaResolutionErrorStatusBarItem.hide(); - } - }; const handleContentClosed = (uriString: string) => { if (handleContentChange(uriString)) { delete schemaDocuments[uriString]; @@ -484,59 +497,81 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP toDispose.push(workspace.onDidChangeTextDocument(e => handleContentChange(e.document.uri.toString()))); toDispose.push(workspace.onDidCloseTextDocument(d => handleContentClosed(d.uri.toString()))); - toDispose.push(window.onDidChangeActiveTextEditor(handleActiveEditorChange)); + toDispose.push(commands.registerCommand(CommandIds.retryResolveSchemaCommandId, triggerValidation)); - const handleRetryResolveSchemaCommand = () => { - if (window.activeTextEditor) { - schemaResolutionErrorStatusBarItem.text = '$(watch)'; - const activeDocUri = window.activeTextEditor.document.uri.toString(); - client.sendRequest(ForceValidateRequest.type, activeDocUri).then((diagnostics) => { - const schemaErrorIndex = diagnostics.findIndex(isSchemaResolveError); - if (schemaErrorIndex !== -1) { - // Show schema resolution errors in status bar only; ref: #51032 - const schemaResolveDiagnostic = diagnostics[schemaErrorIndex]; - fileSchemaErrors.set(activeDocUri, schemaResolveDiagnostic.message); - } else { - schemaResolutionErrorStatusBarItem.hide(); + toDispose.push(commands.registerCommand(CommandIds.configureTrustedDomainsCommandId, configureTrustedDomains)); + + toDispose.push(languages.registerCodeActionsProvider(documentSelector, { + provideCodeActions(_document: TextDocument, _range: Range, context: CodeActionContext): CodeAction[] { + const codeActions: CodeAction[] = []; + + for (const diagnostic of context.diagnostics) { + if (typeof diagnostic.code !== 'number') { + continue; } - schemaResolutionErrorStatusBarItem.text = '$(alert)'; - }); - } - }; + switch (diagnostic.code) { + case ErrorCodes.UntrustedSchemaError: { + const title = l10n.t('Configure Trusted Domains...'); + const action = new CodeAction(title, CodeActionKind.QuickFix); + const schemaUri = diagnostic.relatedInformation?.[0]?.location.uri; + if (schemaUri) { + action.command = { command: CommandIds.configureTrustedDomainsCommandId, arguments: [schemaUri.toString()], title }; + } else { + action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.trustedDomains], title }; + } + action.diagnostics = [diagnostic]; + action.isPreferred = true; + codeActions.push(action); + } + break; + case ErrorCodes.HTTPDisabledError: { + const title = l10n.t('Enable Schema Downloading...'); + const action = new CodeAction(title, CodeActionKind.QuickFix); + action.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.enableSchemaDownload], title }; + action.diagnostics = [diagnostic]; + action.isPreferred = true; + codeActions.push(action); + } + break; + } + } - toDispose.push(commands.registerCommand('_json.retryResolveSchema', handleRetryResolveSchemaCommand)); + return codeActions; + } + }, { + providedCodeActionKinds: [CodeActionKind.QuickFix] + })); - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(false)); toDispose.push(extensions.onDidChange(async _ => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); })); - const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern( - Uri.parse(`vscode://schemas-associations/`), - '**/schemas-associations.json') - ); + const associationWatcher = workspace.createFileSystemWatcher(new RelativePattern(Uri.parse(`vscode://schemas-associations/`), '**/schemas-associations.json')); toDispose.push(associationWatcher); toDispose.push(associationWatcher.onDidChange(async _e => { - client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations()); + client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); })); // manually register / deregister format provider based on the `json.format.enable` setting avoiding issues with late registration. See #71652. updateFormatterRegistration(); toDispose.push({ dispose: () => rangeFormatting && rangeFormatting.dispose() }); - updateSchemaDownloadSetting(); - toDispose.push(workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration(SettingIds.enableFormatter)) { updateFormatterRegistration(); } else if (e.affectsConfiguration(SettingIds.enableSchemaDownload)) { - updateSchemaDownloadSetting(); + schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); + triggerValidation(); } else if (e.affectsConfiguration(SettingIds.editorFoldingMaximumRegions) || e.affectsConfiguration(SettingIds.editorColorDecoratorsLimit)) { - client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings() }); + client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) }); + } else if (e.affectsConfiguration(SettingIds.trustedDomains)) { + trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); + triggerValidation(); } })); - toDispose.push(workspace.onDidGrantWorkspaceTrust(updateSchemaDownloadSetting)); + toDispose.push(workspace.onDidGrantWorkspaceTrust(() => triggerValidation())); toDispose.push(createLanguageStatusItem(documentSelector, (uri: string) => client.sendRequest(LanguageStatusRequest.type, uri))); @@ -572,20 +607,13 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } } - function updateSchemaDownloadSetting() { - if (!workspace.isTrusted) { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to download schemas in untrusted workspaces.'); - schemaResolutionErrorStatusBarItem.command = 'workbench.trust.manage'; - return; - } - schemaDownloadEnabled = workspace.getConfiguration().get(SettingIds.enableSchemaDownload) !== false; - if (schemaDownloadEnabled) { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Unable to resolve schema. Click to retry.'); - schemaResolutionErrorStatusBarItem.command = '_json.retryResolveSchema'; - handleRetryResolveSchemaCommand(); - } else { - schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Downloading schemas is disabled. Click to configure.'); - schemaResolutionErrorStatusBarItem.command = { command: 'workbench.action.openSettings', arguments: [SettingIds.enableSchemaDownload], title: '' }; + async function triggerValidation() { + const activeTextEditor = window.activeTextEditor; + if (activeTextEditor && languageParticipants.hasLanguage(activeTextEditor.document.languageId)) { + schemaResolutionErrorStatusBarItem.text = '$(watch)'; + schemaResolutionErrorStatusBarItem.tooltip = l10n.t('Validating...'); + const activeDocUri = activeTextEditor.document.uri.toString(); + await client.sendRequest(ForceValidateRequest.type, activeDocUri); } } @@ -612,6 +640,113 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }); } + function getSettings(forceRefresh: boolean): Settings { + if (!settingsCache || forceRefresh) { + settingsCache = computeSettings(); + } + return settingsCache; + } + + async function getSchemaAssociations(forceRefresh: boolean): Promise { + if (!schemaAssociationsCache || forceRefresh) { + schemaAssociationsCache = computeSchemaAssociations(); + runtime.logOutputChannel.info(`Computed schema associations: ${(await schemaAssociationsCache).map(a => `${a.uri} -> [${a.fileMatch.join(', ')}]`).join('\n')}`); + + } + return schemaAssociationsCache; + } + + async function isTrusted(uri: Uri): Promise { + if (uri.scheme !== 'http' && uri.scheme !== 'https') { + return true; + } + const uriString = uri.toString(true); + + // Check against trustedDomains setting + if (matchesUrlPattern(uri, trustedDomains)) { + return true; + } + + const knownAssociations = await getSchemaAssociations(false); + for (const association of knownAssociations) { + if (association.uri === uriString) { + return true; + } + } + const settingsCache = getSettings(false); + if (settingsCache.json && settingsCache.json.schemas) { + for (const schemaSetting of settingsCache.json.schemas) { + const schemaUri = schemaSetting.url; + if (schemaUri === uriString) { + return true; + } + } + } + return false; + } + + async function configureTrustedDomains(schemaUri: string): Promise { + interface QuickPickItemWithAction { + label: string; + description?: string; + execute: () => Promise; + } + + const items: QuickPickItemWithAction[] = []; + + try { + const uri = Uri.parse(schemaUri); + const domain = `${uri.scheme}://${uri.authority}`; + + // Add "Trust domain" option + items.push({ + label: l10n.t('Trust Domain: {0}', domain), + description: l10n.t('Allow all schemas from this domain'), + execute: async () => { + const config = workspace.getConfiguration(); + const currentDomains = config.get>(SettingIds.trustedDomains, {}); + currentDomains[domain] = true; + await config.update(SettingIds.trustedDomains, currentDomains, true); + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + + // Add "Trust URI" option + items.push({ + label: l10n.t('Trust URI: {0}', schemaUri), + description: l10n.t('Allow only this specific schema'), + execute: async () => { + const config = workspace.getConfiguration(); + const currentDomains = config.get>(SettingIds.trustedDomains, {}); + currentDomains[schemaUri] = true; + await config.update(SettingIds.trustedDomains, currentDomains, true); + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + } catch (e) { + runtime.logOutputChannel.error(`Failed to parse schema URI: ${schemaUri}`); + } + + + // Always add "Configure setting" option + items.push({ + label: l10n.t('Configure Setting'), + description: l10n.t('Open settings editor'), + execute: async () => { + await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); + } + }); + + const selected = await window.showQuickPick(items, { + placeHolder: l10n.t('Select how to configure trusted schema domains') + }); + + if (selected) { + await selected.execute(); + } + } + + return { dispose: async () => { await client.stop(); @@ -621,9 +756,9 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }; } -async function getSchemaAssociations(): Promise { - return getSchemaExtensionAssociations() - .concat(await getDynamicSchemaAssociations()); +async function computeSchemaAssociations(): Promise { + const extensionAssociations = getSchemaExtensionAssociations(); + return extensionAssociations.concat(await getDynamicSchemaAssociations()); } function getSchemaExtensionAssociations(): ISchemaAssociation[] { @@ -680,7 +815,9 @@ async function getDynamicSchemaAssociations(): Promise { return result; } -function getSettings(): Settings { + + +function computeSettings(): Settings { const configuration = workspace.getConfiguration(); const httpSettings = workspace.getConfiguration('http'); @@ -781,8 +918,14 @@ function updateMarkdownString(h: MarkdownString): MarkdownString { return n; } -function isSchemaResolveError(d: Diagnostic) { - return d.code === /* SchemaResolveError */ 0x300; +export namespace ErrorCodes { + export const SchemaResolveError = 0x10000; + export const UntrustedSchemaError = SchemaResolveError + SchemaRequestServiceErrors.UntrustedSchemaError; + export const HTTPDisabledError = SchemaResolveError + SchemaRequestServiceErrors.HTTPDisabledError; +} + +export function isSchemaResolveError(d: Diagnostic) { + return typeof d.code === 'number' && d.code >= ErrorCodes.SchemaResolveError; } diff --git a/extensions/json-language-features/client/src/languageStatus.ts b/extensions/json-language-features/client/src/languageStatus.ts index 1064a0b59561d..a608b4be7ca33 100644 --- a/extensions/json-language-features/client/src/languageStatus.ts +++ b/extensions/json-language-features/client/src/languageStatus.ts @@ -6,9 +6,9 @@ import { window, languages, Uri, Disposable, commands, QuickPickItem, extensions, workspace, Extension, WorkspaceFolder, QuickPickItemKind, - ThemeIcon, TextDocument, LanguageStatusSeverity, l10n, DocumentSelector + ThemeIcon, TextDocument, LanguageStatusSeverity, l10n, DocumentSelector, Diagnostic } from 'vscode'; -import { JSONLanguageStatus, JSONSchemaSettings } from './jsonClient'; +import { CommandIds, ErrorCodes, isSchemaResolveError, JSONLanguageStatus, JSONSchemaSettings, SettingIds } from './jsonClient'; type ShowSchemasInput = { schemas: string[]; @@ -168,7 +168,7 @@ export function createLanguageStatusItem(documentSelector: DocumentSelector, sta statusItem.name = l10n.t('JSON Validation Status'); statusItem.severity = LanguageStatusSeverity.Information; - const showSchemasCommand = commands.registerCommand('_json.showAssociatedSchemaList', showSchemaList); + const showSchemasCommand = commands.registerCommand(CommandIds.showAssociatedSchemaList, showSchemaList); const activeEditorListener = window.onDidChangeActiveTextEditor(() => { updateLanguageStatus(); @@ -195,7 +195,7 @@ export function createLanguageStatusItem(documentSelector: DocumentSelector, sta statusItem.detail = l10n.t('multiple JSON schemas configured'); } statusItem.command = { - command: '_json.showAssociatedSchemaList', + command: CommandIds.showAssociatedSchemaList, title: l10n.t('Show Schemas'), arguments: [{ schemas, uri: document.uri.toString() } satisfies ShowSchemasInput] }; @@ -279,3 +279,86 @@ export function createDocumentSymbolsLimitItem(documentSelector: DocumentSelecto } +export function createSchemaLoadStatusItem(newItem: (fileSchemaError: Diagnostic) => Disposable) { + let statusItem: Disposable | undefined; + const fileSchemaErrors: Map = new Map(); + + const toDispose: Disposable[] = []; + toDispose.push(window.onDidChangeActiveTextEditor(textEditor => { + statusItem?.dispose(); + statusItem = undefined; + const doc = textEditor?.document; + if (doc) { + const fileSchemaError = fileSchemaErrors.get(doc.uri.toString()); + if (fileSchemaError !== undefined) { + statusItem = newItem(fileSchemaError); + } + } + })); + toDispose.push(workspace.onDidCloseTextDocument(document => { + fileSchemaErrors.delete(document.uri.toString()); + })); + + function update(uri: Uri, diagnostics: Diagnostic[]) { + const fileSchemaError = diagnostics.find(isSchemaResolveError); + const uriString = uri.toString(); + + if (fileSchemaError === undefined) { + fileSchemaErrors.delete(uriString); + if (statusItem && uriString === window.activeTextEditor?.document.uri.toString()) { + statusItem.dispose(); + statusItem = undefined; + } + } else { + const current = fileSchemaErrors.get(uriString); + if (current?.message === fileSchemaError.message) { + return; + } + fileSchemaErrors.set(uriString, fileSchemaError); + if (uriString === window.activeTextEditor?.document.uri.toString()) { + statusItem?.dispose(); + statusItem = newItem(fileSchemaError); + } + } + } + return { + update, + dispose() { + statusItem?.dispose(); + toDispose.forEach(d => d.dispose()); + toDispose.length = 0; + statusItem = undefined; + fileSchemaErrors.clear(); + } + }; +} + + + +export function createSchemaLoadIssueItem(documentSelector: DocumentSelector, schemaDownloadEnabled: boolean | undefined, diagnostic: Diagnostic): Disposable { + const statusItem = languages.createLanguageStatusItem('json.documentSymbolsStatus', documentSelector); + statusItem.name = l10n.t('JSON Outline Status'); + statusItem.severity = LanguageStatusSeverity.Error; + statusItem.text = 'Schema download issue'; + if (!workspace.isTrusted) { + statusItem.detail = l10n.t('Workspace untrusted'); + statusItem.command = { command: CommandIds.workbenchTrustManage, title: 'Configure Trust' }; + } else if (!schemaDownloadEnabled) { + statusItem.detail = l10n.t('Download disabled'); + statusItem.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.enableSchemaDownload], title: 'Configure' }; + } else if (typeof diagnostic.code === 'number' && diagnostic.code === ErrorCodes.UntrustedSchemaError) { + statusItem.detail = l10n.t('Location untrusted'); + const schemaUri = diagnostic.relatedInformation?.[0]?.location.uri; + if (schemaUri) { + statusItem.command = { command: CommandIds.configureTrustedDomainsCommandId, arguments: [schemaUri.toString()], title: 'Configure Trusted Domains' }; + } else { + statusItem.command = { command: CommandIds.workbenchActionOpenSettings, arguments: [SettingIds.trustedDomains], title: 'Configure Trusted Domains' }; + } + } else { + statusItem.detail = l10n.t('Unable to resolve schema'); + statusItem.command = { command: CommandIds.retryResolveSchemaCommandId, title: 'Retry' }; + } + return Disposable.from(statusItem); +} + + diff --git a/extensions/json-language-features/client/src/utils/urlMatch.ts b/extensions/json-language-features/client/src/utils/urlMatch.ts new file mode 100644 index 0000000000000..a870c2d072626 --- /dev/null +++ b/extensions/json-language-features/client/src/utils/urlMatch.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Uri } from 'vscode'; + +/** + * Check whether a URL matches the list of trusted domains or URIs. + * + * trustedDomains is an object where: + * - Keys are full domains (https://www.microsoft.com) or full URIs (https://www.test.com/schemas/mySchema.json) + * - Keys can include wildcards (https://*.microsoft.com) or glob patterns + * - Values are booleans indicating if the domain/URI is trusted (true) or blocked (false) + * + * @param url The URL to check + * @param trustedDomains Object mapping domain patterns to boolean trust values + */ +export function matchesUrlPattern(url: Uri, trustedDomains: Record): boolean { + // Check localhost + if (isLocalhostAuthority(url.authority)) { + return true; + } + + for (const [pattern, isTrusted] of Object.entries(trustedDomains)) { + if (typeof pattern !== 'string' || pattern.trim() === '') { + continue; + } + + // Wildcard matches everything + if (pattern === '*') { + return isTrusted; + } + + try { + const patternUri = Uri.parse(pattern); + + // Scheme must match + if (url.scheme !== patternUri.scheme) { + continue; + } + + // Check authority (host:port) + if (!matchesAuthority(url.authority, patternUri.authority)) { + continue; + } + + // Check path + if (!matchesPath(url.path, patternUri.path)) { + continue; + } + + return isTrusted; + } catch { + // Invalid pattern, skip + continue; + } + } + + return false; +} + +function matchesAuthority(urlAuthority: string, patternAuthority: string): boolean { + urlAuthority = urlAuthority.toLowerCase(); + patternAuthority = patternAuthority.toLowerCase(); + + if (patternAuthority === urlAuthority) { + return true; + } + // Handle wildcard subdomains (e.g., *.github.com) + if (patternAuthority.startsWith('*.')) { + const patternDomain = patternAuthority.substring(2); + // Exact match or subdomain match + return urlAuthority === patternDomain || urlAuthority.endsWith('.' + patternDomain); + } + + return false; +} + +function matchesPath(urlPath: string, patternPath: string): boolean { + // Empty pattern path or just "/" matches any path + if (!patternPath || patternPath === '/') { + return true; + } + + // Exact match + if (urlPath === patternPath) { + return true; + } + + // If pattern ends with '/', it matches any path starting with it + if (patternPath.endsWith('/')) { + return urlPath.startsWith(patternPath); + } + + // Otherwise, pattern must be a prefix + return urlPath.startsWith(patternPath + '/') || urlPath === patternPath; +} + + +const rLocalhost = /^(.+\.)?localhost(:\d+)?$/i; +const r127 = /^127\.0\.0\.1(:\d+)?$/; +const rIPv6Localhost = /^\[::1\](:\d+)?$/; + +function isLocalhostAuthority(authority: string): boolean { + return rLocalhost.test(authority) || r127.test(authority) || rIPv6Localhost.test(authority); +} diff --git a/extensions/json-language-features/package.json b/extensions/json-language-features/package.json index 50da0468e48b9..429e051159e81 100644 --- a/extensions/json-language-features/package.json +++ b/extensions/json-language-features/package.json @@ -126,6 +126,22 @@ "tags": [ "usesOnlineServices" ] + }, + "json.schemaDownload.trustedDomains": { + "type": "object", + "default": { + "https://schemastore.azurewebsites.net/": true, + "https://raw.githubusercontent.com/": true, + "https://www.schemastore.org/": true, + "https://json-schema.org/": true + }, + "additionalProperties": { + "type": "boolean" + }, + "description": "%json.schemaDownload.trustedDomains.desc%", + "tags": [ + "usesOnlineServices" + ] } } }, diff --git a/extensions/json-language-features/package.nls.json b/extensions/json-language-features/package.nls.json index abc07c993dc80..9052d3781c9ce 100644 --- a/extensions/json-language-features/package.nls.json +++ b/extensions/json-language-features/package.nls.json @@ -19,6 +19,6 @@ "json.enableSchemaDownload.desc": "When enabled, JSON schemas can be fetched from http and https locations.", "json.command.clearCache": "Clear Schema Cache", "json.command.sort": "Sort Document", - "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https." - + "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https.", + "json.schemaDownload.trustedDomains.desc": "List of trusted domains for downloading JSON schemas over http(s). Use '*' to trust all domains. '*' can also be used as a wildcard in domain names." } diff --git a/extensions/json-language-features/server/package-lock.json b/extensions/json-language-features/server/package-lock.json index fc31206a0cdab..4761136e1bf2a 100644 --- a/extensions/json-language-features/server/package-lock.json +++ b/extensions/json-language-features/server/package-lock.json @@ -12,7 +12,7 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.6.4", + "vscode-json-languageservice": "^5.7.1", "vscode-languageserver": "^10.0.0-next.15", "vscode-uri": "^3.1.0" }, @@ -67,9 +67,9 @@ "license": "MIT" }, "node_modules/vscode-json-languageservice": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.6.4.tgz", - "integrity": "sha512-i0MhkFmnQAbYr+PiE6Th067qa3rwvvAErCEUo0ql+ghFXHvxbwG3kLbwMaIUrrbCLUDEeULiLgROJjtuyYoIsA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.1.tgz", + "integrity": "sha512-sMK2F8p7St0lJCr/4IfbQRoEUDUZRR7Ud0IiSl8I/JtN+m9Gv+FJlNkSAYns2R7Ebm/PKxqUuWYOfBej/rAdBQ==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", diff --git a/extensions/json-language-features/server/package.json b/extensions/json-language-features/server/package.json index 00fff97cbe702..6534e6f0eca86 100644 --- a/extensions/json-language-features/server/package.json +++ b/extensions/json-language-features/server/package.json @@ -15,7 +15,7 @@ "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "request-light": "^0.8.0", - "vscode-json-languageservice": "^5.6.4", + "vscode-json-languageservice": "^5.7.1", "vscode-languageserver": "^10.0.0-next.15", "vscode-uri": "^3.1.0" }, diff --git a/extensions/json-language-features/server/src/jsonServer.ts b/extensions/json-language-features/server/src/jsonServer.ts index cbe1e7d02b48a..811cbcd2e9195 100644 --- a/extensions/json-language-features/server/src/jsonServer.ts +++ b/extensions/json-language-features/server/src/jsonServer.ts @@ -5,7 +5,7 @@ import { Connection, - TextDocuments, InitializeParams, InitializeResult, NotificationType, RequestType, + TextDocuments, InitializeParams, InitializeResult, NotificationType, RequestType, ResponseError, DocumentRangeFormattingRequest, Disposable, ServerCapabilities, TextDocumentSyncKind, TextEdit, DocumentFormattingRequest, TextDocumentIdentifier, FormattingOptions, Diagnostic, CodeAction, CodeActionKind } from 'vscode-languageserver'; @@ -36,6 +36,10 @@ namespace ForceValidateRequest { export const type: RequestType = new RequestType('json/validate'); } +namespace ForceValidateAllRequest { + export const type: RequestType = new RequestType('json/validateAll'); +} + namespace LanguageStatusRequest { export const type: RequestType = new RequestType('json/languageStatus'); } @@ -102,8 +106,8 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) } return connection.sendRequest(VSCodeContentRequest.type, uri).then(responseText => { return responseText; - }, error => { - return Promise.reject(error.message); + }, (error: ResponseError) => { + return Promise.reject(error); }); }; } @@ -298,6 +302,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) }); // Retry schema validation on all open documents + connection.onRequest(ForceValidateAllRequest.type, async () => { + diagnosticsSupport?.requestRefresh(); + }); + connection.onRequest(ForceValidateRequest.type, async uri => { const document = documents.get(uri); if (document) { @@ -387,11 +395,11 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) connection.onDidChangeWatchedFiles((change) => { // Monitored files have changed in VSCode let hasChanges = false; - change.changes.forEach(c => { + for (const c of change.changes) { if (languageService.resetSchema(c.uri)) { hasChanges = true; } - }); + } if (hasChanges) { diagnosticsSupport?.requestRefresh(); } From 96a75ab878d7698eaf09822e088b757942c9b936 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 13 Jan 2026 17:40:00 -0600 Subject: [PATCH 10/17] fix races in prompt for input (#287651) fixes #287642 --- .../browser/tools/monitoring/outputMonitor.ts | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index eb1d8c9a3b848..2ac03d97b9476 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -61,6 +61,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private _pollingResult: IPollingResult & { pollDurationMs: number } | undefined; get pollingResult(): IPollingResult & { pollDurationMs: number } | undefined { return this._pollingResult; } + /** + * Flag to track if user has inputted since idle was detected. + * This is used to skip showing prompts if the user already provided input. + */ + private _userInputtedSinceIdleDetected = false; + private _userInputListener: IDisposable | undefined; + private readonly _outputMonitorTelemetryCounters: IOutputMonitorTelemetryCounters = { inputToolManualAcceptCount: 0, inputToolManualRejectCount: 0, @@ -159,6 +166,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { pollDurationMs: Date.now() - pollStartTime, resources }; + // Clean up idle input listener if still active + this._userInputListener?.dispose(); + this._userInputListener = undefined; const promptPart = this._promptPart; this._promptPart = undefined; if (promptPart) { @@ -180,9 +190,28 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return { shouldContinuePollling: false, output }; } + // Check if user already inputted since idle was detected (before we even got here) + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + const confirmationPrompt = await this._determineUserInputOptions(this._execution, token); + // Check again after the async LLM call - user may have inputted while we were analyzing + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + if (confirmationPrompt?.detectedRequestForFreeFormInput) { + // Check again right before showing prompt + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + // Clean up the input listener now - the prompt will set up its own + this._cleanupIdleInputListener(); this._outputMonitorTelemetryCounters.inputToolFreeFormInputShownCount++; const receivedTerminalInput = await this._requestFreeFormTerminalInput(token, this._execution, confirmationPrompt); if (receivedTerminalInput) { @@ -200,8 +229,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const suggestedOptionResult = await this._selectAndHandleOption(confirmationPrompt, token); if (suggestedOptionResult?.sentToTerminal) { // Continue polling as we sent the input + this._cleanupIdleInputListener(); + return { shouldContinuePollling: true }; + } + // Check again after LLM call - user may have inputted while we were selecting option + if (this._userInputtedSinceIdleDetected) { + this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } + // Clean up the input listener now - the prompt will set up its own + this._cleanupIdleInputListener(); const confirmed = await this._confirmRunInTerminal(token, suggestedOptionResult?.suggestedOption ?? confirmationPrompt.options[0], this._execution, confirmationPrompt); if (confirmed) { // Continue polling as we sent the input @@ -213,6 +250,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } } + // Clean up input listener before custom poll/error assessment + this._cleanupIdleInputListener(); + // Let custom poller override if provided const custom = await this._pollFn?.(this._execution, token, this._taskService); const resources = custom?.resources; @@ -310,12 +350,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (detectsNonInteractiveHelpPattern(currentOutput)) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } const promptResult = detectsInputRequiredPattern(currentOutput); if (promptResult) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } @@ -331,6 +373,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._logService.trace(`OutputMonitor: waitForIdle check: waited=${waited}ms, recentlyIdle=${recentlyIdle}, isActive=${isActive}`); if (recentlyIdle && isActive !== true) { this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); return this._state; } } @@ -345,6 +388,32 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return OutputMonitorState.Timeout; } + /** + * Sets up a listener for user input that triggers immediately when idle is detected. + * This ensures we catch any input that happens between idle detection and prompt creation. + */ + private _setupIdleInputListener(): void { + // Clean up any existing listener + this._userInputListener?.dispose(); + this._userInputtedSinceIdleDetected = false; + + // Set up new listener + this._userInputListener = this._execution.instance.onDidInputData((data) => { + if (data === '\r' || data === '\n' || data === '\r\n') { + this._userInputtedSinceIdleDetected = true; + } + }); + } + + /** + * Cleans up the idle input listener and resets the flag. + */ + private _cleanupIdleInputListener(): void { + this._userInputtedSinceIdleDetected = false; + this._userInputListener?.dispose(); + this._userInputListener = undefined; + } + private async _promptForMorePolling(command: string, token: CancellationToken, context: IToolInvocationContext | undefined): Promise<{ promise: Promise; part?: ChatElicitationRequestPart }> { if (token.isCancellationRequested || this._state === OutputMonitorState.Cancelled) { return { promise: Promise.resolve(false) }; @@ -404,7 +473,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } const promptText = - `Analyze the following terminal output. If it contains a prompt requesting user input (such as a confirmation, selection, or yes/no question) and that prompt has NOT already been answered, extract the prompt text. The prompt may ask to choose from a set. If so, extract the possible options as a JSON object with keys 'prompt', 'options' (an array of strings or an object with option to description mappings), and 'freeFormInput': false. If no options are provided, and free form input is requested, for example: Password:, return the word freeFormInput. For example, if the options are "[Y] Yes [A] Yes to All [N] No [L] No to All [C] Cancel", the option to description mappings would be {"Y": "Yes", "A": "Yes to All", "N": "No", "L": "No to All", "C": "Cancel"}. If there is no such prompt, return null. If the option is ambiguous, return null. + `Analyze the following terminal output. If it contains a prompt requesting user input (such as a confirmation, selection, or yes/no question) that appears at the VERY END of the output and has NOT already been answered (i.e., there is no user response or subsequent output after the prompt), extract the prompt text. IMPORTANT: Only detect prompts that are at the end of the output with no content following them - if there is any output after the prompt, the prompt has already been answered and you should return null. The prompt may ask to choose from a set. If so, extract the possible options as a JSON object with keys 'prompt', 'options' (an array of strings or an object with option to description mappings), and 'freeFormInput': false. If no options are provided, and free form input is requested, for example: Password:, return the word freeFormInput. For example, if the options are "[Y] Yes [A] Yes to All [N] No [L] No to All [C] Cancel", the option to description mappings would be {"Y": "Yes", "A": "Yes to All", "N": "No", "L": "No to All", "C": "Cancel"}. If there is no such prompt, return null. If the option is ambiguous, return null. Examples: 1. Output: "Do you want to overwrite? (y/n)" Response: {"prompt": "Do you want to overwrite?", "options": ["y", "n"], "freeFormInput": false} @@ -434,6 +503,10 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { Response: {"prompt": "Password:", "freeFormInput": true, "options": []} 10. Output: "press ctrl-c to detach, ctrl-d to kill" Response: null + 11. Output: "Continue (y/n)? y" + Response: null (the prompt was already answered with 'y') + 12. Output: "Do you want to proceed? (yes/no)\nyes\nProceeding with operation..." + Response: null (the prompt was already answered and there is subsequent output) Alternatively, the prompt may request free form input, for example: 1. Output: "Enter your username:" From d7291115c0ddf2ecc8dfe35ca40789a4ab577b2a Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:40:50 -0800 Subject: [PATCH 11/17] fix edge case showing "Open Picker" with chatSession optionGroups (#287650) --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 9669d88db0071..8997ec10510fd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1381,8 +1381,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return hideAll(); } - this.chatSessionHasOptions.set(true); - // First update all context keys with current values (before evaluating visibility) for (const optionGroup of optionGroups) { const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); @@ -1405,6 +1403,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + // Only show the picker if there are visible option groups + if (visibleGroupIds.size === 0) { + return hideAll(); + } + + this.chatSessionHasOptions.set(true); + const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = From 7b7243f1d0695c2313684c6e6448705e95d50ba8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:18:32 -0800 Subject: [PATCH 12/17] Add better timing info to chat sessions Fixes #278567 Resubmission of #278858 --- .../api/common/extHostChatSessions.ts | 19 +++++-- .../agentSessions/agentSessionsModel.ts | 53 ++++++++++++------ .../agentSessions/agentSessionsPicker.ts | 2 +- .../agentSessions/agentSessionsViewer.ts | 13 +++-- .../chat/common/chatService/chatService.ts | 20 ++++++- .../common/chatService/chatServiceImpl.ts | 12 +++- .../chat/common/chatSessionsService.ts | 7 +-- .../contrib/chat/common/model/chatModel.ts | 10 +++- .../chat/common/model/chatSessionStore.ts | 7 ++- .../agentSessionViewModel.test.ts | 52 +++++++++++------- .../agentSessionsDataSource.test.ts | 9 +-- .../localAgentSessionsProvider.test.ts | 55 ++++++++++++------- .../vscode.proposed.chatSessionsProvider.d.ts | 26 ++++++++- 13 files changed, 196 insertions(+), 89 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index f98838cc2f344..2f4697224abfe 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -31,6 +31,8 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; +type ChatSessionTiming = vscode.ChatSessionItem['timing']; + // #region Chat Session Item Controller class ChatSessionItemImpl implements vscode.ChatSessionItem { @@ -41,7 +43,7 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #status?: vscode.ChatSessionStatus; #archived?: boolean; #tooltip?: string | vscode.MarkdownString; - #timing?: { startTime: number; endTime?: number }; + #timing?: { created: number; lastRequestStarted?: number; lastRequestEnded?: number; startTime?: number; endTime?: number }; #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; #onChanged: () => void; @@ -130,11 +132,11 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { } } - get timing(): { startTime: number; endTime?: number } | undefined { + get timing(): ChatSessionTiming | undefined { return this.#timing; } - set timing(value: { startTime: number; endTime?: number } | undefined) { + set timing(value: ChatSessionTiming | undefined) { if (this.#timing !== value) { this.#timing = value; this.#onChanged(); @@ -409,6 +411,12 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + return { resource: sessionContent.resource, label: sessionContent.label, @@ -418,8 +426,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { - startTime: sessionContent.timing?.startTime ?? 0, - endTime: sessionContent.timing?.endTime + created, + lastRequestStarted, + lastRequestEnded, }, changes: sessionContent.changes instanceof Array ? sessionContent.changes : diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index b579321fec1d4..4612c9f2dff26 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -359,19 +359,24 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide a start and end time to track + // Times: it is important to always provide timing information to track // unread/read state for example. // If somehow the provider does not provide any, fallback to last known - let startTime = session.timing.startTime; - let endTime = session.timing.endTime; - if (!startTime || !endTime) { + let created = session.timing.created; + let lastRequestStarted = session.timing.lastRequestStarted; + let lastRequestEnded = session.timing.lastRequestEnded; + if (!created || lastRequestEnded === undefined) { const existing = this._sessions.get(session.resource); - if (!startTime && existing?.timing.startTime) { - startTime = existing.timing.startTime; + if (!created && existing?.timing.created) { + created = existing.timing.created; } - if (!endTime && existing?.timing.endTime) { - endTime = existing.timing.endTime; + if (lastRequestEnded === undefined && existing?.timing.lastRequestEnded) { + lastRequestEnded = existing.timing.lastRequestEnded; + } + + if (lastRequestStarted === undefined && existing?.timing.lastRequestStarted) { + lastRequestStarted = existing.timing.lastRequestStarted; } } @@ -386,7 +391,13 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status, archived: session.archived, - timing: { startTime, endTime, inProgressTime, finishedOrFailedTime }, + timing: { + created, + lastRequestStarted, + lastRequestEnded, + inProgressTime, + finishedOrFailedTime + }, changes: normalizedChanges, })); } @@ -454,7 +465,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private isRead(session: IInternalAgentSessionData): boolean { const readDate = this.sessionStates.get(session.resource)?.read; - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime); + return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); } private setRead(session: IInternalAgentSessionData, read: boolean): void { @@ -473,7 +484,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region Sessions Cache -interface ISerializedAgentSession extends Omit { +interface ISerializedAgentSession { readonly providerType: string; readonly providerLabel: string; @@ -492,7 +503,11 @@ interface ISerializedAgentSession extends Omit ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index cd91ba6fbdb70..ba5bfac455de8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = { export function getSessionDescription(session: IAgentSession): string { const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined; - const timeAgo = fromNow(session.timing.endTime || session.timing.startTime); + const timeAgo = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part); return descriptionParts.join(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index f3d3e6e29cdcd..17c8d9f3a5aae 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -323,7 +323,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -826,7 +827,9 @@ export class AgentSessionsSorter implements ITreeSorter { } //Sort by end or start time (most recent first) - return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); + const timeA = sessionA.timing.lastRequestEnded ?? sessionA.timing.lastRequestStarted ?? sessionA.timing.created; + const timeB = sessionB.timing.lastRequestEnded ?? sessionB.timing.lastRequestStarted ?? sessionB.timing.created; + return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 7a757d8eb1ea4..6986780910b17 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -941,8 +941,24 @@ export interface IChatSessionStats { } export interface IChatSessionTiming { - startTime: number; - endTime?: number; + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted: number | undefined; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded: number | undefined; } export const enum ResponseModelState { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d517d0ce5034d..e5d90f3d715b8 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -375,7 +375,11 @@ export class ChatService extends Disposable implements IChatService { ...entry, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: entry.timing ?? { startTime: entry.lastMessageDate }, + timing: entry.timing ?? { + created: entry.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: entry.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, @@ -391,7 +395,11 @@ export class ChatService extends Disposable implements IChatService { ...metadata, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: metadata.timing ?? { startTime: metadata.lastMessageDate }, + timing: metadata.timing ?? { + created: metadata.lastMessageDate, + lastRequestStarted: undefined, + lastRequestEnded: metadata.lastMessageDate, + }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 3a2144b35961d..94126a5ffcf9f 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService } from './chatService/chatService.js'; +import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -82,10 +82,7 @@ export interface IChatSessionItem { description?: string | IMarkdownString; status?: ChatSessionStatus; tooltip?: string | IMarkdownString; - timing: { - startTime: number; - endTime?: number; - }; + timing: IChatSessionTiming; changes?: { files: number; insertions: number; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 7627b5f85fbcd..2a5cc4df78e6e 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1687,10 +1687,14 @@ export class ChatModel extends Disposable implements IChatModel { } get timing(): IChatSessionTiming { - const lastResponse = this._requests.at(-1)?.response; + const lastRequest = this._requests.at(-1); + const lastResponse = lastRequest?.response; + const lastRequestStarted = lastRequest?.timestamp; + const lastRequestEnded = lastResponse?.completedAt ?? lastResponse?.timestamp; return { - startTime: this._timestamp, - endTime: lastResponse?.completedAt ?? lastResponse?.timestamp + created: this._timestamp, + lastRequestStarted, + lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 63ac4c99c214b..1465a8d5c5465 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -665,12 +665,13 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P session.lastMessageDate : session.requests.at(-1)?.timestamp ?? session.creationDate; - const timing = session instanceof ChatModel ? + const timing: IChatSessionTiming = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { - startTime: session.creationDate, - endTime: lastMessageDate + created: session.creationDate, + lastRequestStarted: session.requests.at(-1)?.timestamp, + lastRequestEnded: lastMessageDate, }; return { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 114f666d13545..bacf032abd9b5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -176,8 +176,8 @@ suite('Agent Sessions', () => { test('should handle session with all properties', async () => { return runWithFakedTimers({}, async () => { - const startTime = Date.now(); - const endTime = startTime + 1000; + const created = Date.now(); + const lastRequestEnded = created + 1000; const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', @@ -190,8 +190,8 @@ suite('Agent Sessions', () => { status: ChatSessionStatus.Completed, tooltip: 'Session tooltip', iconPath: ThemeIcon.fromId('check'), - timing: { startTime, endTime }, - changes: { files: 1, insertions: 10, deletions: 5, details: [] } + timing: { created, lastRequestStarted: created, lastRequestEnded }, + changes: { files: 1, insertions: 10, deletions: 5 } } ] }; @@ -210,8 +210,8 @@ suite('Agent Sessions', () => { assert.strictEqual(session.description.value, '**Bold** description'); } assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.startTime, startTime); - assert.strictEqual(session.timing.endTime, endTime); + assert.strictEqual(session.timing.created, created); + assert.strictEqual(session.timing.lastRequestEnded, lastRequestEnded); assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 }); }); }); @@ -1521,9 +1521,10 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing before the READ_STATE_INITIAL_DATE (December 8, 2025) - const oldSessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 10 /* November */, 2), + const oldSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), }; const provider: IChatSessionItemProvider = { @@ -1552,9 +1553,10 @@ suite('Agent Sessions', () => { test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing after the READ_STATE_INITIAL_DATE (December 8, 2025) - const newSessionTiming = { - startTime: Date.UTC(2025, 11 /* December */, 10), - endTime: Date.UTC(2025, 11 /* December */, 11), + const newSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 11 /* December */, 10), + lastRequestStarted: Date.UTC(2025, 11 /* December */, 10), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 11), }; const provider: IChatSessionItemProvider = { @@ -1583,9 +1585,10 @@ suite('Agent Sessions', () => { test('should use endTime for read state comparison when available', async () => { return runWithFakedTimers({}, async () => { // Session with startTime before initial date but endTime after - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), - endTime: Date.UTC(2025, 11 /* December */, 10), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: Date.UTC(2025, 11 /* December */, 10), }; const provider: IChatSessionItemProvider = { @@ -1606,7 +1609,7 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use endTime (December 10) which is after the initial date + // Should use lastRequestEnded (December 10) which is after the initial date assert.strictEqual(session.isRead(), false); }); }); @@ -1614,8 +1617,10 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { // Session with only startTime before initial date - const sessionTiming = { - startTime: Date.UTC(2025, 10 /* November */, 1), + const sessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2025, 10 /* November */, 1), + lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), + lastRequestEnded: undefined, }; const provider: IChatSessionItemProvider = { @@ -2054,8 +2059,15 @@ function makeSimpleSessionItem(id: string, overrides?: Partial }; } -function makeNewSessionTiming(): IChatSessionItem['timing'] { +function makeNewSessionTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); return { - startTime: Date.now(), + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index f29f8f83327e5..d551277757baf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -36,8 +36,9 @@ suite('AgentSessionsDataSource', () => { label: `Session ${overrides.id ?? 'default'}`, icon: Codicon.terminal, timing: { - startTime: overrides.startTime ?? now, - endTime: overrides.endTime ?? now, + created: overrides.startTime ?? now, + lastRequestEnded: undefined, + lastRequestStarted: undefined, }, isArchived: () => overrides.isArchived ?? false, setArchived: () => { }, @@ -73,8 +74,8 @@ suite('AgentSessionsDataSource', () => { return { compare: (a, b) => { // Sort by end time, most recent first - const aTime = a.timing.endTime || a.timing.startTime; - const bTime = b.timing.endTime || b.timing.startTime; + const aTime = a.timing.lastRequestEnded ?? a.timing.lastRequestStarted ?? a.timing.created; + const bTime = b.timing.lastRequestEnded ?? b.timing.lastRequestStarted ?? b.timing.created; return bTime - aTime; } }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 04e96b80adc35..ac5db0d49804d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -18,11 +18,24 @@ import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/loca import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; +function createTestTiming(options?: { + created?: number; + lastRequestStarted?: number | undefined; + lastRequestEnded?: number | undefined; +}): IChatSessionItem['timing'] { + const now = Date.now(); + return { + created: options?.created ?? now, + lastRequestStarted: options?.lastRequestStarted, + lastRequestEnded: options?.lastRequestEnded, + }; +} + class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; @@ -318,7 +331,7 @@ suite('LocalAgentsSessionsProvider', () => { title: 'Test Session', lastMessageDate: Date.now(), isActive: true, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), lastResponseState: ResponseModelState.Complete }]); @@ -342,7 +355,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -368,7 +381,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); mockChatService.setHistorySessionItems([{ sessionResource, @@ -376,7 +389,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -404,7 +417,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -434,7 +447,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -463,7 +476,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -492,7 +505,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -536,7 +549,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 }, + timing: createTestTiming(), stats: { added: 30, removed: 8, @@ -581,7 +594,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -592,7 +605,7 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Session Timing', () => { - test('should use model timestamp for startTime when model exists', async () => { + test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -611,16 +624,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: modelTimestamp } + timing: createTestTiming({ created: modelTimestamp }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); + assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); }); - test('should use lastMessageDate for startTime when model does not exist', async () => { + test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -634,16 +647,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: { startTime: lastMessageDate } + timing: createTestTiming({ created: lastMessageDate }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); + assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); }); - test('should set endTime from last response completedAt', async () => { + test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -663,12 +676,12 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: completedAt } + timing: createTestTiming({ lastRequestEnded: completedAt }) }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.endTime, completedAt); + assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); }); }); @@ -691,7 +704,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: { startTime: 0, endTime: 1 } + timing: createTestTiming() }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 6094894b761c1..c1cbdf9c7157f 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -195,15 +195,37 @@ declare module 'vscode' { archived?: boolean; /** - * The times at which session started and ended + * Timing information for the chat session */ timing?: { + /** + * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + */ + created: number; + + /** + * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if no requests have been made yet. + */ + lastRequestStarted?: number; + + /** + * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * + * Should be undefined if the most recent request is still in progress or if no requests have been made yet. + */ + lastRequestEnded?: number; + /** * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `created` and `lastRequestStarted` instead. */ - startTime: number; + startTime?: number; + /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. + * @deprecated Use `lastRequestEnded` instead. */ endTime?: number; }; From 38f6584b07fedf9096e74c7df812eb395f865926 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:23:18 -0800 Subject: [PATCH 13/17] Cleanup --- src/vs/workbench/api/common/extHostChatSessions.ts | 2 +- .../chat/browser/agentSessions/agentSessionsModel.ts | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 2f4697224abfe..c4d34921e4521 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -43,7 +43,7 @@ class ChatSessionItemImpl implements vscode.ChatSessionItem { #status?: vscode.ChatSessionStatus; #archived?: boolean; #tooltip?: string | vscode.MarkdownString; - #timing?: { created: number; lastRequestStarted?: number; lastRequestEnded?: number; startTime?: number; endTime?: number }; + #timing?: ChatSessionTiming; #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; #onChanged: () => void; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 4612c9f2dff26..73776e50163f9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -365,17 +365,17 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode let created = session.timing.created; let lastRequestStarted = session.timing.lastRequestStarted; let lastRequestEnded = session.timing.lastRequestEnded; - if (!created || lastRequestEnded === undefined) { + if (!created || !lastRequestEnded) { const existing = this._sessions.get(session.resource); if (!created && existing?.timing.created) { created = existing.timing.created; } - if (lastRequestEnded === undefined && existing?.timing.lastRequestEnded) { + if (!lastRequestEnded && existing?.timing.lastRequestEnded) { lastRequestEnded = existing.timing.lastRequestEnded; } - if (lastRequestStarted === undefined && existing?.timing.lastRequestStarted) { + if (!lastRequestStarted && existing?.timing.lastRequestStarted) { lastRequestStarted = existing.timing.lastRequestStarted; } } @@ -569,7 +569,7 @@ class AgentSessionsCache { try { const cached = JSON.parse(sessionsCache) as ISerializedAgentSession[]; - return cached.map(session => ({ + return cached.map((session): IInternalAgentSessionData => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -589,9 +589,6 @@ class AgentSessionsCache { created: session.timing.created ?? session.timing.startTime ?? 0, lastRequestStarted: session.timing.lastRequestStarted ?? session.timing.startTime, lastRequestEnded: session.timing.lastRequestEnded ?? session.timing.endTime, - // Deprecated fields for backward compatibility - startTime: session.timing.created ?? session.timing.startTime, - endTime: session.timing.lastRequestEnded ?? session.timing.endTime, }, changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({ From 4212deb21060d87a95f009cc55515e5c8f378861 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:30:24 -0800 Subject: [PATCH 14/17] Only keep around text models for live code blocks Seeing if we can improve perf by only keeping text models for live code blocks. This was potentially helpful in ask mode but not as useful in agent mode --- .../chat/common/model/chatViewModel.ts | 26 ------------------ .../contrib/chat/common/widget/annotations.ts | 27 +------------------ 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 4fd1a06dea977..c31571559bae9 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -8,7 +8,6 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { hash } from '../../../../../base/common/hash.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, dispose } from '../../../../../base/common/lifecycle.js'; -import * as marked from '../../../../../base/common/marked/marked.js'; import { IObservable } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -17,7 +16,6 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; -import { annotateVulnerabilitiesInText } from '../widget/annotations.js'; import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js'; import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js'; import { ChatStreamStatsTracker, IChatStreamStats } from './chatStreamStats.js'; @@ -270,7 +268,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { _model.getRequests().forEach((request, i) => { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request); this._items.push(requestModel); - this.updateCodeBlockTextModels(requestModel); if (request.response) { this.onAddResponse(request.response); @@ -282,7 +279,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { if (e.kind === 'addRequest') { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request); this._items.push(requestModel); - this.updateCodeBlockTextModels(requestModel); if (e.request.response) { this.onAddResponse(e.request.response); @@ -317,13 +313,9 @@ export class ChatViewModel extends Disposable implements IChatViewModel { private onAddResponse(responseModel: IChatResponseModel) { const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this); this._register(response.onDidChange(() => { - if (response.isComplete) { - this.updateCodeBlockTextModels(response); - } return this._onDidChange.fire(null); })); this._items.push(response); - this.updateCodeBlockTextModels(response); } getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] { @@ -348,24 +340,6 @@ export class ChatViewModel extends Disposable implements IChatViewModel { super.dispose(); dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel)); } - - updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) { - let content: string; - if (isRequestVM(model)) { - content = model.messageText; - } else { - content = annotateVulnerabilitiesInText(model.response.value).map(x => x.content.value).join(''); - } - - let codeBlockIndex = 0; - marked.walkTokens(marked.lexer(content), token => { - if (token.type === 'code') { - const lang = token.lang || ''; - const text = token.text; - this.codeBlockModelCollection.update(this._model.sessionResource, model, codeBlockIndex++, { text, languageId: lang, isComplete: true }); - } - }); - } } const variablesHash = new WeakMap(); diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index 600decb9f3697..a1dbed9eb8968 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IRange } from '../../../../../editor/common/core/range.js'; import { isLocation } from '../../../../../editor/common/languages.js'; import { IChatProgressRenderableResponseContent, IChatProgressResponseContent, appendMarkdownString, canMergeMarkdownStrings } from '../model/chatModel.js'; -import { IChatAgentVulnerabilityDetails, IChatMarkdownContent } from '../chatService/chatService.js'; +import { IChatAgentVulnerabilityDetails } from '../chatService/chatService.js'; export const contentRefUrl = 'http://_vscodecontentref_'; // must be lowercase for URI @@ -79,31 +79,6 @@ export interface IMarkdownVulnerability { readonly description: string; readonly range: IRange; } - -export function annotateVulnerabilitiesInText(response: ReadonlyArray): readonly IChatMarkdownContent[] { - const result: IChatMarkdownContent[] = []; - for (const item of response) { - const previousItem = result[result.length - 1]; - if (item.kind === 'markdownContent') { - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push(item); - } - } else if (item.kind === 'markdownVuln') { - const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); - const markdownText = `${item.content.value}`; - if (previousItem?.kind === 'markdownContent') { - result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; - } else { - result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); - } - } - } - - return result; -} - export function extractCodeblockUrisFromText(text: string): { uri: URI; isEdit?: boolean; textWithoutResult: string } | undefined { const match = /(.*?)<\/vscode_codeblock_uri>/ms.exec(text); if (match) { From e64d58389d6a8978ced2449d799cad41f497af6f Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Tue, 13 Jan 2026 16:38:31 -0800 Subject: [PATCH 15/17] vscode mcp: support custom workspace path; enable restart tool (#286297) * vscode mcp: support specifying workspace path; enable restart tool * fix: include workspace path in launch options for browser * fix: ensure workspace path is set for tests requiring a workspace * Address PR review comments: support optional workspace path * fix: standardize workspace path variable naming in tests * fix: fallback to rootPath for workspacePath in CI environments --- test/automation/src/application.ts | 5 ++- test/automation/src/code.ts | 2 +- test/automation/src/electron.ts | 12 +++++- test/automation/src/playwrightBrowser.ts | 10 ++++- test/mcp/src/application.ts | 9 +++-- test/mcp/src/automation.ts | 11 ++--- test/mcp/src/automationTools/core.ts | 40 ++++++++++--------- .../src/areas/languages/languages.test.ts | 28 +++++++++++-- .../src/areas/multiroot/multiroot.test.ts | 3 ++ .../smoke/src/areas/notebook/notebook.test.ts | 9 ++++- test/smoke/src/areas/search/search.test.ts | 9 ++++- .../src/areas/statusbar/statusbar.test.ts | 21 ++++++++-- .../src/areas/workbench/data-loss.test.ts | 39 ++++++++++++++---- 13 files changed, 146 insertions(+), 52 deletions(-) diff --git a/test/automation/src/application.ts b/test/automation/src/application.ts index 81acded3853bf..848640a49839e 100644 --- a/test/automation/src/application.ts +++ b/test/automation/src/application.ts @@ -49,8 +49,8 @@ export class Application { return !!this.options.web; } - private _workspacePathOrFolder: string; - get workspacePathOrFolder(): string { + private _workspacePathOrFolder: string | undefined; + get workspacePathOrFolder(): string | undefined { return this._workspacePathOrFolder; } @@ -109,6 +109,7 @@ export class Application { private async startApplication(extraArgs: string[] = []): Promise { const code = this._code = await launch({ ...this.options, + workspacePath: this._workspacePathOrFolder, extraArgs: [...(this.options.extraArgs || []), ...extraArgs], }); diff --git a/test/automation/src/code.ts b/test/automation/src/code.ts index fe498419122d9..c61b23da7db92 100644 --- a/test/automation/src/code.ts +++ b/test/automation/src/code.ts @@ -18,7 +18,7 @@ export interface LaunchOptions { // Allows you to override the Playwright instance playwright?: typeof playwright; codePath?: string; - readonly workspacePath: string; + readonly workspacePath?: string; userDataDir?: string; readonly extensionsPath?: string; readonly logger: Logger; diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index a34e802ed5ad1..473ebf01ae8fc 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -22,8 +22,7 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom const { codePath, workspacePath, extensionsPath, userDataDir, remote, logger, logsPath, crashesPath, extraArgs } = options; const env = { ...process.env }; - const args = [ - workspacePath, + const args: string[] = [ '--skip-release-notes', '--skip-welcome', '--disable-telemetry', @@ -35,6 +34,12 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom '--disable-workspace-trust', `--logsPath=${logsPath}` ]; + + // Only add workspace path if provided + if (workspacePath) { + args.unshift(workspacePath); + } + if (options.useInMemorySecretStorage) { args.push('--use-inmemory-secretstorage'); } @@ -49,6 +54,9 @@ export async function resolveElectronConfiguration(options: LaunchOptions): Prom } if (remote) { + if (!workspacePath) { + throw new Error('Workspace path is required when running remote'); + } // Replace workspace path with URI args[0] = `--${workspacePath.endsWith('.code-workspace') ? 'file' : 'folder'}-uri=vscode-remote://test+test/${URI.file(workspacePath).path}`; diff --git a/test/automation/src/playwrightBrowser.ts b/test/automation/src/playwrightBrowser.ts index a459826b57163..3ca9894a95a0f 100644 --- a/test/automation/src/playwrightBrowser.ts +++ b/test/automation/src/playwrightBrowser.ts @@ -157,7 +157,15 @@ async function launchBrowser(options: LaunchOptions, endpoint: string) { `["logLevel","${options.verbose ? 'trace' : 'info'}"]` ].join(',')}]`; - const gotoPromise = measureAndLog(() => page.goto(`${endpoint}&${workspacePath.endsWith('.code-workspace') ? 'workspace' : 'folder'}=${URI.file(workspacePath!).path}&payload=${payloadParam}`), 'page.goto()', logger); + // Build URL with optional workspace path + let url = `${endpoint}&`; + if (workspacePath) { + const workspaceParam = workspacePath.endsWith('.code-workspace') ? 'workspace' : 'folder'; + url += `${workspaceParam}=${URI.file(workspacePath).path}&`; + } + url += `payload=${payloadParam}`; + + const gotoPromise = measureAndLog(() => page.goto(url), 'page.goto()', logger); const pageLoadedPromise = page.waitForLoadState('load'); await gotoPromise; diff --git a/test/mcp/src/application.ts b/test/mcp/src/application.ts index a60c7b9764dbf..fa8c2ff9dec42 100644 --- a/test/mcp/src/application.ts +++ b/test/mcp/src/application.ts @@ -232,7 +232,7 @@ async function setup(): Promise { logger.log('Smoketest setup done!\n'); } -export async function getApplication({ recordVideo }: { recordVideo?: boolean } = {}) { +export async function getApplication({ recordVideo, workspacePath }: { recordVideo?: boolean; workspacePath?: string } = {}) { const testCodePath = getDevElectronPath(); const electronPath = testCodePath; if (!fs.existsSync(electronPath || '')) { @@ -252,7 +252,8 @@ export async function getApplication({ recordVideo }: { recordVideo?: boolean } quality, version: parseVersion(version ?? '0.0.0'), codePath: opts.build, - workspacePath: rootPath, + // Use provided workspace path, or fall back to rootPath on CI (GitHub Actions) + workspacePath: workspacePath ?? (process.env.GITHUB_ACTIONS ? rootPath : undefined), logger, logsPath: logsRootPath, crashesPath: crashesRootPath, @@ -292,12 +293,12 @@ export class ApplicationService { return this._application; } - async getOrCreateApplication({ recordVideo }: { recordVideo?: boolean } = {}): Promise { + async getOrCreateApplication({ recordVideo, workspacePath }: { recordVideo?: boolean; workspacePath?: string } = {}): Promise { if (this._closing) { await this._closing; } if (!this._application) { - this._application = await getApplication({ recordVideo }); + this._application = await getApplication({ recordVideo, workspacePath }); this._application.code.driver.currentPage.on('close', () => { this._closing = (async () => { if (this._application) { diff --git a/test/mcp/src/automation.ts b/test/mcp/src/automation.ts index 9163af43e892b..3263081ecfc2c 100644 --- a/test/mcp/src/automation.ts +++ b/test/mcp/src/automation.ts @@ -18,17 +18,18 @@ export async function getServer(appService: ApplicationService): Promise server.tool( 'vscode_automation_start', - 'Start VS Code Build', + 'Start VS Code Build. If workspacePath is not provided, VS Code will open with the last used workspace or an empty window.', { - recordVideo: z.boolean().optional() + recordVideo: z.boolean().optional().describe('Whether to record a video of the session'), + workspacePath: z.string().optional().describe('Optional path to a workspace or folder to open. If not provided, opens the last used workspace.') }, - async ({ recordVideo }) => { - const app = await appService.getOrCreateApplication({ recordVideo }); + async ({ recordVideo, workspacePath }) => { + const app = await appService.getOrCreateApplication({ recordVideo, workspacePath }); await app.startTracing(); return { content: [{ type: 'text' as const, - text: app ? `VS Code started successfully` : `Failed to start VS Code` + text: app ? `VS Code started successfully${workspacePath ? ` with workspace: ${workspacePath}` : ''}` : `Failed to start VS Code` }] }; } diff --git a/test/mcp/src/automationTools/core.ts b/test/mcp/src/automationTools/core.ts index 591d743789633..d18adf35ef0a6 100644 --- a/test/mcp/src/automationTools/core.ts +++ b/test/mcp/src/automationTools/core.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; import { ApplicationService } from '../application'; /** @@ -12,25 +13,26 @@ import { ApplicationService } from '../application'; export function applyCoreTools(server: McpServer, appService: ApplicationService): RegisteredTool[] { const tools: RegisteredTool[] = []; - // Playwright keeps using this as a start... maybe it needs some massaging - // server.tool( - // 'vscode_automation_restart', - // 'Restart VS Code with optional workspace or folder and extra arguments', - // { - // workspaceOrFolder: z.string().optional().describe('Optional path to workspace or folder to open'), - // extraArgs: z.array(z.string()).optional().describe('Optional extra command line arguments') - // }, - // async (args) => { - // const { workspaceOrFolder, extraArgs } = args; - // await app.restart({ workspaceOrFolder, extraArgs }); - // return { - // content: [{ - // type: 'text' as const, - // text: `VS Code restarted successfully${workspaceOrFolder ? ` with workspace: ${workspaceOrFolder}` : ''}` - // }] - // }; - // } - // ); + tools.push(server.tool( + 'vscode_automation_restart', + 'Restart VS Code with optional workspace or folder and extra command-line arguments', + { + workspaceOrFolder: z.string().optional().describe('Path to a workspace or folder to open on restart'), + extraArgs: z.array(z.string()).optional().describe('Extra CLI arguments to pass on restart') + }, + async ({ workspaceOrFolder, extraArgs }) => { + const app = await appService.getOrCreateApplication(); + await app.restart({ workspaceOrFolder, extraArgs }); + const workspaceText = workspaceOrFolder ? ` with workspace: ${workspaceOrFolder}` : ''; + const argsText = extraArgs?.length ? ` (args: ${extraArgs.join(' ')})` : ''; + return { + content: [{ + type: 'text' as const, + text: `VS Code restarted successfully${workspaceText}${argsText}` + }] + }; + } + )); tools.push(server.tool( 'vscode_automation_stop', diff --git a/test/smoke/src/areas/languages/languages.test.ts b/test/smoke/src/areas/languages/languages.test.ts index 9ec05b0c9e29e..508a35d9d4d61 100644 --- a/test/smoke/src/areas/languages/languages.test.ts +++ b/test/smoke/src/areas/languages/languages.test.ts @@ -15,7 +15,12 @@ export function setup(logger: Logger) { it('verifies quick outline (js)', async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length >= 6); @@ -24,7 +29,12 @@ export function setup(logger: Logger) { it('verifies quick outline (css)', async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length === 2); @@ -33,7 +43,12 @@ export function setup(logger: Logger) { it('verifies problems view (css)', async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.editor.waitForTypeInEditor('style.css', '.foo{}'); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.WARNING)); @@ -45,8 +60,13 @@ export function setup(logger: Logger) { it('verifies settings (css)', async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.settingsEditor.addUserSetting('css.lint.emptyRules', '"error"'); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.ERROR)); diff --git a/test/smoke/src/areas/multiroot/multiroot.test.ts b/test/smoke/src/areas/multiroot/multiroot.test.ts index cedbac51e7a34..f48f1cad1b75c 100644 --- a/test/smoke/src/areas/multiroot/multiroot.test.ts +++ b/test/smoke/src/areas/multiroot/multiroot.test.ts @@ -46,6 +46,9 @@ export function setup(logger: Logger) { // Shared before/after handling installAllHandlers(logger, opts => { + if (!opts.workspacePath) { + throw new Error('Multiroot tests require a workspace to be open'); + } const workspacePath = createWorkspaceFile(opts.workspacePath); return { ...opts, workspacePath }; }); diff --git a/test/smoke/src/areas/notebook/notebook.test.ts b/test/smoke/src/areas/notebook/notebook.test.ts index a0b81837266d4..b104ce26f76ff 100644 --- a/test/smoke/src/areas/notebook/notebook.test.ts +++ b/test/smoke/src/areas/notebook/notebook.test.ts @@ -21,8 +21,13 @@ export function setup(logger: Logger) { after(async function () { const app = this.app as Application; - cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }); - cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }); + cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }); }); // the heap snapshot fails to parse diff --git a/test/smoke/src/areas/search/search.test.ts b/test/smoke/src/areas/search/search.test.ts index f635ad827dfb8..8ac0bba570f69 100644 --- a/test/smoke/src/areas/search/search.test.ts +++ b/test/smoke/src/areas/search/search.test.ts @@ -15,8 +15,13 @@ export function setup(logger: Logger) { after(function () { const app = this.app as Application; - retry(async () => cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); - retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + retry(async () => cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }), 0, 5); + retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }), 0, 5); }); it('verifies the sidebar moves to the right', async function () { diff --git a/test/smoke/src/areas/statusbar/statusbar.test.ts b/test/smoke/src/areas/statusbar/statusbar.test.ts index ccfbeb5772f23..f681758562ec9 100644 --- a/test/smoke/src/areas/statusbar/statusbar.test.ts +++ b/test/smoke/src/areas/statusbar/statusbar.test.ts @@ -15,11 +15,16 @@ export function setup(logger: Logger) { it('verifies presence of all default status bar elements', async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.BRANCH_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.SYNC_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.PROBLEMS_STATUS); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.ENCODING_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.EOL_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.INDENTATION_STATUS); @@ -29,11 +34,16 @@ export function setup(logger: Logger) { it(`verifies that 'quick input' opens when clicking on status bar elements`, async function () { const app = this.app as Application; + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + await app.workbench.statusbar.clickOn(StatusBarElement.BRANCH_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.INDENTATION_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); @@ -56,7 +66,12 @@ export function setup(logger: Logger) { it(`verifies if changing EOL is reflected in the status bar`, async function () { const app = this.app as Application; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.EOL_STATUS); await app.workbench.quickinput.selectQuickInputElement(1); diff --git a/test/smoke/src/areas/workbench/data-loss.test.ts b/test/smoke/src/areas/workbench/data-loss.test.ts index 3e27f1acba912..f876f8596bdfd 100644 --- a/test/smoke/src/areas/workbench/data-loss.test.ts +++ b/test/smoke/src/areas/workbench/data-loss.test.ts @@ -27,10 +27,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + // Open 3 editors - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); await app.workbench.editors.newUntitledFile(); @@ -53,10 +58,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + const textToType = 'Hello, Code'; // open editor and type - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await app.workbench.editor.waitForTypeInEditor('app.js', textToType); await app.workbench.editors.waitForTab('app.js', true); @@ -94,6 +104,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); + const workspacePathOrFolder = app.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + if (autoSave) { await app.workbench.settingsEditor.addUserSetting('files.autoSave', '"afterDelay"'); } @@ -105,7 +120,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await app.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await app.workbench.editor.waitForTypeInEditor('readme.md', textToType); await app.workbench.editors.waitForTab('readme.md', !autoSave); @@ -175,10 +190,15 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); + const workspacePathOrFolder = stableApp.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + // Open 3 editors - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'bin', 'www')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'app.js')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); await stableApp.workbench.editors.newUntitledFile(); @@ -231,6 +251,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); + const workspacePathOrFolder = stableApp.workspacePathOrFolder; + if (!workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } + const textToTypeInUntitled = 'Hello from Untitled'; await stableApp.workbench.editors.newUntitledFile(); @@ -238,7 +263,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await stableApp.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'readme.md')); + await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); await stableApp.workbench.editor.waitForTypeInEditor('readme.md', textToType); await stableApp.workbench.editors.waitForTab('readme.md', true); From 90a7324651e4e3db8be5c76648516f781be2a673 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:56:07 -0800 Subject: [PATCH 16/17] Update mock --- .../workbench/contrib/chat/test/common/model/mockChatModel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 34f407b43a934..d9f5d6113d30f 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,13 +10,14 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; sessionId = ''; readonly timestamp = 0; - readonly timing = { startTime: 0 }; + readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; From 0169cdc342dda11d7938c7a5a13a643e6a1d1682 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 13 Jan 2026 18:49:54 -0800 Subject: [PATCH 17/17] Context key for vaild option groups --- .../chat/browser/actions/chatExecuteActions.ts | 4 +++- .../chat/browser/widget/input/chatInputPart.ts | 18 ++++++++++++++++++ .../chat/common/actions/chatContextKeys.ts | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index dc52a099eb25f..810a13614cfc2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -156,6 +156,7 @@ export class ChatSubmitAction extends SubmitAction { const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, whenNotInProgress, + ChatContextKeys.chatSessionOptionsValid, ); super({ @@ -494,7 +495,8 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { const menuCondition = ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask); const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasText, - whenNotInProgress + whenNotInProgress, + ChatContextKeys.chatSessionOptionsValid ); super({ diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 8997ec10510fd..b76274ea66596 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -330,6 +330,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private withinEditSessionKey: IContextKey; private filePartOfEditSessionKey: IContextKey; private chatSessionHasOptions: IContextKey; + private chatSessionOptionsValid: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; private chatSessionPickerWidgets: Map = new Map(); @@ -518,6 +519,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); + this.chatSessionOptionsValid = ChatContextKeys.chatSessionOptionsValid.bindTo(contextKeyService); const chatToolCount = ChatContextKeys.chatToolCount.bindTo(contextKeyService); @@ -1362,6 +1364,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; const hideAll = () => { this.chatSessionHasOptions.set(false); + this.chatSessionOptionsValid.set(true); // No options means nothing to validate this.hideAllSessionPickerWidgets(); }; @@ -1408,6 +1411,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return hideAll(); } + // Validate that all selected options exist in their respective option group items + let allOptionsValid = true; + for (const optionGroup of optionGroups) { + const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); + if (currentOption) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + const isValidOption = optionGroup.items.some(item => item.id === currentOptionId); + if (!isValidOption) { + this.logService.trace(`[ChatInputPart] Selected option '${currentOptionId}' is not valid for group '${optionGroup.id}'`); + allOptionsValid = false; + } + } + } + this.chatSessionOptionsValid.set(allOptionsValid); + this.chatSessionHasOptions.set(true); const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 62efc9196578d..5f7e826e76bbb 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -55,6 +55,7 @@ export namespace ChatContextKeys { export const chatEditingCanRedo = new RawContextKey('chatEditingCanRedo', false, { type: 'boolean', description: localize('chatEditingCanRedo', "True when it is possible to redo an interaction in the editing panel.") }); export const languageModelsAreUserSelectable = new RawContextKey('chatModelsAreUserSelectable', false, { type: 'boolean', description: localize('chatModelsAreUserSelectable', "True when the chat model can be selected manually by the user.") }); export const chatSessionHasModels = new RawContextKey('chatSessionHasModels', false, { type: 'boolean', description: localize('chatSessionHasModels', "True when the chat is in a contributed chat session that has available 'models' to display.") }); + export const chatSessionOptionsValid = new RawContextKey('chatSessionOptionsValid', true, { type: 'boolean', description: localize('chatSessionOptionsValid', "True when all selected session options exist in their respective option group items.") }); export const extensionInvalid = new RawContextKey('chatExtensionInvalid', false, { type: 'boolean', description: localize('chatExtensionInvalid', "True when the installed chat extension is invalid and needs to be updated.") }); export const inputCursorAtTop = new RawContextKey('chatCursorAtTop', false); export const inputHasAgent = new RawContextKey('chatInputHasAgent', false);