From 757c5ced02f612fd507212b61b046a8da23609ef Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:41:19 -0800 Subject: [PATCH 01/19] Refactor canResolveChatSession to accept session type (#299112) refactor: update canResolveChatSession method to accept session type instead of URI --- .../browser/agentSessions/agentSessionsOpener.ts | 2 +- .../chatSessions/chatSessions.contribution.ts | 12 ++++++------ .../chat/browser/widgetHosts/editor/chatEditor.ts | 2 +- .../browser/widgetHosts/viewPane/chatViewPane.ts | 9 ++++----- .../chat/common/chatService/chatServiceImpl.ts | 2 +- .../contrib/chat/common/chatSessionsService.ts | 2 +- .../chat/test/common/mockChatSessionsService.ts | 4 ++-- 7 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index be01ac3973c42..52b0707f567ba 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -103,7 +103,7 @@ async function openSessionDefault(accessor: ServicesAccessor, session: IAgentSes } const isLocalChatSession = session.resource.scheme === Schemas.vscodeChatEditor || session.resource.scheme === Schemas.vscodeLocalChatSession; - if (!isLocalChatSession && !(await chatSessionsService.canResolveChatSession(session.resource))) { + if (!isLocalChatSession && !(await chatSessionsService.canResolveChatSession(session.resource.scheme))) { target = openOptions?.sideBySide ? SIDE_GROUP : ACTIVE_GROUP; // force to open in editor if session cannot be resolved in panel options = { ...options, revealIfOpened: true }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 10dc9a1739356..967ae4f95511a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -781,20 +781,20 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return !!controller; } - async canResolveChatSession(chatSessionResource: URI) { + async canResolveChatSession(sessionType: string) { await this._extensionService.whenInstalledExtensionsRegistered(); - const resolvedType = this._resolveToPrimaryType(chatSessionResource.scheme) || chatSessionResource.scheme; + const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; const contribution = this._contributions.get(resolvedType)?.contribution; if (contribution && !this._isContributionAvailable(contribution)) { return false; } - if (this._contentProviders.has(chatSessionResource.scheme)) { + if (this._contentProviders.has(sessionType)) { return true; } - await this._extensionService.activateByEvent(`onChatSession:${chatSessionResource.scheme}`); - return this._contentProviders.has(chatSessionResource.scheme); + await this._extensionService.activateByEvent(`onChatSession:${sessionType}`); + return this._contentProviders.has(sessionType); } private async tryActivateControllers(providersToResolve: readonly string[] | undefined): Promise { @@ -1024,7 +1024,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } } - if (!(await raceCancellationError(this.canResolveChatSession(sessionResource), token))) { + if (!(await raceCancellationError(this.canResolveChatSession(sessionResource.scheme), token))) { throw Error(`Can not find provider for ${sessionResource}`); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index 8a1c71fb6156b..0a3344392bc4f 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -219,7 +219,7 @@ export class ChatEditor extends AbstractEditorWithViewState c.type === chatSessionType); if (contribution) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 4974f0b91ce33..905351e3adfc5 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -707,7 +707,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const model = ref?.object; if (model) { - await this.updateWidgetLockState(model.sessionResource); // Update widget lock state based on session type + await this.updateWidgetLockState(getChatSessionType(model.sessionResource)); // Update widget lock state based on session type // remember as model to restore in view state this.viewState.sessionResource = model.sessionResource; @@ -729,8 +729,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return model; } - private async updateWidgetLockState(sessionResource: URI): Promise { - const sessionType = getChatSessionType(sessionResource); + private async updateWidgetLockState(sessionType: string): Promise { if (sessionType === localChatSessionType) { this._widget.unlockFromCodingAgent(); return; @@ -738,9 +737,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { let canResolve = false; try { - canResolve = await this.chatSessionsService.canResolveChatSession(sessionResource); + canResolve = await this.chatSessionsService.canResolveChatSession(sessionType); } catch (error) { - this.logService.warn(`Failed to resolve chat session '${sessionResource.toString()}' for locking`, error); + this.logService.warn(`Failed to resolve chat session type '${sessionType}' for locking`, error); } if (!canResolve) { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 25bf8d21d2edd..b5ed3c924c0d1 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -579,7 +579,7 @@ export class ChatService extends Disposable implements IChatService { } private async loadRemoteSession(sessionResource: URI, location: ChatAgentLocation, token: CancellationToken): Promise { - await this.chatSessionService.canResolveChatSession(sessionResource); + await this.chatSessionService.canResolveChatSession(sessionResource.scheme); // Check if session already exists { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index da4c1b50db356..7d4396c392b6a 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -256,7 +256,7 @@ export interface IChatSessionsService { getContentProviderSchemes(): string[]; registerChatSessionContentProvider(scheme: string, provider: IChatSessionContentProvider): IDisposable; - canResolveChatSession(sessionResource: URI): Promise; + canResolveChatSession(sessionType: string): Promise; getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise; hasAnySessionOptions(sessionResource: URI): boolean; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 7ee70008f5278..da463673ef1c6 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -161,8 +161,8 @@ export class MockChatSessionsService implements IChatSessionsService { return provider.provideChatSessionContent(sessionResource, token); } - async canResolveChatSession(chatSessionResource: URI): Promise { - return this.contentProviders.has(chatSessionResource.scheme); + async canResolveChatSession(sessionType: string): Promise { + return this.contentProviders.has(sessionType); } getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined { From 605a07d2f8e97297f56425c95f40ba566d7ba6ac Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:48:04 -0800 Subject: [PATCH 02/19] Don't depend on sessionResource in a few places (#299110) * Don't depend on sessionResource in a few places Removes some antipaterns and unused variables. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/browser/mainThreadChatAgents2.ts | 3 +- .../browser/actions/chatContinueInAction.ts | 5 +- .../chat/browser/attachments/chatVariables.ts | 124 ++++++------ .../contrib/chat/browser/widget/chatWidget.ts | 7 +- .../browser/widget/input/chatInputPart.ts | 6 +- .../input/editor/chatInputEditorContrib.ts | 3 +- .../widget/input/editor/chatPasteProviders.ts | 6 +- .../common/requestParser/chatRequestParser.ts | 11 +- .../browser/attachments/chatVariables.test.ts | 191 ++++++++++++++++++ 9 files changed, 281 insertions(+), 75 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 4fdf590d55372..bf0ba0049b04c 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -32,6 +32,7 @@ import { isValidPromptType } from '../../contrib/chat/common/promptSyntax/prompt import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../contrib/chat/common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../contrib/chat/browser/attachments/chatVariables.js'; import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatService, IChatTask, IChatTaskSerialized, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../contrib/chat/common/constants.js'; @@ -459,7 +460,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA return; } - const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionResource, model.getValue()).parts; + const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), model.getValue()).parts; const agentPart = parsedRequest.find((part): part is ChatRequestAgentPart => part instanceof ChatRequestAgentPart); const thisAgentId = this._agents.get(handle)?.id; if (agentPart?.agent.id !== thisAgentId) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index 40827f04060d7..3ab18f156f540 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -34,6 +34,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { ChatModel } from '../../common/model/chatModel.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; import { ChatSendResult, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../common/constants.js'; @@ -403,7 +404,7 @@ export class CreateRemoteAgentJobAction { userPrompt = 'implement this.'; } - const attachedContext = widget.input.getAttachedAndImplicitContext(sessionResource); + const attachedContext = widget.input.getAttachedAndImplicitContext(); widget.input.acceptInput(true); // For inline editor mode, add selection or cursor information @@ -479,7 +480,7 @@ export class CreateRemoteAgentJobAction { const requestParser = instantiationService.createInstance(ChatRequestParser); // Add the request to the model first - const parsedRequest = requestParser.parseChatRequest(sessionResource, userPrompt, ChatAgentLocation.Chat); + const parsedRequest = requestParser.parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), userPrompt, ChatAgentLocation.Chat); const addedRequest = chatModel.addRequest( parsedRequest, { variables: attachedContext.asArray() }, diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts index 47e1491204420..ffdb625d5c9f1 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts @@ -5,11 +5,72 @@ import { IChatVariablesService, IDynamicVariable } from '../../common/attachments/chatVariables.js'; import { IToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; -import { IChatWidgetService } from '../chat.js'; +import { IChatWidget, IChatWidgetService } from '../chat.js'; import { ChatDynamicVariableModel } from './chatDynamicVariables.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { URI } from '../../../../../base/common/uri.js'; +export function getDynamicVariablesForWidget(widget: IChatWidget): ReadonlyArray { + if (!widget.viewModel || !widget.supportsFileReferences) { + return []; + } + + const model = widget.getContrib(ChatDynamicVariableModel.ID); + if (!model) { + return []; + } + + // track for editing state + if (widget.viewModel.editing && model.variables.length > 0) { + return model.variables; + } + + if (widget.input.attachmentModel.attachments.length > 0 && widget.viewModel.editing) { + const references: IDynamicVariable[] = []; + const editorModel = widget.inputEditor.getModel(); + const modelTextLength = editorModel?.getValueLength() ?? 0; + for (const attachment of widget.input.attachmentModel.attachments) { + // If the attachment has a range, it is a dynamic variable + if (attachment.range) { + if (attachment.range.start >= attachment.range.endExclusive) { + continue; + } + + if (attachment.range.start < 0 || attachment.range.endExclusive > modelTextLength) { + continue; + } + + if (!editorModel) { + continue; + } + + const startPos = editorModel.getPositionAt(attachment.range.start); + const endPos = editorModel.getPositionAt(attachment.range.endExclusive); + + const referenceObj: IDynamicVariable = { + id: attachment.id, + fullName: attachment.name, + modelDescription: attachment.modelDescription, + range: new Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column), + icon: attachment.icon, + isFile: attachment.kind === 'file', + isDirectory: attachment.kind === 'directory', + data: attachment.value + }; + references.push(referenceObj); + } + } + + return references.length > 0 ? references : model.variables; + } + + return model.variables; +} + +export function getSelectedToolAndToolSetsForWidget(widget: IChatWidget): IToolAndToolSetEnablementMap { + return widget.input.selectedToolsModel.entriesMap.get(); +} + export class ChatVariablesService implements IChatVariablesService { declare _serviceBrand: undefined; @@ -18,65 +79,11 @@ export class ChatVariablesService implements IChatVariablesService { ) { } getDynamicVariables(sessionResource: URI): ReadonlyArray { - // This is slightly wrong... the parser pulls dynamic references from the input widget, but there is no guarantee that message came from the input here. - // Need to ... - // - Parser takes list of dynamic references (annoying) - // - Or the parser is known to implicitly act on the input widget, and we need to call it before calling the chat service (maybe incompatible with the future, but easy) const widget = this.chatWidgetService.getWidgetBySessionResource(sessionResource); - if (!widget || !widget.viewModel || !widget.supportsFileReferences) { - return []; - } - - const model = widget.getContrib(ChatDynamicVariableModel.ID); - if (!model) { + if (!widget) { return []; } - - // track for editing state - if (widget.viewModel.editing && model.variables.length > 0) { - return model.variables; - } - - if (widget.input.attachmentModel.attachments.length > 0 && widget.viewModel.editing) { - const references: IDynamicVariable[] = []; - const editorModel = widget.inputEditor.getModel(); - const modelTextLength = editorModel?.getValueLength() ?? 0; - for (const attachment of widget.input.attachmentModel.attachments) { - // If the attachment has a range, it is a dynamic variable - if (attachment.range) { - if (attachment.range.start >= attachment.range.endExclusive) { - continue; - } - - if (attachment.range.start < 0 || attachment.range.endExclusive > modelTextLength) { - continue; - } - - if (!editorModel) { - continue; - } - - const startPos = editorModel.getPositionAt(attachment.range.start); - const endPos = editorModel.getPositionAt(attachment.range.endExclusive); - - const referenceObj: IDynamicVariable = { - id: attachment.id, - fullName: attachment.name, - modelDescription: attachment.modelDescription, - range: new Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column), - icon: attachment.icon, - isFile: attachment.kind === 'file', - isDirectory: attachment.kind === 'directory', - data: attachment.value - }; - references.push(referenceObj); - } - } - - return references.length > 0 ? references : model.variables; - } - - return model.variables; + return getDynamicVariablesForWidget(widget); } getSelectedToolAndToolSets(sessionResource: URI): IToolAndToolSetEnablementMap { @@ -84,7 +91,6 @@ export class ChatVariablesService implements IChatVariablesService { if (!widget) { return new Map(); } - return widget.input.selectedToolsModel.entriesMap.get(); - + return getSelectedToolAndToolSetsForWidget(widget); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 7c172e77ee98f..09a203bd5b06c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -56,6 +56,7 @@ import { IChatModel, IChatModelInputState, IChatResponseModel } from '../../comm import { ChatMode, getModeNameForTelemetry, IChatModeService } from '../../common/chatModes.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; import { ChatRequestQueueKind, ChatSendResult, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatSlashCommandService } from '../../common/participants/chatSlashCommands.js'; @@ -333,7 +334,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser) - .parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { + .parseChatRequestWithReferences(getDynamicVariablesForWidget(this), getSelectedToolAndToolSetsForWidget(this), this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities, @@ -854,7 +855,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } const previous = this.parsedChatRequest; - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(this), getSelectedToolAndToolSetsForWidget(this), this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities }); if (!previous || !IParsedChatRequest.equals(previous, this.parsedChatRequest)) { this._onDidChangeParsedInput.fire(); } @@ -2218,7 +2219,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const editorValue = this.getInput(); const requestInputs: IChatRequestInputOptions = { input: !query ? editorValue : query.query, - attachedContext: options?.enableImplicitContext === false ? this.input.getAttachedContext(this.viewModel.sessionResource) : this.input.getAttachedAndImplicitContext(this.viewModel.sessionResource), + attachedContext: options?.enableImplicitContext === false ? this.input.getAttachedContext() : this.input.getAttachedAndImplicitContext(), }; const isUserQuery = !query; 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 1a4b90632fb52..87a0817bba6e5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -248,15 +248,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly selectedToolsModel: ChatSelectedTools; - public getAttachedContext(sessionResource: URI) { + public getAttachedContext() { const contextArr = new ChatRequestVariableSet(); contextArr.add(...this.attachmentModel.attachments, ...this.chatContextService.getWorkspaceContextItems()); return contextArr; } - public getAttachedAndImplicitContext(sessionResource: URI): ChatRequestVariableSet { + public getAttachedAndImplicitContext(): ChatRequestVariableSet { - const contextArr = this.getAttachedContext(sessionResource); + const contextArr = this.getAttachedContext(); if (this.implicitContext) { const implicitChatVariables = this.implicitContext.enabledBaseEntries(this.configurationService.getValue('chat.implicitContext.suggestedContext')); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index ce838a3e0a146..0bf73505e4e6f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -20,6 +20,7 @@ import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../../. import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../../../common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../../attachments/chatVariables.js'; import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; import { IChatWidget } from '../../../chat.js'; import { ChatWidget } from '../../chatWidget.js'; @@ -411,7 +412,7 @@ class ChatTokenDeleter extends Disposable { // If this was a simple delete, try to find out whether it was inside a token if (!change.text && this.widget.viewModel) { const attachmentCapabilities = previousSelectedAgent?.capabilities ?? this.widget.attachmentCapabilities; - const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionResource, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind, attachmentCapabilities }); + const previousParsedValue = parser.parseChatRequestWithReferences(getDynamicVariablesForWidget(this.widget), getSelectedToolAndToolSetsForWidget(this.widget), previousInputValue, this.widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind, attachmentCapabilities }); // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestSlashPromptPart || p instanceof ChatRequestToolPart); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts index 65089b2020086..eafcf7aa1c601 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts @@ -24,8 +24,9 @@ import { IInstantiationService } from '../../../../../../../platform/instantiati import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { IExtensionService, isProposedApiEnabled } from '../../../../../../services/extensions/common/extensions.js'; import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; -import { IChatVariablesService, IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; +import { IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; import { IChatWidgetService } from '../../../chat.js'; +import { getDynamicVariablesForWidget } from '../../../attachments/chatVariables.js'; import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariables.js'; import { cleanupOldImages, createFileForMedia, resizeImage } from '../../../chatImageUtils.js'; @@ -201,7 +202,6 @@ class CopyAttachmentsProvider implements DocumentPasteEditProvider { constructor( @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IChatVariablesService private readonly chatVariableService: IChatVariablesService ) { } async prepareDocumentPaste(model: ITextModel, _ranges: readonly IRange[], _dataTransfer: IReadonlyVSDataTransfer, _token: CancellationToken): Promise { @@ -212,7 +212,7 @@ class CopyAttachmentsProvider implements DocumentPasteEditProvider { } const attachments = widget.attachmentModel.attachments; - const dynamicVariables = this.chatVariableService.getDynamicVariables(widget.viewModel.sessionResource); + const dynamicVariables = getDynamicVariablesForWidget(widget); if (attachments.length === 0 && dynamicVariables.length === 0) { return undefined; diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index 9ec0508a489f2..bf1214267a518 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -12,7 +12,7 @@ import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; -import { IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; +import { IToolAndToolSetEnablementMap, IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent @@ -37,11 +37,16 @@ export class ChatRequestParser { ) { } parseChatRequest(sessionResource: URI, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context?: IChatParserContext): IParsedChatRequest { - const parts: IParsedChatRequestPart[] = []; const references = this.variableService.getDynamicVariables(sessionResource); // must access this list before any async calls + const selectedToolAndToolSets = this.variableService.getSelectedToolAndToolSets(sessionResource); + return this.parseChatRequestWithReferences(references, selectedToolAndToolSets, message, location, context); + } + + parseChatRequestWithReferences(references: ReadonlyArray, selectedToolAndToolSets: IToolAndToolSetEnablementMap, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context?: IChatParserContext): IParsedChatRequest { + const parts: IParsedChatRequestPart[] = []; const toolsByName = new Map(); const toolSetsByName = new Map(); - for (const [entry, enabled] of this.variableService.getSelectedToolAndToolSets(sessionResource)) { + for (const [entry, enabled] of selectedToolAndToolSets) { if (enabled) { if (isToolSet(entry)) { toolSetsByName.set(entry.referenceName, entry); diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts new file mode 100644 index 0000000000000..59e05f0411bb7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { IDynamicVariable } from '../../../common/attachments/chatVariables.js'; +import { IChatWidget } from '../../../browser/chat.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../../browser/attachments/chatVariables.js'; +import { ChatDynamicVariableModel } from '../../../browser/attachments/chatDynamicVariables.js'; +import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { IToolData, IToolSet, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; + +function createMockVariable(overrides?: Partial): IDynamicVariable { + return { + id: 'var-1', + fullName: 'test-var', + range: new Range(1, 1, 1, 10), + data: 'test-data', + ...overrides, + }; +} + +function createMockAttachment(overrides?: Partial): IChatRequestVariableEntry { + return { + id: 'attach-1', + name: 'test-attachment', + kind: 'file', + value: 'test-value', + ...overrides, + } as IChatRequestVariableEntry; +} + +function createMockWidget(options: { + hasViewModel?: boolean; + supportsFileReferences?: boolean; + contribVariables?: IDynamicVariable[]; + editing?: boolean; + attachments?: IChatRequestVariableEntry[]; + editorTextLength?: number; +}): IChatWidget { + const { + hasViewModel = true, + supportsFileReferences = true, + contribVariables = [], + editing = false, + attachments = [], + editorTextLength = 100, + } = options; + + const contribModel = { + id: ChatDynamicVariableModel.ID, + variables: contribVariables, + }; + + return { + viewModel: hasViewModel ? { editing: editing ? {} : undefined } : undefined, + supportsFileReferences, + getContrib: (id: string) => id === ChatDynamicVariableModel.ID ? contribModel : undefined, + input: { + attachmentModel: { attachments }, + }, + inputEditor: { + getModel: () => ({ + getValueLength: () => editorTextLength, + getPositionAt: (offset: number) => ({ lineNumber: 1, column: offset + 1 }), + }), + }, + } as unknown as IChatWidget; +} + +suite('getDynamicVariablesForWidget', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns empty when no viewModel', () => { + const widget = createMockWidget({ hasViewModel: false }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), []); + }); + + test('returns empty when file references not supported', () => { + const widget = createMockWidget({ supportsFileReferences: false }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), []); + }); + + test('returns contrib model variables when not editing', () => { + const variables = [createMockVariable()]; + const widget = createMockWidget({ contribVariables: variables }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), variables); + }); + + test('returns contrib model variables when editing with existing variables', () => { + const variables = [createMockVariable()]; + const widget = createMockWidget({ editing: true, contribVariables: variables }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), variables); + }); + + test('converts attachments to dynamic variables when editing with attachments and no contrib variables', () => { + const attachments = [ + createMockAttachment({ + id: 'a1', + name: 'file.ts', + kind: 'file', + value: 'file-value', + range: { start: 0, endExclusive: 8 }, + }), + ]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, 'a1'); + assert.strictEqual(result[0].fullName, 'file.ts'); + assert.strictEqual(result[0].isFile, true); + assert.strictEqual(result[0].isDirectory, false); + assert.strictEqual(result[0].data, 'file-value'); + }); + + test('skips attachments without range when editing', () => { + const attachments = [createMockAttachment({ range: undefined })]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + + // No ranged attachments, falls back to contrib model variables (empty) + assert.deepStrictEqual(result, []); + }); + + test('skips attachments with empty range', () => { + const attachments = [createMockAttachment({ range: { start: 5, endExclusive: 5 } })]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + assert.deepStrictEqual(result, []); + }); + + test('skips attachments with out-of-bounds range', () => { + const attachments = [createMockAttachment({ range: { start: 0, endExclusive: 200 } })]; + const widget = createMockWidget({ editing: true, attachments, editorTextLength: 100, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + assert.deepStrictEqual(result, []); + }); + + test('skips attachments with negative start', () => { + const attachments = [createMockAttachment({ range: { start: -1, endExclusive: 5 } })]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + assert.deepStrictEqual(result, []); + }); + + test('sets isDirectory for directory attachments', () => { + const attachments = [ + createMockAttachment({ + kind: 'directory', + range: { start: 0, endExclusive: 5 }, + }), + ]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].isFile, false); + assert.strictEqual(result[0].isDirectory, true); + }); +}); + +suite('getSelectedToolAndToolSetsForWidget', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns the entriesMap from the selected tools model', () => { + const toolData: IToolData = { + id: 'tool-1', + toolReferenceName: 'myTool', + displayName: 'My Tool', + modelDescription: 'A test tool', + canBeReferencedInPrompt: true, + source: ToolDataSource.Internal, + }; + const expectedMap = new Map([[toolData, true]]); + const entriesMap = observableValue('test', expectedMap); + + const widget = { + input: { + selectedToolsModel: { entriesMap }, + }, + } as unknown as IChatWidget; + + const result = getSelectedToolAndToolSetsForWidget(widget); + assert.strictEqual(result, expectedMap); + }); +}); From 06c96dcab40c625d51c387b5ea00c7760acb7afa Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 05:56:19 +0100 Subject: [PATCH 03/19] sessions - AI customizations for selfhost (#299053) * sessions - AI customizations for selfhost * more * Update .github/hooks/hooks.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/skills/sessions/SKILL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 + .github/hooks/hooks.json | 3 +- .github/skills/agent-sessions-layout/SKILL.md | 18 +-- .github/skills/sessions/SKILL.md | 109 +++++++++++------- .vscode/tasks.json | 4 +- 5 files changed, 86 insertions(+), 50 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 06005d9424ba3..c0a4142db0398 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,7 @@ Visual Studio Code is built with a layered architecture using TypeScript, web AP - `workbench/api/` - Extension host and VS Code API implementation - `src/vs/code/` - Electron main process specific implementation - `src/vs/server/` - Server specific implementation +- `src/vs/sessions/` - Agent sessions window, a dedicated workbench layer for agentic workflows (sits alongside `vs/workbench`, may import from it but not vice versa) The core architecture follows these principles: - **Layered architecture** - from `base`, `platform`, `editor`, to `workbench` @@ -135,6 +136,7 @@ function f(x: number, y: string): void { } - Prefer regex capture groups with names over numbered capture groups. - If you create any temporary new files, scripts, or helper files for iteration, clean up these files by removing them at the end of the task - Never duplicate imports. Always reuse existing imports if they are present. +- When removing an import, do not leave behind blank lines where the import was. Ensure the surrounding code remains compact. - Do not use `any` or `unknown` as the type for variables, parameters, or return values unless absolutely necessary. If they need type annotations, they should have proper types or interfaces defined. - When adding file watching, prefer correlated file watchers (via fileService.createWatcher) to shared ones. - When adding tooltips to UI elements, prefer the use of IHoverService service. diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json index 3e0f178b02329..5e27e6db893af 100644 --- a/.github/hooks/hooks.json +++ b/.github/hooks/hooks.json @@ -4,7 +4,8 @@ "sessionStart": [ { "type": "command", - "bash": "if [ -f ~/.vscode-worktree-setup ]; then nohup bash -c 'npm ci && npm run compile' > /tmp/worktree-setup-$(date +%Y-%m-%d_%H-%M-%S).log 2>&1 & fi" + "bash": "if [ -f ~/.vscode-worktree-setup ]; then nohup bash -c 'npm ci && npm run compile' > /tmp/worktree-setup-$(date +%Y-%m-%d_%H-%M-%S).log 2>&1 & fi", + "powershell": "if (Test-Path \"$env:USERPROFILE\\.vscode-worktree-setup\") { $log = \"$env:TEMP\\worktree-setup-$(Get-Date -Format 'yyyy-MM-dd_HH-mm-ss').log\"; $dir = $PWD.Path; Start-Job -ScriptBlock { param($d, $l) Set-Location $d; & { npm ci; if ($LASTEXITCODE -eq 0) { npm run compile } } *> $l } -ArgumentList $dir, $log | Out-Null }" } ], "sessionEnd": [ diff --git a/.github/skills/agent-sessions-layout/SKILL.md b/.github/skills/agent-sessions-layout/SKILL.md index a76794d9c7d8a..af4f03a3f6066 100644 --- a/.github/skills/agent-sessions-layout/SKILL.md +++ b/.github/skills/agent-sessions-layout/SKILL.md @@ -45,7 +45,7 @@ When proposing or implementing changes, follow these rules from the spec: 4. **New parts go in the right section** — Any new parts should be added to the horizontal branch alongside Chat Bar and Auxiliary Bar 5. **Preserve no-op methods** — Unsupported features (zen mode, centered layout, etc.) should remain as no-ops, not throw errors 6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites -7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`), not the standard workbench parts +7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`, `ProjectBarPart`), not the standard workbench parts 8. **Use separate storage keys** — Agent session parts use their own storage keys (prefixed with `workbench.agentsession.` or `workbench.chatbar.`) to avoid conflicts with regular workbench state 9. **Use agent session menu IDs** — Actions should use `Menus.*` menu IDs (from `sessions/browser/menus.ts`), not shared `MenuId.*` constants @@ -53,20 +53,24 @@ When proposing or implementing changes, follow these rules from the spec: | File | Purpose | |------|---------| -| `sessions/LAYOUT.md` | Authoritative specification | +| `sessions/LAYOUT.md` | Authoritative layout specification | | `sessions/browser/workbench.ts` | Main layout implementation (`Workbench` class) | | `sessions/browser/menus.ts` | Agent sessions menu IDs (`Menus` export) | | `sessions/browser/layoutActions.ts` | Layout actions (toggle sidebar, panel, secondary sidebar) | | `sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService` | -| `sessions/browser/style.css` | Layout-specific styles | -| `sessions/browser/parts/` | Agent session part implementations | +| `sessions/browser/media/style.css` | Layout-specific styles | +| `sessions/browser/parts/parts.ts` | `AgenticParts` enum | | `sessions/browser/parts/titlebarPart.ts` | Titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, TitleService | -| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer) | +| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer and macOS traffic light spacer) | | `sessions/browser/parts/chatBarPart.ts` | Chat Bar part | -| `sessions/browser/widget/` | Agent sessions chat widget | +| `sessions/browser/parts/auxiliaryBarPart.ts` | Auxiliary Bar part (with run script dropdown) | +| `sessions/browser/parts/panelPart.ts` | Panel part | +| `sessions/browser/parts/projectBarPart.ts` | Project Bar part (folder entries, icon customization) | +| `sessions/contrib/configuration/browser/configuration.contribution.ts` | Sets `workbench.editor.useModal` to `'all'` for modal editor overlay | | `sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts` | Title bar widget and session picker | -| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script contribution | +| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script split button for titlebar | | `sessions/contrib/accountMenu/browser/account.contribution.ts` | Account widget for sidebar footer | +| `sessions/electron-browser/parts/titlebarPart.ts` | Desktop (Electron) titlebar part | ## 5. Testing Changes diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index fc49548b7a384..d82e03178ffd5 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -15,8 +15,6 @@ The `src/vs/sessions/` directory contains authoritative specification documents. | Layout spec | `src/vs/sessions/LAYOUT.md` | Grid structure, part positions, sizing, CSS classes, API reference | | AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | AI customization editor and tree view design | | Chat Widget | `src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md` | Chat widget wrapper architecture, deferred session creation, option delivery | -| AI Customization Mgmt | `src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md` | Management editor specification | -| AI Customization Tree | `src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md` | Tree view specification | If you modify the implementation, you **must** update the corresponding spec to keep it in sync. Update the Revision History table at the bottom of `LAYOUT.md` with a dated entry. @@ -62,44 +60,57 @@ src/vs/sessions/ ├── AI_CUSTOMIZATIONS.md # AI customization design document ├── sessions.common.main.ts # Common (browser + desktop) entry point ├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) -├── common/ # Shared types and context keys -│ └── contextkeys.ts # ChatBar context keys +├── common/ # Shared types, context keys, and theme +│ ├── categories.ts # Command categories +│ ├── contextkeys.ts # ChatBar and welcome context keys +│ └── theme.ts # Theme contributions ├── browser/ # Core workbench implementation │ ├── workbench.ts # Main Workbench class (implements IWorkbenchLayoutService) │ ├── menus.ts # Agent sessions menu IDs (Menus export) │ ├── layoutActions.ts # Layout toggle actions (sidebar, panel, auxiliary bar) │ ├── paneCompositePartService.ts # AgenticPaneCompositePartService -│ ├── style.css # Layout-specific styles │ ├── widget/ # Agent sessions chat widget -│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc -│ │ ├── agentSessionsChatWidget.ts # Main wrapper around ChatWidget -│ │ ├── agentSessionsChatTargetConfig.ts # Observable target state -│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar -│ │ └── media/ -│ └── parts/ # Workbench part implementations -│ ├── parts.ts # AgenticParts enum -│ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) -│ ├── sidebarPart.ts # Sidebar (with footer for account widget) -│ ├── chatBarPart.ts # Chat Bar (primary chat surface) -│ ├── auxiliaryBarPart.ts # Auxiliary Bar (with run script dropdown) -│ ├── panelPart.ts # Panel (terminal, output, etc.) -│ ├── projectBarPart.ts # Project bar (folder entries) -│ ├── agentSessionsChatInputPart.ts # Chat input part adapter -│ ├── agentSessionsChatWelcomePart.ts # Welcome view (mascot + target buttons + pickers) -│ └── media/ # Part CSS files +│ │ └── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc +│ ├── parts/ # Workbench part implementations +│ │ ├── parts.ts # AgenticParts enum +│ │ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) +│ │ ├── sidebarPart.ts # Sidebar (with footer for account widget) +│ │ ├── chatBarPart.ts # Chat Bar (primary chat surface) +│ │ ├── auxiliaryBarPart.ts # Auxiliary Bar +│ │ ├── panelPart.ts # Panel (terminal, output, etc.) +│ │ ├── projectBarPart.ts # Project bar (folder entries) +│ │ └── media/ # Part CSS files +│ └── media/ # Layout-specific styles ├── electron-browser/ # Desktop-specific entry points │ ├── sessions.main.ts # Desktop main bootstrap │ ├── sessions.ts # Electron process entry │ ├── sessions.html # Production HTML shell -│ └── sessions-dev.html # Development HTML shell +│ ├── sessions-dev.html # Development HTML shell +│ ├── titleService.ts # Desktop title service override +│ └── parts/ +│ └── titlebarPart.ts # Desktop titlebar part +├── services/ # Service overrides +│ ├── configuration/browser/ # Configuration service overrides +│ └── workspace/browser/ # Workspace service overrides +├── test/ # Unit tests +│ └── browser/ +│ └── layoutActions.test.ts └── contrib/ # Feature contributions ├── accountMenu/browser/ # Account widget for sidebar footer - ├── aiCustomizationManagement/browser/ # AI customization management editor + ├── agentFeedback/browser/ # Agent feedback attachments, overlays, hover ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar + ├── applyToParentRepo/browser/ # Apply changes to parent repo ├── changesView/browser/ # File changes view ├── chat/browser/ # Chat actions (run script, branch, prompts) ├── configuration/browser/ # Configuration overrides - └── sessions/browser/ # Sessions view, title bar widget, active session service + ├── files/browser/ # File-related contributions + ├── fileTreeView/browser/ # File tree view (filesystem provider) + ├── gitSync/browser/ # Git sync contributions + ├── logs/browser/ # Log contributions + ├── sessions/browser/ # Sessions view, title bar widget, active session service + ├── terminal/browser/ # Terminal contributions + ├── welcome/browser/ # Welcome view contribution + └── workspace/browser/ # Workspace contributions ``` ## 4. Layout @@ -165,18 +176,21 @@ The agent sessions window uses **its own menu IDs** defined in `browser/menus.ts | Menu ID | Purpose | |---------|---------| -| `Menus.TitleBarLeft` | Left toolbar (toggle sidebar) | -| `Menus.TitleBarCenter` | Not used directly (see CommandCenter) | -| `Menus.TitleBarRight` | Right toolbar (run script, open, toggle auxiliary bar) | +| `Menus.ChatBarTitle` | Chat bar title actions | | `Menus.CommandCenter` | Center toolbar with session picker widget | -| `Menus.TitleBarControlMenu` | Submenu intercepted to render `SessionsTitleBarWidget` | +| `Menus.CommandCenterCenter` | Center section of command center | +| `Menus.TitleBarContext` | Titlebar context menu | +| `Menus.TitleBarLeftLayout` | Left layout toolbar | +| `Menus.TitleBarSessionTitle` | Session title in titlebar | +| `Menus.TitleBarSessionMenu` | Session menu in titlebar | +| `Menus.TitleBarRightLayout` | Right layout toolbar | | `Menus.PanelTitle` | Panel title bar actions | | `Menus.SidebarTitle` | Sidebar title bar actions | | `Menus.SidebarFooter` | Sidebar footer (account widget) | +| `Menus.SidebarCustomizations` | Sidebar customizations menu | | `Menus.AuxiliaryBarTitle` | Auxiliary bar title actions | | `Menus.AuxiliaryBarTitleLeft` | Auxiliary bar left title actions | -| `Menus.OpenSubMenu` | "Open..." split button (Open Terminal, Open in VS Code) | -| `Menus.ChatBarTitle` | Chat bar title actions | +| `Menus.AgentFeedbackEditorContent` | Agent feedback editor content menu | ## 7. Context Keys @@ -187,7 +201,7 @@ Defined in `common/contextkeys.ts`: | `activeChatBar` | `string` | ID of the active chat bar panel | | `chatBarFocus` | `boolean` | Whether chat bar has keyboard focus | | `chatBarVisible` | `boolean` | Whether chat bar is visible | - +| `sessionsWelcomeVisible` | `boolean` | Whether the sessions welcome overlay is visible | ## 8. Contributions Feature contributions live under `contrib//browser/` and are registered via imports in `sessions.desktop.main.ts` (desktop) or `sessions.common.main.ts` (browser-compatible). @@ -199,13 +213,18 @@ Feature contributions live under `contrib//browser/` and are regist | **Sessions View** | `contrib/sessions/browser/` | Sessions list in sidebar, session picker, active session service | | **Title Bar Widget** | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | Session picker in titlebar center | | **Account Widget** | `contrib/accountMenu/browser/` | Account button in sidebar footer | -| **Run Script** | `contrib/chat/browser/runScriptAction.ts` | Run configured script in terminal | -| **Branch Chat Session** | `contrib/chat/browser/branchChatSessionAction.ts` | Branch a chat session | -| **Open in VS Code / Terminal** | `contrib/chat/browser/chat.contribution.ts` | Open worktree in VS Code or terminal | -| **Prompts Service** | `contrib/chat/browser/promptsService.ts` | Agentic prompts service override | +| **Chat Actions** | `contrib/chat/browser/` | Chat actions (run script, branch, prompts, customizations debug log) | | **Changes View** | `contrib/changesView/browser/` | File changes in auxiliary bar | -| **AI Customization Editor** | `contrib/aiCustomizationManagement/browser/` | Management editor for prompts, hooks, MCP, etc. | +| **Agent Feedback** | `contrib/agentFeedback/browser/` | Agent feedback attachments, editor overlays, hover | | **AI Customization Tree** | `contrib/aiCustomizationTreeView/browser/` | Sidebar tree for AI customizations | +| **Apply to Parent Repo** | `contrib/applyToParentRepo/browser/` | Apply changes to parent repo | +| **Files** | `contrib/files/browser/` | File-related contributions | +| **File Tree View** | `contrib/fileTreeView/browser/` | File tree view (filesystem provider) | +| **Git Sync** | `contrib/gitSync/browser/` | Git sync contributions | +| **Logs** | `contrib/logs/browser/` | Log contributions | +| **Terminal** | `contrib/terminal/browser/` | Terminal contributions | +| **Welcome** | `contrib/welcome/browser/` | Welcome view contribution | +| **Workspace** | `contrib/workspace/browser/` | Workspace contributions | | **Configuration** | `contrib/configuration/browser/` | Configuration overrides | ### 8.2 Service Overrides @@ -216,6 +235,10 @@ The agent sessions window registers its own implementations for: - `IPromptsService` → `AgenticPromptsService` (scopes prompt discovery to active session worktree) - `IActiveSessionService` → `ActiveSessionService` (tracks active session) +Service overrides also live under `services/`: +- `services/configuration/browser/` - configuration service overrides +- `services/workspace/browser/` - workspace service overrides + ### 8.3 `WindowVisibility.Sessions` Views and contributions that should only appear in the agent sessions window (not in regular VS Code) use `WindowVisibility.Sessions` in their registration. @@ -224,12 +247,14 @@ Views and contributions that should only appear in the agent sessions window (no | File | Purpose | |------|---------| -| `sessions.common.main.ts` | Common entry — imports browser-compatible services, workbench contributions | -| `sessions.desktop.main.ts` | Desktop entry — imports desktop services, electron contributions, all `contrib/` modules | +| `sessions.common.main.ts` | Common entry; imports browser-compatible services, workbench contributions | +| `sessions.desktop.main.ts` | Desktop entry; imports desktop services, electron contributions, all `contrib/` modules | | `electron-browser/sessions.main.ts` | Desktop bootstrap | | `electron-browser/sessions.ts` | Electron process entry | | `electron-browser/sessions.html` | Production HTML shell | | `electron-browser/sessions-dev.html` | Development HTML shell | +| `electron-browser/titleService.ts` | Desktop title service override | +| `electron-browser/parts/titlebarPart.ts` | Desktop titlebar part | ## 10. Development Guidelines @@ -243,7 +268,13 @@ Views and contributions that should only appear in the agent sessions window (no 6. Use agent session part classes, not standard workbench parts 7. Mark views with `WindowVisibility.Sessions` so they only appear in this window -### 10.2 Layout Changes +### 10.2 Validating Changes + +1. Run `npm run compile-check-ts-native` to run a repo-wide TypeScript compilation check (including `src/vs/sessions/`). This is a fast way to catch TypeScript errors introduced by your changes. +2. Run `npm run valid-layers-check` to verify layering rules are not violated. +3. Run tests under `src/vs/sessions/test/` to confirm nothing is broken. + +### 10.3 Layout Changes 1. **Read `LAYOUT.md` first** — it's the authoritative spec 2. Use the `agent-sessions-layout` skill for detailed implementation guidance diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9e9cc12ca99ca..e6bf967ddf04f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -225,8 +225,7 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "problemMatcher": [], - "inSessions": true + "problemMatcher": [] }, { "label": "Run Dev Sessions", @@ -238,7 +237,6 @@ "args": [ "--sessions" ], - "inSessions": true, "problemMatcher": [] }, { From 1f4b2e1a17bc4e5e8956874e5e3fbba2476f7da0 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 05:56:40 +0100 Subject: [PATCH 04/19] modal - focus editor on title click (#299038) Co-authored-by: Dmitriy Vasyura --- .../workbench/browser/parts/editor/modalEditorPart.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index f53f8530a8748..a5bf50ce859b1 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -119,7 +119,6 @@ export class ModalEditorPart { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId, - tabIndex: -1 }); shadowElement.appendChild(editorPartContainer); @@ -230,6 +229,12 @@ export class ModalEditorPart { editorPart.toggleMaximized(); })); + // Focus active editor when clicking into the title area with no other click target + disposables.add(addDisposableListener(headerElement, EventType.CLICK, e => { + EventHelper.stop(e); + + editorPart.activeGroup.focus(); + })); // Layout the modal editor part const layoutModal = () => { @@ -270,8 +275,8 @@ export class ModalEditorPart { this.hostService.setWindowDimmed(mainWindow, true); disposables.add(toDisposable(() => this.hostService.setWindowDimmed(mainWindow, false))); - // Focus the modal - editorPartContainer.focus(); + // Focus + editorPart.activeGroup.focus(); return { part: editorPart, From a7f87d92f9ee0ecd38ee866bf0e884ddbf8cb2d6 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 05:57:06 +0100 Subject: [PATCH 05/19] sessions - allow to open preview from markdown files (#299047) * sessions - allow to open preview from markdown files * Update src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../browser/markdownPreview.contribution.ts | 24 +++++++++++++++++++ src/vs/sessions/sessions.desktop.main.ts | 1 + 2 files changed, 25 insertions(+) create mode 100644 src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts diff --git a/src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts b/src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts new file mode 100644 index 0000000000000..f186d71637d78 --- /dev/null +++ b/src/vs/sessions/contrib/markdownPreview/browser/markdownPreview.contribution.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; + +// Show a floating "Open Preview" button in the editor content +// area when editing markdown or related prompt/instructions/chatagent/skill +// language content in the sessions window. +MenuRegistry.appendMenuItem(MenuId.EditorContent, { + command: { + id: 'markdown.showPreviewToSide', + title: localize('openPreview', "Open Preview"), + }, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + ContextKeyExpr.regex(EditorContextKeys.languageId.key, /^(markdown|prompt|instructions|chatagent|skill)$/), + ), +}); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 4b35ada85412d..17d622826eb3d 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -212,6 +212,7 @@ import './contrib/gitSync/browser/gitSync.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed import './contrib/configuration/browser/configuration.contribution.js'; +import './contrib/markdownPreview/browser/markdownPreview.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/logs/browser/logs.contribution.js'; From da27892b6d6607425b7f0eb29235e9b456e277a3 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 05:57:13 +0100 Subject: [PATCH 06/19] sessions - tweaks to empty message (#299034) --- .../browser/agentSessions/agentSessionsControl.ts | 14 +++++++------- .../agentSessions/media/agentsessionsviewer.css | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 727d81d2f94d5..57ca104e997a4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -157,10 +157,10 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo hide(this.emptyFilterMessage); const span = append(this.emptyFilterMessage, $('span')); - span.textContent = localize('agentSessions.noFilterResults', "No matching sessions."); + span.textContent = `${localize('agentSessions.noFilterResults', "No matching sessions")} - `; const link = append(this.emptyFilterMessage, $('span.reset-filter-link')); - link.textContent = localize('agentSessions.clearFilters', "Clear Filter"); + link.textContent = localize('agentSessions.resetFilter', "Reset Filter"); link.tabIndex = 0; link.setAttribute('role', 'button'); this._register(addDisposableListener(link, EventType.CLICK, () => this.options.filter.reset())); @@ -235,7 +235,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo })); this._register(sessionFilter.onDidGetChildren(count => { - this.updateEmptyFilterMessage(count); + this.updateEmpty(count === 0); })); const model = this.agentSessionsService.model; @@ -287,18 +287,18 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo })); } - private updateEmptyFilterMessage(visibleChildren: number): void { + private updateEmpty(isEmpty: boolean): void { if (!this.emptyFilterMessage || !this.sessionsList) { return; } const model = this.agentSessionsService.model; const hasSessionsInModel = model.sessions.length > 0; - const hasVisibleChildren = visibleChildren > 0; const isFilterActive = !this.options.filter.isDefault(); - const showMessage = hasSessionsInModel && !hasVisibleChildren && isFilterActive; - setVisibility(showMessage, this.emptyFilterMessage); + const showEmpty = hasSessionsInModel && isEmpty && isFilterActive; + setVisibility(showEmpty, this.emptyFilterMessage); + setVisibility(!showEmpty, this.sessionsList.getHTMLElement()); } private hasTodaySessions(): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 3481a4a075dea..3ee43124df596 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -352,7 +352,6 @@ color: var(--vscode-descriptionForeground); .reset-filter-link { - margin-left: 4px; color: var(--vscode-textLink-foreground); cursor: pointer; text-decoration: none; From 575c39d1606f3d7318850530884fbdcb5285f9ee Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 05:58:02 +0100 Subject: [PATCH 07/19] sessions - fix close action showing up in modal editors when tabbed (#299041) --- .../contrib/configuration/browser/configuration.contribution.ts | 1 - src/vs/workbench/browser/parts/editor/editorTitleControl.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 25c9f5cb9145d..843068b74d642 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -41,7 +41,6 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'terminal.integrated.initialHint': false, 'workbench.editor.restoreEditors': false, - 'workbench.editor.showTabs': 'single', 'workbench.startupEditor': 'none', 'workbench.tips.enabled': false, 'workbench.layoutControl.type': 'toggles', diff --git a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts index 00f7bf67590d6..132ca85f1656e 100644 --- a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts @@ -176,6 +176,7 @@ export class EditorTitleControl extends Themable { } updateOptions(oldOptions: IEditorPartOptions, newOptions: IEditorPartOptions): void { + // Update editor tabs control if options changed if ( oldOptions.showTabs !== newOptions.showTabs || From b16ecea6152e2b4466ab1a767ab384596296b3aa Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 4 Mar 2026 16:05:06 +1100 Subject: [PATCH 08/19] feat: enhance chat session option updates (#299109) --- .../browser/widget/input/chatInputPart.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 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 87a0817bba6e5..9423e2431e497 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -664,10 +664,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (sessionResource) { const ctx = this.chatService.getChatSessionFromInternalUri(sessionResource); if (ctx) { - this.chatSessionsService.notifySessionOptionsChange( - ctx.chatSessionResource, - [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] - ).catch(err => this.logService.error('Failed to notify extension of agent change:', err)); + let needsUpdate = true; + const agentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, agentOptionId); + if (typeof agentOption !== 'undefined') { + const agentId = (typeof agentOption === 'string' ? agentOption : agentOption.id) || ChatMode.Agent.id; + const isDefaultAgent = agentId === ChatMode.Agent.id; + needsUpdate = isDefaultAgent + ? mode.id !== ChatMode.Agent.id + : mode.label.read(undefined) !== agentId; // Extensions use Label (name) as identifier for custom agents. + } + if (needsUpdate) { + this.chatSessionsService.notifySessionOptionsChange( + ctx.chatSessionResource, + [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] + ).catch(err => this.logService.error('Failed to notify extension of agent change:', err)); + } } } })); From f4e743c4abdd309d99fc1425228281da2ed0e039 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 4 Mar 2026 17:10:14 +1100 Subject: [PATCH 09/19] Ensure to update ChatInputPart state before updating viewmodel (#299119) --- src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 09a203bd5b06c..8a391fa71d69b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1924,10 +1924,13 @@ export class ChatWidget extends Disposable implements IChatWidget { this._codeBlockModelCollection.clear(); - this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); - + // Set the input model on the inputPart before assigning this.viewModel. Assigning this.viewModel + // fires onDidChangeViewModel, which ChatInputPart listens to and expects the input model to be initialized. // Pass input model reference to input part for state syncing this.inputPart.setInputModel(model.inputModel, model.getRequests().length === 0); + + this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); + this.listWidget.setViewModel(this.viewModel); if (this._lockedAgent) { From 719f750060e81659a46914affe7305e753d37457 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 3 Mar 2026 22:15:44 -0800 Subject: [PATCH 10/19] Add /troubleshoot command for access to debug logs (#299024) --- .../actions/chatOpenAgentDebugPanelAction.ts | 4 +- .../attachments/chatAttachmentWidgets.ts | 10 + .../chat/browser/chatDebug/chatDebugEditor.ts | 8 +- .../browser/chatDebug/chatDebugFilters.ts | 84 +++++++ .../browser/chatDebug/chatDebugLogsView.ts | 32 ++- .../chat/browser/chatDebug/chatDebugTypes.ts | 2 + .../browser/chatDebug/media/chatDebug.css | 7 + .../contrib/chat/browser/chatSlashCommands.ts | 105 ++++++++- .../chatReferencesContentPart.ts | 3 +- .../input/editor/chatInputCompletions.ts | 4 +- .../chat/common/actions/chatContextKeys.ts | 1 + .../common/attachments/chatVariableEntries.ts | 11 +- .../common/chatService/chatServiceImpl.ts | 5 +- .../common/participants/chatSlashCommands.ts | 10 +- .../resolveDebugEventDetailsTool.ts | 188 +++++++++++++++ .../chat/common/tools/builtinTools/tools.ts | 5 + .../test/browser/chatDebugFilters.test.ts | 220 ++++++++++++++++++ 17 files changed, 680 insertions(+), 19 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/chatDebugFilters.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 9fefe82c3acf2..860559d64e3d2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -67,7 +67,7 @@ export function registerChatOpenAgentDebugPanelAction() { }); } - async run(accessor: ServicesAccessor, context?: URI | unknown): Promise { + async run(accessor: ServicesAccessor, context?: URI | unknown, filter?: string): Promise { const editorService = accessor.get(IEditorService); const chatWidgetService = accessor.get(IChatWidgetService); const chatDebugService = accessor.get(IChatDebugService); @@ -88,7 +88,7 @@ export function registerChatOpenAgentDebugPanelAction() { } chatDebugService.activeSessionResource = sessionResource; - const options: IChatDebugEditorOptions = { pinned: true, sessionResource, viewHint: 'logs' }; + const options: IChatDebugEditorOptions = { pinned: true, sessionResource, viewHint: 'logs', filter }; await editorService.openEditor(ChatDebugEditorInput.instance, options); } }); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index c1763a79436c1..8a20bd7098282 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -640,6 +640,16 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { })); } + // Handle click for debug events attachments + if (attachment.kind === 'debugEvents') { + this.element.style.cursor = 'pointer'; + this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, () => { + const d = new Date(attachment.snapshotTime); + const filter = `before:${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; + this.commandService.executeCommand('workbench.action.chat.openAgentDebugPanelForSession', attachment.sessionResource, filter); + })); + } + // Setup tooltip hover for string context attachments if ((isStringVariableEntry(attachment) || attachment.kind === 'generic') && attachment.tooltip) { this._setupTooltipHover(attachment.tooltip); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index e9b7160ea45b9..ec403b30f956d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -341,7 +341,7 @@ export class ChatDebugEditor extends EditorPane { } private _applyNavigationOptions(options: IChatDebugEditorOptions): void { - const { sessionResource, viewHint } = options; + const { sessionResource, viewHint, filter } = options; if (viewHint === 'logs' && sessionResource) { this.navigateToSession(sessionResource, 'logs'); } else if (viewHint === 'flowchart' && sessionResource) { @@ -356,6 +356,12 @@ export class ChatDebugEditor extends EditorPane { } else if (this.viewState === ViewState.Home) { this.showView(ViewState.Home); } + + // Apply filter text if provided (e.g. from debug events snapshot) + if (filter !== undefined && this.filterState) { + this.filterState.setTextFilter(filter); + this.logsView?.setFilterText(filter); + } } override layout(dimension: Dimension): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts index 8f25da718a1c1..fefa283cceb64 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts @@ -38,6 +38,10 @@ export class ChatDebugFilterState extends Disposable { // Text filter textFilter: string = ''; + // Parsed timestamp filters (epoch ms) + beforeTimestamp: number | undefined; + afterTimestamp: number | undefined; + isKindVisible(kind: string, category?: string): boolean { switch (kind) { case 'toolCall': return this.filterKindToolCall; @@ -70,10 +74,90 @@ export class ChatDebugFilterState extends Disposable { const normalized = text.toLowerCase(); if (this.textFilter !== normalized) { this.textFilter = normalized; + this._parseTimestampFilters(normalized); + this._onDidChange.fire(); + } + } + + setBeforeTimestamp(timestamp: number | undefined): void { + if (this.beforeTimestamp !== timestamp) { + this.beforeTimestamp = timestamp; this._onDidChange.fire(); } } + /** + * Parse `before:YYYY[-MM[-DD[THH[:MM[:SS]]]]]` from the filter text. + * Each component after the year is optional. + */ + private _parseTimestampFilters(text: string): void { + this.beforeTimestamp = ChatDebugFilterState.parseTimeToken(text, 'before'); + this.afterTimestamp = ChatDebugFilterState.parseTimeToken(text, 'after'); + } + + static parseTimeToken(text: string, prefix: string): number | undefined { + const regex = new RegExp(`${prefix}:(\\d{4})(?:-(\\d{2})(?:-(\\d{2})(?:t(\\d{1,2})(?::(\\d{2})(?::(\\d{2}))?)?)?)?)?(?!\\w)`); + const m = regex.exec(text); + if (!m) { + return undefined; + } + + const year = parseInt(m[1], 10); + const month = m[2] !== undefined ? parseInt(m[2], 10) - 1 : undefined; + const day = m[3] !== undefined ? parseInt(m[3], 10) : undefined; + const hour = m[4] !== undefined ? parseInt(m[4], 10) : undefined; + const minute = m[5] !== undefined ? parseInt(m[5], 10) : undefined; + const second = m[6] !== undefined ? parseInt(m[6], 10) : undefined; + + // For 'before:', round up to the end of the most specific unit given. + // For 'after:', use the start of the most specific unit. + if (prefix === 'before') { + if (second !== undefined) { + return new Date(year, month!, day!, hour!, minute!, second, 999).getTime(); + } else if (minute !== undefined) { + return new Date(year, month!, day!, hour!, minute, 59, 999).getTime(); + } else if (hour !== undefined) { + return new Date(year, month!, day!, hour, 59, 59, 999).getTime(); + } else if (day !== undefined) { + return new Date(year, month!, day, 23, 59, 59, 999).getTime(); + } else if (month !== undefined) { + // End of the given month + return new Date(year, month + 1, 0, 23, 59, 59, 999).getTime(); + } else { + // End of the given year + return new Date(year, 11, 31, 23, 59, 59, 999).getTime(); + } + } else { + return new Date( + year, + month ?? 0, + day ?? 1, + hour ?? 0, + minute ?? 0, + second ?? 0, + 0, + ).getTime(); + } + } + + /** Returns the text filter with before:/after: tokens removed. */ + get textFilterWithoutTimestamps(): string { + return this.textFilter + .replace(/\b(?:before|after):\d{4}(?:-\d{2}(?:-\d{2}(?:t\d{1,2}(?::\d{2}(?::\d{2})?)?)?)?)?\b/g, '') + .trim(); + } + + isTimestampVisible(created: Date): boolean { + const time = created.getTime(); + if (this.beforeTimestamp !== undefined && time > this.beforeTimestamp) { + return false; + } + if (this.afterTimestamp !== undefined && time < this.afterTimestamp) { + return false; + } + return true; + } + fire(): void { this._onDidChange.fire(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 5fa4cde6b6f52..a9bbec0b6d53f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -28,6 +28,7 @@ import { ChatDebugEventRenderer, ChatDebugEventDelegate, ChatDebugEventTreeRende import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem, LogsViewMode } from './chatDebugTypes.js'; import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; +import { IChatWidgetService } from '../chat.js'; const $ = DOM.$; @@ -70,6 +71,7 @@ export class ChatDebugLogsView extends Disposable { @IChatDebugService private readonly chatDebugService: IChatDebugService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { super(); this.container = DOM.append(parent, $('.chat-debug-logs')); @@ -104,7 +106,7 @@ export class ChatDebugLogsView extends Disposable { new ServiceCollection([IContextKeyService, scopedContextKeyService]) )); this.filterWidget = this._register(childInstantiationService.createInstance(FilterWidget, { - placeholder: localize('chatDebug.search', "Filter (e.g. text, !exclude)"), + placeholder: localize('chatDebug.search', "Filter (e.g. text, !exclude, before:YYYY-MM-DDTHH:MM:SS)"), ariaLabel: localize('chatDebug.filterAriaLabel', "Filter debug events"), })); @@ -119,6 +121,23 @@ export class ChatDebugLogsView extends Disposable { const filterContainer = DOM.append(this.headerContainer, $('.viewpane-filter-container')); filterContainer.appendChild(this.filterWidget.element); + // Troubleshoot button + const troubleshootButton = this._register(new Button(this.headerContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.troubleshoot', "Add snapshot to Chat") })); + troubleshootButton.element.classList.add('chat-debug-troubleshoot-button', 'monaco-text-button'); + DOM.append(troubleshootButton.element, $(`span${ThemeIcon.asCSSSelector(Codicon.chatSparkle)}`)); + this._register(troubleshootButton.onDidClick(async () => { + if (!this.currentSessionResource) { + return; + } + const widget = await this.chatWidgetService.openSession(this.currentSessionResource); + if (widget) { + const value = '/troubleshoot '; + widget.inputEditor.setValue(value); + widget.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 }); + widget.focusInput(); + } + })); + this._register(this.filterWidget.onDidChangeFilterText(text => { this.filterState.setTextFilter(text); })); @@ -241,6 +260,10 @@ export class ChatDebugLogsView extends Disposable { this.currentSessionResource = sessionResource; } + setFilterText(text: string): void { + this.filterWidget.setFilterText(text); + } + show(): void { DOM.show(this.container); this.loadEvents(); @@ -297,8 +320,11 @@ export class ChatDebugLogsView extends Disposable { return this.filterState.isKindVisible(e.kind, category); }); - // Filter by text search - const filterText = this.filterState.textFilter; + // Filter by timestamp (before:/after: syntax) + filtered = filtered.filter(e => this.filterState.isTimestampVisible(e.created)); + + // Filter by text search (excluding before:/after: tokens) + const filterText = this.filterState.textFilterWithoutTimestamps; if (filterText) { const terms = filterText.split(/\s*,\s*/).filter(t => t.length > 0); const includeTerms = terms.filter(t => !t.startsWith('!')).map(t => t.trim()); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts index c34697347125e..a6ac1bc979972 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts @@ -19,6 +19,8 @@ const $ = DOM.$; export interface IChatDebugEditorOptions extends IEditorOptions { readonly sessionResource?: URI; readonly viewHint?: 'home' | 'overview' | 'logs' | 'flowchart'; + /** When set, automatically applies this text as the log filter. */ + readonly filter?: string; } export const enum ViewState { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index 93068162a11eb..7efa2352ee7cc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -283,6 +283,7 @@ .chat-debug-editor-header .viewpane-filter-container { flex: 1; max-width: 500px; + margin-right: auto; } .chat-debug-editor-header .viewpane-filter-container .monaco-inputbox { border-color: var(--vscode-panelInput-border, transparent) !important; @@ -293,6 +294,12 @@ align-items: center; gap: 6px; } +.chat-debug-troubleshoot-button.monaco-button { + width: auto; + display: inline-flex; + align-items: center; + flex-shrink: 0; +} .chat-debug-view-mode-labels { display: grid; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 3570a436c48f4..97e45f0280935 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -15,9 +15,11 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatAgentService } from '../common/participants/chatAgents.js'; +import { IChatDebugEvent, IChatDebugService } from '../common/chatDebugService.js'; import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; -import { IChatService } from '../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatService } from '../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { IChatRequestVariableEntry } from '../common/attachments/chatVariableEntries.js'; import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js'; import { ChatSubmitAction, OpenModePickerAction, OpenModelPickerAction } from './actions/chatExecuteActions.js'; import { ManagePluginsAction } from './actions/chatPluginActions.js'; @@ -29,11 +31,15 @@ import { CONFIGURE_PROMPTS_ACTION_ID } from './promptSyntax/runPromptAction.js'; import { CONFIGURE_SKILLS_ACTION_ID } from './promptSyntax/skillActions.js'; import { AutoApproveStorageKeys, - globalAutoApproveDescription + globalAutoApproveDescription, } from './tools/languageModelToolsService.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; +import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { Target } from '../common/promptSyntax/promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { IChatWidgetService } from './chat.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -46,13 +52,25 @@ export class ChatSlashCommandsContribution extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @IAgentSessionsService agentSessionsService: IAgentSessionsService, @IChatService chatService: IChatService, + @IChatDebugService chatDebugService: IChatDebugService, @IConfigurationService configurationService: IConfigurationService, @IDialogService dialogService: IDialogService, @INotificationService notificationService: INotificationService, @IStorageService storageService: IStorageService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @IChatWidgetService chatWidgetService: IChatWidgetService, ) { super(); + + const troubleshootSessions = new Set(); + const hasTroubleshootDataKey = ChatContextKeys.chatSessionHasTroubleshootData.bindTo(this.contextKeyService); + this._store.add(chatWidgetService.onDidChangeFocusedSession(() => { + const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; + hasTroubleshootDataKey.set(!!sessionResource && troubleshootSessions.has(sessionResource.toString())); + languageModelToolsService.flushToolUpdates(); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'clear', detail: nls.localize('clear', "Start a new chat and archive the current one"), @@ -119,6 +137,54 @@ export class ChatSlashCommandsContribution extends Disposable { await commandService.executeCommand('github.copilot.debug.showChatLogView'); })); } + this._store.add(slashCommandService.registerSlashCommand({ + command: 'troubleshoot', + detail: nls.localize('troubleshoot', "Troubleshoot with a snapshot of debug events from the conversation so far (run again to refresh)"), + sortText: 'z3_troubleshoot', + executeImmediately: false, + silent: true, + locations: [ChatAgentLocation.Chat], + }, async (prompt, _progress, _history, _location, sessionResource, _token, options) => { + troubleshootSessions.add(sessionResource.toString()); + hasTroubleshootDataKey.set(true); + languageModelToolsService.flushToolUpdates(); + await chatDebugService.invokeProviders(sessionResource); + const events = chatDebugService.getEvents(sessionResource); + const summary = events.length > 0 + ? formatDebugEventsForContext(events) + : nls.localize('troubleshoot.noEvents', "No debug events found for this conversation."); + + const attachedContext: IChatRequestVariableEntry[] = [{ + id: 'chatDebugEvents', + name: nls.localize('troubleshoot.contextName', "Debug Events Snapshot"), + kind: 'debugEvents', + snapshotTime: Date.now(), + sessionResource, + value: summary, + modelDescription: 'These are the debug event logs from the current chat conversation. Analyze them to help answer the user\'s troubleshooting question.\n' + + '\n' + + 'CRITICAL INSTRUCTION: You MUST call the resolveDebugEventDetails tool on relevant events BEFORE answering. The log lines below are only summaries — they do NOT contain the actual data (file paths, prompt content, tool I/O, etc.). The real information is only available by resolving events. Never answer based solely on the summary lines. Always resolve first, then answer.\n' + + '\n' + + 'Call resolveDebugEventDetails in parallel on all events that could be relevant to the user\'s question. When in doubt, resolve more events rather than fewer.\n' + + '\n' + + 'IMPORTANT: Do NOT mention event IDs, tool resolution steps, or internal debug mechanics in your response. The user does not know about debug events or event IDs. Present your findings directly and naturally, as if you simply know the answer. Never say things like "I need to resolve events" or show event IDs.\n' + + '\n' + + 'Event types and what resolving them returns:\n' + + '- generic (category: "discovery"): File discovery for instructions, skills, agents, hooks. Resolving returns a fileList with full file paths, load status, skip reasons, and source folders. Always resolve these for questions about customization files.\n' + + '- userMessage: The full prompt sent to the model. Resolving returns the complete message and all prompt sections (system prompt, instructions, context). Essential for understanding what the model received.\n' + + '- agentResponse: The model\'s response. Resolving returns the full response text and sections.\n' + + '- modelTurn: An LLM round-trip. Resolving returns model name, token usage, timing, errors, and prompt sections.\n' + + '- toolCall: A tool invocation. Resolving returns tool name, input, output, status, and duration.\n' + + '- subagentInvocation: A sub-agent spawn. Resolving returns agent name, status, duration, and counts.\n' + + '- generic (other): Miscellaneous logs. Resolving returns additional text details.', + }]; + + chatService.sendRequest(sessionResource, prompt, { + ...options, + queue: ChatRequestQueueKind.Queued, + attachedContext, + }); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'agents', detail: nls.localize('agents', "Configure custom agents"), @@ -347,3 +413,38 @@ export class ChatSlashCommandsContribution extends Disposable { })); } } + +function formatDebugEventsForContext(events: readonly IChatDebugEvent[]): string { + const lines: string[] = []; + for (const event of events) { + const ts = event.created.toISOString(); + const id = event.id ? ` [id=${event.id}]` : ''; + switch (event.kind) { + case 'generic': + lines.push(`[${ts}]${id} ${event.level >= 3 ? 'ERROR' : event.level >= 2 ? 'WARN' : 'INFO'}: ${event.name}${event.details ? ' - ' + event.details : ''}${event.category ? ' (category: ' + event.category + ')' : ''}`); + break; + case 'toolCall': + lines.push(`[${ts}]${id} TOOL_CALL: ${event.toolName}${event.result ? ' result=' + event.result : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'modelTurn': + lines.push(`[${ts}]${id} MODEL_TURN: ${event.requestName ?? 'unknown'}${event.model ? ' model=' + event.model : ''}${event.inputTokens !== undefined ? ' tokens(in=' + event.inputTokens + ',out=' + (event.outputTokens ?? '?') + ')' : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'subagentInvocation': + lines.push(`[${ts}]${id} SUBAGENT: ${event.agentName}${event.status ? ' status=' + event.status : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'userMessage': + lines.push(`[${ts}]${id} USER_MESSAGE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); + break; + case 'agentResponse': + lines.push(`[${ts}]${id} AGENT_RESPONSE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); + break; + default: { + const _: never = event; + void _; + break; + } + } + } + return lines.join('\n'); +} + diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts index 6ff863773dbbb..b0edcc5b8603f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts @@ -389,8 +389,7 @@ class CollapsibleListRenderer implements IListRenderer { const withSlash = `/${c.command}`; return { - label: withSlash, + label: { label: withSlash, description: c.detail }, insertText: c.executeImmediately ? '' : `${withSlash} `, documentation: c.detail, range, @@ -192,7 +192,7 @@ class SlashCommandCompletions extends Disposable { suggestions: slashCommands.map((c, i): CompletionItem => { const withSlash = `${chatSubcommandLeader}${c.command}`; return { - label: withSlash, + label: { label: withSlash, description: c.detail }, insertText: c.executeImmediately ? '' : `${withSlash} `, documentation: c.detail, range, diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index a85c46b7bfba0..926ba9d9f9754 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -89,6 +89,7 @@ export namespace ChatContextKeys { export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); export const hasPendingRequests = new RawContextKey('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") }); export const chatSessionHasDebugData = new RawContextKey('chatSessionHasDebugData', false, { type: 'boolean', description: localize('chatSessionHasDebugData', "True when the current chat session has debug log data.") }); + export const chatSessionHasTroubleshootData = new RawContextKey('chatSessionHasTroubleshootData', false, { type: 'boolean', description: localize('chatSessionHasTroubleshootData', "True when the /troubleshoot slash command has been run in the current chat session.") }); export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 5655cc695fd95..fbf760fe84d67 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -308,13 +308,22 @@ export interface IAgentFeedbackVariableEntry extends IBaseChatRequestVariableEnt }>; } +export interface IChatRequestDebugEventsVariableEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'debugEvents'; + /** Timestamp when the debug events were snapshotted. */ + readonly snapshotTime: number; + /** The session resource these debug events belong to. */ + readonly sessionResource: URI; +} + export type IChatRequestVariableEntry = IGenericChatRequestVariableEntry | IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | IDiagnosticVariableEntry | IImageVariableEntry | IChatRequestToolEntry | IChatRequestToolSetEntry | IChatRequestDirectoryEntry | IChatRequestFileEntry | INotebookOutputVariableEntry | IElementVariableEntry | IPromptFileVariableEntry | IPromptTextVariableEntry | ISCMHistoryItemVariableEntry | ISCMHistoryItemChangeVariableEntry | ISCMHistoryItemChangeRangeVariableEntry | ITerminalVariableEntry - | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry | IAgentFeedbackVariableEntry; + | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry | IAgentFeedbackVariableEntry + | IChatRequestDebugEventsVariableEntry; export namespace IChatRequestVariableEntry { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index b5ed3c924c0d1..0d1bb0635de2f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1135,7 +1135,7 @@ export class ChatService extends Disposable implements IChatService { const message = parsedRequest.text; const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress(p => { progressCallback([p]); - }), history, location, model.sessionResource, token); + }), history, location, model.sessionResource, token, options); agentOrCommandFollowups = Promise.resolve(commandResult?.followUp); rawResult = {}; @@ -1146,6 +1146,9 @@ export class ChatService extends Disposable implements IChatService { if ((token.isCancellationRequested && !rawResult)) { return; } else if (!request) { + // Silent slash command completed successfully — allow queued + // requests to proceed. + shouldProcessPending = !token.isCancellationRequested; return; } else { if (!rawResult) { diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index 26cf4257f0a89..35c609e3c7d23 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -9,7 +9,7 @@ import { Disposable, IDisposable, toDisposable } from '../../../../../base/commo import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { IChatMessage } from '../languageModels.js'; -import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from '../chatService/chatService.js'; +import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData, IChatSendRequestOptions } from '../chatService/chatService.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -44,7 +44,7 @@ export interface IChatSlashData { export interface IChatSlashFragment { content: string | { treeData: IChatResponseProgressFileTreeData }; } -export type IChatSlashCallback = { (prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> }; +export type IChatSlashCallback = { (prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> }; export const IChatSlashCommandService = createDecorator('chatSlashCommandService'); @@ -55,7 +55,7 @@ export interface IChatSlashCommandService { _serviceBrand: undefined; readonly onDidChangeCommands: Event; registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable; - executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void>; + executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void>; getCommands(location: ChatAgentLocation, mode: ChatModeKind): Array; hasCommand(id: string): boolean; } @@ -105,7 +105,7 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom return this._commands.has(id); } - async executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> { + async executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> { const data = this._commands.get(id); if (!data) { throw new Error('No command with id ${id} NOT registered'); @@ -117,6 +117,6 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom throw new Error(`No command with id ${id} NOT resolved`); } - return await data.command(prompt, progress, history, location, sessionResource, token); + return await data.command(prompt, progress, history, location, sessionResource, token, options); } } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts new file mode 100644 index 0000000000000..b3ff4593a43e7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { localize } from '../../../../../../nls.js'; +import { ChatContextKeys } from '../../actions/chatContextKeys.js'; +import { IChatDebugEvent, IChatDebugResolvedEventContent, IChatDebugService } from '../../chatDebugService.js'; +import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../languageModelToolsService.js'; + +export const ResolveDebugEventDetailsToolId = 'vscode_resolveDebugEventDetails_internal'; + +export const ResolveDebugEventDetailsToolData: IToolData = { + id: ResolveDebugEventDetailsToolId, + toolReferenceName: 'resolveDebugEventDetails', + displayName: localize('resolveDebugEventDetails.displayName', "Resolve Debug Event Details"), + when: ChatContextKeys.chatSessionHasTroubleshootData, + canBeReferencedInPrompt: false, + modelDescription: 'Resolves the full details for a specific chat debug event by its event ID. Use this tool to get detailed information about a debug event such as tool call input/output, model turn details, user message sections, or file lists. The event ID can be found in the debug event log summary provided in the conversation context.', + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + eventId: { + type: 'string', + description: 'The ID of the debug event to resolve details for.', + }, + }, + required: ['eventId'], + }, +}; + +function formatResolvedContent(content: IChatDebugResolvedEventContent): string { + switch (content.kind) { + case 'text': + return content.value; + case 'fileList': { + const lines: string[] = [`File list (${content.discoveryType}):`]; + if (content.sourceFolders) { + for (const folder of content.sourceFolders) { + lines.push(` Source folder: ${folder.uri.toString()} (${folder.storage}, ${folder.fileCount} files${folder.exists ? '' : ', missing'})`); + } + } + for (const file of content.files) { + const status = file.status === 'loaded' ? 'loaded' : `skipped${file.skipReason ? `: ${file.skipReason}` : ''}`; + lines.push(` ${file.uri.toString()} [${status}]`); + } + return lines.join('\n'); + } + case 'message': { + const lines: string[] = [`${content.type === 'user' ? 'User' : 'Agent'} message: ${content.message}`]; + for (const section of content.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + } + return lines.join('\n'); + } + case 'toolCall': { + const lines: string[] = [`Tool call: ${content.toolName}`]; + if (content.result) { + lines.push(`Result: ${content.result}`); + } + if (content.durationInMillis !== undefined) { + lines.push(`Duration: ${content.durationInMillis}ms`); + } + if (content.input) { + lines.push(`Input:\n${content.input}`); + } + if (content.output) { + lines.push(`Output:\n${content.output}`); + } + return lines.join('\n'); + } + case 'modelTurn': { + const lines: string[] = [`Model turn: ${content.requestName}`]; + if (content.model) { + lines.push(`Model: ${content.model}`); + } + if (content.status) { + lines.push(`Status: ${content.status}`); + } + if (content.durationInMillis !== undefined) { + lines.push(`Duration: ${content.durationInMillis}ms`); + } + if (content.inputTokens !== undefined || content.outputTokens !== undefined) { + lines.push(`Tokens: input=${content.inputTokens ?? '?'}, output=${content.outputTokens ?? '?'}, cached=${content.cachedTokens ?? '?'}, total=${content.totalTokens ?? '?'}`); + } + if (content.errorMessage) { + lines.push(`Error: ${content.errorMessage}`); + } + if (content.sections) { + for (const section of content.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + } + } + return lines.join('\n'); + } + default: { + const _: never = content; + return JSON.stringify(_); + } + } +} + +function truncate(text: string, maxLength = 30): string { + if (text.length <= maxLength) { + return text; + } + const lastSpace = text.lastIndexOf(' ', maxLength); + const cutoff = lastSpace > maxLength / 2 ? lastSpace : maxLength; + return text.substring(0, cutoff) + '\u2026'; +} + +function getEventLabel(event: IChatDebugEvent): string { + switch (event.kind) { + case 'generic': return event.name; + case 'toolCall': return event.toolName; + case 'modelTurn': return event.requestName ?? localize('debugEvent.modelTurn', "Model Turn"); + case 'userMessage': return localize('debugEvent.userMessage', "User Message: {0}", truncate(event.message)); + case 'agentResponse': return localize('debugEvent.agentResponse', "Agent Response: {0}", truncate(event.message)); + case 'subagentInvocation': return event.agentName; + } +} + +export class ResolveDebugEventDetailsTool implements IToolImpl { + constructor( + @IChatDebugService private readonly chatDebugService: IChatDebugService, + ) { } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const eventId = context.parameters?.eventId; + let eventLabel: string | undefined; + if (typeof eventId === 'string' && context.chatSessionResource) { + const events = this.chatDebugService.getEvents(context.chatSessionResource); + const event = events.find(e => e.id === eventId); + if (event) { + eventLabel = getEventLabel(event); + } + } + + if (eventLabel) { + return { + invocationMessage: localize('resolveDebugEventDetails.invocationMessageNamed', 'Resolving details for "{0}"', eventLabel), + pastTenseMessage: localize('resolveDebugEventDetails.pastTenseMessageNamed', 'Resolved details for "{0}"', eventLabel), + }; + } + return { + invocationMessage: localize('resolveDebugEventDetails.invocationMessage', 'Resolving debug event details'), + pastTenseMessage: localize('resolveDebugEventDetails.pastTenseMessage', 'Resolved debug event details'), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const eventId = invocation.parameters['eventId']; + if (typeof eventId !== 'string' || !eventId) { + return { + content: [{ kind: 'text', value: 'Error: eventId parameter is required.' }], + }; + } + + const sessionResource = invocation.context?.sessionResource; + if (!sessionResource) { + return { + content: [{ kind: 'text', value: 'Error: no chat session context available.' }], + }; + } + + const sessionEvents = this.chatDebugService.getEvents(sessionResource); + if (!sessionEvents.some(e => e.id === eventId)) { + return { + content: [{ kind: 'text', value: `No event with ID "${eventId}" found in the current session.` }], + }; + } + + const resolved = await this.chatDebugService.resolveEvent(eventId); + if (!resolved) { + return { + content: [{ kind: 'text', value: `No details found for event ID: ${eventId}` }], + }; + } + + return { + content: [{ kind: 'text', value: formatResolvedContent(resolved) }], + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts index 0258444f00b34..619e63406dd36 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts @@ -11,6 +11,7 @@ import { AskQuestionsTool, AskQuestionsToolData } from './askQuestionsTool.js'; import { ConfirmationTool, ConfirmationToolData, ConfirmationToolWithOptionsData } from './confirmationTool.js'; import { EditTool, EditToolData } from './editFileTool.js'; import { createManageTodoListToolData, ManageTodoListTool } from './manageTodoListTool.js'; +import { ResolveDebugEventDetailsTool, ResolveDebugEventDetailsToolData } from './resolveDebugEventDetailsTool.js'; import { RunSubagentTool } from './runSubagentTool.js'; export class BuiltinToolsContribution extends Disposable implements IWorkbenchContribution { @@ -39,6 +40,10 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo this._register(toolsService.registerTool(ConfirmationToolData, confirmationTool)); this._register(toolsService.registerTool(ConfirmationToolWithOptionsData, confirmationTool)); + const resolveDebugEventDetailsTool = instantiationService.createInstance(ResolveDebugEventDetailsTool); + this._register(toolsService.registerTool(ResolveDebugEventDetailsToolData, resolveDebugEventDetailsTool)); + this._register(toolsService.readToolSet.addTool(ResolveDebugEventDetailsToolData)); + const runSubagentTool = this._register(instantiationService.createInstance(RunSubagentTool)); let runSubagentRegistration: IDisposable | undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatDebugFilters.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatDebugFilters.test.ts new file mode 100644 index 0000000000000..9bb37a5f82963 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatDebugFilters.test.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ChatDebugFilterState } from '../../browser/chatDebug/chatDebugFilters.js'; + +suite('ChatDebugFilterState', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('parseTimeToken', () => { + + suite('before: prefix', () => { + + test('year only — rounds to end of year', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026', 'before'); + assert.strictEqual(result, new Date(2026, 11, 31, 23, 59, 59, 999).getTime()); + }); + + test('year-month — rounds to end of month', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03', 'before'); + // new Date(2026, 3, 0) gives last day of March + assert.strictEqual(result, new Date(2026, 3, 0, 23, 59, 59, 999).getTime()); + }); + + test('year-month (February, non-leap) — rounds to end of Feb', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2025-02', 'before'); + assert.strictEqual(result, new Date(2025, 2, 0, 23, 59, 59, 999).getTime()); + }); + + test('year-month-day — rounds to end of day', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 23, 59, 59, 999).getTime()); + }); + + test('date with hour only — rounds to end of hour', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03t14', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 59, 59, 999).getTime()); + }); + + test('date with hour:minute — rounds to end of minute', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03t14:30', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 59, 999).getTime()); + }); + + test('full date-time with seconds', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03t14:30:45', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 45, 999).getTime()); + }); + }); + + suite('after: prefix', () => { + + test('year only — start of year', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026', 'after'); + assert.strictEqual(result, new Date(2026, 0, 1, 0, 0, 0, 0).getTime()); + }); + + test('year-month — start of month', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03', 'after'); + assert.strictEqual(result, new Date(2026, 2, 1, 0, 0, 0, 0).getTime()); + }); + + test('year-month-day — start of day', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 0, 0, 0, 0).getTime()); + }); + + test('date with hour only — start of hour', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03t14', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 0, 0, 0).getTime()); + }); + + test('date with hour:minute — start of minute', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03t14:30', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 0, 0).getTime()); + }); + + test('full date-time with seconds', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03t14:30:45', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 45, 0).getTime()); + }); + }); + + suite('no match', () => { + + test('returns undefined for empty string', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('', 'before'), undefined); + }); + + test('returns undefined for unrelated text', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('hello world', 'before'), undefined); + }); + + test('returns undefined for wrong prefix', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('after:2026', 'before'), undefined); + }); + + test('returns undefined for bare time without date', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('before:14:30', 'before'), undefined); + }); + }); + + suite('embedded in text', () => { + + test('extracts token from surrounding text', () => { + const result = ChatDebugFilterState.parseTimeToken('some text before:2026-03-03 more text', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 23, 59, 59, 999).getTime()); + }); + + test('handles both before and after in same string', () => { + const text = 'after:2026-01 before:2026-03'; + const after = ChatDebugFilterState.parseTimeToken(text, 'after'); + const before = ChatDebugFilterState.parseTimeToken(text, 'before'); + assert.strictEqual(after, new Date(2026, 0, 1, 0, 0, 0, 0).getTime()); + assert.strictEqual(before, new Date(2026, 3, 0, 23, 59, 59, 999).getTime()); + }); + }); + }); + + suite('setTextFilter and timestamp parsing', () => { + let state: ChatDebugFilterState; + + setup(() => { + state = disposables.add(new ChatDebugFilterState()); + }); + + test('sets beforeTimestamp and afterTimestamp from text', () => { + state.setTextFilter('after:2026-01-01 before:2026-12-31'); + assert.strictEqual(state.afterTimestamp, new Date(2026, 0, 1, 0, 0, 0, 0).getTime()); + assert.strictEqual(state.beforeTimestamp, new Date(2026, 11, 31, 23, 59, 59, 999).getTime()); + }); + + test('clears timestamps when tokens removed', () => { + state.setTextFilter('before:2026'); + assert.ok(state.beforeTimestamp !== undefined); + state.setTextFilter('hello'); + assert.strictEqual(state.beforeTimestamp, undefined); + }); + }); + + suite('textFilterWithoutTimestamps', () => { + let state: ChatDebugFilterState; + + setup(() => { + state = disposables.add(new ChatDebugFilterState()); + }); + + test('strips year-only token', () => { + state.setTextFilter('before:2026 hello'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('strips year-month token', () => { + state.setTextFilter('after:2026-03 hello'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('strips full date-time token', () => { + state.setTextFilter('before:2026-03-03t14:30:45 hello'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('strips multiple tokens', () => { + state.setTextFilter('after:2026-01 hello before:2026-12'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('returns empty when only tokens', () => { + state.setTextFilter('before:2026'); + assert.strictEqual(state.textFilterWithoutTimestamps, ''); + }); + }); + + suite('isTimestampVisible', () => { + let state: ChatDebugFilterState; + + setup(() => { + state = disposables.add(new ChatDebugFilterState()); + }); + + test('visible when no timestamp filters set', () => { + assert.strictEqual(state.isTimestampVisible(new Date(2026, 5, 15)), true); + }); + + test('hidden when after beforeTimestamp', () => { + state.setTextFilter('before:2026-03'); + // April 1st is after end of March + assert.strictEqual(state.isTimestampVisible(new Date(2026, 3, 1)), false); + }); + + test('visible when before beforeTimestamp', () => { + state.setTextFilter('before:2026-03'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 1, 15)), true); + }); + + test('hidden when before afterTimestamp', () => { + state.setTextFilter('after:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 4, 31)), false); + }); + + test('visible when after afterTimestamp', () => { + state.setTextFilter('after:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 6, 1)), true); + }); + + test('visible when within before/after range', () => { + state.setTextFilter('after:2026-03 before:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 3, 15)), true); + }); + + test('hidden when outside before/after range', () => { + state.setTextFilter('after:2026-03 before:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 0, 1)), false); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 8, 1)), false); + }); + }); +}); From 44c142b1d5b7fb77bb4b8078a0cc436bc7a60322 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 07:50:53 +0100 Subject: [PATCH 11/19] modal - improve handling of Escape key and expand use of modal editors to more kinds (#299060) * modal - improve handling of Escape key and expand use of modal editors to more kinds * Update src/vs/workbench/browser/parts/editor/editorCommands.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../browser/parts/editor/editorCommands.ts | 12 ++++++++---- .../browser/parts/editor/modalEditorPart.ts | 10 +--------- .../preferences/browser/preferencesService.ts | 19 ++++++++++++------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index cedb8c9537371..df84193921256 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -1517,11 +1517,15 @@ function registerModalEditorCommands(): void { f1: true, icon: Codicon.close, precondition: EditorPartModalContext, - keybinding: { + keybinding: [{ primary: KeyCode.Escape, - weight: KeybindingWeight.WorkbenchContrib + 10, - when: EditorPartModalContext - }, + weight: KeybindingWeight.WorkbenchContrib + 10, // higher when no text editor is focused... + when: EditorContextKeys.focus.toNegated() + }, { + primary: KeyCode.Escape, + weight: KeybindingWeight.EditorContrib - 1, // ...lower to prevent accidental close when text editor is focused + when: EditorContextKeys.focus + }], menu: { id: MenuId.ModalEditorTitle, group: 'navigation', diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index a5bf50ce859b1..13c86decaf357 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -7,7 +7,6 @@ import './media/modalEditorPart.css'; import { $, addDisposableListener, append, EventHelper, EventType, hide, isHTMLElement, show } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -90,15 +89,8 @@ export class ModalEditorPart { disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { const event = new StandardKeyboardEvent(e); - // Close on Escape - if (event.equals(KeyCode.Escape)) { - EventHelper.stop(event, true); - - editorPart.close(); - } - // Prevent unsupported commands (not in sessions windows) - else if (!this.environmentService.isSessionsWindow) { + if (!this.environmentService.isSessionsWindow) { const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); if (resolved.kind === ResultKind.KbFound && resolved.commandId) { if ( diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index a1ca22b3c4a2d..f871d6dd5d9e1 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -48,6 +48,7 @@ import { IURLService } from '../../../../platform/url/common/url.js'; import { compareIgnoreCase } from '../../../../base/common/strings.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; const emptyEditableSettingsContent = '{\n}'; @@ -90,7 +91,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic @ITextEditorService private readonly textEditorService: ITextEditorService, @IURLService urlService: IURLService, @IExtensionService private readonly extensionService: IExtensionService, - @IProgressService private readonly progressService: IProgressService + @IProgressService private readonly progressService: IProgressService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(); // The default keybindings.json updates based on keyboard layouts, so here we make sure @@ -273,7 +275,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic ...options, focusSearch: true }; - const group = this.getEditorGroupFromOptions(false, options); + const group = this.getEditorGroupFromOptions(options); return this.editorService.openEditor(input, validateSettingsEditorOptions(options), group); } @@ -354,11 +356,11 @@ export class PreferencesService extends Disposable implements IPreferencesServic this.editorService.openEditor({ resource: editableKeybindings, options }, sideEditorGroup.id) ]); } else { - await this.editorService.openEditor({ resource: editableKeybindings, options }, options.groupId); + await this.editorService.openEditor({ resource: editableKeybindings, options }, this.getEditorGroupFromOptions(options)); } } else { - const group = this.getEditorGroupFromOptions(false, options); + const group = this.getEditorGroupFromOptions(options); const editor = (await this.editorService.openEditor(this.instantiationService.createInstance(KeybindingsEditorInput), { ...options }, group)) as IKeybindingsEditorPane; if (options.query) { editor.search(options.query); @@ -371,8 +373,11 @@ export class PreferencesService extends Disposable implements IPreferencesServic return this.editorService.openEditor({ resource: this.defaultKeybindingsResource, label: nls.localize('defaultKeybindings', "Default Keybindings") }); } - private getEditorGroupFromOptions(isTextual: boolean, options: { groupId?: number; openToSide?: boolean }): PreferredGroup { - if (!isTextual && this.configurationService.getValue('workbench.editor.useModal') !== 'off') { + private getEditorGroupFromOptions(options: { groupId?: number; openToSide?: boolean }): PreferredGroup { + if ( + this.configurationService.getValue('workbench.editor.useModal') !== 'off' && // modal editors enabled in settings + !this.environmentService.enableSmokeTestDriver && !this.environmentService.extensionTestsLocationURI // but not in smoke test or extension test environments to reduce flakiness + ) { return MODAL_GROUP; } if (options.openToSide) { @@ -385,7 +390,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic } private async openSettingsJson(resource: URI, options: IOpenSettingsOptions): Promise { - const group = this.getEditorGroupFromOptions(true, options); + const group = this.getEditorGroupFromOptions(options); const editor = await this.doOpenSettingsJson(resource, options, group); if (editor && options?.revealSetting) { await this.revealSetting(options.revealSetting.key, !!options.revealSetting.edit, editor, resource); From 0d35e5d19e22e3135203ba9edba9bff435e4576b Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 07:51:24 +0100 Subject: [PATCH 12/19] eng - explain fallback for how to check for compilation issues fast in CLI envs (#299117) * eng - explain fallback for how to check for compilation issues fast in CLI envs * Update .github/copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/copilot-instructions.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * . --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c0a4142db0398..be8c26eeadd62 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,15 +50,15 @@ Each extension follows the standard VS Code extension structure with `package.js ## Validating TypeScript changes -MANDATORY: Always check the `VS Code - Build` watch task output via #runTasks/getTaskOutput for compilation errors before running ANY script or declaring work complete, then fix all compilation errors before moving forward. +MANDATORY: Always check for compilation errors before running any tests or validation scripts, or declaring work complete, then fix all compilation errors before moving forward. - NEVER run tests if there are compilation errors -- NEVER use `npm run compile` to compile TypeScript files but call #runTasks/getTaskOutput instead +- NEVER use `npm run compile` to compile TypeScript files ### TypeScript compilation steps -- Monitor the `VS Code - Build` task outputs for real-time compilation errors as you make changes -- This task runs `Core - Build` and `Ext - Build` to incrementally compile VS Code TypeScript sources and built-in extensions -- Start the task if it's not already running in the background +- If the `#runTasks/getTaskOutput` tool is available, check the `VS Code - Build` watch task output for compilation errors. This task runs `Core - Build` and `Ext - Build` to incrementally compile VS Code TypeScript sources and built-in extensions. Start the task if it's not already running in the background. +- If the tool is not available (e.g. in CLI environments) and you only changed code under `src/`, run `npm run compile-check-ts-native` after making changes to type-check the main VS Code sources (it validates `./src/tsconfig.json`). +- If you changed built-in extensions under `extensions/` and the tool is not available, run the corresponding gulp task `gulp compile-extensions` instead so that TypeScript errors in extensions are also reported. - For TypeScript changes in the `build` folder, you can simply run `npm run typecheck` in the `build` folder. ### TypeScript validation steps From 6e1d1b137fd92ea79bfd81db8ad6a5fefd2bae94 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 4 Mar 2026 18:31:16 +1100 Subject: [PATCH 13/19] Avoid unnecesary updates to model in new background agent sessions (#299121) --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9423e2431e497..f4a5f39c53f12 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1945,7 +1945,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // when the session type changes (different session types may have // different model pools via targetChatSessionType). const newSessionType = this.getCurrentSessionType(); - if (newSessionType !== this._currentSessionType) { + if (e.currentSessionResource && newSessionType !== this._currentSessionType) { this._currentSessionType = newSessionType; this.initSelectedModel(); } From 910bb74d164b821a559b3e1d978fe02735cbd79a Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 08:31:59 +0100 Subject: [PATCH 14/19] sessions - indicate in layout that status bar is hidden (#299131) --- src/vs/sessions/browser/workbench.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 24b0d19b0d475..b54a7e6a0d9f5 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -81,6 +81,7 @@ enum LayoutClasses { PANEL_HIDDEN = 'nopanel', AUXILIARYBAR_HIDDEN = 'noauxiliarybar', CHATBAR_HIDDEN = 'nochatbar', + STATUSBAR_HIDDEN = 'nostatusbar', FULLSCREEN = 'fullscreen', MAXIMIZED = 'maximized' } @@ -893,6 +894,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, + LayoutClasses.STATUSBAR_HIDDEN, // sessions window never has a status bar this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined ]); } From b5d4de9c3ceb3306ba53206a4f287330ea74df72 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 09:05:14 +0100 Subject: [PATCH 15/19] sessions - move scrollbar to the right for chat (#299134) --- src/vs/sessions/browser/media/style.css | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 6947f2aff496c..299ce8545d85b 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -43,6 +43,24 @@ background-color: var(--vscode-sideBar-background); } +/* ---- Chat Layout ---- */ + +/* Remove max-width from the session container so the scrollbar extends full width */ +.agent-sessions-workbench .interactive-session { + max-width: none; +} + +/* Constrain content items to the same max-width, centered */ +.agent-sessions-workbench .interactive-session .interactive-item-container { + max-width: 950px; + margin: 0 auto; +} + +.agent-sessions-workbench .interactive-session > .chat-suggest-next-widget { + max-width: 950px; + margin: 0 auto; +} + /* ---- Chat Input ---- */ .agent-sessions-workbench .interactive-session .chat-input-container { @@ -50,7 +68,8 @@ } .agent-sessions-workbench .interactive-session .interactive-input-part { - margin: 0 8px !important; + max-width: 950px; + margin: 0 auto !important; display: inherit !important; /* Align with changes view */ padding: 4px 0 6px 0 !important; From b5a312a098b113ed27c3377e3b0a0308f1817c79 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 09:22:00 +0100 Subject: [PATCH 16/19] fix - update precondition for `OpenSessionWorktreeInVSCode` (#299140) --- src/vs/sessions/contrib/chat/browser/chat.contribution.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index c10d4b4cdc021..5e49081cc2bf9 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -47,11 +47,12 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { id: OpenSessionWorktreeInVSCodeAction.ID, title: localize2('openInVSCode', 'Open in VS Code'), icon: Codicon.vscodeInsiders, - precondition: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), + precondition: IsActiveSessionBackgroundProviderContext, menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', order: 10, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), }] }); } From 4ff01e687e87f3e8db43db34ff040607cc9257ae Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:58:25 +0100 Subject: [PATCH 17/19] Git - tweak copilot worktree folder detection (#299147) * Git - tweak copilot worktree folder detection * Pull request feedback --- extensions/git/src/artifactProvider.ts | 4 ++-- extensions/git/src/repository.ts | 6 +++--- extensions/git/src/util.ts | 12 +++--------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/extensions/git/src/artifactProvider.ts b/extensions/git/src/artifactProvider.ts index 832b5626ae0a8..f9e2d99087fd6 100644 --- a/extensions/git/src/artifactProvider.ts +++ b/extensions/git/src/artifactProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { LogOutputChannel, SourceControlArtifactProvider, SourceControlArtifactGroup, SourceControlArtifact, Event, EventEmitter, ThemeIcon, l10n, workspace, Uri, Disposable, Command } from 'vscode'; -import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktree } from './util'; +import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktreeFolder } from './util'; import { Repository } from './repository'; import type { Ref, Worktree } from './api/git'; import { RefType } from './api/git.constants'; @@ -178,7 +178,7 @@ export class GitArtifactProvider implements SourceControlArtifactProvider, IDisp ]).join(' \u2022 '), icon: w.main ? new ThemeIcon('repo') - : isCopilotWorktree(w.path) + : isCopilotWorktreeFolder(w.path) ? new ThemeIcon('chat-sparkle') : new ThemeIcon('worktree') })); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index b79bb3bc4aabf..bd6b6a5c7ff66 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -25,7 +25,7 @@ import { IPushErrorHandlerRegistry } from './pushError'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { StatusBarCommands } from './statusbar'; import { toGitUri } from './uri'; -import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isCopilotWorktree, isDescendant, isLinuxSnap, isRemote, isWindows, Limiter, onceEvent, pathEquals, relativePath } from './util'; +import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isCopilotWorktreeFolder, isDescendant, isLinuxSnap, isRemote, isWindows, Limiter, onceEvent, pathEquals, relativePath } from './util'; import { IFileWatcher, watch } from './watch'; import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; import { GitArtifactProvider } from './artifactProvider'; @@ -954,7 +954,7 @@ export class Repository implements Disposable { const icon = repository.kind === 'submodule' ? new ThemeIcon('archive') : repository.kind === 'worktree' - ? isCopilotWorktree(repository.root) + ? isCopilotWorktreeFolder(repository.root) ? new ThemeIcon('chat-sparkle') : new ThemeIcon('worktree') : new ThemeIcon('repo'); @@ -967,7 +967,7 @@ export class Repository implements Disposable { // from the Repositories view. this._isHidden = workspace.workspaceFolders === undefined || (repository.kind === 'worktree' && - isCopilotWorktree(repository.root) && parent !== undefined); + isCopilotWorktreeFolder(repository.root) && parent !== undefined); const root = Uri.file(repository.root); this._sourceControl = scm.createSourceControl('git', 'Git', root, icon, this._isHidden, parent); diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index cbf1b56e34e51..58a6d06419a78 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n, workspace, Uri, DiagnosticSeverity, env, SourceControlHistoryItem } from 'vscode'; -import { dirname, normalize, sep, relative } from 'path'; +import { basename, dirname, normalize, sep, relative } from 'path'; import { Readable } from 'stream'; import { promises as fs, createReadStream } from 'fs'; import byline from 'byline'; @@ -867,12 +867,6 @@ export function getStashDescription(stash: Stash): string | undefined { return descriptionSegments.join(' \u2022 '); } -export const CopilotWorktreeBranchPrefix = 'copilot-worktree-'; - -export function isCopilotWorktree(path: string): boolean { - const lastSepIndex = path.lastIndexOf(sep); - - return lastSepIndex !== -1 - ? path.substring(lastSepIndex + 1).startsWith(CopilotWorktreeBranchPrefix) - : path.startsWith(CopilotWorktreeBranchPrefix); +export function isCopilotWorktreeFolder(path: string): boolean { + return basename(path).startsWith('copilot-'); } From dede9833a8f8dfa6f019f93fa8d5249d5b6d83cb Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 4 Mar 2026 10:12:24 +0100 Subject: [PATCH 18/19] Update widget and hover for sessions --- .../browser/account.contribution.ts | 197 ++++++------------ .../browser/media/accountWidget.css | 130 +++++++++++- .../browser/media/updateHoverWidget.css | 64 ++++++ .../accountMenu/browser/updateHoverWidget.ts | 187 +++++++++++++++++ .../test/browser/accountWidget.fixture.ts | 152 ++++++++++++++ .../test/browser/updateHoverWidget.fixture.ts | 85 ++++++++ .../test/browser/updateWidget.fixture.ts | 114 ---------- 7 files changed, 676 insertions(+), 253 deletions(-) create mode 100644 src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css create mode 100644 src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts create mode 100644 src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts create mode 100644 src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts delete mode 100644 src/vs/sessions/contrib/accountMenu/test/browser/updateWidget.fixture.ts diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 2cd913c75f647..75afc5655941e 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -12,7 +12,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE } from '../../../../workbench/contrib/update/browser/update.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -23,9 +23,10 @@ import { IAction } from '../../../../base/common/actions.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Downloading, IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; -import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; -import { sessionsUpdateButtonDownloadingBackground, sessionsUpdateButtonDownloadedBackground } from '../../../common/theme.js'; +import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { UpdateHoverWidget } from './updateHoverWidget.js'; // --- Account Menu Items --- // const AccountMenu = new MenuId('SessionsAccountMenu'); @@ -83,20 +84,26 @@ MenuRegistry.appendMenuItem(AccountMenu, { // Update actions registerUpdateMenuItems(AccountMenu, '3_updates'); -class AccountWidget extends ActionViewItem { +export class AccountWidget extends ActionViewItem { private accountButton: Button | undefined; + private updateButton: Button | undefined; + private readonly updateHoverWidget: UpdateHoverWidget; private readonly viewItemDisposables = this._register(new DisposableStore()); constructor( action: IAction, options: IBaseActionViewItemOptions, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IUpdateService private readonly updateService: IUpdateService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService, + @IProductService private readonly productService: IProductService, ) { super(undefined, action, { ...options, icon: false, label: false }); + this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); } protected override getTooltip(): string | undefined { @@ -121,14 +128,33 @@ class AccountWidget extends ActionViewItem { })); this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); + // Update button (right) + const updateContainer = append(container, $('.account-widget-update')); + this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.updateButton.element.classList.add('account-widget-update-button', 'sidebar-action-button'); + this.viewItemDisposables.add(this.updateHoverWidget.attachTo(this.updateButton.element)); + this.updateAccountButton(); this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); + this.updateUpdateButton(); + this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); this.viewItemDisposables.add(this.accountButton.onDidClick(e => { e?.preventDefault(); e?.stopPropagation(); this.showAccountMenu(this.accountButton!.element); })); + + this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); } private showAccountMenu(anchor: HTMLElement): void { @@ -156,134 +182,57 @@ class AccountWidget extends ActionViewItem { : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; } - - override onClick(): void { - // Handled by custom click handlers - } -} - -export class UpdateWidget extends ActionViewItem { - - private updateButton: Button | undefined; - private readonly viewItemDisposables = this._register(new DisposableStore()); - - constructor( - action: IAction, - options: IBaseActionViewItemOptions, - @IUpdateService private readonly updateService: IUpdateService, - ) { - super(undefined, action, { ...options, icon: false, label: false }); - } - - protected override getTooltip(): string | undefined { - return undefined; - } - - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('update-widget', 'sidebar-action'); - - const updateContainer = append(container, $('.update-widget-action')); - this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - this.updateButton.element.classList.add('update-widget-button', 'sidebar-action-button'); - this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); - - this.updateUpdateButton(); - this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); - } - - private isUpdateReady(): boolean { - return this.updateService.state.type === StateType.Ready; - } - - private isUpdatePending(): boolean { - const type = this.updateService.state.type; - return type === StateType.AvailableForDownload - || type === StateType.CheckingForUpdates - || type === StateType.Downloading - || type === StateType.Downloaded - || type === StateType.Updating - || type === StateType.Overwriting; - } - private updateUpdateButton(): void { if (!this.updateButton) { return; } const state = this.updateService.state; - if (this.isUpdatePending() && !this.isUpdateReady()) { - this.updateButton.enabled = false; - this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; - this.updateDownloadProgress(state); - } else { - this.updateButton.enabled = true; - this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; - - const el = this.updateButton.element; - if (state.type === StateType.Ready) { - const color = asCssVariable(sessionsUpdateButtonDownloadedBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} 100%, transparent 100%)`; - } else { - // Ensure non-update states (e.g. Idle, Disabled, Uninitialized) do not look like a completed download - el.style.backgroundImage = ''; - } + if (this.shouldHideUpdateButton(state.type)) { + this.clearUpdateButtonStyling(); + this.updateButton.element.classList.add('hidden'); + return; } - } - private updateDownloadProgress(state: State): void { - if (!this.updateButton) { + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.style.backgroundImage = ''; + this.updateButton.enabled = state.type === StateType.Ready; + this.updateButton.label = this.getUpdateProgressMessage(state.type); + + if (state.type === StateType.Ready) { + this.updateButton.element.classList.add('account-widget-update-button-ready'); return; } - const el = this.updateButton.element; - - if (state.type === StateType.Downloading) { - const { downloadedBytes, totalBytes } = state as Downloading; - if (downloadedBytes !== undefined && totalBytes && totalBytes > 0) { - const percent = Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)); - const color = asCssVariable(sessionsUpdateButtonDownloadingBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} ${percent}%, transparent ${percent}%)`; - } else { - // Indeterminate: show a subtle pulsing background - const color = asCssVariable(sessionsUpdateButtonDownloadingBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} 0%, transparent 100%)`; - } - } else if (state.type === StateType.Downloaded) { - const color = asCssVariable(sessionsUpdateButtonDownloadedBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} 100%, transparent 100%)`; - } else { - this.clearDownloadProgress(); - } + this.updateButton.element.classList.remove('account-widget-update-button-ready'); + } + + private shouldHideUpdateButton(type: StateType): boolean { + return type === StateType.Uninitialized + || type === StateType.Idle + || type === StateType.Disabled + || type === StateType.CheckingForUpdates; } - private clearDownloadProgress(): void { + private clearUpdateButtonStyling(): void { if (this.updateButton) { this.updateButton.element.style.backgroundImage = ''; + this.updateButton.element.classList.remove('account-widget-update-button-ready'); } } private getUpdateProgressMessage(type: StateType): string { switch (type) { - case StateType.CheckingForUpdates: - return localize('checkingForUpdates', "Checking for Updates..."); + case StateType.Ready: + return localize('update', "Update"); + case StateType.AvailableForDownload: case StateType.Downloading: - return localize('downloadingUpdate', "Downloading Update..."); + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading..."); case StateType.Downloaded: - return localize('installingUpdate', "Installing Update..."); + return localize('installingUpdate', "Installing..."); case StateType.Updating: return localize('updatingApp', "Updating..."); - case StateType.Overwriting: - return localize('overwritingUpdate', "Downloading Update..."); default: return localize('updating', "Updating..."); } @@ -293,6 +242,7 @@ export class UpdateWidget extends ActionViewItem { await this.updateService.quitAndInstall(); } + override onClick(): void { // Handled by custom click handlers } @@ -315,11 +265,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu return instantiationService.createInstance(AccountWidget, action, options); }, undefined)); - const sessionsUpdateWidgetAction = 'sessions.action.updateWidget'; - this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsUpdateWidgetAction, (action, options) => { - return instantiationService.createInstance(UpdateWidget, action, options); - }, undefined)); - // Register the action with menu item after the view item provider // so the toolbar picks up the custom widget this._register(registerAction2(class extends Action2 { @@ -339,30 +284,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu } })); - this._register(registerAction2(class extends Action2 { - constructor() { - super({ - id: sessionsUpdateWidgetAction, - title: localize2('sessionsUpdateWidget', 'Sessions Update'), - menu: { - id: Menus.SidebarFooter, - group: 'navigation', - order: 0, - when: ContextKeyExpr.or( - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Overwriting), - ) - } - }); - } - async run(): Promise { - // Handled by the custom view item - } - })); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index 01bdd2c100b03..aeff16819c71b 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -3,6 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + +.account-widget { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + /* Account Button */ .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { overflow: hidden; @@ -10,9 +26,121 @@ flex: 1; } +.account-widget-account { + overflow: hidden; + min-width: 0; + flex: 1; +} + /* Update Button */ -.monaco-workbench .part.sidebar > .sidebar-footer .update-widget-action { +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready:hover { + background-color: var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready:hover { + background-color: var(--vscode-button-background) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready:focus { + background-color: var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready:focus { + background-color: var(--vscode-button-background) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.hidden { + display: none; +} + +.account-widget-update .account-widget-update-button.hidden { + display: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.account-widget-account .account-widget-account-button { overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > span:last-child { + flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-widget-account .account-widget-account-button > span:last-child { flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css new file mode 100644 index 0000000000000..752f4e7faca4d --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-update-hover { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; +} + +.sessions-update-hover-header { + font-weight: 600; + font-size: 13px; +} + +/* Progress bar track */ +.sessions-update-hover-progress-track { + height: 4px; + border-radius: 2px; + background-color: var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.3)); + overflow: hidden; +} + +/* Progress bar fill */ +.sessions-update-hover-progress-fill { + height: 100%; + border-radius: 2px; + background-color: var(--vscode-progressBar-background, #0078d4); + transition: width 0.2s ease; +} + +/* Details grid */ +.sessions-update-hover-grid { + display: grid; + grid-template-columns: auto auto auto auto; + column-gap: 8px; + row-gap: 2px; + font-size: 12px; + align-items: baseline; +} + +.sessions-update-hover-label { + color: var(--vscode-descriptionForeground); +} + +/* Version number emphasis */ +.sessions-update-hover-version { + color: var(--vscode-textLink-foreground); +} + +/* Compact age label */ +.sessions-update-hover-age { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +/* Commit hashes - subtle */ +.sessions-update-hover-commit { + color: var(--vscode-descriptionForeground); + font-family: var(--monaco-monospace-font); + font-size: 11px; +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts new file mode 100644 index 0000000000000..fc80636b0a8f7 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { localize } from '../../../../nls.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Downloading, IUpdate, IUpdateService, State, StateType, Updating } from '../../../../platform/update/common/update.js'; +import './media/updateHoverWidget.css'; + +export class UpdateHoverWidget { + + constructor( + private readonly updateService: IUpdateService, + private readonly productService: IProductService, + private readonly hoverService: IHoverService, + ) { } + + attachTo(target: HTMLElement) { + return this.hoverService.setupDelayedHover( + target, + () => ({ + content: this.createHoverContent(), + position: { hoverPosition: HoverPosition.RIGHT }, + appearance: { showPointer: true } + }), + { groupId: 'sessions-account-update' } + ); + } + + createHoverContent(state: State = this.updateService.state): HTMLElement { + const update = this.getUpdateFromState(state); + const currentVersion = this.productService.version ?? localize('unknownVersion', "Unknown"); + const targetVersion = update?.productVersion ?? update?.version ?? localize('unknownVersion', "Unknown"); + const currentCommit = this.productService.commit; + const targetCommit = update?.version; + const progressPercent = this.getUpdateProgressPercent(state); + + const container = document.createElement('div'); + container.classList.add('sessions-update-hover'); + + // Header: e.g. "Downloading VS Code Insiders" + const header = document.createElement('div'); + header.classList.add('sessions-update-hover-header'); + header.textContent = this.getUpdateHeaderLabel(state.type); + container.appendChild(header); + + // Progress bar + if (progressPercent !== undefined) { + const progressTrack = document.createElement('div'); + progressTrack.classList.add('sessions-update-hover-progress-track'); + const progressFill = document.createElement('div'); + progressFill.classList.add('sessions-update-hover-progress-fill'); + progressFill.style.width = `${progressPercent}%`; + progressTrack.appendChild(progressFill); + container.appendChild(progressTrack); + } + + // Version info grid + const detailsGrid = document.createElement('div'); + detailsGrid.classList.add('sessions-update-hover-grid'); + + const currentDate = this.productService.date ? new Date(this.productService.date) : undefined; + const currentAge = currentDate ? this.formatCompactAge(currentDate.getTime()) : undefined; + const newAge = update?.timestamp ? this.formatCompactAge(update.timestamp) : undefined; + + this.appendGridRow(detailsGrid, localize('updateHoverCurrentVersionLabel', "Current"), currentVersion, currentAge, currentCommit); + this.appendGridRow(detailsGrid, localize('updateHoverNewVersionLabel', "New"), targetVersion, newAge, targetCommit); + + container.appendChild(detailsGrid); + + return container; + } + + private appendGridRow(grid: HTMLElement, label: string, version: string, age?: string, commit?: string): void { + const labelEl = document.createElement('span'); + labelEl.classList.add('sessions-update-hover-label'); + labelEl.textContent = label; + grid.appendChild(labelEl); + + const versionEl = document.createElement('span'); + versionEl.classList.add('sessions-update-hover-version'); + versionEl.textContent = version; + grid.appendChild(versionEl); + + const ageEl = document.createElement('span'); + ageEl.classList.add('sessions-update-hover-age'); + ageEl.textContent = age ?? ''; + grid.appendChild(ageEl); + + const commitEl = document.createElement('span'); + commitEl.classList.add('sessions-update-hover-commit'); + commitEl.textContent = commit ? commit.substring(0, 7) : ''; + grid.appendChild(commitEl); + } + + private formatCompactAge(timestamp: number): string { + const seconds = Math.round((Date.now() - timestamp) / 1000); + if (seconds < 60) { + return localize('compactAgeNow', "now"); + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return localize('compactAgeMinutes', "{0}m ago", minutes); + } + const hours = Math.round(seconds / 3600); + if (hours < 24) { + return localize('compactAgeHours', "{0}h ago", hours); + } + const days = Math.round(seconds / 86400); + if (days < 7) { + return localize('compactAgeDays', "{0}d ago", days); + } + const weeks = Math.round(days / 7); + if (weeks < 5) { + return localize('compactAgeWeeks', "{0}w ago", weeks); + } + const months = Math.round(days / 30); + return localize('compactAgeMonths', "{0}mo ago", months); + } + + private getUpdateFromState(state: State): IUpdate | undefined { + switch (state.type) { + case StateType.AvailableForDownload: + case StateType.Downloaded: + case StateType.Ready: + case StateType.Overwriting: + case StateType.Updating: + return state.update; + case StateType.Downloading: + return state.update; + default: + return undefined; + } + } + + /** + * Returns progress as a percentage (0-100), or undefined if progress is not applicable. + */ + private getUpdateProgressPercent(state: State): number | undefined { + switch (state.type) { + case StateType.Downloading: { + const downloadingState = state as Downloading; + if (downloadingState.downloadedBytes !== undefined && downloadingState.totalBytes && downloadingState.totalBytes > 0) { + return Math.min(100, Math.round((downloadingState.downloadedBytes / downloadingState.totalBytes) * 100)); + } + return 0; + } + case StateType.Updating: { + const updatingState = state as Updating; + if (updatingState.currentProgress !== undefined && updatingState.maxProgress && updatingState.maxProgress > 0) { + return Math.min(100, Math.round((updatingState.currentProgress / updatingState.maxProgress) * 100)); + } + return 0; + } + case StateType.Downloaded: + case StateType.Ready: + return 100; + case StateType.AvailableForDownload: + case StateType.Overwriting: + return 0; + default: + return undefined; + } + } + + private getUpdateHeaderLabel(type: StateType): string { + const productName = this.productService.nameShort; + switch (type) { + case StateType.Ready: + return localize('updateReady', "{0} Update Ready", productName); + case StateType.AvailableForDownload: + return localize('downloadAvailable', "{0} Update Available", productName); + case StateType.Downloading: + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading {0}", productName); + case StateType.Downloaded: + return localize('installingUpdate', "Installing {0}", productName); + case StateType.Updating: + return localize('updatingApp', "Updating {0}", productName); + default: + return localize('updating', "Updating {0}", productName); + } + } +} diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts new file mode 100644 index 0000000000000..26c7d3a822d54 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ICopilotTokenInfo, IDefaultAccount, IPolicyData } from '../../../../../base/common/defaultAccount.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State, UpdateType } from '../../../../../platform/update/common/update.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AccountWidget } from '../../browser/account.contribution.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +// Import the CSS +import '../../../../browser/media/sidebarActionButton.css'; +import '../../browser/media/accountWidget.css'; + +const mockUpdate = { version: '1.0.0' }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function createMockDefaultAccountService(accountPromise: Promise): IDefaultAccountService { + const onDidChangeDefaultAccount = new Emitter(); + const onDidChangePolicyData = new Emitter(); + const onDidChangeCopilotTokenInfo = new Emitter(); + const service: IDefaultAccountService = { + _serviceBrand: undefined, + onDidChangeDefaultAccount: onDidChangeDefaultAccount.event, + onDidChangePolicyData: onDidChangePolicyData.event, + onDidChangeCopilotTokenInfo: onDidChangeCopilotTokenInfo.event, + policyData: null, + copilotTokenInfo: null, + getDefaultAccount: () => accountPromise, + getDefaultAccountAuthenticationProvider: () => ({ id: 'github', name: 'GitHub', enterprise: false }), + setDefaultAccountProvider: () => { }, + refresh: () => accountPromise, + signIn: async () => null, + signOut: async () => { }, + }; + return service; +} + +function renderAccountWidget(ctx: ComponentFixtureContext, state: State, accountPromise: Promise): void { + ctx.container.style.padding = '16px'; + ctx.container.style.width = '340px'; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const mockUpdateService = createMockUpdateService(state); + const mockAccountService = createMockDefaultAccountService(accountPromise); + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: registerWorkbenchServices, + }); + + const action = ctx.disposableStore.add(new Action('sessions.action.accountWidget', 'Sessions Account')); + const contextMenuService = instantiationService.get(IContextMenuService); + const menuService = instantiationService.get(IMenuService); + const contextKeyService = instantiationService.get(IContextKeyService); + const hoverService = instantiationService.get(IHoverService); + const productService = instantiationService.get(IProductService); + const widget = new AccountWidget(action, {}, mockAccountService, mockUpdateService, contextMenuService, menuService, contextKeyService, hoverService, productService); + ctx.disposableStore.add(widget); + widget.render(ctx.container); +} + +const signedInAccount: IDefaultAccount = { + authenticationProvider: { + id: 'github', + name: 'GitHub', + enterprise: false, + }, + accountName: 'avery.long.account.name@example.com', + sessionId: 'session-id', + enterprise: false, +}; + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + LoadingSignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), new Promise(() => { })), + }), + + SignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(null)), + }), + + SignedInNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(signedInAccount)), + }), + + CheckingForUpdatesHidden: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.CheckingForUpdates(true), Promise.resolve(signedInAccount)), + }), + + Ready: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Ready(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + AvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.AvailableForDownload(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Downloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000), Promise.resolve(signedInAccount)), + }), + + DownloadedInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloaded(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + Updating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Updating(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Overwriting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Overwriting(mockUpdate, true), Promise.resolve(signedInAccount)), + }), +}); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts new file mode 100644 index 0000000000000..8092585ab6dfd --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { UpdateHoverWidget } from '../../browser/updateHoverWidget.js'; + +const mockUpdate = { version: 'a1b2c3d4e5f6', productVersion: '1.100.0', timestamp: Date.now() - 2 * 60 * 60 * 1000 }; +const mockUpdateSameVersion = { version: 'a1b2c3d4e5f6', productVersion: '1.99.0', timestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function renderHoverWidget(ctx: ComponentFixtureContext, state: State): void { + ctx.container.style.padding = '16px'; + ctx.container.style.width = '320px'; + ctx.container.style.backgroundColor = 'var(--vscode-editorHoverWidget-background)'; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + }); + + const updateService = createMockUpdateService(state); + const productService = new class extends mock() { + override readonly version = '1.99.0'; + override readonly nameShort = 'VS Code Insiders'; + override readonly commit = 'f0e1d2c3b4a5'; + override readonly date = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + }; + const hoverService = instantiationService.get(IHoverService); + const widget = new UpdateHoverWidget(updateService, productService, hoverService); + ctx.container.appendChild(widget.createHoverContent(state)); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + UpdateHoverReady: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdate, true, false)), + }), + + UpdateHoverAvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.AvailableForDownload(mockUpdate)), + }), + + UpdateHoverDownloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000)), + }), + + UpdateHoverInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloaded(mockUpdate, true, false)), + }), + + UpdateHoverUpdating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Updating(mockUpdate, 40, 100)), + }), + + UpdateHoverSameVersion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdateSameVersion, true, false)), + }), +}); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateWidget.fixture.ts deleted file mode 100644 index 225223a3dda42..0000000000000 --- a/src/vs/sessions/contrib/accountMenu/test/browser/updateWidget.fixture.ts +++ /dev/null @@ -1,114 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Action } from '../../../../../base/common/actions.js'; -import { Emitter } from '../../../../../base/common/event.js'; -import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; -import { UpdateWidget } from '../../browser/account.contribution.js'; - -// Ensure color registrations are loaded -import '../../../../common/theme.js'; -import '../../../../../platform/theme/common/colors/inputColors.js'; - -// Import the CSS -import '../../../../browser/media/sidebarActionButton.css'; -import '../../browser/media/accountWidget.css'; - -const mockUpdate = { version: '1.0.0' }; - -function createMockUpdateService(state: State): IUpdateService { - const onStateChange = new Emitter(); - const service: IUpdateService = { - _serviceBrand: undefined, - state, - onStateChange: onStateChange.event, - checkForUpdates: async () => { }, - downloadUpdate: async () => { }, - applyUpdate: async () => { }, - quitAndInstall: async () => { }, - isLatestVersion: async () => true, - _applySpecificUpdate: async () => { }, - setInternalOrg: async () => { }, - }; - return service; -} - -function renderUpdateWidget(ctx: ComponentFixtureContext, state: State): void { - ctx.container.style.padding = '16px'; - ctx.container.style.width = '300px'; - ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; - - const mockService = createMockUpdateService(state); - - const instantiationService = createEditorServices(ctx.disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(IUpdateService, mockService); - }, - }); - - const action = ctx.disposableStore.add(new Action('sessions.action.updateWidget', 'Sessions Update')); - const widget = instantiationService.createInstance(UpdateWidget, action, {}); - ctx.disposableStore.add(widget); - widget.render(ctx.container); -} - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - Ready: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: (ctx) => renderUpdateWidget(ctx, State.Ready(mockUpdate, true, false)), - }), - - CheckingForUpdates: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.CheckingForUpdates(true)), - }), - - AvailableForDownload: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.AvailableForDownload(mockUpdate)), - }), - - Downloading0Percent: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 0, 100_000_000)), - }), - - Downloading30Percent: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000)), - }), - - Downloading65Percent: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 65_000_000, 100_000_000)), - }), - - Downloading100Percent: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 100_000_000, 100_000_000)), - }), - - DownloadingIndeterminate: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false)), - }), - - Downloaded: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Downloaded(mockUpdate, true, false)), - }), - - Updating: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Updating(mockUpdate)), - }), - - Overwriting: defineComponentFixture({ - labels: { kind: 'animated' }, - render: (ctx) => renderUpdateWidget(ctx, State.Overwriting(mockUpdate, true)), - }), -}); From 240196b5955050f4be2d98839d02483dda0465a8 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Tue, 3 Mar 2026 20:32:42 +0100 Subject: [PATCH 19/19] Adds support for stronglyRecommended extensions. Implements #299039 --- .vscode/extensions.json | 6 +- src/vs/platform/dialogs/common/dialogs.ts | 12 ++ .../common/extensionRecommendations.ts | 2 + .../common/extensionRecommendationsIpc.ts | 9 + .../browser/parts/dialogs/dialogHandler.ts | 9 +- ...ensionRecommendationNotificationService.ts | 169 ++++++++++++++++++ .../extensionRecommendationsService.ts | 10 ++ .../browser/extensions.contribution.ts | 11 ++ .../stronglyRecommendedExtensionList.ts | 99 ++++++++++ .../browser/workspaceRecommendations.ts | 29 ++- .../common/extensionsFileTemplate.ts | 9 + .../common/workspaceExtensionsConfig.ts | 8 + .../stronglyRecommendedDialog.fixture.ts | 85 +++++++++ 13 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 src/vs/workbench/contrib/extensions/browser/stronglyRecommendedExtensionList.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/stronglyRecommendedDialog.fixture.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3fb87652c814d..bd45eb0e5703c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,11 +4,15 @@ "recommendations": [ "dbaeumer.vscode-eslint", "editorconfig.editorconfig", - "github.vscode-pull-request-github", "ms-vscode.vscode-github-issue-notebooks", "ms-vscode.extension-test-runner", "jrieken.vscode-pr-pinger", "typescriptteam.native-preview", "ms-vscode.ts-customized-language-service" + ], + "stronglyRecommended": [ + "github.vscode-pull-request-github", + "ms-vscode.vscode-extras", + "ms-vscode.vscode-selfhost-test-provider" ] } diff --git a/src/vs/platform/dialogs/common/dialogs.ts b/src/vs/platform/dialogs/common/dialogs.ts index fc73e57f82433..925b82a8a28f8 100644 --- a/src/vs/platform/dialogs/common/dialogs.ts +++ b/src/vs/platform/dialogs/common/dialogs.ts @@ -5,6 +5,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { basename } from '../../../base/common/resources.js'; @@ -286,12 +287,23 @@ export const IDialogService = createDecorator('dialogService'); export interface ICustomDialogOptions { readonly buttonDetails?: string[]; + readonly buttonOptions?: Array; readonly markdownDetails?: ICustomDialogMarkdown[]; + readonly renderBody?: (container: HTMLElement, disposables: DisposableStore) => void; readonly classes?: string[]; readonly icon?: ThemeIcon; readonly disableCloseAction?: boolean; } +export interface ICustomDialogButtonOptions { + readonly sublabel?: string; + readonly styleButton?: (button: ICustomDialogButtonControl) => void; +} + +export interface ICustomDialogButtonControl { + set enabled(value: boolean); +} + export interface ICustomDialogMarkdown { readonly markdown: IMarkdownString; readonly classes?: string[]; diff --git a/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts b/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts index fe258ed580c87..00b2a6ce99357 100644 --- a/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts +++ b/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts @@ -45,5 +45,7 @@ export interface IExtensionRecommendationNotificationService { promptImportantExtensionsInstallNotification(recommendations: IExtensionRecommendations): Promise; promptWorkspaceRecommendations(recommendations: Array): Promise; + promptStronglyRecommendedExtensions(recommendations: Array): Promise; + resetStronglyRecommendedIgnoreState(): void; } diff --git a/src/vs/platform/extensionRecommendations/common/extensionRecommendationsIpc.ts b/src/vs/platform/extensionRecommendations/common/extensionRecommendationsIpc.ts index da863e3c6e29a..28ae56a38bed0 100644 --- a/src/vs/platform/extensionRecommendations/common/extensionRecommendationsIpc.ts +++ b/src/vs/platform/extensionRecommendations/common/extensionRecommendationsIpc.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { URI } from '../../../base/common/uri.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IExtensionRecommendationNotificationService, IExtensionRecommendations, RecommendationsNotificationResult } from './extensionRecommendations.js'; @@ -23,10 +24,18 @@ export class ExtensionRecommendationNotificationServiceChannelClient implements throw new Error('not supported'); } + promptStronglyRecommendedExtensions(recommendations: Array): Promise { + throw new Error('not supported'); + } + hasToIgnoreRecommendationNotifications(): boolean { throw new Error('not supported'); } + resetStronglyRecommendedIgnoreState(): void { + throw new Error('not supported'); + } + } export class ExtensionRecommendationNotificationServiceChannel implements IServerChannel { diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index 5af4f540bac41..be80260090836 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -9,6 +9,7 @@ import { IConfirmation, IConfirmationResult, IInputResult, ICheckbox, IInputElem import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import Severity from '../../../../base/common/severity.js'; +import { IButton } from '../../../../base/browser/ui/button/button.js'; import { Dialog, IDialogResult } from '../../../../base/browser/ui/dialog/dialog.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -105,8 +106,14 @@ export class BrowserDialogHandler extends AbstractDialogHandler { parent.appendChild(result.element); result.element.classList.add(...(markdownDetail.classes || [])); }); + customOptions.renderBody?.(parent, dialogDisposables); } : undefined; + const buttonOptions = customOptions?.buttonOptions?.map(opt => opt ? { + sublabel: opt.sublabel, + styleButton: opt.styleButton ? (button: IButton) => opt.styleButton!(button) : undefined + } : undefined) ?? customOptions?.buttonDetails?.map(detail => ({ sublabel: detail })); + const dialog = new Dialog( this.layoutService.activeContainer, message, @@ -118,7 +125,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { renderBody, icon: customOptions?.icon, disableCloseAction: customOptions?.disableCloseAction, - buttonOptions: customOptions?.buttonDetails?.map(detail => ({ sublabel: detail })), + buttonOptions, checkboxLabel: checkbox?.label, checkboxChecked: checkbox?.checked, inputs diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts index b70a49c4016b4..3caf43bdc4580 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts @@ -6,6 +6,7 @@ import { distinct } from '../../../../base/common/arrays.js'; import { CancelablePromise, createCancelablePromise, Promises, raceCancellablePromises, raceCancellation, timeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -13,11 +14,13 @@ import { isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IGalleryExtension } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js'; import { IExtensionRecommendationNotificationService, IExtensionRecommendations, RecommendationsNotificationResult, RecommendationSource, RecommendationSourceToString } from '../../../../platform/extensionRecommendations/common/extensionRecommendations.js'; import { INotificationHandle, INotificationService, IPromptChoice, IPromptChoiceWithMenu, NotificationPriority, Severity } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { renderStronglyRecommendedExtensionList, StronglyRecommendedExtensionListResult } from './stronglyRecommendedExtensionList.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IUserDataSyncEnablementService, SyncResource } from '../../../../platform/userDataSync/common/userDataSync.js'; @@ -42,6 +45,18 @@ type ExtensionWorkspaceRecommendationsNotificationClassification = { const ignoreImportantExtensionRecommendationStorageKey = 'extensionsAssistant/importantRecommendationsIgnore'; const donotShowWorkspaceRecommendationsStorageKey = 'extensionsAssistant/workspaceRecommendationsIgnore'; +const stronglyRecommendedIgnoreStorageKey = 'extensionsAssistant/stronglyRecommendedIgnore'; +const stronglyRecommendedMajorVersionIgnoreStorageKey = 'extensionsAssistant/stronglyRecommendedMajorVersionIgnore'; + +interface MajorVersionIgnoreEntry { + readonly id: string; + readonly majorVersion: number; +} + +function parseMajorVersion(version: string): number { + const major = parseInt(version.split('.')[0], 10); + return isNaN(major) ? 0 : major; +} type RecommendationsNotificationActions = { onDidInstallRecommendedExtensions(extensions: IExtension[]): void; @@ -132,6 +147,7 @@ export class ExtensionRecommendationNotificationService extends Disposable imple constructor( @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, @IStorageService private readonly storageService: IStorageService, @INotificationService private readonly notificationService: INotificationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @@ -468,6 +484,159 @@ export class ExtensionRecommendationNotificationService extends Disposable imple } } + async promptStronglyRecommendedExtensions(recommendations: Array): Promise { + if (this.hasToIgnoreRecommendationNotifications()) { + return; + } + + const ignoredList = this._getStronglyRecommendedIgnoreList(); + recommendations = recommendations.filter(rec => { + const key = isString(rec) ? rec.toLowerCase() : rec.toString(); + return !ignoredList.includes(key); + }); + if (!recommendations.length) { + return; + } + + let installed = await this.extensionManagementService.getInstalled(); + installed = installed.filter(l => this.extensionEnablementService.getEnablementState(l) !== EnablementState.DisabledByExtensionKind); + recommendations = recommendations.filter(recommendation => + installed.every(local => + isString(recommendation) ? !areSameExtensions({ id: recommendation }, local.identifier) : !this.uriIdentityService.extUri.isEqual(recommendation, local.location) + ) + ); + if (!recommendations.length) { + return; + } + + const allExtensions = await this.getInstallableExtensions(recommendations); + if (!allExtensions.length) { + return; + } + + const majorVersionIgnoreList = this._getStronglyRecommendedMajorVersionIgnoreList(); + const extensions = allExtensions.filter(ext => { + const ignored = majorVersionIgnoreList.find(e => e.id === ext.identifier.id.toLowerCase()); + return !ignored || parseMajorVersion(ext.version) > ignored.majorVersion; + }); + if (!extensions.length) { + return; + } + + const message = extensions.length === 1 + ? localize('stronglyRecommended1', "This workspace strongly recommends installing the '{0}' extension. Do you want to install?", extensions[0].displayName) + : localize('stronglyRecommendedN', "This workspace strongly recommends installing {0} extensions. Do you want to install?", extensions.length); + + let listResult!: StronglyRecommendedExtensionListResult; + + const { result } = await this.dialogService.prompt({ + message, + buttons: [ + { + label: localize('install', "Install"), + run: () => true, + }, + ], + cancelButton: localize('cancel', "Cancel"), + custom: { + icon: Codicon.extensions, + renderBody: (container, disposables) => { + listResult = renderStronglyRecommendedExtensionList(container, disposables, extensions); + }, + buttonOptions: [{ + styleButton: (button) => listResult.styleInstallButton(button), + }], + }, + }); + + if (result) { + const selected = extensions.filter(e => listResult.checkboxStates.get(e)); + const unselected = extensions.filter(e => !listResult.checkboxStates.get(e)); + if (unselected.length) { + this._addToStronglyRecommendedIgnoreList( + unselected.map(e => e.identifier.id) + ); + } + if (listResult.doNotShowAgainUnlessMajorVersionChange()) { + this._addToStronglyRecommendedIgnoreWithMajorVersion( + extensions.map(e => ({ id: e.identifier.id, majorVersion: parseMajorVersion(e.version) })) + ); + } + if (selected.length) { + const galleryExtensions: IGalleryExtension[] = []; + const resourceExtensions: IExtension[] = []; + for (const extension of selected) { + if (extension.gallery) { + galleryExtensions.push(extension.gallery); + } else if (extension.resourceExtension) { + resourceExtensions.push(extension); + } + } + await Promises.settled([ + Promises.settled(selected.map(extension => this.extensionsWorkbenchService.open(extension, { pinned: true }))), + galleryExtensions.length ? this.extensionManagementService.installGalleryExtensions(galleryExtensions.map(e => ({ extension: e, options: {} }))) : Promise.resolve(), + resourceExtensions.length ? Promise.allSettled(resourceExtensions.map(r => this.extensionsWorkbenchService.install(r))) : Promise.resolve(), + ]); + } + } + } + + private _getStronglyRecommendedIgnoreList(): string[] { + const raw = this.storageService.get(stronglyRecommendedIgnoreStorageKey, StorageScope.WORKSPACE); + if (raw === undefined) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private _addToStronglyRecommendedIgnoreList(recommendations: Array): void { + const list = this._getStronglyRecommendedIgnoreList(); + for (const rec of recommendations) { + const key = isString(rec) ? rec.toLowerCase() : rec.toString(); + if (!list.includes(key)) { + list.push(key); + } + } + this.storageService.store(stronglyRecommendedIgnoreStorageKey, JSON.stringify(list), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + private _getStronglyRecommendedMajorVersionIgnoreList(): MajorVersionIgnoreEntry[] { + const raw = this.storageService.get(stronglyRecommendedMajorVersionIgnoreStorageKey, StorageScope.WORKSPACE); + if (raw === undefined) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private _addToStronglyRecommendedIgnoreWithMajorVersion(entries: MajorVersionIgnoreEntry[]): void { + const list = this._getStronglyRecommendedMajorVersionIgnoreList(); + for (const entry of entries) { + const key = entry.id.toLowerCase(); + const existing = list.findIndex(e => e.id === key); + if (existing !== -1) { + list[existing] = { id: key, majorVersion: entry.majorVersion }; + } else { + list.push({ id: key, majorVersion: entry.majorVersion }); + } + } + this.storageService.store(stronglyRecommendedMajorVersionIgnoreStorageKey, JSON.stringify(list), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + resetStronglyRecommendedIgnoreState(): void { + this.storageService.remove(stronglyRecommendedIgnoreStorageKey, StorageScope.WORKSPACE); + this.storageService.remove(stronglyRecommendedMajorVersionIgnoreStorageKey, StorageScope.WORKSPACE); + } + private setIgnoreRecommendationsConfig(configVal: boolean) { this.configurationService.updateValue('extensions.ignoreRecommendations', configVal); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts index 12cf0a6c61fc1..9cba507bb0b56 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts @@ -110,6 +110,7 @@ export class ExtensionRecommendationsService extends Disposable implements IExte this._register(Event.any(this.workspaceRecommendations.onDidChangeRecommendations, this.configBasedRecommendations.onDidChangeRecommendations, this.extensionRecommendationsManagementService.onDidChangeIgnoredRecommendations)(() => this._onDidChangeRecommendations.fire())); this.promptWorkspaceRecommendations(); + this.promptStronglyRecommendedExtensions(); } private isEnabled(): boolean { @@ -274,6 +275,15 @@ export class ExtensionRecommendationsService extends Disposable implements IExte } } + private async promptStronglyRecommendedExtensions(): Promise { + const allowedRecommendations = this.workspaceRecommendations.stronglyRecommended + .filter(rec => !isString(rec) || this.isExtensionAllowedToBeRecommended(rec)); + + if (allowedRecommendations.length) { + await this.extensionRecommendationNotificationService.promptStronglyRecommendedExtensions(allowedRecommendations); + } + } + private _registerP(o: CancelablePromise): CancelablePromise { this._register(toDisposable(() => o.cancel())); return o; diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 37e6e916e167c..218741bdd9969 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -1895,6 +1895,17 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi run: () => this.commandService.executeCommand('workbench.extensions.action.addToWorkspaceIgnoredRecommendations') }); + this.registerExtensionAction({ + id: 'workbench.extensions.action.resetStronglyRecommendedIgnoreState', + title: localize2('workbench.extensions.action.resetStronglyRecommendedIgnoreState', "Reset Strongly Recommended Extensions Ignore State"), + category: EXTENSIONS_CATEGORY, + menu: { + id: MenuId.CommandPalette, + when: WorkbenchStateContext.notEqualsTo('empty'), + }, + run: async (accessor: ServicesAccessor) => accessor.get(IExtensionRecommendationNotificationService).resetStronglyRecommendedIgnoreState() + }); + this.registerExtensionAction({ id: ConfigureWorkspaceRecommendedExtensionsAction.ID, title: { value: ConfigureWorkspaceRecommendedExtensionsAction.LABEL, original: 'Configure Recommended Extensions (Workspace)' }, diff --git a/src/vs/workbench/contrib/extensions/browser/stronglyRecommendedExtensionList.ts b/src/vs/workbench/contrib/extensions/browser/stronglyRecommendedExtensionList.ts new file mode 100644 index 0000000000000..c4cb34ce44332 --- /dev/null +++ b/src/vs/workbench/contrib/extensions/browser/stronglyRecommendedExtensionList.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener } from '../../../../base/browser/dom.js'; +import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { ICustomDialogButtonControl } from '../../../../platform/dialogs/common/dialogs.js'; +import { defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; + +export interface StronglyRecommendedExtensionEntry { + readonly displayName: string; + readonly publisherDisplayName: string; + readonly version: string; +} + +export interface StronglyRecommendedExtensionListResult { + readonly checkboxStates: ReadonlyMap; + readonly hasSelection: boolean; + readonly doNotShowAgainUnlessMajorVersionChange: () => boolean; + styleInstallButton(button: ICustomDialogButtonControl): void; +} + +export function renderStronglyRecommendedExtensionList( + container: HTMLElement, + disposables: DisposableStore, + extensions: readonly T[], +): StronglyRecommendedExtensionListResult { + const checkboxStates = new Map(); + const onSelectionChanged = disposables.add(new Emitter()); + + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '8px'; + container.style.padding = '8px 0'; + + const updateCheckbox = (ext: T, cb: Checkbox) => { + checkboxStates.set(ext, cb.checked); + onSelectionChanged.fire(); + }; + + for (const ext of extensions) { + checkboxStates.set(ext, true); + + const row = container.appendChild($('.strongly-recommended-extension-row')); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '8px'; + + const cb = disposables.add(new Checkbox(ext.displayName, true, defaultCheckboxStyles)); + disposables.add(cb.onChange(() => updateCheckbox(ext, cb))); + row.appendChild(cb.domNode); + + const label = row.appendChild($('span')); + label.textContent = `${ext.displayName} v${ext.version} \u2014 ${ext.publisherDisplayName}`; + label.style.cursor = 'pointer'; + disposables.add(addDisposableListener(label, 'click', () => { + cb.checked = !cb.checked; + updateCheckbox(ext, cb); + })); + } + + const separator = container.appendChild($('div')); + separator.style.borderTop = '1px solid var(--vscode-widget-border)'; + separator.style.marginTop = '4px'; + separator.style.paddingTop = '4px'; + + const doNotShowRow = container.appendChild($('.strongly-recommended-do-not-show-row')); + doNotShowRow.style.display = 'flex'; + doNotShowRow.style.alignItems = 'center'; + doNotShowRow.style.gap = '8px'; + + const doNotShowCb = disposables.add(new Checkbox( + localize('doNotShowAgainUnlessMajorVersionChange', "Do not show again unless major version change"), + false, + defaultCheckboxStyles, + )); + doNotShowRow.appendChild(doNotShowCb.domNode); + + const doNotShowLabel = doNotShowRow.appendChild($('span')); + doNotShowLabel.textContent = localize('doNotShowAgainUnlessMajorVersionChange', "Do not show again unless major version change"); + doNotShowLabel.style.cursor = 'pointer'; + disposables.add(addDisposableListener(doNotShowLabel, 'click', () => { doNotShowCb.checked = !doNotShowCb.checked; })); + + const hasSelection = () => [...checkboxStates.values()].some(v => v); + + return { + checkboxStates, + get hasSelection() { return hasSelection(); }, + doNotShowAgainUnlessMajorVersionChange: () => doNotShowCb.checked, + styleInstallButton(button: ICustomDialogButtonControl) { + const updateEnabled = () => { button.enabled = hasSelection(); }; + disposables.add(onSelectionChanged.event(updateEnabled)); + }, + }; +} diff --git a/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts index 69ff685c65837..8563dcc4c1537 100644 --- a/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts @@ -25,6 +25,9 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { private _recommendations: ExtensionRecommendation[] = []; get recommendations(): ReadonlyArray { return this._recommendations; } + private _stronglyRecommended: Array = []; + get stronglyRecommended(): ReadonlyArray { return this._stronglyRecommended; } + private _onDidChangeRecommendations = this._register(new Emitter()); readonly onDidChangeRecommendations = this._onDidChangeRecommendations.event; @@ -32,6 +35,7 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { get ignoredRecommendations(): ReadonlyArray { return this._ignoredRecommendations; } private workspaceExtensions: URI[] = []; + private workspaceExtensionIds = new Map(); private readonly onDidChangeWorkspaceExtensionsScheduler: RunOnceScheduler; constructor( @@ -90,8 +94,12 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { // ignore } } + this.workspaceExtensionIds.clear(); if (workspaceExtensions.length) { const resourceExtensions = await this.workbenchExtensionManagementService.getExtensions(workspaceExtensions); + for (const ext of resourceExtensions) { + this.workspaceExtensionIds.set(ext.identifier.id.toLowerCase(), ext.location); + } return resourceExtensions.map(extension => extension.location); } return []; @@ -110,6 +118,7 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { } this._recommendations = []; + this._stronglyRecommended = []; this._ignoredRecommendations = []; for (const extensionsConfig of extensionsConfigs) { @@ -133,6 +142,24 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { } } } + if (extensionsConfig.stronglyRecommended) { + for (const extensionId of extensionsConfig.stronglyRecommended) { + if (invalidRecommendations.indexOf(extensionId) === -1) { + const workspaceExtUri = this.workspaceExtensionIds.get(extensionId.toLowerCase()); + const extension = workspaceExtUri ?? extensionId; + const reason = { + reasonId: ExtensionRecommendationReason.Workspace, + reasonText: localize('stronglyRecommendedExtension', "This extension is strongly recommended by users of the current workspace.") + }; + this._stronglyRecommended.push(extension); + if (workspaceExtUri) { + this._recommendations.push({ extension: workspaceExtUri, reason }); + } else { + this._recommendations.push({ extension: extensionId, reason }); + } + } + } + } } for (const extension of this.workspaceExtensions) { @@ -152,7 +179,7 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { const invalidExtensions: string[] = []; let message = ''; - const allRecommendations = distinct(contents.flatMap(({ recommendations }) => recommendations || [])); + const allRecommendations = distinct(contents.flatMap(({ recommendations, stronglyRecommended }) => [...(recommendations || []), ...(stronglyRecommended || [])])); const regEx = new RegExp(EXTENSION_IDENTIFIER_PATTERN); for (const extensionId of allRecommendations) { if (regEx.test(extensionId)) { diff --git a/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts b/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts index 818e662847eea..574806a1bc8f5 100644 --- a/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts +++ b/src/vs/workbench/contrib/extensions/common/extensionsFileTemplate.ts @@ -25,6 +25,15 @@ export const ExtensionsConfigurationSchema: IJSONSchema = { errorMessage: localize('app.extension.identifier.errorMessage', "Expected format '${publisher}.${name}'. Example: 'vscode.csharp'.") }, }, + stronglyRecommended: { + type: 'array', + description: localize('app.extensions.json.stronglyRecommended', "List of extensions that are strongly recommended for users of this workspace. Users will be prompted with a dialog to install these extensions. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), + items: { + type: 'string', + pattern: EXTENSION_IDENTIFIER_PATTERN, + errorMessage: localize('app.extension.identifier.errorMessage', "Expected format '${publisher}.${name}'. Example: 'vscode.csharp'.") + }, + }, unwantedRecommendations: { type: 'array', description: localize('app.extensions.json.unwantedRecommendations', "List of extensions recommended by VS Code that should not be recommended for users of this workspace. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), diff --git a/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts b/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts index a48ce69a12bb2..ba02eaf679f9c 100644 --- a/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts +++ b/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts @@ -24,6 +24,7 @@ export const EXTENSIONS_CONFIG = '.vscode/extensions.json'; export interface IExtensionsConfigContent { recommendations?: string[]; + stronglyRecommended?: string[]; unwantedRecommendations?: string[]; } @@ -35,6 +36,7 @@ export interface IWorkspaceExtensionsConfigService { readonly onDidChangeExtensionsConfigs: Event; getExtensionsConfigs(): Promise; getRecommendations(): Promise; + getStronglyRecommended(): Promise; getUnwantedRecommendations(): Promise; toggleRecommendation(extensionId: string): Promise; @@ -84,6 +86,11 @@ export class WorkspaceExtensionsConfigService extends Disposable implements IWor return distinct(configs.flatMap(c => c.recommendations ? c.recommendations.map(c => c.toLowerCase()) : [])); } + async getStronglyRecommended(): Promise { + const configs = await this.getExtensionsConfigs(); + return distinct(configs.flatMap(c => c.stronglyRecommended ? c.stronglyRecommended.map(c => c.toLowerCase()) : [])); + } + async getUnwantedRecommendations(): Promise { const configs = await this.getExtensionsConfigs(); return distinct(configs.flatMap(c => c.unwantedRecommendations ? c.unwantedRecommendations.map(c => c.toLowerCase()) : [])); @@ -296,6 +303,7 @@ export class WorkspaceExtensionsConfigService extends Disposable implements IWor private parseExtensionConfig(extensionsConfigContent: IExtensionsConfigContent): IExtensionsConfigContent { return { recommendations: distinct((extensionsConfigContent.recommendations || []).map(e => e.toLowerCase())), + stronglyRecommended: distinct((extensionsConfigContent.stronglyRecommended || []).map(e => e.toLowerCase())), unwantedRecommendations: distinct((extensionsConfigContent.unwantedRecommendations || []).map(e => e.toLowerCase())) }; } diff --git a/src/vs/workbench/test/browser/componentFixtures/stronglyRecommendedDialog.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/stronglyRecommendedDialog.fixture.ts new file mode 100644 index 0000000000000..def925aad9c86 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/stronglyRecommendedDialog.fixture.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; +import { localize } from '../../../../nls.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultDialogStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { StronglyRecommendedExtensionEntry, renderStronglyRecommendedExtensionList } from '../../../contrib/extensions/browser/stronglyRecommendedExtensionList.js'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; + +export default defineThemedFixtureGroup({ + TwoExtensions: defineComponentFixture({ render: ctx => renderDialog(ctx, twoExtensions) }), + SingleExtension: defineComponentFixture({ render: ctx => renderDialog(ctx, singleExtension) }), + ManyExtensions: defineComponentFixture({ render: ctx => renderDialog(ctx, manyExtensions) }), + NoneSelected: defineComponentFixture({ render: ctx => renderDialog(ctx, twoExtensions, { allUnchecked: true }) }), +}); + +const twoExtensions: StronglyRecommendedExtensionEntry[] = [ + { displayName: 'TypeScript Customized Language Service', publisherDisplayName: 'Microsoft', version: '3.1.0' }, + { displayName: 'VS Code Extras', publisherDisplayName: 'Microsoft', version: '1.0.5' }, +]; + +const singleExtension: StronglyRecommendedExtensionEntry[] = [ + { displayName: 'TypeScript Customized Language Service', publisherDisplayName: 'Microsoft', version: '3.1.0' }, +]; + +const manyExtensions: StronglyRecommendedExtensionEntry[] = [ + { displayName: 'TypeScript Customized Language Service', publisherDisplayName: 'Microsoft', version: '3.1.0' }, + { displayName: 'VS Code Extras', publisherDisplayName: 'Microsoft', version: '1.0.5' }, + { displayName: 'ESLint', publisherDisplayName: 'Dirk Baeumer', version: '2.4.4' }, + { displayName: 'Prettier', publisherDisplayName: 'Esben Petersen', version: '10.1.0' }, + { displayName: 'GitLens', publisherDisplayName: 'GitKraken', version: '15.6.2' }, +]; + +function renderDialog({ container, disposableStore }: ComponentFixtureContext, extensions: StronglyRecommendedExtensionEntry[], options?: { allUnchecked?: boolean }): void { + container.style.width = '700px'; + container.style.height = '500px'; + container.style.position = 'relative'; + container.style.overflow = 'hidden'; + + // The dialog uses position:fixed on its modal block, which escapes the shadow DOM container. + // Override to position:absolute so it stays within the fixture bounds. + const fixtureStyle = new CSSStyleSheet(); + fixtureStyle.replaceSync('.monaco-dialog-modal-block { position: absolute; }'); + const shadowRoot = container.getRootNode() as ShadowRoot; + shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, fixtureStyle]; + + const message = extensions.length === 1 + ? localize('strongExtensionFixture', "This workspace strongly recommends installing the '{0}' extension. Do you want to install?", extensions[0].displayName) + : localize('strongExtensionsFixture', "This workspace strongly recommends installing {0} extensions. Do you want to install?", extensions.length); + + let listResult!: ReturnType; + + const dialog = disposableStore.add(new Dialog( + container, + message, + [ + localize('install', "Install"), + localize('cancel', "Cancel"), + ], + { + type: 'info', + renderBody: (bodyContainer: HTMLElement) => { + listResult = renderStronglyRecommendedExtensionList(bodyContainer, disposableStore, extensions); + }, + buttonOptions: [{ + styleButton: (button) => listResult.styleInstallButton(button), + }], + cancelId: 1, + buttonStyles: defaultButtonStyles, + checkboxStyles: defaultCheckboxStyles, + inputBoxStyles: defaultInputBoxStyles, + dialogStyles: defaultDialogStyles, + } + )); + + dialog.show(); + + if (options?.allUnchecked) { + for (const cb of container.querySelectorAll('.strongly-recommended-extension-row .monaco-custom-toggle')) { + cb.click(); + } + } +}