From 08535d9c5ef4a8b9f89a49e8ac53d98000cda9df Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:13:55 -0800 Subject: [PATCH 01/28] Fix terminal-suggest extension icon Same root cause as #299396 but only caused the icon to be missing so not critical --- .../terminal-suggest/{src => }/media/icon.png | Bin extensions/terminal-suggest/package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename extensions/terminal-suggest/{src => }/media/icon.png (100%) diff --git a/extensions/terminal-suggest/src/media/icon.png b/extensions/terminal-suggest/media/icon.png similarity index 100% rename from extensions/terminal-suggest/src/media/icon.png rename to extensions/terminal-suggest/media/icon.png diff --git a/extensions/terminal-suggest/package.json b/extensions/terminal-suggest/package.json index 734e3e91c82a5..5eea60ebf7bdc 100644 --- a/extensions/terminal-suggest/package.json +++ b/extensions/terminal-suggest/package.json @@ -6,7 +6,7 @@ "version": "1.0.1", "private": true, "license": "MIT", - "icon": "./src/media/icon.png", + "icon": "./media/icon.png", "engines": { "vscode": "^1.95.0" }, From 655fe3f4c11f063e3755d5653a9485774e9bb824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:47:28 +0000 Subject: [PATCH 02/28] Initial plan From 002eadd84c81c5f9dd4579c70e2159de0179de41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:52:35 +0000 Subject: [PATCH 03/28] fix: correct deprecated TypeScript format.enable setting name in tooltip Co-authored-by: mjbvz <12821956+mjbvz@users.noreply.github.com> --- extensions/typescript-language-features/package.nls.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 40c4081de540b..28b65dc0736d0 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -64,7 +64,7 @@ "format.semicolons.remove": "Remove unnecessary semicolons.", "format.indentSwitchCase": "Indent case clauses in switch statements. Requires using TypeScript 5.1+ in the workspace.", "format.enable": "Enable/disable the default JavaScript and TypeScript formatter.", - "configuration.format.enable.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.enable#` instead.", + "configuration.format.enable.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.enabled#` instead.", "configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterCommaDelimiter#` instead.", "configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterConstructor#` instead.", "configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterSemicolonInForStatements#` instead.", From 1775c2e2c9aee2e557660072110556d27b57d55d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:40:51 +0000 Subject: [PATCH 04/28] Initial plan From 4743e19d32a7a9184caeb7d090f7c9f06f0da070 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:47:05 +0000 Subject: [PATCH 05/28] fix: use vscode codicon as theme icon for Release Notes tab Co-authored-by: mjbvz <12821956+mjbvz@users.noreply.github.com> --- .../contrib/update/browser/media/releasenoteseditor.css | 5 ----- .../workbench/contrib/update/browser/releaseNotesEditor.ts | 7 +++++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css b/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css index 4210055bfeb65..a4a092d83492f 100644 --- a/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css +++ b/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css @@ -2,8 +2,3 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -.file-icons-enabled .show-file-icons .webview-vs_code_release_notes-name-file-icon.file-icon::before { - content: ' '; - background-image: url('../../../../browser/media/code-icon.svg'); -} diff --git a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts index 550329a85ef9a..59967d3cd3985 100644 --- a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts +++ b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import './media/releasenoteseditor.css'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; import { KeybindingParser } from '../../../../base/common/keybindingParser.js'; @@ -20,6 +20,7 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { asTextOrError, IRequestService } from '../../../../platform/request/common/request.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from '../../markdown/browser/markdownDocumentRenderer.js'; import { WebviewInput } from '../../webviewPanel/browser/webviewEditorInput.js'; import { IWebviewWorkbenchService } from '../../webviewPanel/browser/webviewWorkbenchService.js'; @@ -39,6 +40,8 @@ import { asWebviewUri } from '../../webview/common/webview.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +const ReleaseNotesEditorIcon = registerIcon('release-notes-view-icon', Codicon.vscode, nls.localize('releaseNotesViewIcon', 'Icon of the release notes editor.')); + export class ReleaseNotesManager extends Disposable { private readonly _simpleSettingRenderer: SimpleSettingRenderer; private readonly _releaseNotesCache = new Map>(); @@ -124,7 +127,7 @@ export class ReleaseNotesManager extends Disposable { }, 'releaseNotes', title, - undefined, + ReleaseNotesEditorIcon, { group: ACTIVE_GROUP, preserveFocus: false }); const disposables = new DisposableStore(); From 2dbd83bd9fcd0b08538d85d0a673ead3593e2e05 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 6 Mar 2026 08:26:53 -0800 Subject: [PATCH 06/28] chat: register file system provider via workbench contribution (#299528) * chat: register file system provider via workbench contribution - Moves the registerProvider call from ChatResponseResourceFileSystemProvider constructor to a new ChatResponseResourceWorkbenchContribution class that gets instantiated by the workbench. This ensures the vscode-chat-response-resource:// file system provider is registered even though the service has no other dependencies that would trigger eager instantiation. - Changes the singleton registration from Eager to Delayed since the workbench contribution now depends on it, triggering instantiation. - Fixes file system provider not being found errors (ENOPRO) when MCPs or extensions try to access chat response resources. Fixes #299504 (Commit message generated by Copilot) * bump --- .../contrib/chat/browser/chat.contribution.ts | 5 +++-- .../chatResponseResourceFileSystemProvider.ts | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d9bcf54ea39cb..4fbdb207fa0fe 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -37,7 +37,7 @@ import '../common/widget/chatColors.js'; import { IChatEditingService } from '../common/editing/chatEditingService.js'; import { IChatLayoutService } from '../common/widget/chatLayoutService.js'; import { ChatModeService, IChatMode, IChatModeService } from '../common/chatModes.js'; -import { ChatResponseResourceFileSystemProvider, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; +import { ChatResponseResourceFileSystemProvider, ChatResponseResourceWorkbenchContribution, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; import { IChatService } from '../common/chatService/chatService.js'; import { ChatService } from '../common/chatService/chatServiceImpl.js'; import { IChatSessionsService } from '../common/chatSessionsService.js'; @@ -1747,9 +1747,9 @@ registerWorkbenchContribution2(SimpleBrowserOverlay.ID, SimpleBrowserOverlay, Wo registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); -registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Eager); registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatResponseResourceWorkbenchContribution.ID, ChatResponseResourceWorkbenchContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContributions, WorkbenchPhase.Eventually); registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); @@ -1785,6 +1785,7 @@ registerEditorFeature(ChatPasteProvidersFeature); agentPluginDiscoveryRegistry.register(new SyncDescriptor(ConfiguredAgentPluginDiscovery)); agentPluginDiscoveryRegistry.register(new SyncDescriptor(MarketplaceAgentPluginDiscovery)); +registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Delayed); registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts index 48596accfdf0b..6cab24df2d67e 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts @@ -11,14 +11,15 @@ import { newWriteableStream, ReadableStreamEvents } from '../../../../../base/co import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { createFileSystemProviderError, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IStat } from '../../../../../platform/files/common/files.js'; +import { createFileSystemProviderError, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileService, IFileSystemProvider, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IStat } from '../../../../../platform/files/common/files.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatResponseResource } from '../model/chatModel.js'; import { IChatService, IChatToolInvocation, IChatToolInvocationSerialized } from '../chatService/chatService.js'; import { isToolResultInputOutputDetails } from '../tools/languageModelToolsService.js'; export const IChatResponseResourceFileSystemProvider = createDecorator('chatResponseResourceFileSystemProvider'); -export interface IChatResponseResourceFileSystemProvider { +export interface IChatResponseResourceFileSystemProvider extends IFileSystemProvider { readonly _serviceBrand: undefined; /** @@ -59,7 +60,6 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement @IFileService private readonly _fileService: IFileService ) { super(); - this._register(this._fileService.registerProvider(ChatResponseResource.scheme, this)); this._register(this.chatService.onDidDisposeSession(e => { for (const sessionResource of e.sessionResource) { const uris = this._sessionAssociations.get(sessionResource); @@ -187,3 +187,16 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement return part.isText ? new TextEncoder().encode(part.value) : decodeBase64(part.value).buffer; } } + +export class ChatResponseResourceWorkbenchContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chatResponseResourceWorkbenchContribution'; + + constructor( + @IChatResponseResourceFileSystemProvider chatResponseResourceFsProvider: IChatResponseResourceFileSystemProvider, + @IFileService fileService: IFileService, + ) { + super(); + this._register(fileService.registerProvider(ChatResponseResource.scheme, chatResponseResourceFsProvider)); + } +} From 8e0baf5de1712bc8407c7a153344bd73155ae068 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 6 Mar 2026 16:59:42 +0000 Subject: [PATCH 07/28] Refactor workspace trust editor styles for improved layout (#299798) * refactor workspace trust editor styles for improved layout and responsiveness Co-authored-by: Copilot * set max-width for workspace trust editor to improve layout --------- Co-authored-by: mrleemurray Co-authored-by: Copilot --- .../browser/media/workspaceTrustEditor.css | 109 +++++++++++++----- 1 file changed, 77 insertions(+), 32 deletions(-) diff --git a/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css b/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css index 3bd850fb25bf5..7ac6aaa7c3799 100644 --- a/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css +++ b/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ .workspace-trust-editor { - max-width: 1000px; - padding-top: 11px; - margin: auto; - height: calc(100% - 11px); + max-width: 1600px; + padding-top: 12px; + margin: 0; + height: calc(100% - 12px); } .workspace-trust-editor:focus { @@ -15,12 +15,16 @@ } .workspace-trust-editor > .workspace-trust-header { - padding: 14px; + padding: 16px 24px; display: flex; flex-direction: column; align-items: center; } +.workspace-trust-editor > .workspace-trust-header:focus:not(:focus-visible) { + outline: none; +} + .workspace-trust-editor .workspace-trust-header .workspace-trust-title { font-size: 24px; font-weight: 600; @@ -35,7 +39,7 @@ } .workspace-trust-editor .workspace-trust-header .workspace-trust-title .workspace-trust-title-icon { - color: var(--workspace-trust-selected-color) !important; + color: var(--workspace-trust-selected-color); } .workspace-trust-editor .workspace-trust-header .workspace-trust-description { @@ -43,7 +47,8 @@ user-select: text; max-width: 600px; text-align: center; - padding: 14px 0; + padding: 8px 0; + line-height: 20px; } .workspace-trust-editor .workspace-trust-section-title { @@ -64,59 +69,67 @@ /** Features List */ .workspace-trust-editor .workspace-trust-features { - padding: 14px; + padding: 24px; cursor: default; user-select: text; display: flex; flex-direction: row; - flex-flow: wrap; - justify-content: space-evenly; + flex-wrap: wrap; + justify-content: center; + gap: 16px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations { - min-height: 315px; border: 1px solid var(--workspace-trust-unselected-color); - margin: 4px 4px; + border-radius: var(--vscode-cornerRadius-medium); display: flex; flex-direction: column; - padding: 10px 40px; + padding: 8px 36px; } .workspace-trust-editor.trusted .workspace-trust-features .workspace-trust-limitations.trusted, .workspace-trust-editor.untrusted .workspace-trust-features .workspace-trust-limitations.untrusted { - border-width: 2px; - border-color: var(--workspace-trust-selected-color) !important; - padding: 9px 39px; - outline-offset: 2px; + outline: 2px solid var(--workspace-trust-selected-color); + outline-offset: -2px; +} + +.workspace-trust-editor .workspace-trust-features .workspace-trust-limitations:focus:not(:focus-visible) { + outline: none; +} + +.workspace-trust-editor .workspace-trust-features .workspace-trust-limitations:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations ul { list-style: none; padding-inline-start: 0px; + margin: 16px 0; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations li { display: flex; - padding-bottom: 10px; + padding-bottom: 4px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations .list-item-icon { - padding-right: 5px; line-height: 24px; + padding: 0 6px 0 0; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations.trusted .list-item-icon { - color: var(--workspace-trust-check-color) !important; - font-size: 18px; + color: var(--workspace-trust-check-color); + font-size: 16px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations.untrusted .list-item-icon { - color: var(--workspace-trust-x-color) !important; - font-size: 20px; + color: var(--workspace-trust-x-color); + font-size: 16px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations .list-item-text { - font-size: 16px; + font-size: 14px; line-height: 24px; } @@ -130,7 +143,7 @@ font-size: 16px; font-weight: 600; line-height: 24px; - padding: 10px 0px; + padding: 16px 0px; display: flex; } @@ -143,12 +156,13 @@ .workspace-trust-editor.trusted .workspace-trust-features .workspace-trust-limitations.trusted .workspace-trust-limitations-header .workspace-trust-limitations-title .workspace-trust-title-icon, .workspace-trust-editor.untrusted .workspace-trust-features .workspace-trust-limitations.untrusted .workspace-trust-limitations-header .workspace-trust-limitations-title .workspace-trust-title-icon { display: unset; - color: var(--workspace-trust-selected-color) !important; + color: var(--workspace-trust-selected-color); } .workspace-trust-editor .workspace-trust-features .workspace-trust-untrusted-description { font-style: italic; - padding-bottom: 10px; + color: var(--vscode-descriptionForeground); + padding-bottom: 8px; } /** Buttons Container */ @@ -170,7 +184,7 @@ } .workspace-trust-editor .workspace-trust-settings .trusted-uris-button-bar { - margin-top: 5px; + margin-top: 8px; } .workspace-trust-editor .workspace-trust-settings .trusted-uris-button-bar .monaco-button { @@ -183,7 +197,7 @@ .workspace-trust-editor .workspace-trust-features .workspace-trust-buttons-row .workspace-trust-buttons > .monaco-button, .workspace-trust-editor .workspace-trust-features .workspace-trust-buttons-row .workspace-trust-buttons > .monaco-button-dropdown, .workspace-trust-editor .workspace-trust-settings .trusted-uris-button-bar .monaco-button { - margin: 4px 5px; /* allows button focus outline to be visible */ + margin: 8px 4px; /* allows button focus outline to be visible */ } .workspace-trust-editor .workspace-trust-features .workspace-trust-buttons-row .workspace-trust-buttons .monaco-button-dropdown .monaco-dropdown-button { @@ -192,7 +206,7 @@ .workspace-trust-limitations { width: 50%; - max-width: 350px; + max-width: 400px; min-width: 250px; flex: 1; } @@ -208,7 +222,7 @@ } .workspace-trust-intro-dialog .workspace-trust-dialog-image-row.badge-row img { - max-height: 40px; + max-height: 36px; padding-right: 10px; } @@ -218,7 +232,9 @@ } .workspace-trust-editor .workspace-trust-settings { - padding: 20px 14px; + padding: 24px 36px; + border-top: 1px solid var(--vscode-editorWidget-border); + margin-top: 8px; } .workspace-trust-editor .workspace-trust-settings .workspace-trusted-folders-title { @@ -229,6 +245,10 @@ display: none; } +.workspace-trust-editor .trusted-uris-table { + margin-top: 16px; +} + .workspace-trust-editor .monaco-table-tr .monaco-table-td .path { width: 100%; } @@ -292,3 +312,28 @@ .workspace-trust-editor .workspace-trust-settings .monaco-list-row:hover .monaco-table-tr .monaco-table-td .actions .monaco-action-bar { display: flex; } + +/** Responsive: single-column layout for narrow widths */ +@container (max-width: 600px) { + .workspace-trust-editor .workspace-trust-features { + flex-direction: column; + align-items: center; + } + + .workspace-trust-limitations { + width: 100%; + max-width: 400px; + } +} + +@media (max-width: 600px) { + .workspace-trust-editor .workspace-trust-features { + flex-direction: column; + align-items: center; + } + + .workspace-trust-limitations { + width: 100%; + max-width: 400px; + } +} From 3623d50299a82428c437ee7702af4fc427f0f287 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 6 Mar 2026 12:04:21 -0500 Subject: [PATCH 08/28] Render command title vs ID for chat tip hover (#299811) fixes #299579 --- .../contrib/chat/browser/chatTipCatalog.ts | 18 +++++++++--------- .../chat/test/browser/chatTipService.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index f112d72e2cd1b..404ab0b82b7e3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -127,7 +127,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.switchToAuto', - "Using GPT-4.1? Try switching to [Auto](command:workbench.action.chat.openModelPicker) in the model picker for better coding performance." + "Using GPT-4.1? Try switching to [Auto](command:workbench.action.chat.openModelPicker \"Open Model Picker\") in the model picker for better coding performance." ) ); }, @@ -142,7 +142,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.init', - "Use [{0}](command:{1}){2} to generate or update a workspace instructions file for AI coding agents.", + "Use [{0}](command:{1} \"Run /init\"){2} to generate or update a workspace instructions file for AI coding agents.", '/init', GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, kb @@ -163,7 +163,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.createPrompt', - "Use [{0}](command:{1}){2} to generate a reusable prompt file with the agent.", + "Use [{0}](command:{1} \"Run /create-prompt\"){2} to generate a reusable prompt file with the agent.", '/create-prompt', GENERATE_PROMPT_COMMAND_ID, kb @@ -185,7 +185,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.createAgent', - "Use [{0}](command:{1}){2} to scaffold a custom agent for your workflow.", + "Use [{0}](command:{1} \"Run /create-agent\"){2} to scaffold a custom agent for your workflow.", '/create-agent', GENERATE_AGENT_COMMAND_ID, kb @@ -207,7 +207,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.createSkill', - "Use [{0}](command:{1}){2} to create a skill the agent can load when relevant.", + "Use [{0}](command:{1} \"Run /create-skill\"){2} to create a skill the agent can load when relevant.", '/create-skill', GENERATE_SKILL_COMMAND_ID, kb @@ -229,7 +229,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.planMode', - "Try the [{0}](command:workbench.action.chat.openPlan){1} to research and plan before implementing changes.", + "Try the [{0}](command:workbench.action.chat.openPlan \"Start Plan Mode\"){1} to research and plan before implementing changes.", 'Plan agent', kb ) @@ -301,7 +301,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.forkConversation', - "Use [{0}](command:{1}){2} to branch the conversation. Explore a different approach without losing the original context.", + "Use [{0}](command:{1} \"Run /fork\"){2} to branch the conversation. Explore a different approach without losing the original context.", '/fork', INSERT_FORK_CONVERSATION_COMMAND_ID, kb @@ -321,7 +321,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.agenticBrowser', - "Enable [{0}](command:workbench.action.openSettings?%5B%22workbench.browser.enableChatTools%22%5D) to let the agent open and interact with pages in the Integrated Browser.", + "Enable [{0}](command:workbench.action.openSettings?%5B%22workbench.browser.enableChatTools%22%5D \"Open Settings\") to let the agent open and interact with pages in the Integrated Browser.", 'agentic browser integration' ) ); @@ -362,7 +362,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.thinkingPhrases', - "Customize the loading messages shown while the agent works with [{0}](command:workbench.action.openSettings?%5B%22{1}%22%5D).", + "Customize the loading messages shown while the agent works with [{0}](command:workbench.action.openSettings?%5B%22{1}%22%5D \"Open Settings\").", 'thinking phrases', ChatConfiguration.ThinkingPhrases ) diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 66009e7549b64..dc13e3e902e82 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -138,6 +138,22 @@ suite('ChatTipService', () => { assert.ok(tip.content.value.length > 0, 'Tip should have content'); }); + test('uses descriptive titles for tip command links', () => { + for (const tip of TIP_CATALOG) { + const markdown = tip.buildMessage({ + keybindingService: { + lookupKeybinding: () => undefined, + } as Partial as IKeybindingService, + }).value; + + const commandLinkRegex = /\[[^\]]+\]\((command:[^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = commandLinkRegex.exec(markdown)) !== null) { + assert.ok(/\s"[^"]+"$/.test(match[1]), `Expected command link in ${tip.id} to include a descriptive title: ${match[0]}`); + } + } + }); + test('records # file reference usage for attach files tip eligibility', () => { const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); instantiationService.stub(IChatService, { From 2b32258b0e9c13dc1c61124145f7457861666247 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 6 Mar 2026 18:09:01 +0100 Subject: [PATCH 09/28] merge to main (#299794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sessions - fix chat input shrinking at narrow widths (#299498) style - set width to 100% for `interactive-input-part` * modal - force focus into first modal editor always * fix: update precondition for FixDiagnosticsAction and hide input widget on command execution (#299499) fixes https://github.com/microsoft/vscode/issues/299251 * refactor: remove workspace context service dependency from FolderPicker * Add logging for agent feedback actions * modal - some fixes to actions and layout * modal - surface some editor actions in a new toolbar (#299582) * modal - surface some editor actions in a new toolbar * ccr * keybindings - remove "Edit as JSON" as its now available from the title menu * settings - remove "Edit as JSON" as its now available from the title menu * update hover fixes * terminal fixes * terminal improvements * Sessions: fix auth scopes of gh FSP * sessions customizations: make it easier to scan mcp/plugin marketplac… (#299636) sessions customizations: make it easier to scan mcp/plugin marketplace list * sessions: add built-in prompt files with override support (#299629) * sessions: add built-in prompt files with override support Ship bundled .prompt.md files with the Sessions app that appear as slash commands out of the box. Built-in prompts use a BUILTIN_STORAGE constant (cast as PromptsStorage) defined in the aiCustomization layer, avoiding changes to the core PromptsStorage enum and prompt service types. - AgenticPromptsService discovers prompts from vs/sessions/prompts/ at runtime via FileAccess and injects them into the listing pipeline - Override logic: user/workspace prompts with matching names take precedence over built-in ones - Built-in prompts open as read-only in the management editor - Sessions tree view, workspace service, and counts handle BUILTIN_STORAGE - Add /create-pr as the first built-in prompt - Bundle prompt files via gulpfile resource includes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * sessions: use AICustomizationPromptsStorage type for builtin storage Adopt the new AICustomizationPromptsStorage union type in the sessions tree view method signature. Use string-keyed Records and targeted casts at the PromptsStorage boundary to stay type-safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: remove PromptsStorage casts, widen IStorageSourceFilter Use AICustomizationPromptsStorage in sessions-local interfaces (IAICustomizationGroupItem, IAICustomizationFileItem) and widen IStorageSourceFilter.sources to readonly string[] so BUILTIN_STORAGE flows through without casts. The only remaining cast is at the IPromptPath creation boundary in AgenticPromptsService. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: move BUILTIN_STORAGE to sessions common layer Move AICustomizationPromptsStorage type and BUILTIN_STORAGE constant from the workbench browser UI module to sessions/contrib/chat/common so that AgenticPromptsService (a service) does not depend on UI code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * sessions: fix ESLint dangerous type assertion in builtin prompts (#299663) Replace the `as IPromptPath` cast in discoverBuiltinPrompts with a createBuiltinPromptPath factory function that contains the type narrowing in one place, satisfying the code-no-dangerous-type-assertions ESLint rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Enhance Agent Sessions Control and Renderer with observable active session resource * fix terminal * Enable model management in NewChatWidget * review feedback * different competion settings for copilot markdown and plaintext --------- Co-authored-by: Benjamin Pasero Co-authored-by: Benjamin Pasero Co-authored-by: Johannes Rieken Co-authored-by: BeniBenj Co-authored-by: Osvaldo Ortega Co-authored-by: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- build/gulpfile.vscode.ts | 1 + extensions/github/src/commands.ts | 26 - .../markdown-language-features/package.json | 8 + src/vs/platform/actions/common/actions.ts | 1 + src/vs/sessions/AI_CUSTOMIZATIONS.md | 12 + src/vs/sessions/browser/media/style.css | 1 + src/vs/sessions/browser/workbench.ts | 2 + .../browser/media/updateHoverWidget.css | 1 + .../test/browser/updateHoverWidget.fixture.ts | 2 - .../browser/agentFeedbackEditorActions.ts | 67 +- .../browser/agentFeedbackEditorOverlay.ts | 29 +- .../agentFeedbackEditorWidgetContribution.ts | 283 +++++--- .../browser/agentFeedbackService.ts | 69 +- .../media/agentFeedbackEditorWidget.css | 92 ++- .../browser/sessionEditorComments.ts | 119 ++++ ...gentFeedbackEditorOverlayWidget.fixture.ts | 136 ++++ .../agentFeedbackEditorWidget.fixture.ts | 346 +++++++++ .../browser/sessionEditorComments.test.ts | 98 +++ .../browser/aiCustomizationTreeViewViews.ts | 32 +- .../changesView/browser/changesView.ts | 73 +- .../changesView/browser/media/changesView.css | 13 + .../aiCustomizationWorkspaceService.ts | 5 +- .../contrib/chat/browser/folderPicker.ts | 6 +- .../contrib/chat/browser/newChatViewPane.ts | 2 +- .../contrib/chat/browser/promptsService.ts | 79 ++- .../chat/common/builtinPromptsStorage.ts | 28 + .../browser/codeReview.contributions.ts | 149 ++++ .../codeReview/browser/codeReviewService.ts | 353 ++++++++++ .../test/browser/codeReviewService.test.ts | 661 ++++++++++++++++++ .../browser/configuration.contribution.ts | 5 + .../browser/githubFileSystemProvider.ts | 8 +- .../sessions/browser/customizationCounts.ts | 6 +- .../test/browser/customizationCounts.test.ts | 20 +- .../browser/sessionsTerminalContribution.ts | 267 ++++--- .../sessionsTerminalContribution.test.ts | 184 ++--- src/vs/sessions/prompts/create-pr.prompt.md | 11 + src/vs/sessions/sessions.desktop.main.ts | 1 + .../parts/editor/editor.contribution.ts | 7 +- .../parts/editor/media/modalEditorPart.css | 8 + .../browser/parts/editor/modalEditorPart.ts | 43 +- .../agentSessions/agentSessionsControl.ts | 6 +- .../agentSessions/agentSessionsViewer.ts | 6 +- .../aiCustomization/aiCustomizationIcons.ts | 5 + .../aiCustomizationListWidget.ts | 7 +- .../aiCustomizationManagement.ts | 12 + .../aiCustomizationManagementEditor.ts | 3 +- .../browser/aiCustomization/mcpListWidget.ts | 22 +- .../media/aiCustomizationManagement.css | 13 + .../aiCustomization/pluginListWidget.ts | 22 +- .../chatSetup/chatSetupContributions.ts | 9 +- .../common/aiCustomizationWorkspaceService.ts | 6 +- .../preferences/browser/keybindingsEditor.ts | 16 +- .../browser/media/keybindingsEditor.css | 31 +- .../browser/media/settingsEditor2.css | 7 - .../browser/preferences.contribution.ts | 47 +- .../preferences/browser/settingsEditor2.ts | 14 +- .../actions/common/menusExtensionPoint.ts | 5 + .../services/editor/browser/editorService.ts | 20 + .../test/browser/modalEditorGroup.test.ts | 21 +- .../agentSessionsViewer.fixture.ts | 2 +- 60 files changed, 2986 insertions(+), 542 deletions(-) create mode 100644 src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts create mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts create mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts create mode 100644 src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts create mode 100644 src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts create mode 100644 src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts create mode 100644 src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts create mode 100644 src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts create mode 100644 src/vs/sessions/prompts/create-pr.prompt.md diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index ac4ee9cec7d83..e343569490dc1 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -100,6 +100,7 @@ const vscodeResourceIncludes = [ // Sessions 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', + 'out-build/vs/sessions/prompts/*.prompt.md', // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 33acf5a406b87..78dd3271588c4 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -90,28 +90,6 @@ function resolveSessionRepo(gitAPI: GitAPI, sessionMetadata: { worktreePath?: st return { repository, remoteInfo, gitRemote: { name: gitRemote.name, fetchUrl: gitRemote.fetchUrl! }, head: head as ResolvedSessionRepo['head'] }; } -async function checkOpenPullRequest(gitAPI: GitAPI, _sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { - const resolved = resolveSessionRepo(gitAPI, sessionMetadata, false); - if (!resolved) { - vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); - return; - } - - try { - const octokit = await getOctokit(); - const { data: openPRs } = await octokit.pulls.list({ - owner: resolved.remoteInfo.owner, - repo: resolved.remoteInfo.repo, - head: `${resolved.remoteInfo.owner}:${resolved.head.name}`, - state: 'all', - }); - - vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', openPRs.length > 0); - } catch { - vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); - } -} - async function createPullRequest(gitAPI: GitAPI, sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { if (!sessionResource) { return; @@ -263,9 +241,5 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return openPullRequest(gitAPI, sessionResource, sessionMetadata); })); - disposables.add(vscode.commands.registerCommand('github.checkOpenPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { - return checkOpenPullRequest(gitAPI, sessionResource, sessionMetadata); - })); - return disposables; } diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 745ba66b56c5e..2993574a7fa64 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -230,6 +230,14 @@ "group": "1_markdown" } ], + "modalEditor/editorTitle": [ + { + "command": "markdown.showPreviewToSide", + "when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview", + "alt": "markdown.showPreview", + "group": "navigation" + } + ], "explorer/context": [ { "command": "markdown.showPreview", diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index e563293ea2a4b..497ec1a43ab0c 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -89,6 +89,7 @@ export class MenuId { static readonly EditorContextShare = new MenuId('EditorContextShare'); static readonly EditorTitle = new MenuId('EditorTitle'); static readonly ModalEditorTitle = new MenuId('ModalEditorTitle'); + static readonly ModalEditorEditorTitle = new MenuId('ModalEditorEditorTitle'); static readonly CompactWindowEditorTitle = new MenuId('CompactWindowEditorTitle'); static readonly EditorTitleRun = new MenuId('EditorTitleRun'); static readonly EditorTitleContext = new MenuId('EditorTitleContext'); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index a3309067c7f49..e3c8ec0b8d1ce 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -90,9 +90,21 @@ The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): - **Discovery**: `AgenticPromptFilesLocator` scopes workspace folders to the active session's worktree +- **Built-in prompts**: Discovers bundled `.prompt.md` files from `vs/sessions/prompts/` and surfaces them with `PromptsStorage.builtin` storage type +- **User override**: Built-in prompts are omitted when a user or workspace prompt with the same name exists - **Creation targets**: `getSourceFolders()` override replaces VS Code profile user roots with `~/.copilot/{subfolder}` for CLI compatibility - **Hook folders**: Falls back to `.github/hooks` in the active worktree +### Built-in Prompts + +Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. They are: + +- Discovered at runtime via `FileAccess.asFileUri('vs/sessions/prompts')` +- Tagged with `PromptsStorage.builtin` storage type +- Shown in a "Built-in" group in the AI Customization tree view and management editor +- Filtered out when a user/workspace prompt shares the same clean name (override behavior) +- Included in storage filters for prompts and CLI-user types + ### Count Consistency `customizationCounts.ts` uses the **same data sources** as the list widget's `loadItems()`: diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 299ce8545d85b..4454c30fb8ce1 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -68,6 +68,7 @@ } .agent-sessions-workbench .interactive-session .interactive-input-part { + width: 100%; max-width: 950px; margin: 0 auto !important; display: inherit !important; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b54a7e6a0d9f5..1b34d6c3aeceb 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -591,6 +591,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.getPart(Parts.EDITOR_PART).create(editorPartContainer, { restorePreviousState: false }); mark('code/didCreatePart/workbench.parts.editor'); + this.getPart(Parts.EDITOR_PART).layout(0, 0, 0, 0); // needed to make some view methods work + this.mainContainer.appendChild(editorPartContainer); } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css index 752f4e7faca4d..6291d8e292250 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css @@ -8,6 +8,7 @@ flex-direction: column; gap: 8px; min-width: 200px; + padding: 12px 16px; } .sessions-update-hover-header { diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts index 8092585ab6dfd..6ca47e2ee1b88 100644 --- a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts +++ b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts @@ -32,8 +32,6 @@ function createMockUpdateService(state: State): IUpdateService { } 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, { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index 620cf99ae3db9..2fd5134bb0475 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -6,8 +6,9 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { EditorsOrder, IEditorIdentifier } from '../../../../workbench/common/editor.js'; @@ -16,14 +17,20 @@ import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/c import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; +import { getActiveResourceCandidates, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments } from './sessionEditorComments.js'; export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; export const navigateNextFeedbackActionId = 'agentFeedbackEditor.action.navigateNext'; export const clearAllFeedbackActionId = 'agentFeedbackEditor.action.clearAll'; export const navigationBearingFakeActionId = 'agentFeedbackEditor.navigation.bearings'; +export const hasSessionEditorComments = new RawContextKey('agentFeedbackEditor.hasSessionComments', false); +export const hasSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasAgentFeedback', false); abstract class AgentFeedbackEditorAction extends Action2 { @@ -37,16 +44,27 @@ abstract class AgentFeedbackEditorAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); const agentFeedbackService = accessor.get(IAgentFeedbackService); + const chatEditingService = accessor.get(IChatEditingService); + const agentSessionsService = accessor.get(IAgentSessionsService); + const codeReviewService = accessor.get(ICodeReviewService); const candidates = getActiveResourceCandidates(editorService.activeEditorPane?.input); - const sessionResource = candidates - .map(candidate => agentFeedbackService.getMostRecentSessionForResource(candidate)) - .find((value): value is URI => !!value); - if (!sessionResource) { - return; - } + for (const candidate of candidates) { + const sessionResource = getSessionForResource(candidate, chatEditingService, agentSessionsService) + ?? agentFeedbackService.getMostRecentSessionForResource(candidate); + if (!sessionResource) { + continue; + } - return this.runWithSession(accessor, sessionResource); + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + ); + if (comments.length > 0) { + return this.runWithSession(accessor, sessionResource); + } + } } abstract runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise | void; @@ -65,7 +83,7 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 0, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -74,9 +92,11 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { const chatWidgetService = accessor.get(IChatWidgetService); const agentFeedbackService = accessor.get(IAgentFeedbackService); const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); if (!widget) { + logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); return; } @@ -114,27 +134,36 @@ class NavigateFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'navigate', order: _next ? 2 : 1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionEditorComments), }, }); } - override runWithSession(accessor: ServicesAccessor, sessionResource: URI): void { + override async runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise { const agentFeedbackService = accessor.get(IAgentFeedbackService); + const codeReviewService = accessor.get(ICodeReviewService); const editorService = accessor.get(IEditorService); - - const feedback = agentFeedbackService.getNextFeedback(sessionResource, this._next); - if (!feedback) { + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + ); + + const comment = agentFeedbackService.getNextNavigableItem(sessionResource, comments, this._next); + if (!comment) { return; } - editorService.openEditor({ - resource: feedback.resourceUri, + await editorService.openEditor({ + resource: comment.resourceUri, options: { preserveFocus: false, revealIfVisible: true, + selection: { startLineNumber: comment.range.startLineNumber, startColumn: comment.range.startColumn }, // place the cursor but not selection } }); + + agentFeedbackService.setNavigationAnchor(sessionResource, comment.id); } } @@ -152,7 +181,7 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -177,6 +206,6 @@ export function registerAgentFeedbackEditorActions(): void { }, group: 'navigate', order: -1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionEditorComments), }); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 56cb43ad9347d..60990b0392663 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -18,13 +18,15 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { EditorGroupView } from '../../../../workbench/browser/parts/editor/editorGroupView.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; +import { hasSessionAgentFeedback, hasSessionEditorComments, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; import { assertType } from '../../../../base/common/types.js'; import { localize } from '../../../../nls.js'; import { getActiveResourceCandidates, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments, hasAgentFeedbackComments } from './sessionEditorComments.js'; class AgentFeedbackActionViewItem extends ActionViewItem { @@ -54,7 +56,7 @@ class AgentFeedbackActionViewItem extends ActionViewItem { } } -class AgentFeedbackOverlayWidget extends Disposable { +export class AgentFeedbackOverlayWidget extends Disposable { private readonly _domNode: HTMLElement; private readonly _toolbarNode: HTMLElement; @@ -145,6 +147,8 @@ class AgentFeedbackOverlayController { @IAgentSessionsService agentSessionsService: IAgentSessionsService, @IInstantiationService instaService: IInstantiationService, @IChatEditingService chatEditingService: IChatEditingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ICodeReviewService codeReviewService: ICodeReviewService, ) { this._domNode.classList.add('agent-feedback-editor-overlay'); this._domNode.style.position = 'absolute'; @@ -155,6 +159,8 @@ class AgentFeedbackOverlayController { const widget = this._store.add(instaService.createInstance(AgentFeedbackOverlayWidget)); this._domNode.appendChild(widget.getDomNode()); this._store.add(toDisposable(() => this._domNode.remove())); + const hasCommentsContext = hasSessionEditorComments.bindTo(contextKeyService); + const hasAgentFeedbackContext = hasSessionAgentFeedback.bindTo(contextKeyService); const show = () => { if (!container.contains(this._domNode)) { @@ -181,19 +187,34 @@ class AgentFeedbackOverlayController { const candidates = getActiveResourceCandidates(group.activeEditorPane?.input); let navigationBearings = undefined; + let hasAgentFeedback = false; for (const candidate of candidates) { const sessionResource = getSessionForResource(candidate, chatEditingService, agentSessionsService); - if (sessionResource && agentFeedbackService.getFeedback(sessionResource).length > 0) { - navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource); + if (!sessionResource) { + continue; + } + + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).read(r), + ); + if (comments.length > 0) { + navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource, comments); + hasAgentFeedback = hasAgentFeedbackComments(comments); break; } } if (!navigationBearings) { + hasCommentsContext.set(false); + hasAgentFeedbackContext.set(false); hide(); return; } + hasCommentsContext.set(true); + hasAgentFeedbackContext.set(hasAgentFeedback); widget.show(navigationBearings); show(); })); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 42e5e404bed7f..987c39acfef55 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -5,9 +5,11 @@ import './media/agentFeedbackEditorWidget.css'; +import { Action } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; +import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; import { IEditorContribution, IEditorDecorationsCollection, ScrollType } from '../../../../editor/common/editorCommon.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; @@ -19,46 +21,16 @@ import { Range } from '../../../../editor/common/core/range.js'; import { overviewRulerRangeHighlight } from '../../../../editor/common/core/editorColorRegistry.js'; import { OverviewRulerLane } from '../../../../editor/common/model.js'; import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import * as nls from '../../../../nls.js'; -import { IAgentFeedback, IAgentFeedbackService } from './agentFeedbackService.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { getSessionForResource } from './agentFeedbackEditorUtils.js'; - -/** - * Groups nearby feedback items within a threshold number of lines. - */ -function groupNearbyFeedback(items: readonly IAgentFeedback[], lineThreshold: number = 5): IAgentFeedback[][] { - if (items.length === 0) { - return []; - } - - // Sort by start line number - const sorted = [...items].sort((a, b) => a.range.startLineNumber - b.range.startLineNumber); - - const groups: IAgentFeedback[][] = []; - let currentGroup: IAgentFeedback[] = [sorted[0]]; - - for (let i = 1; i < sorted.length; i++) { - const firstItem = currentGroup[0]; - const currentItem = sorted[i]; - - const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber; - - if (verticalSpan <= lineThreshold) { - currentGroup.push(currentItem); - } else { - groups.push(currentGroup); - currentGroup = [currentItem]; - } - } - - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - - return groups; -} +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getResourceEditorComments, getSessionEditorComments, groupNearbySessionEditorComments, ISessionEditorComment, SessionEditorCommentSource, toSessionEditorCommentId } from './sessionEditorComments.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { isEqual } from '../../../../base/common/resources.js'; /** * Widget that displays agent feedback comments for a group of nearby feedback items. @@ -87,9 +59,10 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid constructor( private readonly _editor: ICodeEditor, - private readonly _feedbackItems: readonly IAgentFeedback[], - private readonly _agentFeedbackService: IAgentFeedbackService, + private readonly _commentItems: readonly ISessionEditorComment[], private readonly _sessionResource: URI, + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, ) { super(); @@ -171,26 +144,13 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid } private _dismiss(): void { - // Remove all feedback items in this widget from the service - for (const feedback of this._feedbackItems) { - this._agentFeedbackService.removeFeedback(this._sessionResource, feedback.id); + for (const comment of this._commentItems) { + this._removeComment(comment); } - - this._domNode.classList.add('fadeOut'); - - const dispose = () => { - this.dispose(); - }; - - const handle = setTimeout(dispose, 150); - this._domNode.addEventListener('animationend', () => { - clearTimeout(handle); - dispose(); - }, { once: true }); } private _updateTitle(): void { - const count = this._feedbackItems.length; + const count = this._commentItems.length; if (count === 1) { this._titleNode.textContent = nls.localize('oneComment', "1 comment"); } else { @@ -213,37 +173,141 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid clearNode(this._bodyNode); this._itemElements.clear(); - for (const feedback of this._feedbackItems) { + for (const comment of this._commentItems) { const item = $('div.agent-feedback-widget-item'); - this._itemElements.set(feedback.id, item); + item.classList.add(`agent-feedback-widget-item-${comment.source}`); + if (comment.suggestion) { + item.classList.add('agent-feedback-widget-item-suggestion'); + } + this._itemElements.set(comment.id, item); + + const itemHeader = $('div.agent-feedback-widget-item-header'); + const itemMeta = $('div.agent-feedback-widget-item-meta'); - // Line indicator const lineInfo = $('span.agent-feedback-widget-line-info'); - if (feedback.range.startLineNumber === feedback.range.endLineNumber) { - lineInfo.textContent = nls.localize('lineNumber', "Line {0}", feedback.range.startLineNumber); + if (comment.range.startLineNumber === comment.range.endLineNumber) { + lineInfo.textContent = nls.localize('lineNumber', "Line {0}", comment.range.startLineNumber); } else { - lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", feedback.range.startLineNumber, feedback.range.endLineNumber); + lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", comment.range.startLineNumber, comment.range.endLineNumber); + } + itemMeta.appendChild(lineInfo); + + if (comment.source !== SessionEditorCommentSource.AgentFeedback) { + const typeBadge = $('span.agent-feedback-widget-item-type'); + typeBadge.textContent = this._getTypeLabel(comment); + itemMeta.appendChild(typeBadge); + } + + itemHeader.appendChild(itemMeta); + + const actionBarContainer = $('div.agent-feedback-widget-item-actions'); + const actionBar = this._eventStore.add(new ActionBar(actionBarContainer)); + if (comment.canConvertToAgentFeedback) { + actionBar.push(new Action( + 'agentFeedback.widget.convert', + nls.localize('convertComment', "Convert to Agent Feedback"), + ThemeIcon.asClassName(Codicon.comment), + true, + () => this._convertToAgentFeedback(comment), + ), { icon: true, label: false }); } - item.appendChild(lineInfo); + actionBar.push(new Action( + 'agentFeedback.widget.remove', + nls.localize('removeComment', "Remove"), + ThemeIcon.asClassName(Codicon.close), + true, + () => this._removeComment(comment), + ), { icon: true, label: false }); + itemHeader.appendChild(actionBarContainer); + item.appendChild(itemHeader); - // Feedback text const text = $('span.agent-feedback-widget-text'); - text.textContent = feedback.text; + text.textContent = comment.text; item.appendChild(text); - // Hover handlers for range highlighting + if (comment.suggestion?.edits.length) { + item.appendChild(this._renderSuggestion(comment)); + } + this._eventStore.add(addDisposableListener(item, 'mouseenter', () => { - this._highlightRange(feedback); + this._highlightRange(comment); })); this._eventStore.add(addDisposableListener(item, 'mouseleave', () => { this._rangeHighlightDecoration.clear(); })); + this._eventStore.add(addDisposableListener(item, 'click', e => { + if ((e.target as HTMLElement | null)?.closest('.action-bar')) { + return; + } + this.focusFeedback(comment.id); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, comment.id); + this._revealComment(comment); + })); + this._bodyNode.appendChild(item); } } + private _getTypeLabel(comment: ISessionEditorComment): string { + if (comment.source === SessionEditorCommentSource.CodeReview) { + return comment.suggestion + ? nls.localize('reviewSuggestion', "Review Suggestion") + : nls.localize('reviewComment', "Review"); + } + + return comment.suggestion + ? nls.localize('feedbackSuggestion', "Feedback Suggestion") + : nls.localize('feedbackComment', "Feedback"); + } + + private _renderSuggestion(comment: ISessionEditorComment): HTMLElement { + const suggestionNode = $('div.agent-feedback-widget-suggestion'); + const title = $('div.agent-feedback-widget-suggestion-title'); + title.textContent = nls.localize('suggestedChange', "Suggested Change"); + suggestionNode.appendChild(title); + + for (const edit of comment.suggestion?.edits ?? []) { + const editNode = $('div.agent-feedback-widget-suggestion-edit'); + const rangeLabel = $('div.agent-feedback-widget-suggestion-range'); + if (edit.range.startLineNumber === edit.range.endLineNumber) { + rangeLabel.textContent = nls.localize('suggestionLineNumber', "Line {0}", edit.range.startLineNumber); + } else { + rangeLabel.textContent = nls.localize('suggestionLineRange', "Lines {0}-{1}", edit.range.startLineNumber, edit.range.endLineNumber); + } + editNode.appendChild(rangeLabel); + + const newText = $('pre.agent-feedback-widget-suggestion-text'); + newText.textContent = edit.newText; + editNode.appendChild(newText); + suggestionNode.appendChild(editNode); + } + + return suggestionNode; + } + + private _removeComment(comment: ISessionEditorComment): void { + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + return; + } + + this._agentFeedbackService.removeFeedback(this._sessionResource, comment.sourceId); + } + + private _convertToAgentFeedback(comment: ISessionEditorComment): void { + if (!comment.canConvertToAgentFeedback) { + return; + } + + const feedback = this._agentFeedbackService.addFeedback(this._sessionResource, comment.resourceUri, comment.range, comment.text, comment.suggestion); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + } + } + /** * Expand the widget body. */ @@ -277,7 +341,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid el.classList.remove('focused'); } - const feedback = this._feedbackItems.find(f => f.id === feedbackId); + const feedback = this._commentItems.find(f => f.id === feedbackId); if (!feedback) { return; } @@ -300,7 +364,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._rangeHighlightDecoration.clear(); } - private _highlightRange(feedback: IAgentFeedback): void { + private _highlightRange(feedback: ISessionEditorComment): void { const endLineNumber = feedback.range.endLineNumber; const range = new Range( feedback.range.startLineNumber, 1, @@ -333,7 +397,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid * Returns true if this widget contains the given feedback item (by id). */ containsFeedback(feedbackId: string): boolean { - return this._feedbackItems.some(f => f.id === feedbackId); + return this._commentItems.some(f => f.id === feedbackId); } /** @@ -374,8 +438,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid */ toggle(show: boolean): void { this._domNode.classList.toggle('visible', show); - if (show && this._feedbackItems.length > 0) { - this.layout(this._feedbackItems[0].range.startLineNumber); + if (show && this._commentItems.length > 0) { + this.layout(this._commentItems[0].range.startLineNumber); } } @@ -411,6 +475,16 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._editor.removeOverlayWidget(this); super.dispose(); } + + private _revealComment(comment: ISessionEditorComment): void { + const range = new Range( + comment.range.startLineNumber, + 1, + comment.range.endLineNumber, + this._editor.getModel()?.getLineMaxColumn(comment.range.endLineNumber) ?? 1, + ); + this._editor.revealRangeInCenterIfOutsideViewport(range, ScrollType.Smooth); + } } /** @@ -430,25 +504,20 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, ) { super(); - this._store.add(this._agentFeedbackService.onDidChangeFeedback(e => { - if (this._sessionResource && e.sessionResource.toString() === this._sessionResource.toString()) { - this._rebuildWidgets(); - } - })); - this._store.add(this._agentFeedbackService.onDidChangeNavigation(sessionResource => { if (this._sessionResource && sessionResource.toString() === this._sessionResource.toString()) { this._handleNavigation(); } })); - this._store.add(this._editor.onDidChangeModel(() => { - this._resolveSession(); - this._rebuildWidgets(); - })); + const rebuildSignal = observableSignalFromEvent(this, Event.any( + this._agentFeedbackService.onDidChangeFeedback, + this._editor.onDidChangeModel, + )); this._store.add(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => { for (const widget of this._widgets) { @@ -456,8 +525,17 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito } })); - this._resolveSession(); - this._rebuildWidgets(); + this._store.add(autorun(reader => { + rebuildSignal.read(reader); + this._resolveSession(); + if (!this._sessionResource) { + this._clearWidgets(); + return; + } + + this._rebuildWidgets(this._codeReviewService.getReviewState(this._sessionResource).read(reader)); + this._handleNavigation(); + })); } private _resolveSession(): void { @@ -469,10 +547,10 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); } - private _rebuildWidgets(): void { + private _rebuildWidgets(reviewState = this._sessionResource ? this._codeReviewService.getReviewState(this._sessionResource).get() : undefined): void { this._clearWidgets(); - if (!this._sessionResource) { + if (!this._sessionResource || !reviewState) { return; } @@ -481,17 +559,20 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito return; } - const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource); - // Filter to feedback items belonging to this editor's file - const fileFeedback = allFeedback.filter(f => f.resourceUri.toString() === model.uri.toString()); - if (fileFeedback.length === 0) { + const comments = getSessionEditorComments( + this._sessionResource, + this._agentFeedbackService.getFeedback(this._sessionResource), + reviewState, + ); + const fileComments = getResourceEditorComments(model.uri, comments); + if (fileComments.length === 0) { return; } - const groups = groupNearbyFeedback(fileFeedback, 5); + const groups = groupNearbySessionEditorComments(fileComments, 5); for (const group of groups) { - const widget = new AgentFeedbackEditorWidget(this._editor, group, this._agentFeedbackService, this._sessionResource); + const widget = new AgentFeedbackEditorWidget(this._editor, group, this._sessionResource, this._agentFeedbackService, this._codeReviewService); this._widgets.push(widget); widget.layout(group[0].range.startLineNumber); @@ -503,17 +584,33 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito return; } - const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource); + const model = this._editor.getModel(); + if (!model) { + return; + } + + const comments = getSessionEditorComments( + this._sessionResource, + this._agentFeedbackService.getFeedback(this._sessionResource), + this._codeReviewService.getReviewState(this._sessionResource).get(), + ); + const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource, comments); if (bearing.activeIdx < 0) { return; } - const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource); - const activeFeedback = allFeedback[bearing.activeIdx]; + const activeFeedback = comments[bearing.activeIdx]; if (!activeFeedback) { return; } + if (!isEqual(activeFeedback.resourceUri, model.uri)) { + for (const widget of this._widgets) { + widget.collapse(); + } + return; + } + // Expand the widget containing the active feedback, collapse all others for (const widget of this._widgets) { if (widget.containsFeedback(activeFeedback.id)) { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 550c1d961b12f..9d99b64cada9d 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -16,6 +16,8 @@ import { agentSessionContainsResource, editingEntriesContainResource } from '../ import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; // --- Types -------------------------------------------------------------------- @@ -25,6 +27,11 @@ export interface IAgentFeedback { readonly resourceUri: URI; readonly range: IRange; readonly sessionResource: URI; + readonly suggestion?: ICodeReviewSuggestion; +} + +export interface INavigableSessionComment { + readonly id: string; } export interface IAgentFeedbackChangeEvent { @@ -50,7 +57,7 @@ export interface IAgentFeedbackService { /** * Add a feedback item for the given session. */ - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback; + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): IAgentFeedback; /** * Remove a single feedback item. @@ -76,11 +83,13 @@ export interface IAgentFeedbackService { * Navigate to next/previous feedback item in a session. */ getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined; + getNextNavigableItem(sessionResource: URI, items: readonly T[], next: boolean): T | undefined; + setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void; /** * Get the current navigation bearings for a session. */ - getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing; + getNavigationBearing(sessionResource: URI, items?: readonly INavigableSessionComment[]): IAgentFeedbackNavigationBearing; /** * Clear all feedback items for a session (e.g., after sending). @@ -91,7 +100,7 @@ export interface IAgentFeedbackService { * Add a feedback item and then submit the feedback. Waits for the * attachment to be updated in the chat widget before submitting. */ - addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string): Promise; + addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): Promise; } // --- Implementation ----------------------------------------------------------- @@ -117,11 +126,12 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe @IEditorService private readonly _editorService: IEditorService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ICommandService private readonly _commandService: ICommandService, + @ILogService private readonly _logService: ILogService, ) { super(); } - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): IAgentFeedback { const key = sessionResource.toString(); let feedbackItems = this._feedbackBySession.get(key); if (!feedbackItems) { @@ -135,6 +145,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe resourceUri, range, sessionResource, + suggestion, }; // Insert at the correct sorted position. @@ -268,42 +279,52 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } }); setTimeout(() => { - this._navigationAnchorBySession.set(key, feedbackId); - this._onDidChangeNavigation.fire(sessionResource); + this.setNavigationAnchor(sessionResource, feedbackId); }, 50); // delay to ensure editor has revealed the correct position before firing navigation event } getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined { + return this.getNextNavigableItem(sessionResource, this.getFeedback(sessionResource), next); + } + + getNextNavigableItem(sessionResource: URI, items: readonly T[], next: boolean): T | undefined { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key); - if (!feedbackItems?.length) { + if (!items.length) { this._navigationAnchorBySession.delete(key); return undefined; } const anchorId = this._navigationAnchorBySession.get(key); - let anchorIndex = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + let anchorIndex = anchorId ? items.findIndex(item => item.id === anchorId) : -1; if (anchorIndex < 0 && !next) { anchorIndex = 0; } const nextIndex = next - ? (anchorIndex + 1) % feedbackItems.length - : (anchorIndex - 1 + feedbackItems.length) % feedbackItems.length; + ? (anchorIndex + 1) % items.length + : (anchorIndex - 1 + items.length) % items.length; + + const item = items[nextIndex]; + this.setNavigationAnchor(sessionResource, item.id); + return item; + } - const feedback = feedbackItems[nextIndex]; - this._navigationAnchorBySession.set(key, feedback.id); + setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void { + const key = sessionResource.toString(); + if (itemId) { + this._navigationAnchorBySession.set(key, itemId); + } else { + this._navigationAnchorBySession.delete(key); + } this._onDidChangeNavigation.fire(sessionResource); - return feedback; } - getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing { + getNavigationBearing(sessionResource: URI, items: readonly INavigableSessionComment[] = this._feedbackBySession.get(sessionResource.toString()) ?? []): IAgentFeedbackNavigationBearing { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key) ?? []; const anchorId = this._navigationAnchorBySession.get(key); - const activeIdx = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; - return { activeIdx, totalCount: feedbackItems.length }; + const activeIdx = anchorId ? items.findIndex(item => item.id === anchorId) : -1; + return { activeIdx, totalCount: items.length }; } clearFeedback(sessionResource: URI): void { @@ -315,8 +336,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] }); } - async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string): Promise { - this.addFeedback(sessionResource, resourceUri, range, text); + async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): Promise { + this.addFeedback(sessionResource, resourceUri, range, text, suggestion); // Wait for the attachment contribution to update the chat widget's attachment model const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); @@ -330,10 +351,14 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe ); } } else { - // This should not normally happen, but if the widget isn't found, wait a bit to give it a chance to initialize before submitting. + this._logService.error('[AgentFeedback] addFeedbackAndSubmit: no chat widget found for session, feedback may not be submitted correctly', sessionResource.toString()); await new Promise(resolve => setTimeout(resolve, 100)); } - await this._commandService.executeCommand('agentFeedbackEditor.action.submit'); + try { + await this._commandService.executeCommand('agentFeedbackEditor.action.submit'); + } catch (err) { + this._logService.error('[AgentFeedback] Failed to execute submit feedback command', err); + } } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css index 34725117ebedf..8eed23bc26c7d 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css @@ -152,6 +152,7 @@ border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); cursor: pointer; position: relative; + gap: 6px; } .agent-feedback-widget-item:last-child { @@ -167,12 +168,61 @@ color: var(--vscode-list-activeSelectionForeground); } +.agent-feedback-widget-item-codeReview { + box-shadow: inset 2px 0 0 var(--vscode-editorWarning-foreground); +} + +.agent-feedback-widget-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.agent-feedback-widget-item-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.agent-feedback-widget-item-actions { + margin-left: auto; + flex: 0 0 auto; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.agent-feedback-widget-item:hover .agent-feedback-widget-item-actions { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.agent-feedback-widget-item-type { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.2px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-item-type { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 22%, transparent); + color: var(--vscode-editorWarning-foreground); +} + /* Line info */ .agent-feedback-widget-line-info { font-size: 10px; font-weight: 600; color: var(--vscode-descriptionForeground); - margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; } @@ -183,6 +233,46 @@ word-wrap: break-word; } +.agent-feedback-widget-suggestion { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 12%, transparent); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-suggestion { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 10%, transparent); +} + +.agent-feedback-widget-suggestion-title, +.agent-feedback-widget-suggestion-range { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-suggestion-edit { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-feedback-widget-suggestion-text { + margin: 0; + padding: 6px 8px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; + font-family: monospace; + font-size: 11px; + line-height: 1.45; + background: color-mix(in srgb, var(--vscode-editor-background) 65%, transparent); +} + /* Gutter decoration for range indicator on hover */ .agent-feedback-widget-range-glyph { margin-left: 8px; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts new file mode 100644 index 0000000000000..f5e740cbd98b4 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentFeedback } from './agentFeedbackService.js'; +import { CodeReviewStateKind, ICodeReviewComment, ICodeReviewState, ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; + +export const enum SessionEditorCommentSource { + AgentFeedback = 'agentFeedback', + CodeReview = 'codeReview', +} + +export interface ISessionEditorComment { + readonly id: string; + readonly sourceId: string; + readonly source: SessionEditorCommentSource; + readonly sessionResource: URI; + readonly resourceUri: URI; + readonly range: IRange; + readonly text: string; + readonly suggestion?: ICodeReviewSuggestion; + readonly severity?: string; + readonly canConvertToAgentFeedback: boolean; +} + +export function getCodeReviewComments(reviewState: ICodeReviewState): readonly ICodeReviewComment[] { + return reviewState.kind === CodeReviewStateKind.Result ? reviewState.comments : []; +} + +export function getSessionEditorComments( + sessionResource: URI, + agentFeedbackItems: readonly IAgentFeedback[], + reviewState: ICodeReviewState, +): readonly ISessionEditorComment[] { + const comments: ISessionEditorComment[] = []; + + for (const item of agentFeedbackItems) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: item.resourceUri, + range: item.range, + text: item.text, + suggestion: item.suggestion, + canConvertToAgentFeedback: false, + }); + } + + for (const item of getCodeReviewComments(reviewState)) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.CodeReview, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.CodeReview, + sessionResource, + resourceUri: item.uri, + range: item.range, + text: item.body, + suggestion: item.suggestion, + severity: item.severity, + canConvertToAgentFeedback: true, + }); + } + + comments.sort(compareSessionEditorComments); + return comments; +} + +export function compareSessionEditorComments(a: ISessionEditorComment, b: ISessionEditorComment): number { + return a.resourceUri.toString().localeCompare(b.resourceUri.toString()) + || Range.compareRangesUsingStarts(Range.lift(a.range), Range.lift(b.range)) + || a.source.localeCompare(b.source) + || a.sourceId.localeCompare(b.sourceId); +} + +export function groupNearbySessionEditorComments(items: readonly ISessionEditorComment[], lineThreshold: number = 5): ISessionEditorComment[][] { + if (items.length === 0) { + return []; + } + + const sorted = [...items].sort(compareSessionEditorComments); + const groups: ISessionEditorComment[][] = []; + let currentGroup: ISessionEditorComment[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const firstItem = currentGroup[0]; + const currentItem = sorted[i]; + + const sameResource = currentItem.resourceUri.toString() === firstItem.resourceUri.toString(); + const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber; + + if (sameResource && verticalSpan <= lineThreshold) { + currentGroup.push(currentItem); + } else { + groups.push(currentGroup); + currentGroup = [currentItem]; + } + } + + groups.push(currentGroup); + return groups; +} + +export function getResourceEditorComments(resourceUri: URI, comments: readonly ISessionEditorComment[]): readonly ISessionEditorComment[] { + const resource = resourceUri.toString(); + return comments.filter(comment => comment.resourceUri.toString() === resource); +} + +export function toSessionEditorCommentId(source: SessionEditorCommentSource, sourceId: string): string { + return `${source}:${sourceId}`; +} + +export function hasAgentFeedbackComments(comments: readonly ISessionEditorComment[]): boolean { + return comments.some(comment => comment.source === SessionEditorCommentSource.AgentFeedback); +} diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts new file mode 100644 index 0000000000000..2314f52bc38b5 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { toAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AgentFeedbackOverlayWidget } from '../../browser/agentFeedbackEditorOverlay.js'; +import { clearAllFeedbackActionId, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from '../../browser/agentFeedbackEditorActions.js'; + +interface INavigationBearings { + readonly activeIdx: number; + readonly totalCount: number; +} + +interface IFixtureOptions { + readonly navigationBearings: INavigationBearings; + readonly hasAgentFeedbackActions?: boolean; +} + +class FixtureMenuService implements IMenuService { + constructor(private readonly _hasAgentFeedbackActions: boolean) { + } + + declare readonly _serviceBrand: undefined; + + createMenu(_id: MenuId): IMenu { + const navigateActions = [ + toAction({ id: navigationBearingFakeActionId, label: 'Navigation Status', run: () => { } }), + toAction({ id: navigatePreviousFeedbackActionId, label: 'Previous', class: 'codicon codicon-arrow-up', run: () => { } }), + toAction({ id: navigateNextFeedbackActionId, label: 'Next', class: 'codicon codicon-arrow-down', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[]; + + const submitActions = this._hasAgentFeedbackActions + ? [ + toAction({ id: submitFeedbackActionId, label: 'Submit', class: 'codicon codicon-send', run: () => { } }), + toAction({ id: clearAllFeedbackActionId, label: 'Clear', class: 'codicon codicon-clear-all', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[] + : []; + + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => submitActions.length > 0 + ? [ + ['navigate', navigateActions], + ['a_submit', submitActions], + ] + : [ + ['navigate', navigateActions], + ], + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.classList.add('monaco-workbench'); + context.container.style.width = '420px'; + context.container.style.height = '64px'; + context.container.style.padding = '12px'; + context.container.style.background = 'var(--vscode-editor-background)'; + + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IMenuService, new FixtureMenuService(options.hasAgentFeedbackActions ?? true)); + registerWorkbenchServices(reg); + }, + }); + + const widget = scopedDisposables.add(instantiationService.createInstance(AgentFeedbackOverlayWidget)); + widget.show(options.navigationBearings); + context.container.appendChild(widget.getDomNode()); +} + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + ZeroOfZero: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 0 }, + hasAgentFeedbackActions: false, + }), + }), + + SingleFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 1 }, + }), + }), + + FirstOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 3 }, + }), + }), + + ReviewOnlyTwoComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 2 }, + hasAgentFeedbackActions: false, + }), + }), + + MiddleOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 1, totalCount: 3 }, + }), + }), + + MixedFourComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 4 }, + hasAgentFeedbackActions: true, + }), + }), + + LastOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 3 }, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts new file mode 100644 index 0000000000000..818c87c9a367e --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -0,0 +1,346 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Color } from '../../../../../base/common/color.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { AgentFeedbackEditorWidget } from '../../browser/agentFeedbackEditorWidgetContribution.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { CodeReviewStateKind, ICodeReviewService, ICodeReviewState, ICodeReviewSuggestion } from '../../../codeReview/browser/codeReviewService.js'; +import { ISessionEditorComment, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; + +const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); +const fileResource = URI.parse('inmemory://model/agent-feedback-widget.ts'); + +const sampleCode = [ + 'function alpha() {', + '\tconst first = 1;', + '\treturn first;', + '}', + '', + 'function beta() {', + '\tconst second = 2;', + '\tconst third = second + 1;', + '\treturn third;', + '}', + '', + 'function gamma() {', + '\tconst done = true;', + '\treturn done;', + '}', +].join('\n'); + +interface IFixtureOptions { + readonly expanded?: boolean; + readonly focusedCommentId?: string; + readonly hidden?: boolean; + readonly commentItems: readonly ISessionEditorComment[]; +} + +function createRange(startLineNumber: number, endLineNumber: number = startLineNumber): IRange { + return { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; +} + +function createFeedbackComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + return { + id: `agentFeedback:${id}`, + sourceId: id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: fileResource, + range: createRange(startLineNumber, endLineNumber), + text, + suggestion, + canConvertToAgentFeedback: false, + }; +} + +function createReviewComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + const range: IRange = { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; + + return { + id: `codeReview:${id}`, + sourceId: id, + source: SessionEditorCommentSource.CodeReview, + text, + resourceUri: fileResource, + range, + sessionResource, + suggestion, + severity: 'warning', + canConvertToAgentFeedback: true, + }; +} + +function createMockAgentFeedbackService(): IAgentFeedbackService { + return new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + + override addFeedback(): IAgentFeedback { + throw new Error('Not implemented for fixture'); + } + + override removeFeedback(): void { } + + override getFeedback(): readonly IAgentFeedback[] { + return []; + } + + override getMostRecentSessionForResource(): URI | undefined { + return undefined; + } + + override async revealFeedback(): Promise { } + + override getNextFeedback(): IAgentFeedback | undefined { + return undefined; + } + + override getNavigationBearing() { + return { activeIdx: -1, totalCount: 0 }; + } + + override getNextNavigableItem() { + return undefined; + } + + override setNavigationAnchor(): void { } + + override clearFeedback(): void { } + + override async addFeedbackAndSubmit(): Promise { } + }(); +} + +function createMockCodeReviewService(): ICodeReviewService { + return new class extends mock() { + private readonly _state = observableValue('fixture.reviewState', { kind: CodeReviewStateKind.Idle }); + + override getReviewState() { + return this._state; + } + + override hasReview(): boolean { + return false; + } + + override requestReview(): void { } + + override removeComment(): void { } + + override dismissReview(): void { } + }(); +} + +function ensureTokenColorMap(): void { + if (TokenizationRegistry.getColorMap()?.length) { + return; + } + + const colorMap = [ + Color.fromHex('#000000'), + Color.fromHex('#d4d4d4'), + Color.fromHex('#9cdcfe'), + Color.fromHex('#ce9178'), + Color.fromHex('#b5cea8'), + Color.fromHex('#4fc1ff'), + Color.fromHex('#c586c0'), + Color.fromHex('#569cd6'), + Color.fromHex('#dcdcaa'), + Color.fromHex('#f44747'), + ]; + + TokenizationRegistry.setColorMap(colorMap); +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '420px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const instantiationService = createEditorServices(scopedDisposables, { colorTheme: context.theme }); + const model = scopedDisposables.add(createTextModel(instantiationService, sampleCode, fileResource, 'typescript')); + + const editorOptions: ICodeEditorWidgetOptions = { + contributions: [], + }; + + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: true, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + editorOptions + )); + + editor.setModel(model); + + const widget = scopedDisposables.add(new AgentFeedbackEditorWidget( + editor, + options.commentItems, + sessionResource, + createMockAgentFeedbackService(), + createMockCodeReviewService(), + )); + + widget.layout(options.commentItems[0].range.startLineNumber); + + if (options.expanded) { + widget.expand(); + } + + if (options.focusedCommentId) { + widget.focusFeedback(options.focusedCommentId); + } + + if (options.hidden) { + const domNode = widget.getDomNode(); + domNode.style.transition = 'none'; + domNode.style.animation = 'none'; + widget.toggle(false); + } +} + +const singleFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), +]; + +const groupedFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createFeedbackComment('f-2', 'This return statement can be simplified.', 3), + createFeedbackComment('f-3', 'Consider documenting why this branch is needed.', 6, 8), +]; + +const reviewOnly = [ + createReviewComment('r-1', 'Handle the null case before returning here.', 7), + createReviewComment('r-2', 'This branch needs a stronger explanation.', 8), +]; + +const mixedComments = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createReviewComment('r-1', 'This should be extracted into a helper.', 3), + createFeedbackComment('f-2', 'Consider renaming this for readability.', 4), +]; + +const reviewSuggestion: ICodeReviewSuggestion = { + edits: [ + { range: createRange(8), oldText: '\tconst third = second + 1;', newText: '\tconst third = second + computeOffset();' }, + ], +}; + +const suggestionMix = [ + createReviewComment('r-3', 'Prefer using the helper so the intent is explicit.', 8, 8, reviewSuggestion), + createFeedbackComment('f-3', 'Keep the helper name aligned with the domain concept.', 9), +]; + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + CollapsedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + }), + }), + + ExpandedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + expanded: true, + }), + }), + + CollapsedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + }), + }), + + ExpandedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + }), + }), + + ExpandedFocusedFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + focusedCommentId: 'agentFeedback:f-2', + }), + }), + + ExpandedReviewOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: reviewOnly, + expanded: true, + }), + }), + + ExpandedMixedComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + }), + }), + + ExpandedFocusedReviewComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + focusedCommentId: 'codeReview:r-1', + }), + }), + + ExpandedReviewSuggestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: suggestionMix, + expanded: true, + }), + }), + + HiddenWidget: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + hidden: true, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts new file mode 100644 index 0000000000000..dec6e0ce3aa50 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CodeReviewStateKind, ICodeReviewState } from '../../../codeReview/browser/codeReviewService.js'; +import { getResourceEditorComments, getSessionEditorComments, groupNearbySessionEditorComments, hasAgentFeedbackComments, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; + +type ICodeReviewResultState = Extract; + +suite('SessionEditorComments', () => { + const session = URI.parse('test://session/1'); + const fileA = URI.parse('file:///a.ts'); + const fileB = URI.parse('file:///b.ts'); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function reviewState(comments: ICodeReviewResultState['comments']): ICodeReviewState { + return { + kind: CodeReviewStateKind.Result, + version: 'v1', + comments, + }; + } + + test('merges and sorts feedback and review comments by resource and range', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-b', text: 'feedback b', resourceUri: fileB, range: new Range(8, 1, 8, 1), sessionResource: session }, + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(12, 1, 12, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(3, 1, 3, 1), body: 'review a', kind: 'issue', severity: 'warning' }, + { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + assert.deepStrictEqual(comments.map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/a.ts:3:codeReview', + '/a.ts:12:agentFeedback', + '/b.ts:2:codeReview', + '/b.ts:8:agentFeedback', + ]); + }); + + test('groups nearby comments only within the same resource', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(10, 1, 10, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(13, 1, 13, 1), body: 'review a', kind: 'issue', severity: 'warning' }, + { id: 'review-b', uri: fileB, range: new Range(11, 1, 11, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + const groups = groupNearbySessionEditorComments(comments, 5); + assert.strictEqual(groups.length, 2); + assert.deepStrictEqual(groups[0].map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/a.ts:10:agentFeedback', + '/a.ts:13:codeReview', + ]); + assert.deepStrictEqual(groups[1].map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/b.ts:11:codeReview', + ]); + }); + + test('preserves review suggestion metadata and capability flags', () => { + const comments = getSessionEditorComments(session, [], reviewState([ + { + id: 'review-suggestion', + uri: fileA, + range: new Range(7, 1, 7, 1), + body: 'prefer a constant', + kind: 'suggestion', + severity: 'info', + suggestion: { + edits: [{ range: new Range(7, 1, 7, 10), oldText: 'let value', newText: 'const value' }], + }, + }, + ])); + + assert.strictEqual(comments.length, 1); + assert.strictEqual(comments[0].source, SessionEditorCommentSource.CodeReview); + assert.strictEqual(comments[0].canConvertToAgentFeedback, true); + assert.strictEqual(comments[0].suggestion?.edits[0].newText, 'const value'); + }); + + test('filters resource comments and detects authored feedback presence', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(1, 1, 1, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + assert.strictEqual(hasAgentFeedbackComments(comments), true); + assert.deepStrictEqual(getResourceEditorComments(fileA, comments).map(comment => comment.source), [SessionEditorCommentSource.AgentFeedback]); + assert.deepStrictEqual(getResourceEditorComments(fileB, comments).map(comment => comment.source), [SessionEditorCommentSource.CodeReview]); + }); +}); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index 7e85b4dd447d7..e68286ee17cb0 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -27,9 +27,10 @@ import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { agentIcon, extensionIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, extensionIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon, builtinIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js'; @@ -80,7 +81,7 @@ interface IAICustomizationGroupItem { readonly type: 'group'; readonly id: string; readonly label: string; - readonly storage: PromptsStorage; + readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; readonly icon: ThemeIcon; } @@ -94,7 +95,7 @@ interface IAICustomizationFileItem { readonly uri: URI; readonly name: string; readonly description?: string; - readonly storage: PromptsStorage; + readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; } @@ -234,7 +235,7 @@ class AICustomizationFileRenderer implements ITreeRenderer; + files?: Map; } /** @@ -375,11 +376,13 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource item.storage === PromptsStorage.local); const userItems = allItems.filter(item => item.storage === PromptsStorage.user); const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + const builtinItems = allItems.filter(item => item.storage === BUILTIN_STORAGE); - cached.files = new Map([ + cached.files = new Map([ [PromptsStorage.local, workspaceItems], [PromptsStorage.user, userItems], [PromptsStorage.extension, extensionItems], + [BUILTIN_STORAGE, builtinItems], ]); const itemCount = allItems.length; @@ -390,6 +393,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceItems.length)); @@ -400,6 +404,9 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionItems.length)); } + if (builtinItems.length > 0) { + groups.push(this.createGroupItem(promptType, BUILTIN_STORAGE, builtinItems.length)); + } return groups; } @@ -407,26 +414,29 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource = { + private createGroupItem(promptType: PromptsType, storage: AICustomizationPromptsStorage, count: number): IAICustomizationGroupItem { + const storageLabels: Record = { [PromptsStorage.local]: localize('workspaceWithCount', "Workspace ({0})", count), [PromptsStorage.user]: localize('userWithCount', "User ({0})", count), [PromptsStorage.extension]: localize('extensionsWithCount', "Extensions ({0})", count), [PromptsStorage.plugin]: localize('pluginsWithCount', "Plugins ({0})", count), + [BUILTIN_STORAGE]: localize('builtinWithCount', "Built-in ({0})", count), }; - const storageIcons: Record = { + const storageIcons: Record = { [PromptsStorage.local]: workspaceIcon, [PromptsStorage.user]: userIcon, [PromptsStorage.extension]: extensionIcon, [PromptsStorage.plugin]: pluginIcon, + [BUILTIN_STORAGE]: builtinIcon, }; - const storageSuffixes: Record = { + const storageSuffixes: Record = { [PromptsStorage.local]: 'workspace', [PromptsStorage.user]: 'user', [PromptsStorage.extension]: 'extensions', [PromptsStorage.plugin]: 'plugins', + [BUILTIN_STORAGE]: 'builtin', }; return { @@ -443,7 +453,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource { + private async getFilesForStorageAndType(storage: AICustomizationPromptsStorage, promptType: PromptsType): Promise { const cached = this.cache.get(promptType); // For skills, use the cached skills data @@ -602,7 +612,7 @@ export class AICustomizationViewPane extends ViewPane { this.treeDisposables.add(this.tree.onDidOpen(async e => { if (e.element && e.element.type === 'file') { this.editorService.openEditor({ - resource: e.element.uri + resource: e.element.uri, }); } else if (e.element && e.element.type === 'link') { const input = AICustomizationManagementEditorInput.getOrCreate(); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index 1ef5586817f44..da1e460a2970c 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -47,7 +47,7 @@ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { chatEditingWidgetFileStateContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; @@ -57,6 +57,7 @@ import { IExtensionService } from '../../../../workbench/services/extensions/com import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; +import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; const $ = dom.$; @@ -64,6 +65,7 @@ const $ = dom.$; export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; +const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; // --- View Mode @@ -87,6 +89,7 @@ interface IChangesFileItem { readonly changeType: ChangeType; readonly linesAdded: number; readonly linesRemoved: number; + readonly reviewCommentCount: number; } interface IChangesFolderItem { @@ -253,6 +256,7 @@ export class ChangesViewPane extends ViewPane { @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, + @ICodeReviewService private readonly codeReviewService: ICodeReviewService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -449,6 +453,7 @@ export class ChangesViewPane extends ViewPane { changeType: isDeletion ? 'deleted' : 'modified', linesAdded, linesRemoved, + reviewCommentCount: 0, }); } @@ -474,25 +479,54 @@ export class ChangesViewPane extends ViewPane { return model?.changes instanceof Array ? model.changes : Iterable.empty(); }); + const reviewCommentCountByFileObs = derived(reader => { + const sessionResource = activeSessionResource.read(reader); + const sessionChanges = [...sessionFileChangesObs.read(reader)]; + + if (!sessionResource || sessionChanges.length === 0) { + return new Map(); + } + + const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); + + if (reviewState.kind !== CodeReviewStateKind.Result || reviewState.version !== reviewVersion) { + return new Map(); + } + + const result = new Map(); + for (const comment of reviewState.comments) { + const uriKey = comment.uri.toString(); + result.set(uriKey, (result.get(uriKey) ?? 0) + 1); + } + + return result; + }); + // Convert session file changes to list items (cloud/background sessions) - const sessionFilesObs = derived(reader => - [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + const sessionFilesObs = derived(reader => { + const reviewCommentCountByFile = reviewCommentCountByFileObs.read(reader); + + return [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { const isDeletion = entry.modifiedUri === undefined; const isAddition = entry.originalUri === undefined; + const uri = isIChatSessionFileChange2(entry) + ? entry.modifiedUri ?? entry.uri + : entry.modifiedUri; return { type: 'file', - uri: isIChatSessionFileChange2(entry) - ? entry.modifiedUri ?? entry.uri - : entry.modifiedUri, + uri, originalUri: entry.originalUri, state: ModifiedFileEntryState.Accepted, isDeletion, changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', linesAdded: entry.insertions, linesRemoved: entry.deletions, + reviewCommentCount: reviewCommentCountByFile.get(uri.toString()) ?? 0, }; - }) - ); + }); + }); // Combine both entry sources for display const combinedEntriesObs = derived(reader => { @@ -596,6 +630,9 @@ export class ChangesViewPane extends ViewPane { ); return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; } + if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) { + return { showIcon: true, showLabel: false, isSecondary: true }; + } if (action.id === 'chatEditing.synchronizeChanges') { return { showIcon: true, showLabel: true, isSecondary: true }; } @@ -859,6 +896,7 @@ interface IChangesTreeTemplate { readonly templateDisposables: DisposableStore; readonly toolbar: MenuWorkbenchToolBar | undefined; readonly contextKeyService: IContextKeyService | undefined; + readonly reviewCommentsBadge: HTMLElement; readonly decorationBadge: HTMLElement; readonly addedSpan: HTMLElement; readonly removedSpan: HTMLElement; @@ -881,6 +919,9 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer, _index: number, templateData: IChangesTreeTemplate): void { @@ -933,6 +974,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer 0) { + templateData.reviewCommentsBadge.style.display = ''; + templateData.reviewCommentsBadge.className = 'changes-review-comments-badge'; + templateData.reviewCommentsBadge.replaceChildren( + dom.$('.codicon.codicon-comment-unresolved'), + dom.$('span', undefined, `${data.reviewCommentCount}`) + ); + } else { + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.reviewCommentsBadge.replaceChildren(); + } + // Update decoration badge (A/M/D) const badge = templateData.decorationBadge; badge.className = 'changes-decoration-badge'; @@ -996,6 +1050,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; @@ -285,7 +283,7 @@ export class FolderPicker extends Disposable { } dom.clearNode(trigger); - const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + const folderUri = this._selectedFolderUri; const label = folderUri ? basename(folderUri) : localize('pickFolder', "Pick Folder"); dom.append(trigger, renderIcon(Codicon.folder)); diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 50815341dbb9a..b033d5236522e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -669,7 +669,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._focusEditor(); }, getModels: () => this._getAvailableModels(), - canManageModels: () => false, + canManageModels: () => true, }; const pickerOptions: IChatInputPickerOptions = { diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index f226fd20410a8..45db75ebadd74 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -8,21 +8,28 @@ import { PromptFilesLocator } from '../../../../workbench/contrib/chat/common/pr import { Event } from '../../../../base/common/event.js'; import { basename, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { FileAccess } from '../../../../base/common/network.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { HOOKS_SOURCE_FOLDER } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { HOOKS_SOURCE_FOLDER, getCleanPromptName } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptPath, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE, IBuiltinPromptPath } from '../../chat/common/builtinPromptsStorage.js'; import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +/** URI root for built-in prompts bundled with the Sessions app. */ +export const BUILTIN_PROMPTS_URI = FileAccess.asFileUri('vs/sessions/prompts'); + export class AgenticPromptsService extends PromptsService { private _copilotRoot: URI | undefined; + private _builtinPromptsCache: Map> | undefined; protected override createPromptFilesLocator(): PromptFilesLocator { return this.instantiationService.createInstance(AgenticPromptFilesLocator); @@ -36,6 +43,76 @@ export class AgenticPromptsService extends PromptsService { return this._copilotRoot; } + /** + * Returns built-in prompt files bundled with the Sessions app. + */ + private async getBuiltinPromptFiles(type: PromptsType): Promise { + if (type !== PromptsType.prompt) { + return []; + } + + if (!this._builtinPromptsCache) { + this._builtinPromptsCache = new Map(); + } + + let cached = this._builtinPromptsCache.get(type); + if (!cached) { + cached = this.discoverBuiltinPrompts(type); + this._builtinPromptsCache.set(type, cached); + } + return cached; + } + + private async discoverBuiltinPrompts(type: PromptsType): Promise { + const fileService = this.instantiationService.invokeFunction(accessor => accessor.get(IFileService)); + const promptsDir = FileAccess.asFileUri('vs/sessions/prompts'); + try { + const stat = await fileService.resolve(promptsDir); + if (!stat.children) { + return []; + } + return stat.children + .filter(child => !child.isDirectory && child.name.endsWith('.prompt.md')) + .map(child => ({ uri: child.resource, storage: BUILTIN_STORAGE, type })); + } catch { + return []; + } + } + + /** + * Override to include built-in prompts and filter out those overridden + * by user or workspace prompts with the same name. + */ + public override async listPromptFiles(type: PromptsType, token: CancellationToken): Promise { + const baseResults = await super.listPromptFiles(type, token); + const builtinPrompts = await this.getBuiltinPromptFiles(type); + if (builtinPrompts.length === 0) { + return baseResults; + } + + // Collect names of user/workspace prompts to detect overrides + const overriddenNames = new Set(); + for (const p of baseResults) { + if (p.storage === PromptsStorage.local || p.storage === PromptsStorage.user) { + overriddenNames.add(getCleanPromptName(p.uri)); + } + } + + const nonOverridden = builtinPrompts.filter( + p => !overriddenNames.has(getCleanPromptName(p.uri)) + ); + // Built-in items use BUILTIN_STORAGE ('builtin') which is not in the + // core IPromptPath union but is handled by the sessions UI layer. + return [...baseResults, ...nonOverridden] as readonly IPromptPath[]; + } + + public override async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { + if (storage === BUILTIN_STORAGE) { + return this.getBuiltinPromptFiles(type) as Promise; + } + return super.listPromptFilesForStorage(type, storage, token); + } + /** * Override to use ~/.copilot as the user-level source folder for creation, * instead of the VS Code profile's promptsHome. diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts new file mode 100644 index 0000000000000..fb3efadd52f41 --- /dev/null +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; + +/** + * Extended storage type for AI Customization that includes built-in prompts + * shipped with the application, alongside the core `PromptsStorage` values. + */ +export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; + +/** + * Storage type discriminator for built-in prompts shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + +/** + * Prompt path for built-in prompts bundled with the Sessions app. + */ +export interface IBuiltinPromptPath { + readonly uri: URI; + readonly storage: AICustomizationPromptsStorage; + readonly type: PromptsType; +} diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts new file mode 100644 index 0000000000000..805ad59cca094 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService } from './codeReviewService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; + +registerSingleton(ICodeReviewService, CodeReviewService, InstantiationType.Delayed); + +const canRunSessionCodeReviewContextKey = new RawContextKey('sessions.canRunCodeReview', true, { + type: 'boolean', + description: localize('sessions.canRunCodeReview', "True when a new code review can be started for the active session version."), +}); + +function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disposable { + class RunSessionCodeReviewAction extends Action2 { + static readonly ID = 'sessions.codeReview.run'; + + constructor() { + super({ + id: RunSessionCodeReviewAction.ID, + title: localize('sessions.runCodeReview', "Run Code Review"), + tooltip, + category: CHAT_CATEGORY, + icon, + precondition: canRunSessionCodeReviewContextKey, + menu: [ + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 7, + when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), + }, + ], + }); + } + + override async run(accessor: ServicesAccessor, sessionResource?: URI): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const agentSessionsService = accessor.get(IAgentSessionsService); + const codeReviewService = accessor.get(ICodeReviewService); + + const resource = URI.isUri(sessionResource) + ? sessionResource + : sessionManagementService.getActiveSession()?.resource; + + if (!resource) { + return; + } + + const session = agentSessionsService.getSession(resource); + if (!(session?.changes instanceof Array) || session.changes.length === 0) { + return; + } + + const files = getCodeReviewFilesFromSessionChanges(session.changes); + const version = getCodeReviewVersion(files); + + codeReviewService.requestReview(resource, version, files); + } + } + + return registerAction2(RunSessionCodeReviewAction) as Disposable; +} + +class CodeReviewToolbarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.codeReviewToolbar'; + + private readonly _actionRegistration = this._register(new MutableDisposable()); + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, + ) { + super(); + + const canRunCodeReviewContext = canRunSessionCodeReviewContextKey.bindTo(contextKeyService); + const sessionsChangedSignal = observableFromEvent(this, this._agentSessionsService.model.onDidChangeSessions, () => undefined); + + this._register(autorun(reader => { + const activeSession = this._sessionManagementService.activeSession.read(reader); + sessionsChangedSignal.read(reader); + this._actionRegistration.clear(); + + const sessionResource = activeSession?.resource; + if (!sessionResource) { + canRunCodeReviewContext.set(false); + this._actionRegistration.value = registerSessionCodeReviewAction(localize('sessions.runCodeReview.noSession', "No active session available for code review."), Codicon.codeReview); + return; + } + + const session = this._agentSessionsService.getSession(sessionResource); + if (!(session?.changes instanceof Array) || session.changes.length === 0) { + canRunCodeReviewContext.set(false); + this._actionRegistration.value = registerSessionCodeReviewAction(localize('sessions.runCodeReview.noChanges', "No changes available for code review."), Codicon.codeReview); + return; + } + + const files = getCodeReviewFilesFromSessionChanges(session.changes); + const version = getCodeReviewVersion(files); + const reviewState = this._codeReviewService.getReviewState(sessionResource).read(reader); + + let canRunCodeReview = true; + let tooltip = localize('sessions.runCodeReview.tooltip.default', "Run Code Review"); + let icon = Codicon.codeReview; + + if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === version) { + canRunCodeReview = false; + tooltip = localize('sessions.runCodeReview.tooltip.loading', "Creating code review..."); + icon = Codicon.commentDraft; + } else if (reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version) { + canRunCodeReview = false; + if (reviewState.comments.length === 0) { + tooltip = localize('sessions.runCodeReview.tooltip.allResolved', "All review comments have been addressed."); + icon = Codicon.comment; + } else { + icon = Codicon.commentUnresolved; + tooltip = reviewState.comments.length === 1 + ? localize('sessions.runCodeReview.tooltip.oneUnresolved', "1 review comment unresolved.") + : localize('sessions.runCodeReview.tooltip.manyUnresolved', "{0} review comments unresolved.", reviewState.comments.length); + } + } + + canRunCodeReviewContext.set(canRunCodeReview); + this._actionRegistration.value = registerSessionCodeReviewAction(tooltip, icon); + })); + } +} + +registerWorkbenchContribution2(CodeReviewToolbarContribution.ID, CodeReviewToolbarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts new file mode 100644 index 0000000000000..acd401cf4e3f6 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -0,0 +1,353 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { hash } from '../../../../base/common/hash.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; + +// --- Types ------------------------------------------------------------------- + +export interface ICodeReviewComment { + readonly id: string; + readonly uri: URI; + readonly range: IRange; + readonly body: string; + readonly kind: string; + readonly severity: string; + readonly suggestion?: ICodeReviewSuggestion; +} + +export interface ICodeReviewSuggestion { + readonly edits: readonly ICodeReviewSuggestionChange[]; +} + +export interface ICodeReviewSuggestionChange { + readonly range: IRange; + readonly newText: string; + readonly oldText: string; +} + +export interface ICodeReviewFile { + readonly currentUri: URI; + readonly baseUri?: URI; +} + +export function getCodeReviewFilesFromSessionChanges(changes: readonly (IChatSessionFileChange | IChatSessionFileChange2)[]): readonly ICodeReviewFile[] { + return changes.map(change => { + if (isIChatSessionFileChange2(change)) { + return { + currentUri: change.modifiedUri ?? change.uri, + baseUri: change.originalUri, + }; + } + + return { + currentUri: change.modifiedUri, + baseUri: change.originalUri, + }; + }); +} + +export function getCodeReviewVersion(files: readonly ICodeReviewFile[]): string { + const stableFileList = files + .map(file => `${file.currentUri.toString()}|${file.baseUri?.toString() ?? ''}`) + .sort(); + + return `v1:${stableFileList.length}:${hash(stableFileList)}`; +} + +export const enum CodeReviewStateKind { + Idle = 'idle', + Loading = 'loading', + Result = 'result', + Error = 'error', +} + +export type ICodeReviewState = + | { readonly kind: CodeReviewStateKind.Idle } + | { readonly kind: CodeReviewStateKind.Loading; readonly version: string } + | { readonly kind: CodeReviewStateKind.Result; readonly version: string; readonly comments: readonly ICodeReviewComment[] } + | { readonly kind: CodeReviewStateKind.Error; readonly version: string; readonly reason: string }; + +/** Shape of a single comment as returned by the code review command. */ +interface IRawCodeReviewComment { + readonly uri: IRawCodeReviewUri; + readonly range: IRawCodeReviewRange; + readonly body?: string; + readonly kind?: string; + readonly severity?: string; + readonly suggestion?: IRawCodeReviewSuggestion; +} + +type IRawCodeReviewUri = URI | UriComponents | string; + +interface IRawCodeReviewPosition { + readonly line?: number; + readonly character?: number; +} + +interface IRawCodeReviewRangeWithPositions { + readonly start?: IRawCodeReviewPosition; + readonly end?: IRawCodeReviewPosition; +} + +interface IRawCodeReviewRangeWithLines { + readonly startLine?: number; + readonly startColumn?: number; + readonly endLine?: number; + readonly endColumn?: number; +} + +type IRawCodeReviewRangeTuple = readonly [IRawCodeReviewPosition, IRawCodeReviewPosition]; + +type IRawCodeReviewRange = IRange | IRawCodeReviewRangeWithPositions | IRawCodeReviewRangeWithLines | IRawCodeReviewRangeTuple; + +interface IRawCodeReviewSuggestion { + readonly edits: readonly IRawCodeReviewSuggestionChange[]; +} + +interface IRawCodeReviewSuggestionChange { + readonly range: IRawCodeReviewRange; + readonly newText: string; + readonly oldText: string; +} + +// --- Service Interface ------------------------------------------------------- + +export const ICodeReviewService = createDecorator('codeReviewService'); + +export interface ICodeReviewService { + readonly _serviceBrand: undefined; + + /** + * Get the observable review state for a session. + */ + getReviewState(sessionResource: URI): IObservable; + + /** + * Synchronously check if a completed review exists for the given session+version. + */ + hasReview(sessionResource: URI, version: string): boolean; + + /** + * Request a code review for the given session. The review is associated with + * a version string (fingerprint of changed files). If a review is already in + * progress or completed for this version, this is a no-op. + */ + requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void; + + /** + * Remove a single comment from the review results. + */ + removeComment(sessionResource: URI, commentId: string): void; + + /** + * Dismiss/clear the review for a session entirely. + */ + dismissReview(sessionResource: URI): void; +} + +// --- Implementation ---------------------------------------------------------- + +interface ISessionReviewData { + readonly state: ReturnType>; +} + +function isRawCodeReviewRangeWithPositions(range: IRawCodeReviewRange): range is IRawCodeReviewRangeWithPositions { + return typeof range === 'object' && range !== null && hasKey(range, { start: true, end: true }); +} + +function isRawCodeReviewRangeTuple(range: IRawCodeReviewRange): range is IRawCodeReviewRangeTuple { + return Array.isArray(range) && range.length >= 2; +} + +function normalizeCodeReviewUri(uri: IRawCodeReviewUri): URI { + return typeof uri === 'string' ? URI.parse(uri) : URI.revive(uri); +} + +function normalizeCodeReviewRange(range: IRawCodeReviewRange): IRange { + if (Range.isIRange(range)) { + return Range.lift(range); + } + + if (isRawCodeReviewRangeTuple(range)) { + const [start, end] = range; + return new Range( + (start.line ?? 0) + 1, + (start.character ?? 0) + 1, + (end.line ?? start.line ?? 0) + 1, + (end.character ?? start.character ?? 0) + 1, + ); + } + + if (isRawCodeReviewRangeWithPositions(range) && range.start && range.end) { + return new Range( + (range.start.line ?? 0) + 1, + (range.start.character ?? 0) + 1, + (range.end.line ?? range.start.line ?? 0) + 1, + (range.end.character ?? range.start.character ?? 0) + 1, + ); + } + + const lineRange = range as IRawCodeReviewRangeWithLines; + return new Range( + (lineRange.startLine ?? 0) + 1, + (lineRange.startColumn ?? 0) + 1, + (lineRange.endLine ?? lineRange.startLine ?? 0) + 1, + (lineRange.endColumn ?? lineRange.startColumn ?? 0) + 1, + ); +} + +function normalizeCodeReviewSuggestion(suggestion: IRawCodeReviewSuggestion | undefined): ICodeReviewSuggestion | undefined { + if (!suggestion) { + return undefined; + } + + return { + edits: suggestion.edits.map(edit => ({ + range: normalizeCodeReviewRange(edit.range), + newText: edit.newText, + oldText: edit.oldText, + })), + }; +} + +export class CodeReviewService extends Disposable implements ICodeReviewService { + + declare readonly _serviceBrand: undefined; + + private readonly _reviewsBySession = new Map(); + + constructor( + @ICommandService private readonly _commandService: ICommandService, + ) { + super(); + } + + getReviewState(sessionResource: URI): IObservable { + return this._getOrCreateData(sessionResource).state; + } + + hasReview(sessionResource: URI, version: string): boolean { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return false; + } + const state = data.state.get(); + return state.kind === CodeReviewStateKind.Result && state.version === version; + } + + requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void { + const data = this._getOrCreateData(sessionResource); + const currentState = data.state.get(); + + // Don't re-request if already loading or completed for this version + if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) { + return; + } + if (currentState.kind === CodeReviewStateKind.Result && currentState.version === version) { + return; + } + + data.state.set({ kind: CodeReviewStateKind.Loading, version }, undefined); + + this._executeReview(sessionResource, version, files, data); + } + + removeComment(sessionResource: URI, commentId: string): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return; + } + + const state = data.state.get(); + if (state.kind !== CodeReviewStateKind.Result) { + return; + } + + const filtered = state.comments.filter(c => c.id !== commentId); + data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, comments: filtered }, undefined); + } + + dismissReview(sessionResource: URI): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (data) { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + } + } + + private _getOrCreateData(sessionResource: URI): ISessionReviewData { + const key = sessionResource.toString(); + let data = this._reviewsBySession.get(key); + if (!data) { + data = { + state: observableValue(`codeReview.state.${key}`, { kind: CodeReviewStateKind.Idle }), + }; + this._reviewsBySession.set(key, data); + } + return data; + } + + private async _executeReview( + sessionResource: URI, + version: string, + files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[], + data: ISessionReviewData, + ): Promise { + try { + const result: { type: string; comments?: IRawCodeReviewComment[]; reason?: string } | undefined = + await this._commandService.executeCommand('chat.internal.codeReview.run', { + files: files.map(f => ({ + currentUri: f.currentUri, + baseUri: f.baseUri, + })), + }); + + // Check if version is still current (hasn't been dismissed or replaced) + const currentState = data.state.get(); + if (currentState.kind !== CodeReviewStateKind.Loading || currentState.version !== version) { + return; + } + + if (!result || result.type === 'cancelled') { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + return; + } + + if (result.type === 'error') { + data.state.set({ kind: CodeReviewStateKind.Error, version, reason: result.reason ?? 'Unknown error' }, undefined); + return; + } + + if (result.type === 'success') { + const comments: ICodeReviewComment[] = (result.comments ?? []).map((raw) => ({ + id: generateUuid(), + uri: normalizeCodeReviewUri(raw.uri), + range: normalizeCodeReviewRange(raw.range), + body: raw.body ?? '', + kind: raw.kind ?? '', + severity: raw.severity ?? '', + suggestion: normalizeCodeReviewSuggestion(raw.suggestion), + })); + + transaction(tx => { + data.state.set({ kind: CodeReviewStateKind.Result, version, comments }, tx); + }); + } + } catch (err) { + const currentState = data.state.get(); + if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) { + data.state.set({ kind: CodeReviewStateKind.Error, version, reason: String(err) }, undefined); + } + } + } +} diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts new file mode 100644 index 0000000000000..76dcc8c138566 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -0,0 +1,661 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { Event } from '../../../../../base/common/event.js'; +import { CodeReviewService, CodeReviewStateKind, ICodeReviewService } from '../../browser/codeReviewService.js'; + +suite('CodeReviewService', () => { + + const store = new DisposableStore(); + let service: ICodeReviewService; + let commandService: MockCommandService; + + let session: URI; + let fileA: URI; + let fileB: URI; + + class MockCommandService implements ICommandService { + declare readonly _serviceBrand: undefined; + readonly onWillExecuteCommand = Event.None; + readonly onDidExecuteCommand = Event.None; + + result: unknown = undefined; + lastCommandId: string | undefined; + lastArgs: unknown[] | undefined; + executeDeferred: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | undefined; + + async executeCommand(commandId: string, ...args: unknown[]): Promise { + this.lastCommandId = commandId; + this.lastArgs = args; + + if (this.executeDeferred) { + return await new Promise((resolve, reject) => { + this.executeDeferred = { resolve: resolve as (v: unknown) => void, reject }; + }); + } + + return this.result as T; + } + + /** + * Configure the mock to defer execution until manually resolved/rejected. + */ + deferNextExecution(): void { + this.executeDeferred = undefined; + const self = this; + const originalResult = this.result; + + // Override executeCommand for next call to capture the deferred promise + const origExecute = this.executeCommand.bind(this); + this.executeCommand = async function (commandId: string, ...args: unknown[]): Promise { + self.lastCommandId = commandId; + self.lastArgs = args; + + return new Promise((resolve, reject) => { + self.executeDeferred = { resolve: resolve as (v: unknown) => void, reject }; + }); + } as typeof origExecute; + + // Restore after use + this._restoreExecute = () => { + this.executeCommand = origExecute; + this.result = originalResult; + }; + } + + private _restoreExecute: (() => void) | undefined; + + resolveExecution(value: unknown): void { + this.executeDeferred?.resolve(value); + this.executeDeferred = undefined; + this._restoreExecute?.(); + } + + rejectExecution(error: unknown): void { + this.executeDeferred?.reject(error); + this.executeDeferred = undefined; + this._restoreExecute?.(); + } + } + + setup(() => { + const instantiationService = store.add(new TestInstantiationService()); + + commandService = new MockCommandService(); + instantiationService.stub(ICommandService, commandService); + + service = store.add(instantiationService.createInstance(CodeReviewService)); + session = URI.parse('test://session/1'); + fileA = URI.parse('file:///a.ts'); + fileB = URI.parse('file:///b.ts'); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // --- getReviewState --- + + test('initial state is idle', () => { + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('getReviewState returns the same observable for the same session', () => { + const obs1 = service.getReviewState(session); + const obs2 = service.getReviewState(session); + assert.strictEqual(obs1, obs2); + }); + + test('getReviewState returns different observables for different sessions', () => { + const session2 = URI.parse('test://session/2'); + const obs1 = service.getReviewState(session); + const obs2 = service.getReviewState(session2); + assert.notStrictEqual(obs1, obs2); + }); + + // --- hasReview --- + + test('hasReview returns false when no review exists', () => { + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + test('hasReview returns false when review is for a different version', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Wait for async command to complete + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + assert.strictEqual(service.hasReview(session, 'v2'), false); + }); + + test('hasReview returns true after successful review', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + }); + + // --- requestReview --- + + test('requestReview transitions to loading state', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + if (state.kind === CodeReviewStateKind.Loading) { + assert.strictEqual(state.version, 'v1'); + } + + // Resolve to avoid leaking + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('requestReview calls command with correct arguments', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [ + { currentUri: fileA, baseUri: fileB }, + { currentUri: fileB }, + ]); + + await tick(); + + assert.strictEqual(commandService.lastCommandId, 'chat.internal.codeReview.run'); + const args = commandService.lastArgs?.[0] as { files: { currentUri: URI; baseUri?: URI }[] }; + assert.strictEqual(args.files.length, 2); + assert.strictEqual(args.files[0].currentUri.toString(), fileA.toString()); + assert.strictEqual(args.files[0].baseUri?.toString(), fileB.toString()); + assert.strictEqual(args.files[1].currentUri.toString(), fileB.toString()); + assert.strictEqual(args.files[1].baseUri, undefined); + }); + + test('requestReview with success populates comments', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: new Range(1, 1, 5, 1), + body: 'Bug found', + kind: 'bug', + severity: 'high', + }, + { + uri: fileB, + range: new Range(10, 1, 15, 1), + body: 'Style issue', + kind: 'style', + severity: 'low', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.comments.length, 2); + assert.strictEqual(state.comments[0].body, 'Bug found'); + assert.strictEqual(state.comments[0].kind, 'bug'); + assert.strictEqual(state.comments[0].severity, 'high'); + assert.strictEqual(state.comments[0].uri.toString(), fileA.toString()); + assert.strictEqual(state.comments[1].body, 'Style issue'); + } + }); + + test('requestReview with error transitions to error state', async () => { + commandService.result = { type: 'error', reason: 'Auth failed' }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Error); + if (state.kind === CodeReviewStateKind.Error) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reason, 'Auth failed'); + } + }); + + test('requestReview with cancelled result transitions to idle', async () => { + commandService.result = { type: 'cancelled' }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('requestReview with undefined result transitions to idle', async () => { + commandService.result = undefined; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('requestReview with thrown error transitions to error state', async () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + commandService.rejectExecution(new Error('Network error')); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Error); + if (state.kind === CodeReviewStateKind.Error) { + assert.ok(state.reason.includes('Network error')); + } + }); + + test('requestReview is a no-op when loading for the same version', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Attempt to request again for the same version + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Should still be loading (not re-triggered) + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('requestReview is a no-op when result exists for the same version', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + // Attempt to request again + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Should still have the result + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + }); + + test('requestReview for a new version replaces loading state', async () => { + // Start v1 review — it will complete immediately with empty result + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + + // Request v2 — since v1 is a different version, it should proceed + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'v2 comment' }] }; + service.requestReview(session, 'v2', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v2'); + assert.strictEqual(state.comments.length, 1); + assert.strictEqual(state.comments[0].body, 'v2 comment'); + } + + // v1 is no longer valid + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + // --- removeComment --- + + test('removeComment removes a specific comment', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + { uri: fileB, range: new Range(10, 1, 10, 1), body: 'comment3' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + const commentToRemove = state.comments[1]; + service.removeComment(session, commentToRemove.id); + + const newState = service.getReviewState(session).get(); + assert.strictEqual(newState.kind, CodeReviewStateKind.Result); + if (newState.kind === CodeReviewStateKind.Result) { + assert.strictEqual(newState.comments.length, 2); + assert.strictEqual(newState.comments[0].body, 'comment1'); + assert.strictEqual(newState.comments[1].body, 'comment3'); + } + }); + + test('removeComment is a no-op for unknown comment id', async () => { + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + service.removeComment(session, 'nonexistent-id'); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments.length, 1); + } + }); + + test('removeComment is a no-op when no review exists', () => { + // Should not throw + service.removeComment(session, 'some-id'); + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('removeComment is a no-op when state is not result', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // State is loading — removeComment should be ignored + service.removeComment(session, 'some-id'); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('removeComment preserves version in result', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + service.removeComment(session, state.comments[0].id); + + const newState = service.getReviewState(session).get(); + if (newState.kind === CodeReviewStateKind.Result) { + assert.strictEqual(newState.version, 'v1'); + } + }); + + // --- dismissReview --- + + test('dismissReview resets to idle', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + service.dismissReview(session); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + }); + + test('dismissReview while loading resets to idle', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Loading); + + service.dismissReview(session); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + + // Resolve the pending command — should be ignored since dismissed + commandService.resolveExecution({ type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'late' }] }); + }); + + test('dismissReview is a no-op when no data exists', () => { + // Should not throw + service.dismissReview(session); + }); + + test('hasReview returns false after dismissReview', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + + service.dismissReview(session); + + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + // --- Isolation between sessions --- + + test('different sessions are independent', async () => { + const session2 = URI.parse('test://session/2'); + + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'session1 comment' }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + commandService.result = { + type: 'success', + comments: [{ uri: fileB, range: new Range(2, 1, 2, 1), body: 'session2 comment' }], + }; + service.requestReview(session2, 'v2', [{ currentUri: fileB }]); + await tick(); + + const state1 = service.getReviewState(session).get(); + const state2 = service.getReviewState(session2).get(); + + assert.strictEqual(state1.kind, CodeReviewStateKind.Result); + assert.strictEqual(state2.kind, CodeReviewStateKind.Result); + + if (state1.kind === CodeReviewStateKind.Result && state2.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state1.comments[0].body, 'session1 comment'); + assert.strictEqual(state2.comments[0].body, 'session2 comment'); + } + + // Dismissing session1 doesn't affect session2 + service.dismissReview(session); + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + assert.strictEqual(service.getReviewState(session2).get().kind, CodeReviewStateKind.Result); + }); + + // --- Comment parsing --- + + test('comments with string URIs are parsed correctly', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: 'file:///parsed.ts', + range: new Range(1, 1, 1, 1), + body: 'parsed comment', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].uri.toString(), 'file:///parsed.ts'); + } + }); + + test('comments with missing optional fields get defaults', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: new Range(1, 1, 1, 1), + // body, kind, severity omitted + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].body, ''); + assert.strictEqual(state.comments[0].kind, ''); + assert.strictEqual(state.comments[0].severity, ''); + assert.strictEqual(state.comments[0].suggestion, undefined); + } + }); + + test('comments normalize VS Code API style ranges', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: { + start: { line: 4, character: 2 }, + end: { line: 6, character: 5 }, + }, + body: 'normalized comment', + suggestion: { + edits: [ + { + range: { + start: { line: 8, character: 1 }, + end: { line: 8, character: 9 }, + }, + oldText: 'let value', + newText: 'const value', + }, + ], + }, + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.deepStrictEqual(state.comments[0].range, new Range(5, 3, 7, 6)); + assert.deepStrictEqual(state.comments[0].suggestion?.edits[0].range, new Range(9, 2, 9, 10)); + } + }); + + test('comments normalize serialized URIs and tuple ranges from API payloads', async () => { + const serializedUri = JSON.parse(JSON.stringify(URI.parse('git:/c%3A/Code/vscode.worktrees/copilot-worktree-2026-03-04T14-44-38/src/vs/sessions/contrib/changesView/test/browser/codeReviewService.test.ts?%7B%22path%22%3A%22c%3A%5C%5CCode%5C%5Cvscode.worktrees%5C%5Ccopilot-worktree-2026-03-04T14-44-38%5C%5Csrc%5C%5Cvs%5C%5Csessions%5C%5Ccontrib%5C%5CchangesView%5C%5Ctest%5C%5Cbrowser%5C%5CcodeReviewService.test.ts%22%2C%22ref%22%3A%22copilot-worktree-2026-03-04T14-44-38%22%7D'))); + + commandService.result = { + type: 'success', + comments: [ + { + uri: serializedUri, + range: [ + { line: 72, character: 2 }, + { line: 72, character: 3 }, + ], + body: 'tuple range comment', + kind: 'bug', + severity: 'medium', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].uri.toString(), URI.revive(serializedUri).toString()); + assert.deepStrictEqual(state.comments[0].range, new Range(73, 3, 73, 4)); + } + }); + + test('each comment gets a unique id', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'a' }, + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'b' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.notStrictEqual(state.comments[0].id, state.comments[1].id); + } + }); + + // --- Observable reactivity --- + + test('observable fires on state transitions', async () => { + const states: string[] = []; + const obs = service.getReviewState(session); + + // Collect initial state + states.push(obs.get().kind); + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + states.push(obs.get().kind); + + commandService.resolveExecution({ type: 'success', comments: [] }); + await tick(); + states.push(obs.get().kind); + + service.dismissReview(session); + states.push(obs.get().kind); + + assert.deepStrictEqual(states, [ + CodeReviewStateKind.Idle, + CodeReviewStateKind.Loading, + CodeReviewStateKind.Result, + CodeReviewStateKind.Idle, + ]); + }); +}); + +function tick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 44fb59656b333..7bb224f596d3c 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -30,6 +30,11 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'git.detectWorktrees': false, 'git.showProgress': false, + 'github.copilot.enable': { + 'markdown': true, + 'plaintext': true, + }, + 'github.copilot.chat.claudeCode.enabled': true, 'github.copilot.chat.cli.branchSupport.enabled': true, 'github.copilot.chat.languageContext.typescript.enabled': true, diff --git a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts index ea04197d42d42..5f6d33c46a351 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts @@ -138,7 +138,13 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP // --- GitHub API private async getAuthToken(): Promise { - const sessions = await this.authenticationService.getSessions('github', ['repo'], { silent: true }) ?? await this.authenticationService.getSessions('github', ['repo'], { createIfNone: true }); + let sessions = await this.authenticationService.getSessions('github', [], { silent: true }); + if (!sessions || sessions.length === 0) { + sessions = await this.authenticationService.getSessions('github', [], { createIfNone: true }); + } + if (!sessions || sessions.length === 0) { + throw createFileSystemProviderError('No GitHub authentication sessions available', FileSystemProviderErrorCode.Unavailable); + } return sessions[0].accessToken ?? ''; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index ad30f5c04ad78..4f6c1951b5144 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -10,6 +10,7 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IFileService } from '../../../../platform/files/common/files.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { parseHooksFromFile } from '../../../../workbench/contrib/chat/common/promptSyntax/hookCompatibility.js'; @@ -20,12 +21,14 @@ export interface ISourceCounts { readonly workspace: number; readonly user: number; readonly extension: number; + readonly builtin: number; } -const storageToCountKey: Partial> = { +const storageToCountKey: Partial> = { [PromptsStorage.local]: 'workspace', [PromptsStorage.user]: 'user', [PromptsStorage.extension]: 'extension', + [BUILTIN_STORAGE]: 'builtin', }; export function getSourceCountsTotal(counts: ISourceCounts, filter: IStorageSourceFilter): number { @@ -129,6 +132,7 @@ export async function getSourceCounts( workspace: filtered.filter(i => i.storage === PromptsStorage.local).length, user: filtered.filter(i => i.storage === PromptsStorage.user).length, extension: filtered.filter(i => i.storage === PromptsStorage.extension).length, + builtin: filtered.filter(i => i.storage === BUILTIN_STORAGE).length, }; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts index 9a3ce3ee94220..02d70905b805c 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -126,31 +126,31 @@ suite('customizationCounts', () => { suite('getSourceCountsTotal', () => { test('sums only visible sources', () => { - const counts = { workspace: 5, user: 3, extension: 2 }; + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 8); }); test('returns 0 for empty sources', () => { - const counts = { workspace: 5, user: 3, extension: 2 }; + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 0); }); test('sums all sources', () => { - const counts = { workspace: 5, user: 3, extension: 2 }; + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 10); }); test('handles single source', () => { - const counts = { workspace: 7, user: 0, extension: 0 }; + const counts = { workspace: 7, user: 0, extension: 0, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.local] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 7); }); test('ignores plugin storage in totals (not in ISourceCounts)', () => { - const counts = { workspace: 1, user: 1, extension: 1 }; + const counts = { workspace: 1, user: 1, extension: 1, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.plugin] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 0); }); @@ -334,7 +334,7 @@ suite('customizationCounts', () => { workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 1 }); + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 1, builtin: 0 }); }); test('empty agents returns all zeros', async () => { @@ -348,7 +348,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); }); }); @@ -386,7 +386,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); }); test('skills filtered by storage source filter', async () => { @@ -450,7 +450,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 0, builtin: 0 }); }); test('all skills are excluded from prompt counts', async () => { @@ -469,7 +469,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); }); }); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 290fa8b309c38..0469553aa779c 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { isEqualOrParent } from '../../../../base/common/extpath.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -16,12 +15,15 @@ import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchCont import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; /** * Returns the cwd URI for the given session: worktree or repository path for @@ -38,17 +40,14 @@ function getSessionCwd(session: IActiveSessionItem | undefined): URI | undefined /** * Manages terminal instances in the sessions window, ensuring: * - A terminal exists for the active session's worktree (or repository if no worktree). - * - A path→instanceId mapping tracks which terminal belongs to which worktree. + * - Terminals are shown/hidden based on their initial cwd matching the active path. * - All terminals for a worktree are closed when the session is archived. */ export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsTerminal'; - /** Maps worktree/repository fsPath (lower-cased) to terminal instance ids. */ - private readonly _pathToInstanceIds = new Map>(); private _activeKey: string | undefined; - private _isCreatingTerminal = false; constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @@ -65,6 +64,20 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._onActiveSessionChanged(session); })); + // Hide restored terminals from a previous window session that don't + // belong to the current active session. These arrive asynchronously + // during reconnection and would otherwise flash in the foreground. + this._register(this._terminalService.onDidCreateInstance(instance => { + if (instance.shellLaunchConfig.attachPersistentProcess && this._activeKey) { + instance.getInitialCwd().then(cwd => { + if (cwd.toLowerCase() !== this._activeKey) { + this._terminalService.moveToBackground(instance); + this._logService.trace(`[SessionsTerminal] Hid restored terminal ${instance.instanceId} (cwd: ${cwd})`); + } + }); + } + })); + // When a session is archived, close all terminals for its worktree this._register(this._agentSessionsService.model.onDidChangeSessionArchivedState(session => { if (session.isArchived()) { @@ -74,59 +87,28 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } } })); - - // Clean up mapping when terminals are disposed - this._register(this._terminalService.onDidDisposeInstance(instance => { - for (const [path, ids] of this._pathToInstanceIds) { - if (ids.delete(instance.instanceId) && ids.size === 0) { - this._pathToInstanceIds.delete(path); - } - } - })); - - // When terminals are created externally, try to relate them to the active session - this._register(this._terminalService.onDidCreateInstance(instance => { - if (this._isCreatingTerminal || this._activeKey === undefined) { - return; - } - // If this instance is already tracked by us, nothing to do - const activeIds = this._pathToInstanceIds.get(this._activeKey); - if (activeIds?.has(instance.instanceId)) { - return; - } - this._tryAdoptTerminal(instance); - })); } /** - * Ensures a terminal exists for the given cwd, reusing an existing one - * from the mapping or creating a new one. Sets it as active and optionally - * focuses it. + * Ensures a terminal exists for the given cwd by scanning all terminal + * instances for a matching initial cwd. If none is found, creates a new + * one. Sets it as active and optionally focuses it. */ - async ensureTerminal(cwd: URI, focus: boolean): Promise { + async ensureTerminal(cwd: URI, focus: boolean): Promise { const key = cwd.fsPath.toLowerCase(); - const ids = this._pathToInstanceIds.get(key); - const existingId = ids ? ids.values().next().value : undefined; - const existing = existingId !== undefined ? this._terminalService.getInstanceFromId(existingId) : undefined; - - if (existing) { - await this._terminalService.showBackgroundTerminal(existing); - this._terminalService.setActiveInstance(existing); - } else { - this._isCreatingTerminal = true; - try { - const instance = await this._terminalService.createTerminal({ config: { cwd } }); - this._addInstanceToPath(key, instance.instanceId); - this._terminalService.setActiveInstance(instance); - this._logService.trace(`[SessionsTerminal] Created terminal ${instance.instanceId} for ${cwd.fsPath}`); - } finally { - this._isCreatingTerminal = false; - } + let existing = await this._findTerminalsForKey(key); + + if (existing.length === 0) { + existing = [await this._terminalService.createTerminal({ config: { cwd } })]; + this._terminalService.setActiveInstance(existing[0]); + this._logService.trace(`[SessionsTerminal] Created terminal ${existing[0].instanceId} for ${cwd.fsPath}`); } if (focus) { await this._terminalService.focusActiveInstance(); } + + return existing; } private async _onActiveSessionChanged(session: IActiveSessionItem | undefined): Promise { @@ -143,139 +125,116 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } this._activeKey = targetKey; - await this.ensureTerminal(targetPath, false); + const instances = await this.ensureTerminal(targetPath, false); // If the active key changed while we were awaiting, a newer call has // taken over — skip the visibility update to avoid flicker. if (this._activeKey !== targetKey) { return; } - this._updateTerminalVisibility(targetKey); + await this._updateTerminalVisibility(targetKey, instances.map(instance => instance.instanceId)); } - private _addInstanceToPath(key: string, instanceId: number): void { - let ids = this._pathToInstanceIds.get(key); - if (!ids) { - ids = new Set(); - this._pathToInstanceIds.set(key, ids); + /** + * Finds the first terminal instance whose initial cwd (lower-cased) matches + * the given key. + */ + private async _findTerminalsForKey(key: string): Promise { + const result: ITerminalInstance[] = []; + for (const instance of this._terminalService.instances) { + try { + const cwd = await instance.getInitialCwd(); + if (cwd.toLowerCase() === key) { + result.push(instance); + } + } catch { + // ignore terminals whose cwd cannot be resolved + } } - ids.add(instanceId); + return result; } /** - * Attempts to associate an externally-created terminal with the active - * session by checking whether its initial cwd falls within the active - * session's worktree or repository. Hides the terminal if it cannot be - * related. + * Shows background terminals whose initial cwd matches the active key and + * hides foreground terminals whose initial cwd does not match. */ - private async _tryAdoptTerminal(instance: ITerminalInstance): Promise { - let cwd: string | undefined; - try { - cwd = await instance.getInitialCwd(); - } catch { - return; - } + private async _updateTerminalVisibility(activeKey: string, forceForegroundTerminalIds: number[]): Promise { + const toShow: ITerminalInstance[] = []; + const toHide: ITerminalInstance[] = []; - if (instance.isDisposed) { - return; - } + for (const instance of [...this._terminalService.instances]) { + let cwd: string | undefined; + try { + cwd = (await instance.getInitialCwd()).toLowerCase(); + } catch { + continue; + } - const activeKey = this._activeKey; - if (!activeKey) { - return; + const isForeground = this._terminalService.foregroundInstances.includes(instance); + const isForceVisible = forceForegroundTerminalIds.includes(instance.instanceId); + const belongsToActiveSession = cwd === activeKey; + if ((belongsToActiveSession || isForceVisible) && !isForeground) { + toShow.push(instance); + } else if (!belongsToActiveSession && !isForceVisible && isForeground) { + toHide.push(instance); + } } - // Re-check tracking — the terminal may have been adopted while awaiting - const activeIds = this._pathToInstanceIds.get(activeKey); - if (activeIds?.has(instance.instanceId)) { - return; + for (const instance of toShow) { + await this._terminalService.showBackgroundTerminal(instance, true); } - - const session = this._sessionsManagementService.activeSession.get(); - if (cwd && this._isRelatedToSession(cwd, session, activeKey)) { - this._addInstanceToPath(activeKey, instance.instanceId); - this._logService.trace(`[SessionsTerminal] Adopted terminal ${instance.instanceId} with cwd ${cwd}`); - } else { + for (const instance of toHide) { this._terminalService.moveToBackground(instance); } - } - - /** - * Returns whether the given cwd falls within the active session's - * worktree, repository, or the current active key (home dir fallback). - */ - private _isRelatedToSession(cwd: string, session: IActiveSessionItem | undefined, activeKey: string): boolean { - if (isEqualOrParent(cwd, activeKey, true)) { - return true; - } - if (session?.providerType === AgentSessionProviders.Background && session.repository) { - return isEqualOrParent(cwd, session.repository.fsPath, true); - } - return false; - } - /** - * Hides all foreground terminals that do not belong to the given active key - * and shows all background terminals that do belong to it. - */ - private _updateTerminalVisibility(activeKey: string): void { - const activeIds = this._pathToInstanceIds.get(activeKey); - - // Hide foreground terminals not belonging to the active session - for (const instance of [...this._terminalService.foregroundInstances]) { - if (!activeIds?.has(instance.instanceId)) { - this._terminalService.moveToBackground(instance); + // Set the terminal with the most recent command as active + const foreground = this._terminalService.foregroundInstances; + let mostRecent: ITerminalInstance | undefined; + let mostRecentTimestamp = -1; + for (const instance of foreground) { + const cmdDetection = instance.capabilities.get(TerminalCapability.CommandDetection); + const lastCmd = cmdDetection?.commands.at(-1); + if (lastCmd && lastCmd.timestamp > mostRecentTimestamp) { + mostRecentTimestamp = lastCmd.timestamp; + mostRecent = instance; } } - - // Show background terminals belonging to the active session - if (activeIds) { - for (const id of activeIds) { - const instance = this._terminalService.getInstanceFromId(id); - if (instance && !this._terminalService.foregroundInstances.includes(instance)) { - this._terminalService.showBackgroundTerminal(instance, true); - } - } + if (mostRecent) { + this._terminalService.setActiveInstance(mostRecent); } } - private _closeTerminalsForPath(fsPath: string): void { + private async _closeTerminalsForPath(fsPath: string): Promise { const key = fsPath.toLowerCase(); - const ids = this._pathToInstanceIds.get(key); - if (ids) { - for (const instanceId of ids) { - const instance = this._terminalService.getInstanceFromId(instanceId); - if (instance) { + for (const instance of [...this._terminalService.instances]) { + try { + const cwd = (await instance.getInitialCwd()).toLowerCase(); + if (cwd === key) { this._terminalService.safeDisposeTerminal(instance); - this._logService.trace(`[SessionsTerminal] Closed archived terminal ${instanceId}`); + this._logService.trace(`[SessionsTerminal] Closed archived terminal ${instance.instanceId}`); } + } catch { + // ignore } - this._pathToInstanceIds.delete(key); } } async dumpTracking(): Promise { - const trackedInstanceIds = new Set(); - - console.log('[SessionsTerminal] === Tracked Terminals ==='); - for (const [key, ids] of this._pathToInstanceIds) { - for (const instanceId of ids) { - trackedInstanceIds.add(instanceId); - const instance = this._terminalService.getInstanceFromId(instanceId); - let cwd = ''; - if (instance) { - try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ } - } - console.log(` ${instanceId} - ${cwd} - ${key}`); - } + console.log(`[SessionsTerminal] Active key: ${this._activeKey ?? ''}`); + console.log('[SessionsTerminal] === All Terminals ==='); + for (const instance of this._terminalService.instances) { + let cwd = ''; + try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ } + const isForeground = this._terminalService.foregroundInstances.includes(instance); + console.log(` ${instance.instanceId} - ${cwd} - ${isForeground ? 'foreground' : 'background'}`); } + } - console.log('[SessionsTerminal] === Untracked Terminals ==='); + async showAllTerminals(): Promise { for (const instance of this._terminalService.instances) { - if (!trackedInstanceIds.has(instance.instanceId)) { - let cwd = ''; - try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ } - console.log(` ${instance.instanceId} - ${cwd}`); + if (!this._terminalService.foregroundInstances.includes(instance)) { + await this._terminalService.showBackgroundTerminal(instance, true); + this._logService.trace(`[SessionsTerminal] Moved terminal ${instance.instanceId} to foreground`); } } } @@ -303,10 +262,12 @@ class OpenSessionInTerminalAction extends Action2 { const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); const sessionsManagementService = _accessor.get(ISessionsManagementService); const pathService = _accessor.get(IPathService); + const viewsService = _accessor.get(IViewsService); const activeSession = sessionsManagementService.activeSession.get(); const cwd = getSessionCwd(activeSession) ?? await pathService.userHome(); await contribution.ensureTerminal(cwd, true); + viewsService.openView(TERMINAL_VIEW_ID); } } @@ -329,3 +290,21 @@ class DumpTerminalTrackingAction extends Action2 { } registerAction2(DumpTerminalTrackingAction); + +class ShowAllTerminalsAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.showAllTerminals', + title: localize2('showAllTerminals', "Show All Terminals"), + f1: true, + }); + } + + override async run(): Promise { + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); + await contribution.showAllTerminals(); + } +} + +registerAction2(ShowAllTerminalsAction); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 7305d4591b558..0d84c3735df21 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -6,13 +6,14 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Emitter } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; +import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; @@ -51,23 +52,36 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT } as IActiveSessionItem; } -function makeTerminalInstance(id: number, cwd: string): ITerminalInstance { +function makeTerminalInstance(id: number, cwd: string): ITerminalInstance & { _testCommandHistory: { timestamp: number }[] } { + const commandHistory: { timestamp: number }[] = []; + const capabilities = { + get(cap: TerminalCapability) { + if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) { + return { commands: commandHistory } as unknown as ICommandDetectionCapability; + } + return undefined; + } + } as ITerminalCapabilityStore; + return { instanceId: id, isDisposed: false, getInitialCwd: () => Promise.resolve(cwd), - } as unknown as ITerminalInstance; + capabilities, + _testCommandHistory: commandHistory, + } as unknown as ITerminalInstance & { _testCommandHistory: { timestamp: number }[] }; } -suite('SessionsTerminalContribution', () => { +function addCommandToInstance(instance: ITerminalInstance, timestamp: number): void { + (instance as ITerminalInstance & { _testCommandHistory: { timestamp: number }[] })._testCommandHistory.push({ timestamp }); +} +suite('SessionsTerminalContribution', () => { const store = new DisposableStore(); let contribution: SessionsTerminalContribution; let activeSessionObs: ReturnType>; let onDidChangeSessionArchivedState: Emitter; - let onDidDisposeInstance: Emitter; - let onDidCreateInstance: Emitter; let createdTerminals: { cwd: URI }[]; let activeInstanceSet: number[]; let focusCalls: number; @@ -93,8 +107,6 @@ suite('SessionsTerminalContribution', () => { activeSessionObs = observableValue('activeSession', undefined); onDidChangeSessionArchivedState = store.add(new Emitter()); - onDidDisposeInstance = store.add(new Emitter()); - onDidCreateInstance = store.add(new Emitter()); instantiationService.stub(ILogService, new NullLogService()); @@ -103,8 +115,10 @@ suite('SessionsTerminalContribution', () => { }); instantiationService.stub(ITerminalService, new class extends mock() { - override onDidDisposeInstance = onDidDisposeInstance.event; - override onDidCreateInstance = onDidCreateInstance.event; + override onDidCreateInstance = Event.None; + override get instances(): readonly ITerminalInstance[] { + return [...terminalInstances.values()]; + } override get foregroundInstances(): readonly ITerminalInstance[] { return [...terminalInstances.values()].filter(i => !backgroundedInstances.has(i.instanceId)); } @@ -115,7 +129,6 @@ suite('SessionsTerminalContribution', () => { const instance = makeTerminalInstance(id, cwdStr); createdTerminals.push({ cwd: opts?.config?.cwd }); terminalInstances.set(id, instance); - onDidCreateInstance.fire(instance); return instance; } override getInstanceFromId(id: number): ITerminalInstance | undefined { @@ -285,7 +298,7 @@ suite('SessionsTerminalContribution', () => { await contribution.ensureTerminal(cwd, false); assert.strictEqual(createdTerminals.length, 1, 'should reuse the existing terminal'); - assert.strictEqual(activeInstanceSet.length, 2, 'should set active instance both times'); + assert.strictEqual(activeInstanceSet.length, 1, 'should only set active instance on creation'); }); test('ensureTerminal creates new terminal for different path', async () => { @@ -315,6 +328,7 @@ suite('SessionsTerminalContribution', () => { worktreePath: worktreeUri.fsPath, }); onDidChangeSessionArchivedState.fire(session); + await tick(); assert.strictEqual(disposedInstances.length, 1); }); @@ -328,6 +342,7 @@ suite('SessionsTerminalContribution', () => { worktreePath: worktreeUri.fsPath, }); onDidChangeSessionArchivedState.fire(session); + await tick(); assert.strictEqual(disposedInstances.length, 0); }); @@ -338,27 +353,11 @@ suite('SessionsTerminalContribution', () => { const session = makeAgentSession({ isArchived: true }); onDidChangeSessionArchivedState.fire(session); + await tick(); assert.strictEqual(disposedInstances.length, 0); }); - // --- onDidDisposeInstance --- - - test('cleans up path mapping when terminal is disposed externally', async () => { - const cwd = URI.file('/test-cwd'); - await contribution.ensureTerminal(cwd, false); - assert.strictEqual(createdTerminals.length, 1); - - // Simulate external disposal of the terminal - const instanceId = activeInstanceSet[0]; - const instance = terminalInstances.get(instanceId)!; - onDidDisposeInstance.fire(instance); - - // Now ensureTerminal should create a new one since the mapping was cleaned up - await contribution.ensureTerminal(cwd, false); - assert.strictEqual(createdTerminals.length, 2, 'should create a new terminal after the old one was disposed'); - }); - // --- switching back to previously used path reuses terminal --- test('switching back to a previously used background path reuses the existing terminal', async () => { @@ -379,7 +378,7 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(createdTerminals.length, 2, 'should reuse the terminal for cwd1'); }); - // --- Terminal visibility management --- + // --- Terminal visibility management (cwd-based) --- test('hides terminals from previous session when switching to a new session', async () => { const cwd1 = URI.file('/cwd1'); @@ -387,8 +386,7 @@ suite('SessionsTerminalContribution', () => { activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); await tick(); - const firstTerminalId = createdTerminals.length; - assert.strictEqual(firstTerminalId, 1); + assert.strictEqual(createdTerminals.length, 1); activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); await tick(); @@ -439,33 +437,33 @@ suite('SessionsTerminalContribution', () => { assert.ok(!backgroundedInstances.has(3), 'terminal for cwd3 should be foreground'); }); - test('hides restored terminals that do not belong to the active session', async () => { - // Set an active session first - const cwd1 = URI.file('/cwd1'); - activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); - await tick(); + test('shows pre-existing terminal with matching cwd instead of creating a new one', async () => { + // Manually add a terminal that already exists with a matching cwd + const cwd = URI.file('/worktree'); + const existingInstance = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(existingInstance.instanceId, existingInstance); + backgroundedInstances.add(existingInstance.instanceId); - // Simulate a terminal being restored (e.g. on startup) that is not tracked - const restoredInstance = makeTerminalInstance(nextInstanceId++, '/some/other/path'); - terminalInstances.set(restoredInstance.instanceId, restoredInstance); - onDidCreateInstance.fire(restoredInstance); + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); await tick(); - // The restored terminal should be moved to background - assert.ok(moveToBackgroundCalls.includes(restoredInstance.instanceId), 'restored terminal should be backgrounded'); + assert.strictEqual(createdTerminals.length, 0, 'should reuse existing terminal, not create a new one'); + assert.ok(showBackgroundCalls.includes(existingInstance.instanceId), 'should show the existing terminal'); }); - test('does not hide restored terminals before any session is active', async () => { - // Simulate a terminal being restored before any session is active - const restoredInstance = makeTerminalInstance(nextInstanceId++, '/some/path'); - terminalInstances.set(restoredInstance.instanceId, restoredInstance); - onDidCreateInstance.fire(restoredInstance); + test('hides pre-existing terminal with non-matching cwd when session changes', async () => { + // Manually add a terminal that already exists with a different cwd + const otherInstance = makeTerminalInstance(nextInstanceId++, '/other/path'); + terminalInstances.set(otherInstance.instanceId, otherInstance); + + const cwd = URI.file('/worktree'); + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); await tick(); - assert.strictEqual(moveToBackgroundCalls.length, 0, 'should not background before any session is active'); + assert.ok(moveToBackgroundCalls.includes(otherInstance.instanceId), 'non-matching terminal should be backgrounded'); }); - test('ensureTerminal shows a backgrounded terminal instead of creating a new one', async () => { + test('ensureTerminal finds a backgrounded terminal instead of creating a new one', async () => { const cwd = URI.file('/test-cwd'); await contribution.ensureTerminal(cwd, false); const instanceId = activeInstanceSet[0]; @@ -473,69 +471,71 @@ suite('SessionsTerminalContribution', () => { // Manually background it backgroundedInstances.add(instanceId); - // ensureTerminal should show it, not create a new one - await contribution.ensureTerminal(cwd, false); + // ensureTerminal should find it by cwd, not create a new one + const result = await contribution.ensureTerminal(cwd, false); assert.strictEqual(createdTerminals.length, 1, 'should not create a new terminal'); - assert.ok(showBackgroundCalls.includes(instanceId), 'should show the backgrounded terminal'); + assert.strictEqual(result[0].instanceId, instanceId, 'should return the existing backgrounded terminal'); }); - // --- Terminal adoption --- + test('visibility is determined by initial cwd, not by stored IDs', async () => { + // Create a terminal externally (not via ensureTerminal) with a known cwd + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + const ext1 = makeTerminalInstance(nextInstanceId++, cwd1.fsPath); + const ext2 = makeTerminalInstance(nextInstanceId++, cwd2.fsPath); + terminalInstances.set(ext1.instanceId, ext1); + terminalInstances.set(ext2.instanceId, ext2); - test('adopts externally-created terminal whose cwd matches the active worktree', async () => { - const worktree = URI.file('/worktree'); - activeSessionObs.set(makeAgentSession({ worktree, providerType: AgentSessionProviders.Background }), undefined); + // Switch to cwd1 — ext1 should stay visible, ext2 should be hidden + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); await tick(); - const externalInstance = makeTerminalInstance(nextInstanceId++, worktree.fsPath); - terminalInstances.set(externalInstance.instanceId, externalInstance); - onDidCreateInstance.fire(externalInstance); + assert.ok(!backgroundedInstances.has(ext1.instanceId), 'ext1 should be foreground (matching cwd)'); + assert.ok(backgroundedInstances.has(ext2.instanceId), 'ext2 should be backgrounded (non-matching cwd)'); + + // Switch to cwd2 — ext2 should be shown, ext1 should be hidden + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); await tick(); - assert.ok(!moveToBackgroundCalls.includes(externalInstance.instanceId), 'should not be hidden'); - // Verify it was adopted — ensureTerminal should reuse it - await contribution.ensureTerminal(worktree, false); - assert.strictEqual(createdTerminals.length, 1, 'should reuse adopted terminal, not create a second'); + assert.ok(backgroundedInstances.has(ext1.instanceId), 'ext1 should now be backgrounded'); + assert.ok(!backgroundedInstances.has(ext2.instanceId), 'ext2 should now be foreground'); }); - test('adopts externally-created terminal whose cwd is a subdirectory of the active worktree', async () => { - const worktree = URI.file('/worktree'); - activeSessionObs.set(makeAgentSession({ worktree, providerType: AgentSessionProviders.Background }), undefined); - await tick(); + // --- Most-recent-command active terminal selection --- - const externalInstance = makeTerminalInstance(nextInstanceId++, URI.file('/worktree/subdir').fsPath); - terminalInstances.set(externalInstance.instanceId, externalInstance); - onDidCreateInstance.fire(externalInstance); - await tick(); + test('sets the terminal with the most recent command as active after visibility update', async () => { + const cwd = URI.file('/worktree'); + const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(t1.instanceId, t1); + terminalInstances.set(t2.instanceId, t2); - assert.ok(!moveToBackgroundCalls.includes(externalInstance.instanceId), 'subdirectory terminal should not be hidden'); - }); + // t1 ran a command at timestamp 100, t2 at timestamp 200 (more recent) + addCommandToInstance(t1, 100); + addCommandToInstance(t2, 200); - test('adopts externally-created terminal whose cwd matches the session repository', async () => { - const worktree = URI.file('/worktree'); - const repo = URI.file('/repo'); - activeSessionObs.set(makeAgentSession({ worktree, repository: repo, providerType: AgentSessionProviders.Background }), undefined); + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); await tick(); - const externalInstance = makeTerminalInstance(nextInstanceId++, repo.fsPath); - terminalInstances.set(externalInstance.instanceId, externalInstance); - onDidCreateInstance.fire(externalInstance); - await tick(); - - assert.ok(!moveToBackgroundCalls.includes(externalInstance.instanceId), 'terminal at repository path should not be hidden'); + // The most recent setActiveInstance call should be for t2 + assert.strictEqual(activeInstanceSet.at(-1), t2.instanceId, 'should set the terminal with the most recent command as active'); }); - test('hides externally-created terminal whose cwd does not match the active session', async () => { - const worktree = URI.file('/worktree'); - activeSessionObs.set(makeAgentSession({ worktree, providerType: AgentSessionProviders.Background }), undefined); - await tick(); + test('does not change active instance when no terminals have command history', async () => { + const cwd = URI.file('/worktree'); + const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(t1.instanceId, t1); + terminalInstances.set(t2.instanceId, t2); + + const activeCountBefore = activeInstanceSet.length; - const externalInstance = makeTerminalInstance(nextInstanceId++, '/unrelated/path'); - terminalInstances.set(externalInstance.instanceId, externalInstance); - onDidCreateInstance.fire(externalInstance); + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); await tick(); - assert.ok(moveToBackgroundCalls.includes(externalInstance.instanceId), 'unrelated terminal should be hidden'); + // No setActiveInstance calls from visibility update since no commands were run + assert.strictEqual(activeInstanceSet.length, activeCountBefore, 'should not call setActiveInstance when no command history exists'); }); }); diff --git a/src/vs/sessions/prompts/create-pr.prompt.md b/src/vs/sessions/prompts/create-pr.prompt.md new file mode 100644 index 0000000000000..28cb057aeeaee --- /dev/null +++ b/src/vs/sessions/prompts/create-pr.prompt.md @@ -0,0 +1,11 @@ +--- +description: Create a pull request for the current session +--- + + +Use the GitHub MCP server to create a pull request — do NOT use the `gh` CLI. + +1. Review all changes in the current session +2. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +3. Write a description covering what changed, why, and anything reviewers should know +4. Create the pull request diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index efe6d190c0dac..6d98af657ae2b 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -206,6 +206,7 @@ import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/codeReview/browser/codeReview.contributions.js'; import './contrib/files/browser/files.contribution.js'; import './contrib/gitSync/browser/gitSync.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 14235489654b6..ffadc12ea9668 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -429,7 +429,7 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_MAXIMIZE MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_LOCK_GROUP_COMMAND_ID, title: localize('lockGroup', "Lock Group"), toggled: ActiveEditorGroupLockedContext }, group: '8_group_operations', order: 10, when: IsAuxiliaryWindowContext.toNegated() /* already a primary action for aux windows */ }); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: ConfigureEditorAction.ID, title: localize('configureEditors', "Configure Editors") }, group: '9_configure', order: 10 }); -function appendEditorToolItem(primary: ICommandAction, when: ContextKeyExpression | undefined, order: number, alternative?: ICommandAction, precondition?: ContextKeyExpression | undefined, enableInCompactMode?: boolean): void { +function appendEditorToolItem(primary: ICommandAction, when: ContextKeyExpression | undefined, order: number, alternative?: ICommandAction, precondition?: ContextKeyExpression | undefined, enableInCompactMode?: boolean, enableInModalMode?: boolean): void { const item: IMenuItem = { command: { id: primary.id, @@ -455,6 +455,9 @@ function appendEditorToolItem(primary: ICommandAction, when: ContextKeyExpressio if (enableInCompactMode) { MenuRegistry.appendMenuItem(MenuId.CompactWindowEditorTitle, item); } + if (enableInModalMode) { + MenuRegistry.appendMenuItem(MenuId.ModalEditorEditorTitle, item); + } } const SPLIT_ORDER = 100000; // towards the end @@ -601,6 +604,7 @@ appendEditorToolItem( 10, undefined, EditorContextKeys.hasChanges, + true, true ); @@ -616,6 +620,7 @@ appendEditorToolItem( 11, undefined, EditorContextKeys.hasChanges, + true, true ); diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index 6f5271cc84ca6..3b95d6bf3101e 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -138,5 +138,13 @@ color: inherit; } } + + .modal-editor-action-separator { + width: 1px; + height: 16px; + margin: 0 4px; + background-color: var(--vscode-titleBar-activeForeground); + opacity: 0.3; + } } } diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 2e8ed96d0109f..4f164d14403a9 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import './media/modalEditorPart.css'; -import { $, addDisposableListener, append, EventHelper, EventType, hide, isHTMLElement, show } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventHelper, EventType, hide, isHTMLElement, setVisibility, show } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { prepareActions } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Button } from '../../../../base/browser/ui/button/button.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'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -231,7 +232,30 @@ export class ModalEditorPart { [IEditorService, modalEditorService] ))); - // Create toolbar + // Create editor toolbar + const editorActionsToolbarContainer = append(actionBarContainer, $('div.modal-editor-editor-actions')); + const editorActionsToolbar = disposables.add(scopedInstantiationService.createInstance(WorkbenchToolBar, editorActionsToolbarContainer, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + highlightToggledItems: true, + })); + + const editorActionsSeparator = append(actionBarContainer, $('div.modal-editor-action-separator')); + const editorActionsDisposables = disposables.add(new DisposableStore()); + const updateEditorActions = () => { + editorActionsDisposables.clear(); + + const editorActions = editorPart.activeGroup.createEditorActions(editorActionsDisposables, MenuId.ModalEditorEditorTitle); + editorActionsDisposables.add(editorActions.onDidChange(() => updateEditorActions())); + + const { primary, secondary } = editorActions.actions; + editorActionsToolbar.setActions(prepareActions(primary), prepareActions(secondary)); + + const hasActions = primary.length > 0 || secondary.length > 0; + setVisibility(hasActions, editorActionsSeparator); + }; + disposables.add(Event.runAndSubscribe(modalEditorService.onDidActiveEditorChange, () => updateEditorActions())); + + // Create global toolbar disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ModalEditorTitle, { hiddenItemStrategy: HiddenItemStrategy.NoHide, highlightToggledItems: true, @@ -259,8 +283,6 @@ export class ModalEditorPart { } else { label.element.clear(); } - - editorPart.notifyActiveEditorChanged(); })); // Handle double-click on header to toggle maximize @@ -379,23 +401,18 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { } private enforceModalPartOptions(): void { - const editorCount = this.groups.reduce((count, group) => count + group.count, 0); this.optionsDisposable.value = this.enforcePartOptions({ - showTabs: editorCount > 1 ? 'multiple' : 'none', + showTabs: 'none', enablePreview: true, closeEmptyGroups: true, - tabActionCloseVisibility: editorCount > 1, - editorActionsLocation: 'default', + tabActionCloseVisibility: false, + editorActionsLocation: 'hidden', tabHeight: 'default', wrapTabs: false, allowDropIntoGroup: false }); } - notifyActiveEditorChanged(): void { - this.enforceModalPartOptions(); - } - updateOptions(options?: IModalEditorPartOptions): void { if (typeof options?.maximized === 'boolean' && options.maximized !== this._maximized) { this.toggleMaximized(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index b5335cbe67611..9fba2ec928cb1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -23,6 +23,7 @@ import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Throttler } from '../../../../../base/common/async.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { ITreeContextMenuEvent } from '../../../../../base/browser/ui/tree/tree.js'; import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; import { Separator } from '../../../../../base/common/actions.js'; @@ -241,7 +242,8 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo const sorter = new AgentSessionsSorter(this.options); const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined; - const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, this.options, approvalModel)); + const activeSessionResource = observableValue(this, undefined); + const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, this.options, approvalModel, activeSessionResource)); const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', @@ -313,10 +315,12 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.focusedAgentSessionArchivedContextKey.set(focused.isArchived()); this.focusedAgentSessionReadContextKey.set(focused.isRead()); this.focusedAgentSessionTypeContextKey.set(focused.providerType); + activeSessionResource.set(focused.resource, undefined); } else { this.focusedAgentSessionArchivedContextKey.reset(); this.focusedAgentSessionReadContextKey.reset(); this.focusedAgentSessionTypeContextKey.reset(); + activeSessionResource.set(undefined, undefined); } const selection = list.getSelection().filter(isAgentSession); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 7d46e899058ed..58b5b41e5ab8e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -43,7 +43,8 @@ import { MarkdownString, IMarkdownString } from '../../../../../base/common/html import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js'; import { AgentSessionProviders, getAgentSessionTime } from './agentSessions.js'; import { AgentSessionsGrouping } from './agentSessionsFilter.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; @@ -115,6 +116,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre constructor( private readonly options: IAgentSessionRendererOptions, private readonly _approvalModel: AgentSessionApprovalModel | undefined, + private readonly _activeSessionResource: IObservable, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IProductService private readonly productService: IProductService, @IHoverService private readonly hoverService: IHoverService, @@ -487,8 +489,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre })); template.approvalButtonContainer.textContent = ''; + const isActive = this._activeSessionResource.read(reader)?.toString() === session.element.resource.toString(); const button = buttonStore.add(new Button(template.approvalButtonContainer, { title: localize('allowActionOnce', "Allow once"), + secondary: isActive, ...defaultButtonStyles })); button.label = localize('allowAction', "Allow"); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index f69370268810d..2d2d087fac375 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -67,6 +67,11 @@ export const extensionIcon = registerIcon('ai-customization-extension', Codicon. */ export const pluginIcon = registerIcon('ai-customization-plugin', Codicon.plug, localize('aiCustomizationPluginIcon', "Icon for plugin-contributed items.")); +/** + * Icon for built-in storage. + */ +export const builtinIcon = registerIcon('ai-customization-builtin', Codicon.starFull, localize('aiCustomizationBuiltinIcon', "Icon for built-in items.")); + /** * Icon for MCP servers. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 8a77a8e52230e..2ae76f14240a5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -19,8 +19,8 @@ import { WorkbenchList } from '../../../../../platform/list/browser/listService. import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon } from './aiCustomizationIcons.js'; -import { AICustomizationManagementItemMenuId, AICustomizationManagementSection } from './aiCustomizationManagement.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; +import { AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE } from './aiCustomizationManagement.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { Delayer } from '../../../../../base/common/async.js'; @@ -889,6 +889,7 @@ export class AICustomizationListWidget extends Disposable { const userItems = allItems.filter(item => item.storage === PromptsStorage.user); const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); const pluginItems = allItems.filter(item => item.storage === PromptsStorage.plugin); + const builtinItems = allItems.filter(item => item.storage === BUILTIN_STORAGE); const mapToListItem = (item: IPromptPath): IAICustomizationListItem => { const filename = basename(item.uri); @@ -909,6 +910,7 @@ export class AICustomizationListWidget extends Disposable { items.push(...userItems.map(mapToListItem)); items.push(...extensionItems.map(mapToListItem)); items.push(...pluginItems.map(mapToListItem)); + items.push(...builtinItems.map(mapToListItem)); } // Apply storage source filter (removes items not in visible sources or excluded user roots) @@ -983,6 +985,7 @@ export class AICustomizationListWidget extends Disposable { { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 6aa4d4ba4d467..e9ed6863b8f5d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -5,12 +5,24 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; // Re-export for convenience — consumers import from this file export { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +/** + * Extended storage type for AI Customization that includes built-in prompts + * shipped with the application, alongside the core `PromptsStorage` values. + */ +export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; + +/** + * Storage type discriminator for built-in prompts shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + /** * Editor pane ID for the AI Customizations Management Editor. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index ed1667fbed95d..82cfd7a06ad6a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -39,6 +39,7 @@ import { AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, AICustomizationManagementSection, + BUILTIN_STORAGE, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, SIDEBAR_DEFAULT_WIDTH, @@ -452,7 +453,7 @@ export class AICustomizationManagementEditor extends EditorPane { // Handle item selection this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { const isWorkspaceFile = item.storage === PromptsStorage.local; - const isReadOnly = item.storage === PromptsStorage.extension || item.storage === PromptsStorage.plugin; + const isReadOnly = item.storage === PromptsStorage.extension || item.storage === PromptsStorage.plugin || item.storage === BUILTIN_STORAGE; this.showEmbeddedEditor(item.uri, item.name, isWorkspaceFile, isReadOnly); })); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index e16ecec8d5745..962e085405342 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -81,6 +81,9 @@ class McpServerItemDelegate implements IListVirtualDelegate { if (element.type === 'group-header') { return element.isFirst ? MCP_GROUP_HEADER_HEIGHT : MCP_GROUP_HEADER_HEIGHT_WITH_SEPARATOR; } + if (element.type === 'server-item' && element.server.gallery && !element.server.local) { + return 62; + } return MCP_ITEM_HEIGHT; } @@ -284,15 +287,16 @@ class McpGalleryItemRenderer implements IListRenderer .details > .footer { + display: flex; + align-items: center; +} + +.mcp-gallery-item.extension-list-item .mcp-gallery-action { + margin-left: auto; +} + .mcp-gallery-item .mcp-gallery-install-button { font-size: 11px; padding: 2px 10px; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index ff1135b9a58d4..bd990886dca8b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -83,6 +83,9 @@ class PluginItemDelegate implements IListVirtualDelegate { if (element.type === 'group-header') { return element.isFirst ? PLUGIN_GROUP_HEADER_HEIGHT : PLUGIN_GROUP_HEADER_HEIGHT_WITH_SEPARATOR; } + if (element.type === 'marketplace-item') { + return 62; + } return PLUGIN_ITEM_HEIGHT; } @@ -244,15 +247,16 @@ class PluginMarketplaceItemRenderer implements IListRenderer { + CommandsRegistry.registerCommand(coreCommand, async (accessor, ...args) => { const commandService = accessor.get(ICommandService); const codeEditorService = accessor.get(ICodeEditorService); const markerService = accessor.get(IMarkerService); @@ -499,6 +499,10 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr if (result) { await commandService.executeCommand(actualCommand); } + break; + } + case 'chat.internal.codeReview.run': { + return commandService.executeCommand(actualCommand, ...args); } } }); @@ -506,6 +510,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr registerGenerateCodeCommand('chat.internal.explain', 'github.copilot.chat.explain'); registerGenerateCodeCommand('chat.internal.fix', 'github.copilot.chat.fix'); registerGenerateCodeCommand('chat.internal.review', 'github.copilot.chat.review'); + registerGenerateCodeCommand('chat.internal.codeReview.run', 'github.copilot.chat.codeReview.run'); const internalGenerateCodeContext = ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 286257580e22b..2b6c01066fe4f 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -35,9 +35,9 @@ export type AICustomizationManagementSection = typeof AICustomizationManagementS */ export interface IStorageSourceFilter { /** - * Which storage groups to display (e.g. workspace, user, extension). + * Which storage groups to display (e.g. workspace, user, extension, builtin). */ - readonly sources: readonly PromptsStorage[]; + readonly sources: readonly string[]; /** * If set, only user files under these roots are shown (allowlist). @@ -51,7 +51,7 @@ export interface IStorageSourceFilter { * Removes items whose storage is not in the filter's source list, * and for user-storage items, removes those not under an allowed root. */ -export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { +export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { const sourceSet = new Set(filter.sources); return items.filter(item => { if (!sourceSet.has(item.storage)) { diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts index 5ac483ffef20a..6fce0410f8468 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts @@ -15,7 +15,6 @@ import { HighlightedLabel } from '../../../../base/browser/ui/highlightedlabel/h import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IAction, Action, Separator } from '../../../../base/common/actions.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -43,13 +42,13 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MenuRegistry, MenuId, isIMenuItem } from '../../../../platform/actions/common/actions.js'; import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; import { WORKBENCH_BACKGROUND } from '../../../common/theme.js'; -import { IKeybindingItemEntry, IKeybindingsEditorPane, IPreferencesService } from '../../../services/preferences/common/preferences.js'; +import { IKeybindingItemEntry, IKeybindingsEditorPane } from '../../../services/preferences/common/preferences.js'; import { keybindingsRecordKeysIcon, keybindingsSortIcon, keybindingsAddIcon, preferencesClearInputIcon, keybindingsEditIcon } from './preferencesIcons.js'; import { ITableRenderer, ITableVirtualDelegate } from '../../../../base/browser/ui/table/table.js'; import { KeybindingsEditorInput } from '../../../services/preferences/browser/keybindingsEditorInput.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { ToolBar } from '../../../../base/browser/ui/toolbar/toolbar.js'; -import { defaultButtonStyles, defaultKeybindingLabelStyles, defaultToggleStyles, getInputBoxStyle } from '../../../../platform/theme/browser/defaultStyles.js'; +import { defaultKeybindingLabelStyles, defaultToggleStyles, getInputBoxStyle } from '../../../../platform/theme/browser/defaultStyles.js'; import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { isString } from '../../../../base/common/types.js'; @@ -131,8 +130,7 @@ export class KeybindingsEditor extends EditorPane imp @IEditorService private readonly editorService: IEditorService, @IStorageService storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - @IPreferencesService private readonly preferencesService: IPreferencesService + @IAccessibilityService private readonly accessibilityService: IAccessibilityService ) { super(KeybindingsEditor.ID, group, telemetryService, themeService, storageService); this.delayedFiltering = this._register(new Delayer(300)); @@ -366,8 +364,7 @@ export class KeybindingsEditor extends EditorPane imp const clearInputAction = this._register(new Action(KEYBINDINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, localize('clearInput', "Clear Keybindings Search Input"), ThemeIcon.asClassName(preferencesClearInputIcon), false, async () => this.clearSearchResults())); - const searchRowContainer = DOM.append(this.headerContainer, $('.search-row-container')); - const searchContainer = DOM.append(searchRowContainer, $('.search-container')); + const searchContainer = DOM.append(this.headerContainer, $('.search-container')); this.searchWidget = this._register(this.instantiationService.createInstance(KeybindingsSearchWidget, searchContainer, { ariaLabel: fullTextSearchPlaceholder, placeholder: fullTextSearchPlaceholder, @@ -429,11 +426,6 @@ export class KeybindingsEditor extends EditorPane imp })); toolBar.setActions(actions); this._register(this.keybindingsService.onDidUpdateKeybindings(() => toolBar.setActions(actions))); - - const openKeybindingsJsonContainer = DOM.append(searchRowContainer, $('.open-keybindings-json')); - const openKeybindingsJsonButton = this._register(new Button(openKeybindingsJsonContainer, { secondary: true, title: true, ...defaultButtonStyles })); - openKeybindingsJsonButton.label = localize('openKeybindingsJson', "Edit as JSON"); - this._register(openKeybindingsJsonButton.onDidClick(() => this.preferencesService.openGlobalKeybindingSettings(true, { groupId: this.group.id }))); } private updateSearchOptions(): void { diff --git a/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css b/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css index f93706d61206b..96a94b07cdeaa 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css +++ b/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css @@ -23,13 +23,11 @@ padding: 0px 10px 11px 0; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container { +.keybindings-editor > .keybindings-header > .search-container { position: relative; - flex: 1; - min-width: 0; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container { position: absolute; top: 0; right: 10px; @@ -37,22 +35,22 @@ display: flex; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container > .recording-badge { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container > .recording-badge { margin-right: 8px; padding: 4px; } -.keybindings-editor > .keybindings-header.small > .search-row-container > .search-container > .keybindings-search-actions-container > .recording-badge, -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container > .recording-badge.disabled { +.keybindings-editor > .keybindings-header.small > .search-container > .keybindings-search-actions-container > .recording-badge, +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container > .recording-badge.disabled { display: none; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item > .icon { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item > .icon { width: 16px; height: 18px; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item { margin-right: 4px; } @@ -88,21 +86,6 @@ opacity: 1; } -.keybindings-editor > .keybindings-header > .search-row-container { - display: flex; - align-items: center; - gap: 8px; -} - -.keybindings-editor > .keybindings-header > .search-row-container > .open-keybindings-json { - flex-shrink: 0; -} - -.keybindings-editor > .keybindings-header > .search-row-container > .open-keybindings-json > .monaco-button { - padding: 2px 8px; - line-height: 18px; -} - /** Table styling **/ .keybindings-editor > .keybindings-body .keybindings-table-container { diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css index b88b3073a9d01..f254088220625 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css @@ -96,13 +96,6 @@ flex: auto; } -.settings-editor > .settings-header > .settings-header-controls > .settings-right-controls { - display: flex; - align-items: center; - gap: 8px; - padding-bottom: 4px; -} - .settings-editor > .settings-header > .settings-header-controls .settings-tabs-widget .action-label { opacity: 0.9; border-radius: 0; diff --git a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts index c1bd907895e4c..3f491f41b3ad4 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts @@ -835,6 +835,12 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon group: 'navigation', order: 1, }, + { + id: MenuId.ModalEditorEditorTitle, + when: ResourceContextKey.Resource.isEqualTo(that.userDataProfileService.currentProfile.keybindingsResource.toString()), + group: 'navigation', + order: 1, + }, { id: MenuId.GlobalActivity, group: '2_configuration', @@ -883,6 +889,11 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon id: MenuId.EditorTitle, when: ContextKeyExpr.and(CONTEXT_KEYBINDINGS_EDITOR), group: 'navigation', + }, + { + id: MenuId.ModalEditorEditorTitle, + when: ContextKeyExpr.and(CONTEXT_KEYBINDINGS_EDITOR), + group: 'navigation', } ] }); @@ -1246,13 +1257,24 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon const commandId = '_workbench.openWorkspaceSettingsEditor'; if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.WORKSPACE && !CommandsRegistry.getCommand(commandId)) { CommandsRegistry.registerCommand(commandId, () => this.preferencesService.openWorkspaceSettings({ jsonEditor: false })); + const when = ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.workspaceSettingsResource!.toString()), WorkbenchStateContext.isEqualTo('workspace'), ContextKeyExpr.not('isInDiffEditor')); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: commandId, title: OPEN_USER_SETTINGS_UI_TITLE, icon: preferencesOpenSettingsIcon }, - when: ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.workspaceSettingsResource!.toString()), WorkbenchStateContext.isEqualTo('workspace'), ContextKeyExpr.not('isInDiffEditor')), + when, + group: 'navigation', + order: 1 + }); + MenuRegistry.appendMenuItem(MenuId.ModalEditorEditorTitle, { + command: { + id: commandId, + title: OPEN_USER_SETTINGS_UI_TITLE, + icon: preferencesOpenSettingsIcon + }, + when, group: 'navigation', order: 1 }); @@ -1272,13 +1294,24 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon return this.preferencesService.openFolderSettings({ folderUri: folder.uri, jsonEditor: false, groupId }); } }); + const when = ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.getFolderSettingsResource(folder.uri)!.toString()), ContextKeyExpr.not('isInDiffEditor')); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: commandId, title: OPEN_USER_SETTINGS_UI_TITLE, icon: preferencesOpenSettingsIcon }, - when: ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.getFolderSettingsResource(folder.uri)!.toString()), ContextKeyExpr.not('isInDiffEditor')), + when, + group: 'navigation', + order: 1 + }); + MenuRegistry.appendMenuItem(MenuId.ModalEditorEditorTitle, { + command: { + id: commandId, + title: OPEN_USER_SETTINGS_UI_TITLE, + icon: preferencesOpenSettingsIcon + }, + when, group: 'navigation', order: 1 }); @@ -1320,6 +1353,11 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo when: openUserSettingsEditorWhen, group: 'navigation', order: 1 + }, { + id: MenuId.ModalEditorEditorTitle, + when: openUserSettingsEditorWhen, + group: 'navigation', + order: 1 }] }); } @@ -1349,6 +1387,11 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo when: openSettingsJsonWhen, group: 'navigation', order: 1 + }, { + id: MenuId.ModalEditorEditorTitle, + when: openSettingsJsonWhen, + group: 'navigation', + order: 1 }] }); } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 76b4bbb9c16e9..17e5663deb81a 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -783,15 +783,8 @@ export class SettingsEditor2 extends EditorPane { } })); - const headerRightControlsContainer = DOM.append(headerControlsContainer, $('.settings-right-controls')); - - const openSettingsJsonContainer = DOM.append(headerRightControlsContainer, $('.open-settings-json')); - const openSettingsJsonButton = this._register(new Button(openSettingsJsonContainer, { secondary: true, title: true, ...defaultButtonStyles })); - openSettingsJsonButton.label = localize('openSettingsJson', "Edit as JSON"); - this._register(openSettingsJsonButton.onDidClick(() => this.openSettingsFile())); - if (this.userDataSyncWorkbenchService.enabled && this.userDataSyncEnablementService.canToggleEnablement()) { - const syncControls = this._register(this.instantiationService.createInstance(SyncControls, this.window, headerRightControlsContainer)); + const syncControls = this._register(this.instantiationService.createInstance(SyncControls, this.window, headerControlsContainer)); this._register(syncControls.onDidChangeLastSyncedLabel(lastSyncedLabel => { this.lastSyncedLabel = lastSyncedLabel; this.updateInputAriaLabel(); @@ -2163,9 +2156,10 @@ class SyncControls extends Disposable { ) { super(); - const turnOnSyncButtonContainer = DOM.append(container, $('.turn-on-sync')); + const headerRightControlsContainer = DOM.append(container, $('.settings-right-controls')); + const turnOnSyncButtonContainer = DOM.append(headerRightControlsContainer, $('.turn-on-sync')); this.turnOnSyncButton = this._register(new Button(turnOnSyncButtonContainer, { title: true, ...defaultButtonStyles })); - this.lastSyncedLabel = DOM.append(container, $('.last-synced-label')); + this.lastSyncedLabel = DOM.append(headerRightControlsContainer, $('.last-synced-label')); DOM.hide(this.lastSyncedLabel); this.turnOnSyncButton.enabled = true; diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 3fb7f02494e12..e9e6d9d2cca20 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -52,6 +52,11 @@ const apiMenus: IAPIMenu[] = [ id: MenuId.EditorTitle, description: localize('menus.editorTitle', "The editor title menu") }, + { + key: 'modalEditor/editorTitle', + id: MenuId.ModalEditorEditorTitle, + description: localize('menus.modalEditorEditorTitle', "The editor title menu in the modal editor") + }, { key: 'editor/title/run', id: MenuId.EditorTitleRun, diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 839e48159e636..29189be6086de 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -579,6 +579,16 @@ export class EditorService extends Disposable implements EditorServiceImpl { } } + // Modal group: override `preserveFocus` to move focus into the modal because there is nothing to preserve if this is the first modal editor + if ( + options?.preserveFocus && + this.editorGroupService.activeModalEditorPart?.groups.some(modalGroup => modalGroup.id === group.id) && + this.editorGroupService.activeModalEditorPart.count === 1 && + this.editorGroupService.activeModalEditorPart.groups[0].isEmpty + ) { + options = { ...options, preserveFocus: false }; + } + return group.openEditor(typedEditor, options); } @@ -637,6 +647,16 @@ export class EditorService extends Disposable implements EditorServiceImpl { } } + // Modal group: override `preserveFocus` to move focus into the modal there is nothing to preserve if this is the first modal editor + if ( + typedEditor.options?.preserveFocus && + this.editorGroupService.activeModalEditorPart?.groups.some(modalGroup => modalGroup.id === group.id) && + this.editorGroupService.activeModalEditorPart.count === 1 && + this.editorGroupService.activeModalEditorPart.groups[0].isEmpty + ) { + typedEditor = { ...typedEditor, options: { ...typedEditor.options, preserveFocus: false } }; + } + // Update map of groups to editors let targetGroupEditors = mapGroupToTypedEditors.get(group); if (!targetGroupEditors) { diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index 4b65d8b11cec2..5538bf425daa1 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -14,10 +14,11 @@ import { MockScopableContextKeyService } from '../../../../../platform/keybindin import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEditorInput.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; +import { IEditorService, MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; import { findGroup } from '../../common/editorGroupFinder.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { EditorService } from '../../browser/editorService.js'; suite('Modal Editor Group', () => { @@ -643,5 +644,23 @@ suite('Modal Editor Group', () => { assert.strictEqual(parts.activeModalEditorPart, undefined); }); + test('openEditor with MODAL_GROUP ignores preserveFocus', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const editorService = disposables.add(instantiationService.createInstance(EditorService, undefined)); + instantiationService.stub(IEditorService, editorService); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + const pane = await editorService.openEditor(input, { pinned: true, preserveFocus: true }, MODAL_GROUP); + + assert.ok(pane); + assert.strictEqual(pane.options?.preserveFocus, false); + + parts.activeModalEditorPart?.close(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts index 789f54a5c3eeb..2b098ab315827 100644 --- a/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts @@ -108,7 +108,7 @@ function renderSessionItem(ctx: ComponentFixtureContext, session: IAgentSession, markdownRendererService.setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); const renderer = disposableStore.add( - instantiationService.createInstance(AgentSessionRenderer, rendererOptions, approvalModel ?? undefined) + instantiationService.createInstance(AgentSessionRenderer, rendererOptions, approvalModel ?? undefined, observableValue('activeSessionResource', undefined)) ); container.style.width = '350px'; From 6bb50a9e22c68f6f5b74918115deb66e91c1b693 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:16:16 -0800 Subject: [PATCH 10/28] fix: accept Azure DevOps Git URLs without .git suffix in plugin marketplace (#299576) * Initial plan * fix: accept Azure DevOps Git URLs without .git suffix in plugin marketplace - Remove the `.git` suffix requirement from `normalizeGitRepoPath`; the function now accepts any URL path that has at least two segments. - Update `parseUriMarketplaceReference` to handle paths with and without `.git`: - `cacheSegments` are built without the suffix in both cases. - `canonicalId` is always normalized to include `.git` so that the same repo specified with and without the suffix deduplicates correctly. - Add a JSDoc comment on `normalizeGitRepoPath` explaining the new semantics. - Update the test that expected HTTPS/SSH URLs without `.git` to be rejected; these are now accepted. SCP-style (`git@host:path`) still requires `.git`. - Add tests for Azure DevOps-style URLs and cross-suffix deduplication. Co-authored-by: connor4312 <2230985+connor4312@users.noreply.github.com> * refactor: use gitSuffix constant instead of magic number -4 Co-authored-by: connor4312 <2230985+connor4312@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: connor4312 <2230985+connor4312@users.noreply.github.com> --- .../plugins/pluginMarketplaceService.ts | 24 +++++++++---- .../plugins/pluginMarketplaceService.test.ts | 35 +++++++++++++++++-- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 86054f4845089..8fdfc8a3ba63c 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -639,13 +639,18 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | return undefined; } + const gitSuffix = '.git'; const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); - const pathSegments = normalizedPath.slice(1, -4).split('/').map(sanitizePathSegment); + const pathHasGitSuffix = normalizedPath.toLowerCase().endsWith(gitSuffix); + const pathWithoutGit = pathHasGitSuffix ? normalizedPath.slice(1, normalizedPath.length - gitSuffix.length) : normalizedPath.slice(1); + const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); + // Always normalize the canonical path to include .git so that URLs with and without the suffix deduplicate. + const canonicalPath = pathHasGitSuffix ? normalizedPath.slice(1).toLowerCase() : `${normalizedPath.slice(1).toLowerCase()}${gitSuffix}`; return { rawValue, displayLabel: rawValue, cloneUrl: rawValue, - canonicalId: `git:${uri.authority.toLowerCase()}/${normalizedPath.slice(1).toLowerCase()}`, + canonicalId: `git:${uri.authority.toLowerCase()}/${canonicalPath}`, cacheSegments: [sanitizedAuthority, ...pathSegments], kind: MarketplaceReferenceKind.GitUri, }; @@ -674,14 +679,21 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | }; } +/** + * Normalizes a Git repository path and validates that it has at least two segments + * (i.e., at least one owner/repo pair below the root). Accepts paths with or without + * a `.git` suffix — the suffix is preserved in the returned value so callers can decide + * how to treat it. + */ function normalizeGitRepoPath(path: string): string | undefined { + const gitSuffix = '.git'; const trimmed = path.replace(/\/+/g, '/').replace(/\/+$/g, ''); - if (!trimmed.toLowerCase().endsWith('.git')) { - return undefined; - } const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - const pathWithoutGit = withLeadingSlash.slice(1, -4); + // Strip .git suffix (if present) only for the purposes of validating path depth. + const pathWithoutGit = withLeadingSlash.toLowerCase().endsWith(gitSuffix) + ? withLeadingSlash.slice(1, withLeadingSlash.length - gitSuffix.length) + : withLeadingSlash.slice(1); if (!pathWithoutGit || !pathWithoutGit.includes('/')) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index da94ecb5615b8..879e3a3385037 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -76,12 +76,41 @@ suite('PluginMarketplaceService', () => { assert.deepStrictEqual(parsed.cacheSegments, []); }); - test('rejects non-shorthand marketplace entries without .git', () => { - assert.strictEqual(parseMarketplaceReference('https://example.com/org/repo'), undefined); - assert.strictEqual(parseMarketplaceReference('ssh://git@example.com/org/repo'), undefined); + test('accepts HTTPS and SSH marketplace entries without .git suffix', () => { + const https = parseMarketplaceReference('https://example.com/org/repo'); + assert.ok(https); + assert.strictEqual(https?.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(https?.canonicalId, 'git:example.com/org/repo.git'); + assert.deepStrictEqual(https?.cacheSegments, ['example.com', 'org', 'repo']); + + const ssh = parseMarketplaceReference('ssh://git@example.com/org/repo'); + assert.ok(ssh); + assert.strictEqual(ssh?.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(ssh?.canonicalId, 'git:git@example.com/org/repo.git'); + + // SCP-style (git@host:path) still requires .git because the colon-path syntax is + // unambiguous only for traditional git SSH URLs where .git is conventional. assert.strictEqual(parseMarketplaceReference('git@example.com:org/repo'), undefined); }); + test('parses Azure DevOps HTTPS clone URLs without .git suffix', () => { + const parsed = parseMarketplaceReference('https://dev.azure.com/org/project/_git/repo'); + assert.ok(parsed); + assert.strictEqual(parsed?.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(parsed?.cloneUrl, 'https://dev.azure.com/org/project/_git/repo'); + assert.strictEqual(parsed?.canonicalId, 'git:dev.azure.com/org/project/_git/repo.git'); + assert.deepStrictEqual(parsed?.cacheSegments, ['dev.azure.com', 'org', 'project', '_git', 'repo']); + }); + + test('deduplicates Azure DevOps URLs with and without .git suffix', () => { + const parsed = parseMarketplaceReferences([ + 'https://dev.azure.com/org/project/_git/repo', + 'https://dev.azure.com/org/project/_git/repo.git', + ]); + assert.strictEqual(parsed.length, 1); + assert.strictEqual(parsed[0].canonicalId, 'git:dev.azure.com/org/project/_git/repo.git'); + }); + test('parses HTTPS URI with trailing slash after .git', () => { const parsed = parseMarketplaceReference('https://example.com/org/repo.git/'); assert.ok(parsed); From 8734c3f392da7840223e7e592105f55287bc7ddf Mon Sep 17 00:00:00 2001 From: Sergei Druzhkov Date: Fri, 6 Mar 2026 20:16:30 +0300 Subject: [PATCH 11/28] debug: fix variable updating after set response (#299473) --- src/vs/workbench/contrib/debug/common/debugModel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 9c943348a9737..5de69ed78ca80 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -246,7 +246,8 @@ function handleSetResponse(expression: ExpressionContainer, response: DebugProto expression.reference = response.body.variablesReference; expression.namedVariables = response.body.namedVariables; expression.indexedVariables = response.body.indexedVariables; - // todo @weinand: the set responses contain most properties, but not memory references. Should they? + expression.memoryReference = response.body.memoryReference; + expression.valueLocationReference = response.body.valueLocationReference; } } From 3b4da4f334931ae2da5691def65229c566d15272 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 6 Mar 2026 17:10:23 +0100 Subject: [PATCH 12/28] =?UTF-8?q?Revert=20"chore=20-=20Refactor=20inline?= =?UTF-8?q?=20chat=20classes=20to=20use=20private=20class=20fields=20(#29?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 81f2b5cd2fdf2c2ceb61899f79332db8551f2c35. --- .../inlineChat/browser/inlineChatActions.ts | 11 +- .../browser/inlineChatController.ts | 299 ++++++++--------- .../browser/inlineChatEditorAffordance.ts | 112 +++---- .../browser/inlineChatGutterAffordance.ts | 6 +- .../inlineChat/browser/inlineChatNotebook.ts | 6 +- .../browser/inlineChatOverlayWidget.ts | 303 +++++++++--------- .../browser/inlineChatSessionServiceImpl.ts | 87 +++-- .../inlineChat/browser/inlineChatWidget.ts | 178 +++++----- .../browser/inlineChatZoneWidget.ts | 75 ++--- .../test/browser/testWorkerService.ts | 24 +- 10 files changed, 506 insertions(+), 595 deletions(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 204ae20da8d54..aef9aeefc52be 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -91,11 +91,11 @@ export class StartSessionAction extends Action2 { logService.debug(`[EditorAction2] NOT running command because its precondition is FALSE`, this.desc.id, this.desc.precondition?.serialize()); return; } - return this.#runEditorCommand(editorAccessor, editor, ...args); + return this._runEditorCommand(editorAccessor, editor, ...args); }); } - async #runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]) { + private async _runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]) { const configServce = accessor.get(IConfigurationService); @@ -262,15 +262,12 @@ export class FixDiagnosticsAction extends AbstractInlineChatAction { class KeepOrUndoSessionAction extends AbstractInlineChatAction { - readonly #keep: boolean; - - constructor(keep: boolean, desc: IAction2Options) { + constructor(private readonly _keep: boolean, desc: IAction2Options) { super(desc); - this.#keep = keep; } override async runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, editor: ICodeEditor, ..._args: unknown[]): Promise { - if (this.#keep) { + if (this._keep) { await ctrl.acceptSession(); } else { await ctrl.rejectSession(); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 869084cff1d1f..76aa39da94237 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -112,93 +112,63 @@ export class InlineChatController implements IEditorContribution { * Stores the user's explicitly chosen model (qualified name) from a previous inline chat request in the same session. * When set, this takes priority over the inlineChat.defaultModel setting. */ - static #userSelectedModel: string | undefined; + private static _userSelectedModel: string | undefined; - readonly #store = new DisposableStore(); - readonly #isActiveController = observableValue(this, false); - readonly #renderMode: IObservable<'zone' | 'hover'>; - readonly #zone: Lazy; + private readonly _store = new DisposableStore(); + private readonly _isActiveController = observableValue(this, false); + private readonly _renderMode: IObservable<'zone' | 'hover'>; + private readonly _zone: Lazy; readonly inputOverlayWidget: InlineChatAffordance; - readonly #inputWidget: InlineChatInputWidget; - - readonly #currentSession: IObservable; - - readonly #editor: ICodeEditor; - readonly #instaService: IInstantiationService; - readonly #notebookEditorService: INotebookEditorService; - readonly #inlineChatSessionService: IInlineChatSessionService; - readonly #configurationService: IConfigurationService; - readonly #webContentExtractorService: ISharedWebContentExtractorService; - readonly #fileService: IFileService; - readonly #chatAttachmentResolveService: IChatAttachmentResolveService; - readonly #editorService: IEditorService; - readonly #markerDecorationsService: IMarkerDecorationsService; - readonly #languageModelService: ILanguageModelsService; - readonly #logService: ILogService; - readonly #chatEditingService: IChatEditingService; - readonly #chatService: IChatService; + private readonly _inputWidget: InlineChatInputWidget; + + private readonly _currentSession: IObservable; get widget(): EditorBasedInlineChatWidget { - return this.#zone.value.widget; + return this._zone.value.widget; } get isActive() { - return Boolean(this.#currentSession.get()); + return Boolean(this._currentSession.get()); } get inputWidget(): InlineChatInputWidget { - return this.#inputWidget; + return this._inputWidget; } constructor( - editor: ICodeEditor, - @IInstantiationService instaService: IInstantiationService, - @INotebookEditorService notebookEditorService: INotebookEditorService, - @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, + @INotebookEditorService private readonly _notebookEditorService: INotebookEditorService, + @IInlineChatSessionService private readonly _inlineChatSessionService: IInlineChatSessionService, @ICodeEditorService codeEditorService: ICodeEditorService, @IContextKeyService contextKeyService: IContextKeyService, - @IConfigurationService configurationService: IConfigurationService, - @ISharedWebContentExtractorService webContentExtractorService: ISharedWebContentExtractorService, - @IFileService fileService: IFileService, - @IChatAttachmentResolveService chatAttachmentResolveService: IChatAttachmentResolveService, - @IEditorService editorService: IEditorService, - @IMarkerDecorationsService markerDecorationsService: IMarkerDecorationsService, - @ILanguageModelsService languageModelService: ILanguageModelsService, - @ILogService logService: ILogService, - @IChatEditingService chatEditingService: IChatEditingService, - @IChatService chatService: IChatService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ISharedWebContentExtractorService private readonly _webContentExtractorService: ISharedWebContentExtractorService, + @IFileService private readonly _fileService: IFileService, + @IChatAttachmentResolveService private readonly _chatAttachmentResolveService: IChatAttachmentResolveService, + @IEditorService private readonly _editorService: IEditorService, + @IMarkerDecorationsService private readonly _markerDecorationsService: IMarkerDecorationsService, + @ILanguageModelsService private readonly _languageModelService: ILanguageModelsService, + @ILogService private readonly _logService: ILogService, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IChatService private readonly _chatService: IChatService, ) { - this.#editor = editor; - this.#instaService = instaService; - this.#notebookEditorService = notebookEditorService; - this.#inlineChatSessionService = inlineChatSessionService; - this.#configurationService = configurationService; - this.#webContentExtractorService = webContentExtractorService; - this.#fileService = fileService; - this.#chatAttachmentResolveService = chatAttachmentResolveService; - this.#editorService = editorService; - this.#markerDecorationsService = markerDecorationsService; - this.#languageModelService = languageModelService; - this.#logService = logService; - this.#chatEditingService = chatEditingService; - this.#chatService = chatService; - - const editorObs = observableCodeEditor(editor); + const editorObs = observableCodeEditor(_editor); const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); const ctxFileBelongsToChat = CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.bindTo(contextKeyService); const ctxPendingConfirmation = CTX_INLINE_CHAT_PENDING_CONFIRMATION.bindTo(contextKeyService); - const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, this.#configurationService); - this.#renderMode = observableConfigValue(InlineChatConfigKeys.RenderMode, 'zone', this.#configurationService); + const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, this._configurationService); + this._renderMode = observableConfigValue(InlineChatConfigKeys.RenderMode, 'zone', this._configurationService); // Track whether the current editor's file is being edited by any chat editing session - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const model = editorObs.model.read(r); if (!model) { ctxFileBelongsToChat.set(false); return; } - const sessions = this.#chatEditingService.editingSessionsObs.read(r); + const sessions = this._chatEditingService.editingSessionsObs.read(r); let hasEdits = false; for (const session of sessions) { const entries = session.entries.read(r); @@ -215,25 +185,25 @@ export class InlineChatController implements IEditorContribution { ctxFileBelongsToChat.set(hasEdits); })); - const overlayWidget = this.#inputWidget = this.#store.add(this.#instaService.createInstance(InlineChatInputWidget, editorObs)); - const sessionOverlayWidget = this.#store.add(this.#instaService.createInstance(InlineChatSessionOverlayWidget, editorObs)); - this.inputOverlayWidget = this.#store.add(this.#instaService.createInstance(InlineChatAffordance, this.#editor, overlayWidget)); + const overlayWidget = this._inputWidget = this._store.add(this._instaService.createInstance(InlineChatInputWidget, editorObs)); + const sessionOverlayWidget = this._store.add(this._instaService.createInstance(InlineChatSessionOverlayWidget, editorObs)); + this.inputOverlayWidget = this._store.add(this._instaService.createInstance(InlineChatAffordance, this._editor, overlayWidget)); - this.#zone = new Lazy(() => { + this._zone = new Lazy(() => { - assertType(this.#editor.hasModel(), '[Illegal State] widget should only be created when the editor has a model'); + assertType(this._editor.hasModel(), '[Illegal State] widget should only be created when the editor has a model'); const location: IChatWidgetLocationOptions = { location: ChatAgentLocation.EditorInline, resolveData: () => { - assertType(this.#editor.hasModel()); - const wholeRange = this.#editor.getSelection(); - const document = this.#editor.getModel().uri; + assertType(this._editor.hasModel()); + const wholeRange = this._editor.getSelection(); + const document = this._editor.getModel().uri; return { type: ChatAgentLocation.EditorInline, - id: getEditorId(this.#editor, this.#editor.getModel()), - selection: this.#editor.getSelection(), + id: getEditorId(this._editor, this._editor.getModel()), + selection: this._editor.getSelection(), document, wholeRange }; @@ -243,22 +213,22 @@ export class InlineChatController implements IEditorContribution { // inline chat in notebooks // check if this editor is part of a notebook editor // if so, update the location and use the notebook specific widget - const notebookEditor = this.#notebookEditorService.getNotebookForPossibleCell(this.#editor); + const notebookEditor = this._notebookEditorService.getNotebookForPossibleCell(this._editor); if (!!notebookEditor) { location.location = ChatAgentLocation.Notebook; if (notebookAgentConfig.get()) { location.resolveData = () => { - assertType(this.#editor.hasModel()); + assertType(this._editor.hasModel()); return { type: ChatAgentLocation.Notebook, - sessionInputUri: this.#editor.getModel().uri, + sessionInputUri: this._editor.getModel().uri, }; }; } } - const result = this.#instaService.createInstance(InlineChatZoneWidget, + const result = this._instaService.createInstance(InlineChatZoneWidget, location, { enableWorkingSet: 'implicit', @@ -278,33 +248,33 @@ export class InlineChatController implements IEditorContribution { }, defaultMode: ChatMode.Ask }, - { editor: this.#editor, notebookEditor }, + { editor: this._editor, notebookEditor }, () => Promise.resolve(), ); - this.#store.add(result); + this._store.add(result); result.domNode.classList.add('inline-chat-2'); return result; }); - const sessionsSignal = observableSignalFromEvent(this, this.#inlineChatSessionService.onDidChangeSessions); + const sessionsSignal = observableSignalFromEvent(this, _inlineChatSessionService.onDidChangeSessions); - this.#currentSession = derived(r => { + this._currentSession = derived(r => { sessionsSignal.read(r); const model = editorObs.model.read(r); - const session = model && this.#inlineChatSessionService.getSessionByTextModel(model.uri); + const session = model && _inlineChatSessionService.getSessionByTextModel(model.uri); return session ?? undefined; }); let lastSession: IInlineChatSession2 | undefined = undefined; - this.#store.add(autorun(r => { - const session = this.#currentSession.read(r); + this._store.add(autorun(r => { + const session = this._currentSession.read(r); if (!session) { - this.#isActiveController.set(false, undefined); + this._isActiveController.set(false, undefined); if (lastSession && !lastSession.chatModel.hasRequests) { const state = lastSession.chatModel.inputModel.state.read(undefined); @@ -320,24 +290,23 @@ export class InlineChatController implements IEditorContribution { let foundOne = false; for (const editor of codeEditorService.listCodeEditors()) { - const ctrl = InlineChatController.get(editor); - if (ctrl && ctrl.#isActiveController.read(undefined)) { + if (Boolean(InlineChatController.get(editor)?._isActiveController.read(undefined))) { foundOne = true; break; } } if (!foundOne && editorObs.isFocused.read(r)) { - this.#isActiveController.set(true, undefined); + this._isActiveController.set(true, undefined); } })); const visibleSessionObs = observableValue(this, undefined); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const model = editorObs.model.read(r); - const session = this.#currentSession.read(r); - const isActive = this.#isActiveController.read(r); + const session = this._currentSession.read(r); + const isActive = this._isActiveController.read(r); if (!session || !isActive || !model) { visibleSessionObs.set(undefined, undefined); @@ -353,38 +322,38 @@ export class InlineChatController implements IEditorContribution { }); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { // HIDE/SHOW const session = visibleSessionObs.read(r); - const renderMode = this.#renderMode.read(r); + const renderMode = this._renderMode.read(r); if (!session) { - this.#zone.rawValue?.hide(); - this.#zone.rawValue?.widget.chatWidget.setModel(undefined); - this.#editor.focus(); + this._zone.rawValue?.hide(); + this._zone.rawValue?.widget.chatWidget.setModel(undefined); + _editor.focus(); ctxInlineChatVisible.reset(); } else if (renderMode === 'hover') { // hover mode: set model but don't show zone, keep focus in editor - this.#zone.value.widget.chatWidget.setModel(session.chatModel); - this.#zone.rawValue?.hide(); + this._zone.value.widget.chatWidget.setModel(session.chatModel); + this._zone.rawValue?.hide(); ctxInlineChatVisible.set(true); } else { ctxInlineChatVisible.set(true); - this.#zone.value.widget.chatWidget.setModel(session.chatModel); - if (!this.#zone.value.position) { - this.#zone.value.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r)); - this.#zone.value.widget.chatWidget.input.renderAttachedContext(); // TODO - fights layout bug - this.#zone.value.show(session.initialPosition); + this._zone.value.widget.chatWidget.setModel(session.chatModel); + if (!this._zone.value.position) { + this._zone.value.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r)); + this._zone.value.widget.chatWidget.input.renderAttachedContext(); // TODO - fights layout bug + this._zone.value.show(session.initialPosition); } - this.#zone.value.reveal(this.#zone.value.position!); - this.#zone.value.widget.focus(); + this._zone.value.reveal(this._zone.value.position!); + this._zone.value.widget.focus(); } })); // Show progress overlay widget in hover mode when a request is in progress or edits are not yet settled - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const session = visibleSessionObs.read(r); - const renderMode = this.#renderMode.read(r); + const renderMode = this._renderMode.read(r); if (!session || renderMode !== 'hover') { ctxPendingConfirmation.set(false); sessionOverlayWidget.hide(); @@ -406,7 +375,7 @@ export class InlineChatController implements IEditorContribution { } })); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const session = visibleSessionObs.read(r); if (session) { const entries = session.editingSession.entries.read(r); @@ -424,7 +393,7 @@ export class InlineChatController implements IEditorContribution { for (const entry of otherEntries) { // OPEN other modified files in side group. This is a workaround, temp-solution until we have no more backend // that modifies other files - this.#editorService.openEditor({ resource: entry.modifiedURI }, SIDE_GROUP).catch(onUnexpectedError); + this._editorService.openEditor({ resource: entry.modifiedURI }, SIDE_GROUP).catch(onUnexpectedError); } } })); @@ -445,36 +414,36 @@ export class InlineChatController implements IEditorContribution { }); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const response = lastResponseObs.read(r); - this.#zone.rawValue?.widget.updateInfo(''); + this._zone.rawValue?.widget.updateInfo(''); if (!response?.isInProgress.read(r)) { if (response?.result?.errorDetails) { // ERROR case - this.#zone.rawValue?.widget.updateInfo(`$(error) ${response.result.errorDetails.message}`); + this._zone.rawValue?.widget.updateInfo(`$(error) ${response.result.errorDetails.message}`); alert(response.result.errorDetails.message); } // no response or not in progress - this.#zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', false); - this.#zone.rawValue?.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r)); + this._zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', false); + this._zone.rawValue?.widget.chatWidget.setInputPlaceholder(defaultPlaceholderObs.read(r)); } else { - this.#zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', true); + this._zone.rawValue?.widget.domNode.classList.toggle('request-in-progress', true); let placeholder = response.request?.message.text; const lastProgress = lastResponseProgressObs.read(r); if (lastProgress) { placeholder = renderAsPlaintext(lastProgress.content); } - this.#zone.rawValue?.widget.chatWidget.setInputPlaceholder(placeholder || localize('loading', "Working...")); + this._zone.rawValue?.widget.chatWidget.setInputPlaceholder(placeholder || localize('loading', "Working...")); } })); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const session = visibleSessionObs.read(r); if (!session) { return; @@ -487,25 +456,25 @@ export class InlineChatController implements IEditorContribution { })); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const session = visibleSessionObs.read(r); const entry = session?.editingSession.readEntry(session.uri, r); // make sure there is an editor integration - const pane = this.#editorService.visibleEditorPanes.find(candidate => candidate.getControl() === this.#editor || isNotebookWithCellEditor(candidate, this.#editor)); + const pane = this._editorService.visibleEditorPanes.find(candidate => candidate.getControl() === this._editor || isNotebookWithCellEditor(candidate, this._editor)); if (pane && entry) { entry?.getEditorIntegration(pane); } // make sure the ZONE isn't inbetween a diff and move above if so - if (entry?.diffInfo && this.#zone.value.position) { - const { position } = this.#zone.value; + if (entry?.diffInfo && this._zone.value.position) { + const { position } = this._zone.value; const diff = entry.diffInfo.read(r); for (const change of diff.changes) { if (change.modified.contains(position.lineNumber)) { - this.#zone.value.updatePositionAndHeight(new Position(change.modified.startLineNumber - 1, 1)); + this._zone.value.updatePositionAndHeight(new Position(change.modified.startLineNumber - 1, 1)); break; } } @@ -514,90 +483,90 @@ export class InlineChatController implements IEditorContribution { } dispose(): void { - this.#store.dispose(); + this._store.dispose(); } getWidgetPosition(): Position | undefined { - return this.#zone.rawValue?.position; + return this._zone.rawValue?.position; } focus() { - this.#zone.rawValue?.widget.focus(); + this._zone.rawValue?.widget.focus(); } async run(arg?: InlineChatRunOptions): Promise { - assertType(this.#editor.hasModel()); - const uri = this.#editor.getModel().uri; + assertType(this._editor.hasModel()); + const uri = this._editor.getModel().uri; - const existingSession = this.#inlineChatSessionService.getSessionByTextModel(uri); + const existingSession = this._inlineChatSessionService.getSessionByTextModel(uri); if (existingSession) { await existingSession.editingSession.accept(); existingSession.dispose(); } - this.#isActiveController.set(true, undefined); + this._isActiveController.set(true, undefined); - const session = this.#inlineChatSessionService.createSession(this.#editor); + const session = this._inlineChatSessionService.createSession(this._editor); // Store for tracking model changes during this session const sessionStore = new DisposableStore(); try { - await this.#applyModelDefaults(session, sessionStore); + await this._applyModelDefaults(session, sessionStore); if (arg) { - arg.attachDiagnostics ??= this.#configurationService.getValue(InlineChatConfigKeys.RenderMode) === 'zone'; + arg.attachDiagnostics ??= this._configurationService.getValue(InlineChatConfigKeys.RenderMode) === 'zone'; } // ADD diagnostics (only when explicitly requested) if (arg?.attachDiagnostics) { const entries: IChatRequestVariableEntry[] = []; - for (const [range, marker] of this.#markerDecorationsService.getLiveMarkers(uri)) { - if (range.intersectRanges(this.#editor.getSelection())) { + for (const [range, marker] of this._markerDecorationsService.getLiveMarkers(uri)) { + if (range.intersectRanges(this._editor.getSelection())) { const filter = IDiagnosticVariableEntryFilterData.fromMarker(marker); entries.push(IDiagnosticVariableEntryFilterData.toEntry(filter)); } } if (entries.length > 0) { - this.#zone.value.widget.chatWidget.attachmentModel.addContext(...entries); + this._zone.value.widget.chatWidget.attachmentModel.addContext(...entries); const msg = entries.length > 1 ? localize('fixN', "Fix the attached problems") : localize('fix1', "Fix the attached problem"); - this.#zone.value.widget.chatWidget.input.setValue(msg, true); + this._zone.value.widget.chatWidget.input.setValue(msg, true); arg.message = msg; - this.#zone.value.widget.chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1)); + this._zone.value.widget.chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1)); } } // Check args if (arg && InlineChatRunOptions.isInlineChatRunOptions(arg)) { if (arg.initialRange) { - this.#editor.revealRange(arg.initialRange); + this._editor.revealRange(arg.initialRange); } if (arg.initialSelection) { - this.#editor.setSelection(arg.initialSelection); + this._editor.setSelection(arg.initialSelection); } if (arg.attachments) { await Promise.all(arg.attachments.map(async attachment => { - await this.#zone.value.widget.chatWidget.attachmentModel.addFile(attachment); + await this._zone.value.widget.chatWidget.attachmentModel.addFile(attachment); })); delete arg.attachments; } if (arg.modelSelector) { - const id = (await this.#languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0); + const id = (await this._languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0); if (!id) { throw new Error(`No language models found matching selector: ${JSON.stringify(arg.modelSelector)}.`); } - const model = this.#languageModelService.lookupLanguageModel(id); + const model = this._languageModelService.lookupLanguageModel(id); if (!model) { throw new Error(`Language model not loaded: ${id}.`); } - this.#zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: model, identifier: id }); + this._zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: model, identifier: id }); } if (arg.message) { - this.#zone.value.widget.chatWidget.setInput(arg.message); + this._zone.value.widget.chatWidget.setInput(arg.message); if (arg.autoSend) { - await this.#zone.value.widget.chatWidget.acceptInput(); + await this._zone.value.widget.chatWidget.acceptInput(); } } } @@ -623,7 +592,7 @@ export class InlineChatController implements IEditorContribution { } async acceptSession() { - const session = this.#currentSession.get(); + const session = this._currentSession.get(); if (!session) { return; } @@ -632,23 +601,23 @@ export class InlineChatController implements IEditorContribution { } async rejectSession() { - const session = this.#currentSession.get(); + const session = this._currentSession.get(); if (!session) { return; } - this.#chatService.cancelCurrentRequestForSession(session.chatModel.sessionResource, 'inlineChatReject'); + this._chatService.cancelCurrentRequestForSession(session.chatModel.sessionResource, 'inlineChatReject'); await session.editingSession.reject(); session.dispose(); } - async #selectVendorDefaultModel(session: IInlineChatSession2): Promise { - const model = this.#zone.value.widget.chatWidget.input.selectedLanguageModel.get(); + private async _selectVendorDefaultModel(session: IInlineChatSession2): Promise { + const model = this._zone.value.widget.chatWidget.input.selectedLanguageModel.get(); if (model && !model.metadata.isDefaultForLocation[session.chatModel.initialLocation]) { - const ids = await this.#languageModelService.selectLanguageModels({ vendor: model.metadata.vendor }); + const ids = await this._languageModelService.selectLanguageModels({ vendor: model.metadata.vendor }); for (const identifier of ids) { - const candidate = this.#languageModelService.lookupLanguageModel(identifier); + const candidate = this._languageModelService.lookupLanguageModel(identifier); if (candidate?.isDefaultForLocation[session.chatModel.initialLocation]) { - this.#zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: candidate, identifier }); + this._zone.value.widget.chatWidget.input.setCurrentLanguageModel({ metadata: candidate, identifier }); break; } } @@ -659,39 +628,39 @@ export class InlineChatController implements IEditorContribution { * Applies model defaults based on settings and tracks user model changes. * Prioritization: user session choice > inlineChat.defaultModel setting > vendor default */ - async #applyModelDefaults(session: IInlineChatSession2, sessionStore: DisposableStore): Promise { - const userSelectedModel = InlineChatController.#userSelectedModel; - const defaultModelSetting = this.#configurationService.getValue(InlineChatConfigKeys.DefaultModel); + private async _applyModelDefaults(session: IInlineChatSession2, sessionStore: DisposableStore): Promise { + const userSelectedModel = InlineChatController._userSelectedModel; + const defaultModelSetting = this._configurationService.getValue(InlineChatConfigKeys.DefaultModel); let modelApplied = false; // 1. Try user's explicitly chosen model from a previous inline chat in the same session if (userSelectedModel) { - modelApplied = this.#zone.value.widget.chatWidget.input.switchModelByQualifiedName([userSelectedModel]); + modelApplied = this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([userSelectedModel]); if (!modelApplied) { // User's previously selected model is no longer available, clear it - InlineChatController.#userSelectedModel = undefined; + InlineChatController._userSelectedModel = undefined; } } // 2. Try inlineChat.defaultModel setting if (!modelApplied && defaultModelSetting) { - modelApplied = this.#zone.value.widget.chatWidget.input.switchModelByQualifiedName([defaultModelSetting]); + modelApplied = this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([defaultModelSetting]); if (!modelApplied) { - this.#logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`); + this._logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`); } } // 3. Fall back to vendor default if (!modelApplied) { - await this.#selectVendorDefaultModel(session); + await this._selectVendorDefaultModel(session); } // Track model changes - store user's explicit choice in the given sessions. // NOTE: This currently detects any model change, not just user-initiated ones. let initialModelId: string | undefined; sessionStore.add(autorun(r => { - const newModel = this.#zone.value.widget.chatWidget.input.selectedLanguageModel.read(r); + const newModel = this._zone.value.widget.chatWidget.input.selectedLanguageModel.read(r); if (!newModel) { return; } @@ -701,25 +670,25 @@ export class InlineChatController implements IEditorContribution { } if (initialModelId !== newModel.identifier) { // User explicitly changed model, store their choice as qualified name - InlineChatController.#userSelectedModel = ILanguageModelChatMetadata.asQualifiedName(newModel.metadata); + InlineChatController._userSelectedModel = ILanguageModelChatMetadata.asQualifiedName(newModel.metadata); initialModelId = newModel.identifier; } })); } async createImageAttachment(attachment: URI): Promise { - const value = this.#currentSession.get(); + const value = this._currentSession.get(); if (!value) { return undefined; } if (attachment.scheme === Schemas.file) { - if (await this.#fileService.canHandleResource(attachment)) { - return await this.#chatAttachmentResolveService.resolveImageEditorAttachContext(attachment); + if (await this._fileService.canHandleResource(attachment)) { + return await this._chatAttachmentResolveService.resolveImageEditorAttachContext(attachment); } } else if (attachment.scheme === Schemas.http || attachment.scheme === Schemas.https) { - const extractedImages = await this.#webContentExtractorService.readImage(attachment, CancellationToken.None); + const extractedImages = await this._webContentExtractorService.readImage(attachment, CancellationToken.None); if (extractedImages) { - return await this.#chatAttachmentResolveService.resolveImageEditorAttachContext(attachment, extractedImages); + return await this._chatAttachmentResolveService.resolveImageEditorAttachContext(attachment, extractedImages); } } return undefined; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index 361441642d6d6..e7773395fce86 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -33,13 +33,12 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j class QuickFixActionViewItem extends MenuEntryActionViewItem { - readonly #lightBulbStore = this._store.add(new MutableDisposable()); - readonly #editor: ICodeEditor; - #currentTitle: string | undefined; + private readonly _lightBulbStore = this._store.add(new MutableDisposable()); + private _currentTitle: string | undefined; constructor( action: MenuItemAction, - editor: ICodeEditor, + private readonly _editor: ICodeEditor, @IKeybindingService keybindingService: IKeybindingService, @INotificationService notificationService: INotificationService, @IContextKeyService contextKeyService: IContextKeyService, @@ -56,7 +55,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { elementGetter: () => HTMLElement | undefined = () => undefined; override async run(...args: unknown[]): Promise { - const controller = CodeActionController.get(editor); + const controller = CodeActionController.get(_editor); const info = controller?.lightBulbState.get(); const element = this.elementGetter(); if (controller && info && element) { @@ -68,27 +67,26 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { super(wrappedAction, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); - this.#editor = editor; wrappedAction.elementGetter = () => this.element; } override render(container: HTMLElement): void { super.render(container); - this.#updateFromLightBulb(); + this._updateFromLightBulb(); } protected override getTooltip(): string { - return this.#currentTitle ?? super.getTooltip(); + return this._currentTitle ?? super.getTooltip(); } - #updateFromLightBulb(): void { - const controller = CodeActionController.get(this.#editor); + private _updateFromLightBulb(): void { + const controller = CodeActionController.get(this._editor); if (!controller) { return; } const store = new DisposableStore(); - this.#lightBulbStore.value = store; + this._lightBulbStore.value = store; store.add(autorun(reader => { const info = controller.lightBulbState.read(reader); @@ -101,7 +99,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { } // Update tooltip - this.#currentTitle = info?.title; + this._currentTitle = info?.title; this.updateTooltip(); })); } @@ -109,7 +107,7 @@ class QuickFixActionViewItem extends MenuEntryActionViewItem { class LabelWithKeybindingActionViewItem extends MenuEntryActionViewItem { - readonly #kbLabel: string | undefined; + private readonly _kbLabel: string | undefined; constructor( action: MenuItemAction, @@ -123,14 +121,14 @@ class LabelWithKeybindingActionViewItem extends MenuEntryActionViewItem { super(action, { draggable: false }, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); this.options.label = true; this.options.icon = false; - this.#kbLabel = keybindingService.lookupKeybinding(action.id)?.getLabel() ?? undefined; + this._kbLabel = keybindingService.lookupKeybinding(action.id)?.getLabel() ?? undefined; } protected override updateLabel(): void { if (this.label) { dom.reset(this.label, this.action.label, - ...(this.#kbLabel ? [dom.$('span.inline-chat-keybinding', undefined, this.#kbLabel)] : []) + ...(this._kbLabel ? [dom.$('span.inline-chat-keybinding', undefined, this._kbLabel)] : []) ); } } @@ -142,42 +140,38 @@ class LabelWithKeybindingActionViewItem extends MenuEntryActionViewItem { */ export class InlineChatEditorAffordance extends Disposable implements IContentWidget { - static #idPool = 0; + private static _idPool = 0; - readonly #id = `inline-chat-content-widget-${InlineChatEditorAffordance.#idPool++}`; - readonly #domNode: HTMLElement; - #position: IContentWidgetPosition | null = null; - #isVisible = false; + private readonly _id = `inline-chat-content-widget-${InlineChatEditorAffordance._idPool++}`; + private readonly _domNode: HTMLElement; + private _position: IContentWidgetPosition | null = null; + private _isVisible = false; - readonly #onDidRunAction = this._store.add(new Emitter()); - readonly onDidRunAction: Event = this.#onDidRunAction.event; + private readonly _onDidRunAction = this._store.add(new Emitter()); + readonly onDidRunAction: Event = this._onDidRunAction.event; readonly allowEditorOverflow = true; readonly suppressMouseDown = false; - readonly #editor: ICodeEditor; - constructor( - editor: ICodeEditor, + private readonly _editor: ICodeEditor, selection: IObservable, @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this.#editor = editor; - // Create the widget DOM - this.#domNode = dom.$('.inline-chat-content-widget'); + this._domNode = dom.$('.inline-chat-content-widget'); // Create toolbar with the inline chat start action - const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this.#domNode, MenuId.InlineChatEditorAffordance, { + const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.InlineChatEditorAffordance, { telemetrySource: 'inlineChatEditorAffordance', hiddenItemStrategy: HiddenItemStrategy.Ignore, menuOptions: { renderShortTitle: true }, toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, actionViewItemProvider: (action: IAction) => { if (action instanceof MenuItemAction && action.id === quickFixCommandId) { - return instantiationService.createInstance(QuickFixActionViewItem, action, this.#editor); + return instantiationService.createInstance(QuickFixActionViewItem, action, this._editor); } if (action instanceof MenuItemAction && (action.id === ACTION_START || action.id === ACTION_ASK_IN_CHAT || action.id === 'inlineChat.fixDiagnostics')) { return instantiationService.createInstance(LabelWithKeybindingActionViewItem, action); @@ -186,37 +180,37 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi } })); this._store.add(toolbar.actionRunner.onDidRun((e) => { - this.#onDidRunAction.fire(e.action.id); - this.#hide(); + this._onDidRunAction.fire(e.action.id); + this._hide(); })); this._store.add(autorun(r => { const sel = selection.read(r); if (sel) { - this.#show(sel); + this._show(sel); } else { - this.#hide(); + this._hide(); } })); } - #show(selection: Selection): void { + private _show(selection: Selection): void { if (selection.isEmpty()) { - this.#showAtLineStart(selection.getPosition().lineNumber); + this._showAtLineStart(selection.getPosition().lineNumber); } else { - this.#showAtSelection(selection); + this._showAtSelection(selection); } - if (this.#isVisible) { - this.#editor.layoutContentWidget(this); + if (this._isVisible) { + this._editor.layoutContentWidget(this); } else { - this.#editor.addContentWidget(this); - this.#isVisible = true; + this._editor.addContentWidget(this); + this._isVisible = true; } } - #showAtSelection(selection: Selection): void { + private _showAtSelection(selection: Selection): void { const cursorPosition = selection.getPosition(); const direction = selection.getDirection(); @@ -224,20 +218,20 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi ? ContentWidgetPositionPreference.ABOVE : ContentWidgetPositionPreference.BELOW; - this.#position = { + this._position = { position: cursorPosition, preference: [preference], }; } - #showAtLineStart(lineNumber: number): void { - const model = this.#editor.getModel(); + private _showAtLineStart(lineNumber: number): void { + const model = this._editor.getModel(); if (!model) { return; } const tabSize = model.getOptions().tabSize; - const fontInfo = this.#editor.getOptions().get(EditorOption.fontInfo); + const fontInfo = this._editor.getOptions().get(EditorOption.fontInfo); const lineContent = model.getLineContent(lineNumber); const indent = computeIndentLevel(lineContent, tabSize); const lineHasSpace = indent < 0 ? true : fontInfo.spaceWidth * indent > 22; @@ -260,43 +254,43 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi const effectiveColumnNumber = /^\S\s*$/.test(model.getLineContent(effectiveLineNumber)) ? 2 : 1; - this.#position = { + this._position = { position: { lineNumber: effectiveLineNumber, column: effectiveColumnNumber }, preference: [ContentWidgetPositionPreference.EXACT], }; } - #hide(): void { - if (this.#isVisible) { - this.#isVisible = false; - this.#editor.removeContentWidget(this); + private _hide(): void { + if (this._isVisible) { + this._isVisible = false; + this._editor.removeContentWidget(this); } } getId(): string { - return this.#id; + return this._id; } getDomNode(): HTMLElement { - return this.#domNode; + return this._domNode; } getPosition(): IContentWidgetPosition | null { - return this.#position; + return this._position; } beforeRender(): IDimension | null { - const position = this.#editor.getPosition(); - const lineHeight = position ? this.#editor.getLineHeightForPosition(position) : this.#editor.getOption(EditorOption.lineHeight); + const position = this._editor.getPosition(); + const lineHeight = position ? this._editor.getLineHeightForPosition(position) : this._editor.getOption(EditorOption.lineHeight); - this.#domNode.style.setProperty('--vscode-inline-chat-affordance-height', `${lineHeight}px`); + this._domNode.style.setProperty('--vscode-inline-chat-affordance-height', `${lineHeight}px`); return null; } override dispose(): void { - if (this.#isVisible) { - this.#editor.removeContentWidget(this); + if (this._isVisible) { + this._editor.removeContentWidget(this); } super.dispose(); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts index 03a7766904655..3d82cec90ec04 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts @@ -26,8 +26,8 @@ import { IUserInteractionService } from '../../../../platform/userInteraction/br export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { - readonly #onDidRunAction = this._store.add(new Emitter()); - readonly onDidRunAction: Event = this.#onDidRunAction.event; + private readonly _onDidRunAction = this._store.add(new Emitter()); + readonly onDidRunAction: Event = this._onDidRunAction.event; constructor( myEditorObs: ObservableCodeEditor, @@ -108,6 +108,6 @@ export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { this._store.add(menu); - this._store.add(this.onDidCloseWithCommand(commandId => this.#onDidRunAction.fire(commandId))); + this._store.add(this.onDidCloseWithCommand(commandId => this._onDidRunAction.fire(commandId))); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts index ca722843a32e6..539e8197ee046 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatNotebook.ts @@ -14,7 +14,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js export class InlineChatNotebookContribution { - readonly #store = new DisposableStore(); + private readonly _store = new DisposableStore(); constructor( @IInlineChatSessionService sessionService: IInlineChatSessionService, @@ -22,7 +22,7 @@ export class InlineChatNotebookContribution { @INotebookEditorService notebookEditorService: INotebookEditorService, ) { - this.#store.add(sessionService.onWillStartSession(newSessionEditor => { + this._store.add(sessionService.onWillStartSession(newSessionEditor => { const candidate = CellUri.parse(newSessionEditor.getModel().uri); if (!candidate) { return; @@ -51,6 +51,6 @@ export class InlineChatNotebookContribution { } dispose(): void { - this.#store.dispose(); + this._store.dispose(); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 5112e2fe44381..04a76d7327a9f 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -44,58 +44,51 @@ import { assertType } from '../../../../base/common/types.js'; */ export class InlineChatInputWidget extends Disposable { - readonly #domNode: HTMLElement; - readonly #container: HTMLElement; - readonly #inputContainer: HTMLElement; - readonly #toolbarContainer: HTMLElement; - readonly #input: IActiveCodeEditor; - readonly #position = observableValue(this, null); - readonly position: IObservable = this.#position; - - readonly #showStore = this._store.add(new DisposableStore()); - readonly #stickyScrollHeight: IObservable; - readonly #layoutData: IObservable<{ totalWidth: number; toolbarWidth: number; height: number; editorPad: number }>; - #anchorLineNumber: number = 0; - #anchorLeft: number = 0; - #anchorAbove: boolean = false; - - readonly #editorObs: ObservableCodeEditor; - readonly #contextKeyService: IContextKeyService; - readonly #menuService: IMenuService; + private readonly _domNode: HTMLElement; + private readonly _container: HTMLElement; + private readonly _inputContainer: HTMLElement; + private readonly _toolbarContainer: HTMLElement; + private readonly _input: IActiveCodeEditor; + private readonly _position = observableValue(this, null); + readonly position: IObservable = this._position; + + private readonly _showStore = this._store.add(new DisposableStore()); + private readonly _stickyScrollHeight: IObservable; + private readonly _layoutData: IObservable<{ totalWidth: number; toolbarWidth: number; height: number; editorPad: number }>; + private _anchorLineNumber: number = 0; + private _anchorLeft: number = 0; + private _anchorAbove: boolean = false; + constructor( - editorObs: ObservableCodeEditor, - @IContextKeyService contextKeyService: IContextKeyService, - @IMenuService menuService: IMenuService, + private readonly _editorObs: ObservableCodeEditor, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IMenuService private readonly _menuService: IMenuService, @IInstantiationService instantiationService: IInstantiationService, @IModelService modelService: IModelService, @IConfigurationService configurationService: IConfigurationService, ) { super(); - this.#editorObs = editorObs; - this.#contextKeyService = contextKeyService; - this.#menuService = menuService; - // Create container - this.#domNode = dom.$('.inline-chat-gutter-menu'); + this._domNode = dom.$('.inline-chat-gutter-menu'); // Create inner container (background + focus border) - this.#container = dom.append(this.#domNode, dom.$('.inline-chat-gutter-container')); + this._container = dom.append(this._domNode, dom.$('.inline-chat-gutter-container')); // Create input editor container - this.#inputContainer = dom.append(this.#container, dom.$('.input')); + this._inputContainer = dom.append(this._container, dom.$('.input')); // Create toolbar container - this.#toolbarContainer = dom.append(this.#container, dom.$('.toolbar')); + this._toolbarContainer = dom.append(this._container, dom.$('.toolbar')); // Create vertical actions bar below the input container - const actionsContainer = dom.append(this.#domNode, dom.$('.inline-chat-gutter-actions')); + const actionsContainer = dom.append(this._domNode, dom.$('.inline-chat-gutter-actions')); const actionBar = this._store.add(new ActionBar(actionsContainer, { orientation: ActionsOrientation.VERTICAL, preventLoopNavigation: true, })); - const actionsMenu = this._store.add(this.#menuService.createMenu(MenuId.ChatEditorInlineMenu, this.#contextKeyService)); + const actionsMenu = this._store.add(this._menuService.createMenu(MenuId.ChatEditorInlineMenu, this._contextKeyService)); const updateActions = () => { const actions = getFlatActionBarActions(actionsMenu.getActions({ shouldForwardArgs: true })); actionBar.clear(); @@ -130,13 +123,13 @@ export class InlineChatInputWidget extends Disposable { ]) }; - this.#input = this._store.add(instantiationService.createInstance(CodeEditorWidget, this.#inputContainer, options, codeEditorWidgetOptions)) as IActiveCodeEditor; + this._input = this._store.add(instantiationService.createInstance(CodeEditorWidget, this._inputContainer, options, codeEditorWidgetOptions)) as IActiveCodeEditor; const model = this._store.add(modelService.createModel('', null, URI.parse(`gutter-input:${Date.now()}`), true)); - this.#input.setModel(model); + this._input.setModel(model); // Create toolbar - const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this.#toolbarContainer, MenuId.InlineChatInput, { + const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this._toolbarContainer, MenuId.InlineChatInput, { telemetrySource: 'inlineChatInput.toolbar', hiddenItemStrategy: HiddenItemStrategy.NoHide, toolbarOptions: { @@ -146,8 +139,8 @@ export class InlineChatInputWidget extends Disposable { })); // Initialize sticky scroll height observable - const stickyScrollController = StickyScrollController.get(this.#editorObs.editor); - this.#stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); + const stickyScrollController = StickyScrollController.get(this._editorObs.editor); + this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); // Track toolbar width changes const toolbarWidth = observableValue(this, 0); @@ -157,24 +150,24 @@ export class InlineChatInputWidget extends Disposable { this._store.add(resizeObserver); this._store.add(resizeObserver.observe(toolbar.getElement())); - const contentWidth = observableFromEvent(this, this.#input.onDidChangeModelContent, () => this.#input.getContentWidth()); - const contentHeight = observableFromEvent(this, this.#input.onDidContentSizeChange, () => this.#input.getContentHeight()); + const contentWidth = observableFromEvent(this, this._input.onDidChangeModelContent, () => this._input.getContentWidth()); + const contentHeight = observableFromEvent(this, this._input.onDidContentSizeChange, () => this._input.getContentHeight()); - this.#layoutData = derived(r => { + this._layoutData = derived(r => { const editorPad = 6; const totalWidth = contentWidth.read(r) + editorPad + toolbarWidth.read(r); const minWidth = 220; const maxWidth = 600; - const clampedWidth = this.#input.getOption(EditorOption.wordWrap) === 'on' + const clampedWidth = this._input.getOption(EditorOption.wordWrap) === 'on' ? maxWidth : Math.max(minWidth, Math.min(totalWidth, maxWidth)); - const lineHeight = this.#input.getOption(EditorOption.lineHeight); + const lineHeight = this._input.getOption(EditorOption.lineHeight); const clampedHeight = Math.min(contentHeight.read(r), (3 * lineHeight)); if (totalWidth > clampedWidth) { // enable word wrap - this.#input.updateOptions({ wordWrap: 'on', }); + this._input.updateOptions({ wordWrap: 'on', }); } return { @@ -187,42 +180,42 @@ export class InlineChatInputWidget extends Disposable { // Update container width and editor layout when width changes this._store.add(autorun(r => { - const { editorPad, toolbarWidth, totalWidth, height } = this.#layoutData.read(r); + const { editorPad, toolbarWidth, totalWidth, height } = this._layoutData.read(r); const inputWidth = totalWidth - toolbarWidth - editorPad; - this.#container.style.width = `${totalWidth}px`; - this.#inputContainer.style.width = `${inputWidth}px`; - this.#input.layout({ width: inputWidth, height }); + this._container.style.width = `${totalWidth}px`; + this._inputContainer.style.width = `${inputWidth}px`; + this._input.layout({ width: inputWidth, height }); })); // Toggle focus class on the container - this._store.add(this.#input.onDidFocusEditorText(() => this.#container.classList.add('focused'))); - this._store.add(this.#input.onDidBlurEditorText(() => this.#container.classList.remove('focused'))); + this._store.add(this._input.onDidFocusEditorText(() => this._container.classList.add('focused'))); + this._store.add(this._input.onDidBlurEditorText(() => this._container.classList.remove('focused'))); // Toggle scroll decoration on the toolbar - this._store.add(this.#input.onDidScrollChange(e => { - this.#toolbarContainer.classList.toggle('fake-scroll-decoration', e.scrollTop > 0); + this._store.add(this._input.onDidScrollChange(e => { + this._toolbarContainer.classList.toggle('fake-scroll-decoration', e.scrollTop > 0); })); // Track input text for context key and adjust width based on content - const inputHasText = CTX_INLINE_CHAT_INPUT_HAS_TEXT.bindTo(this.#contextKeyService); - this._store.add(this.#input.onDidChangeModelContent(() => { - inputHasText.set(this.#input.getModel().getValue().trim().length > 0); + const inputHasText = CTX_INLINE_CHAT_INPUT_HAS_TEXT.bindTo(this._contextKeyService); + this._store.add(this._input.onDidChangeModelContent(() => { + inputHasText.set(this._input.getModel().getValue().trim().length > 0); })); this._store.add(toDisposable(() => inputHasText.reset())); // Track focus state - const inputWidgetFocused = CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED.bindTo(this.#contextKeyService); - this._store.add(this.#input.onDidFocusEditorText(() => inputWidgetFocused.set(true))); - this._store.add(this.#input.onDidBlurEditorText(() => inputWidgetFocused.set(false))); + const inputWidgetFocused = CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED.bindTo(this._contextKeyService); + this._store.add(this._input.onDidFocusEditorText(() => inputWidgetFocused.set(true))); + this._store.add(this._input.onDidBlurEditorText(() => inputWidgetFocused.set(false))); this._store.add(toDisposable(() => inputWidgetFocused.reset())); // Handle key events: ArrowDown to move to actions - this._store.add(this.#input.onKeyDown(e => { + this._store.add(this._input.onKeyDown(e => { if (e.keyCode === KeyCode.DownArrow && !actionBar.isEmpty()) { - const model = this.#input.getModel(); - const position = this.#input.getPosition(); + const model = this._input.getModel(); + const position = this._input.getPosition(); if (position && position.lineNumber === model.getLineCount()) { e.preventDefault(); e.stopPropagation(); @@ -244,18 +237,18 @@ export class InlineChatInputWidget extends Disposable { if (firstItem?.element && dom.isAncestorOfActiveElement(firstItem.element)) { event.preventDefault(); event.stopPropagation(); - this.#input.focus(); + this._input.focus(); } } }, true)); // Track focus - hide when focus leaves - const focusTracker = this._store.add(dom.trackFocus(this.#domNode)); + const focusTracker = this._store.add(dom.trackFocus(this._domNode)); this._store.add(focusTracker.onDidBlur(() => this.hide())); } get value(): string { - return this.#input.getModel().getValue().trim(); + return this._input.getModel().getValue().trim(); } /** @@ -265,77 +258,77 @@ export class InlineChatInputWidget extends Disposable { * @param anchorAbove Whether to anchor above the position (widget grows upward) */ show(lineNumber: number, left: number, anchorAbove: boolean, placeholder: string): void { - this.#showStore.clear(); + this._showStore.clear(); // Clear input state - this.#input.updateOptions({ wordWrap: 'off', placeholder }); - this.#input.getModel().setValue(''); + this._input.updateOptions({ wordWrap: 'off', placeholder }); + this._input.getModel().setValue(''); // Store anchor info for scroll updates - this.#anchorLineNumber = lineNumber; - this.#anchorLeft = left; - this.#anchorAbove = anchorAbove; + this._anchorLineNumber = lineNumber; + this._anchorLeft = left; + this._anchorAbove = anchorAbove; // Set initial position - this.#updatePosition(); + this._updatePosition(); // Create overlay widget via observable pattern - this.#showStore.add(this.#editorObs.createOverlayWidget({ - domNode: this.#domNode, - position: this.#position, + this._showStore.add(this._editorObs.createOverlayWidget({ + domNode: this._domNode, + position: this._position, minContentWidthInPx: constObservable(0), allowEditorOverflow: true, })); // If anchoring above, adjust position after render to account for widget height if (anchorAbove) { - this.#updatePosition(); + this._updatePosition(); } // Update position on scroll, hide if anchor line is out of view (only when input is empty) - this.#showStore.add(this.#editorObs.editor.onDidScrollChange(() => { - const visibleRanges = this.#editorObs.editor.getVisibleRanges(); + this._showStore.add(this._editorObs.editor.onDidScrollChange(() => { + const visibleRanges = this._editorObs.editor.getVisibleRanges(); const isLineVisible = visibleRanges.some(range => - this.#anchorLineNumber >= range.startLineNumber && this.#anchorLineNumber <= range.endLineNumber + this._anchorLineNumber >= range.startLineNumber && this._anchorLineNumber <= range.endLineNumber ); - const hasContent = !!this.#input.getModel().getValue(); + const hasContent = !!this._input.getModel().getValue(); if (!isLineVisible && !hasContent) { this.hide(); } else { - this.#updatePosition(); + this._updatePosition(); } })); // Focus the input editor - setTimeout(() => this.#input.focus(), 0); + setTimeout(() => this._input.focus(), 0); } - #updatePosition(): void { - const editor = this.#editorObs.editor; + private _updatePosition(): void { + const editor = this._editorObs.editor; const lineHeight = editor.getOption(EditorOption.lineHeight); - const top = editor.getTopForLineNumber(this.#anchorLineNumber) - editor.getScrollTop(); + const top = editor.getTopForLineNumber(this._anchorLineNumber) - editor.getScrollTop(); let adjustedTop = top; - if (this.#anchorAbove) { - const widgetHeight = this.#domNode.offsetHeight; + if (this._anchorAbove) { + const widgetHeight = this._domNode.offsetHeight; adjustedTop = top - widgetHeight; } else { adjustedTop = top + lineHeight; } // Clamp to viewport bounds when anchor line is out of view - const stickyScrollHeight = this.#stickyScrollHeight.get(); + const stickyScrollHeight = this._stickyScrollHeight.get(); const layoutInfo = editor.getLayoutInfo(); - const widgetHeight = this.#domNode.offsetHeight; + const widgetHeight = this._domNode.offsetHeight; const minTop = stickyScrollHeight; const maxTop = layoutInfo.height - widgetHeight; const clampedTop = Math.max(minTop, Math.min(adjustedTop, maxTop)); const isClamped = clampedTop !== adjustedTop; - this.#domNode.classList.toggle('clamped', isClamped); + this._domNode.classList.toggle('clamped', isClamped); - this.#position.set({ - preference: { top: clampedTop, left: this.#anchorLeft }, + this._position.set({ + preference: { top: clampedTop, left: this._anchorLeft }, stackOrdinal: 10000, }, undefined); } @@ -345,13 +338,13 @@ export class InlineChatInputWidget extends Disposable { */ hide(): void { // Focus editor if focus is still within the editor's DOM - const editorDomNode = this.#editorObs.editor.getDomNode(); + const editorDomNode = this._editorObs.editor.getDomNode(); if (editorDomNode && dom.isAncestorOfActiveElement(editorDomNode)) { - this.#editorObs.editor.focus(); + this._editorObs.editor.focus(); } - this.#position.set(null, undefined); - this.#input.getModel().setValue(''); - this.#showStore.clear(); + this._position.set(null, undefined); + this._input.getModel().setValue(''); + this._showStore.clear(); } } @@ -360,62 +353,52 @@ export class InlineChatInputWidget extends Disposable { */ export class InlineChatSessionOverlayWidget extends Disposable { - readonly #domNode: HTMLElement = document.createElement('div'); - readonly #container: HTMLElement; - readonly #statusNode: HTMLElement; - readonly #icon: HTMLElement; - readonly #message: HTMLElement; - readonly #toolbarNode: HTMLElement; + private readonly _domNode: HTMLElement = document.createElement('div'); + private readonly _container: HTMLElement; + private readonly _statusNode: HTMLElement; + private readonly _icon: HTMLElement; + private readonly _message: HTMLElement; + private readonly _toolbarNode: HTMLElement; - readonly #showStore = this._store.add(new DisposableStore()); - readonly #position = observableValue(this, null); - readonly #minContentWidthInPx = constObservable(0); + private readonly _showStore = this._store.add(new DisposableStore()); + private readonly _position = observableValue(this, null); + private readonly _minContentWidthInPx = constObservable(0); - readonly #stickyScrollHeight: IObservable; - - readonly #editorObs: ObservableCodeEditor; - readonly #instaService: IInstantiationService; - readonly #keybindingService: IKeybindingService; - readonly #logService: ILogService; + private readonly _stickyScrollHeight: IObservable; constructor( - editorObs: ObservableCodeEditor, - @IInstantiationService instaService: IInstantiationService, - @IKeybindingService keybindingService: IKeybindingService, - @ILogService logService: ILogService, + private readonly _editorObs: ObservableCodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @ILogService private readonly _logService: ILogService, ) { super(); - this.#editorObs = editorObs; - this.#instaService = instaService; - this.#keybindingService = keybindingService; - this.#logService = logService; - - this.#domNode.classList.add('inline-chat-session-overlay-widget'); + this._domNode.classList.add('inline-chat-session-overlay-widget'); - this.#container = document.createElement('div'); - this.#domNode.appendChild(this.#container); - this.#container.classList.add('inline-chat-session-overlay-container'); + this._container = document.createElement('div'); + this._domNode.appendChild(this._container); + this._container.classList.add('inline-chat-session-overlay-container'); // Create status node with icon and message - this.#statusNode = document.createElement('div'); - this.#statusNode.classList.add('status'); - this.#icon = dom.append(this.#statusNode, dom.$('span')); - this.#message = dom.append(this.#statusNode, dom.$('span.message')); - this.#container.appendChild(this.#statusNode); + this._statusNode = document.createElement('div'); + this._statusNode.classList.add('status'); + this._icon = dom.append(this._statusNode, dom.$('span')); + this._message = dom.append(this._statusNode, dom.$('span.message')); + this._container.appendChild(this._statusNode); // Create toolbar node - this.#toolbarNode = document.createElement('div'); - this.#toolbarNode.classList.add('toolbar'); + this._toolbarNode = document.createElement('div'); + this._toolbarNode.classList.add('toolbar'); // Initialize sticky scroll height observable - const stickyScrollController = StickyScrollController.get(this.#editorObs.editor); - this.#stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); + const stickyScrollController = StickyScrollController.get(this._editorObs.editor); + this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); } show(session: IInlineChatSession2): void { - assertType(this.#editorObs.editor.hasModel()); - this.#showStore.clear(); + assertType(this._editorObs.editor.hasModel()); + this._showStore.clear(); // Derived entry observable for this session const entry = derived(r => session.editingSession.readEntry(session.uri, r)); @@ -475,34 +458,34 @@ export class InlineChatSessionOverlayWidget extends Disposable { } }); - this.#showStore.add(autorun(r => { + this._showStore.add(autorun(r => { const value = requestMessage.read(r); if (value) { - this.#message.innerText = renderAsPlaintext(value.message); - this.#icon.className = ''; - this.#icon.classList.add(...ThemeIcon.asClassNameArray(value.icon)); + this._message.innerText = renderAsPlaintext(value.message); + this._icon.className = ''; + this._icon.classList.add(...ThemeIcon.asClassNameArray(value.icon)); } else { - this.#message.innerText = ''; - this.#icon.className = ''; + this._message.innerText = ''; + this._icon.className = ''; } })); // Log when pending confirmation changes - this.#showStore.add(autorun(r => { + this._showStore.add(autorun(r => { const response = session.chatModel.lastRequestObs.read(r)?.response; const pending = response?.isPendingConfirmation.read(r); if (pending) { - this.#logService.info(`[InlineChat] UNEXPECTED approval needed: ${pending.detail ?? 'unknown'}`); + this._logService.info(`[InlineChat] UNEXPECTED approval needed: ${pending.detail ?? 'unknown'}`); } })); // Add toolbar - this.#container.appendChild(this.#toolbarNode); - this.#showStore.add(toDisposable(() => this.#toolbarNode.remove())); + this._container.appendChild(this._toolbarNode); + this._showStore.add(toDisposable(() => this._toolbarNode.remove())); const that = this; - this.#showStore.add(this.#instaService.createInstance(MenuWorkbenchToolBar, this.#toolbarNode, MenuId.ChatEditorInlineExecute, { + this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.ChatEditorInlineExecute, { telemetrySource: 'inlineChatProgress.overlayToolbar', hiddenItemStrategy: HiddenItemStrategy.Ignore, toolbarOptions: { @@ -518,52 +501,52 @@ export class InlineChatSessionOverlayWidget extends Disposable { return undefined; // use default action view item with label } - return new ChatEditingAcceptRejectActionViewItem(action, { ...options, keybinding: undefined }, entry, undefined, that.#keybindingService, primaryActions); + return new ChatEditingAcceptRejectActionViewItem(action, { ...options, keybinding: undefined }, entry, undefined, that._keybindingService, primaryActions); } })); // Position in top right of editor, below sticky scroll - const lineHeight = this.#editorObs.getOption(EditorOption.lineHeight); + const lineHeight = this._editorObs.getOption(EditorOption.lineHeight); // Track widget width changes const widgetWidth = observableValue(this, 0); const resizeObserver = new dom.DisposableResizeObserver(() => { - widgetWidth.set(this.#domNode.offsetWidth, undefined); + widgetWidth.set(this._domNode.offsetWidth, undefined); }); - this.#showStore.add(resizeObserver); - this.#showStore.add(resizeObserver.observe(this.#domNode)); + this._showStore.add(resizeObserver); + this._showStore.add(resizeObserver.observe(this._domNode)); - this.#showStore.add(autorun(r => { - const layoutInfo = this.#editorObs.layoutInfo.read(r); - const stickyScrollHeight = this.#stickyScrollHeight.read(r); + this._showStore.add(autorun(r => { + const layoutInfo = this._editorObs.layoutInfo.read(r); + const stickyScrollHeight = this._stickyScrollHeight.read(r); const width = widgetWidth.read(r); const padding = Math.round(lineHeight.read(r) * 2 / 3); // Cap max-width to the editor viewport (content area) const maxWidth = layoutInfo.contentWidth - 2 * padding; - this.#domNode.style.maxWidth = `${maxWidth}px`; + this._domNode.style.maxWidth = `${maxWidth}px`; // Position: top right, below sticky scroll with padding, left of minimap and scrollbar const top = stickyScrollHeight + padding; const left = layoutInfo.width - width - layoutInfo.verticalScrollbarWidth - layoutInfo.minimap.minimapWidth - padding; - this.#position.set({ + this._position.set({ preference: { top, left }, stackOrdinal: 10000, }, undefined); })); // Create overlay widget - this.#showStore.add(this.#editorObs.createOverlayWidget({ - domNode: this.#domNode, - position: this.#position, - minContentWidthInPx: this.#minContentWidthInPx, + this._showStore.add(this._editorObs.createOverlayWidget({ + domNode: this._domNode, + position: this._position, + minContentWidthInPx: this._minContentWidthInPx, allowEditorOverflow: false, })); } hide(): void { - this.#position.set(null, undefined); - this.#showStore.clear(); + this._position.set(null, undefined); + this._showStore.clear(); } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index 4f36ec8bba0c0..4a008a59b0f9d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -41,58 +41,55 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { declare _serviceBrand: undefined; - readonly #store = new DisposableStore(); - readonly #sessions = new ResourceMap(); + private readonly _store = new DisposableStore(); + private readonly _sessions = new ResourceMap(); - readonly #onWillStartSession = this.#store.add(new Emitter()); - readonly onWillStartSession: Event = this.#onWillStartSession.event; + private readonly _onWillStartSession = this._store.add(new Emitter()); + readonly onWillStartSession: Event = this._onWillStartSession.event; - readonly #onDidChangeSessions = this.#store.add(new Emitter()); - readonly onDidChangeSessions: Event = this.#onDidChangeSessions.event; - - readonly #chatService: IChatService; + private readonly _onDidChangeSessions = this._store.add(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; constructor( - @IChatService chatService: IChatService, + @IChatService private readonly _chatService: IChatService, @IChatAgentService chatAgentService: IChatAgentService, ) { - this.#chatService = chatService; // Listen for agent changes and dispose all sessions when there is no agent const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.EditorInline)); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const agent = agentObs.read(r); if (!agent) { // No agent available, dispose all sessions - dispose(this.#sessions.values()); - this.#sessions.clear(); + dispose(this._sessions.values()); + this._sessions.clear(); } })); } dispose() { - this.#store.dispose(); + this._store.dispose(); } createSession(editor: IActiveCodeEditor): IInlineChatSession2 { const uri = editor.getModel().uri; - if (this.#sessions.has(uri)) { + if (this._sessions.has(uri)) { throw new Error('Session already exists'); } - this.#onWillStartSession.fire(editor); + this._onWillStartSession.fire(editor); - const chatModelRef = this.#chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ }); + const chatModelRef = this._chatService.startNewLocalSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ }); const chatModel = chatModelRef.object; chatModel.startEditingSession(false); const store = new DisposableStore(); store.add(toDisposable(() => { - this.#chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatSession'); + this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatSession'); chatModel.editingSession?.reject(); - this.#sessions.delete(uri); - this.#onDidChangeSessions.fire(this); + this._sessions.delete(uri); + this._onDidChangeSessions.fire(this); })); store.add(chatModelRef); @@ -107,7 +104,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { if (state === ModifiedFileEntryState.Accepted || state === ModifiedFileEntryState.Rejected) { const response = chatModel.getRequests().at(-1)?.response; if (response) { - this.#chatService.notifyUserAction({ + this._chatService.notifyUserAction({ sessionResource: response.session.sessionResource, requestId: response.requestId, agentId: response.agent?.id, @@ -141,16 +138,16 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { editingSession: chatModel.editingSession!, dispose: store.dispose.bind(store) }; - this.#sessions.set(uri, result); - this.#onDidChangeSessions.fire(this); + this._sessions.set(uri, result); + this._onDidChangeSessions.fire(this); return result; } getSessionByTextModel(uri: URI): IInlineChatSession2 | undefined { - let result = this.#sessions.get(uri); + let result = this._sessions.get(uri); if (!result) { // no direct session, try to find an editing session which has a file entry for the uri - for (const [_, candidate] of this.#sessions) { + for (const [_, candidate] of this._sessions) { const entry = candidate.editingSession.getEntry(uri); if (entry) { result = candidate; @@ -162,7 +159,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { } getSessionBySessionUri(sessionResource: URI): IInlineChatSession2 | undefined { - for (const session of this.#sessions.values()) { + for (const session of this._sessions.values()) { if (isEqual(session.chatModel.sessionResource, sessionResource)) { return session; } @@ -175,11 +172,11 @@ export class InlineChatEnabler { static Id = 'inlineChat.enabler'; - readonly #ctxHasProvider2: IContextKey; - readonly #ctxHasNotebookProvider: IContextKey; - readonly #ctxPossible: IContextKey; + private readonly _ctxHasProvider2: IContextKey; + private readonly _ctxHasNotebookProvider: IContextKey; + private readonly _ctxPossible: IContextKey; - readonly #store = new DisposableStore(); + private readonly _store = new DisposableStore(); constructor( @IContextKeyService contextKeyService: IContextKeyService, @@ -187,41 +184,41 @@ export class InlineChatEnabler { @IEditorService editorService: IEditorService, @IConfigurationService configService: IConfigurationService, ) { - this.#ctxHasProvider2 = CTX_INLINE_CHAT_HAS_AGENT2.bindTo(contextKeyService); - this.#ctxHasNotebookProvider = CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT.bindTo(contextKeyService); - this.#ctxPossible = CTX_INLINE_CHAT_POSSIBLE.bindTo(contextKeyService); + this._ctxHasProvider2 = CTX_INLINE_CHAT_HAS_AGENT2.bindTo(contextKeyService); + this._ctxHasNotebookProvider = CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT.bindTo(contextKeyService); + this._ctxPossible = CTX_INLINE_CHAT_POSSIBLE.bindTo(contextKeyService); const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.EditorInline)); const notebookAgentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.Notebook)); const notebookAgentConfigObs = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, configService); - this.#store.add(autorun(r => { + this._store.add(autorun(r => { const agent = agentObs.read(r); if (!agent) { - this.#ctxHasProvider2.reset(); + this._ctxHasProvider2.reset(); } else { - this.#ctxHasProvider2.set(true); + this._ctxHasProvider2.set(true); } })); - this.#store.add(autorun(r => { - this.#ctxHasNotebookProvider.set(notebookAgentConfigObs.read(r) && !!notebookAgentObs.read(r)); + this._store.add(autorun(r => { + this._ctxHasNotebookProvider.set(notebookAgentConfigObs.read(r) && !!notebookAgentObs.read(r)); })); const updateEditor = () => { const ctrl = editorService.activeEditorPane?.getControl(); const isCodeEditorLike = isCodeEditor(ctrl) || isDiffEditor(ctrl) || isCompositeEditor(ctrl); - this.#ctxPossible.set(isCodeEditorLike); + this._ctxPossible.set(isCodeEditorLike); }; - this.#store.add(editorService.onDidActiveEditorChange(updateEditor)); + this._store.add(editorService.onDidActiveEditorChange(updateEditor)); updateEditor(); } dispose() { - this.#ctxPossible.reset(); - this.#ctxHasProvider2.reset(); - this.#store.dispose(); + this._ctxPossible.reset(); + this._ctxHasProvider2.reset(); + this._store.dispose(); } } @@ -232,7 +229,7 @@ export class InlineChatEscapeToolContribution extends Disposable { static readonly DONT_ASK_AGAIN_KEY = 'inlineChat.dontAskMoveToPanelChat'; - static readonly #data: IToolData = { + private static readonly _data: IToolData = { id: 'inline_chat_exit', source: ToolDataSource.Internal, canBeReferencedInPrompt: false, @@ -254,7 +251,7 @@ export class InlineChatEscapeToolContribution extends Disposable { super(); - this._store.add(lmTools.registerTool(InlineChatEscapeToolContribution.#data, { + this._store.add(lmTools.registerTool(InlineChatEscapeToolContribution._data, { invoke: async (invocation, _tokenCountFn, _progress, _token) => { const sessionResource = invocation.context?.sessionResource; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts index 9d3dc3edbcb77..a449ed2a51206 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts @@ -95,59 +95,37 @@ export class InlineChatWidget { protected readonly _store = new DisposableStore(); - readonly #ctxInputEditorFocused: IContextKey; - readonly #ctxResponseFocused: IContextKey; + private readonly _ctxInputEditorFocused: IContextKey; + private readonly _ctxResponseFocused: IContextKey; - readonly #chatWidget: ChatWidget; + private readonly _chatWidget: ChatWidget; protected readonly _onDidChangeHeight = this._store.add(new Emitter()); - readonly onDidChangeHeight: Event = Event.filter(this._onDidChangeHeight.event, _ => !this.#isLayouting); + readonly onDidChangeHeight: Event = Event.filter(this._onDidChangeHeight.event, _ => !this._isLayouting); - readonly #requestInProgress = observableValue(this, false); - readonly requestInProgress: IObservable = this.#requestInProgress; + private readonly _requestInProgress = observableValue(this, false); + readonly requestInProgress: IObservable = this._requestInProgress; - #isLayouting: boolean = false; + private _isLayouting: boolean = false; readonly scopedContextKeyService: IContextKeyService; - readonly #options: IInlineChatWidgetConstructionOptions; - readonly #contextKeyService: IContextKeyService; - readonly #keybindingService: IKeybindingService; - readonly #accessibilityService: IAccessibilityService; - readonly #configurationService: IConfigurationService; - readonly #accessibleViewService: IAccessibleViewService; - readonly #chatService: IChatService; - readonly #hoverService: IHoverService; - readonly #chatEntitlementService: IChatEntitlementService; - readonly #markdownRendererService: IMarkdownRendererService; - constructor( location: IChatWidgetLocationOptions, - options: IInlineChatWidgetConstructionOptions, + private readonly _options: IInlineChatWidgetConstructionOptions, @IInstantiationService protected readonly _instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, - @IKeybindingService keybindingService: IKeybindingService, - @IAccessibilityService accessibilityService: IAccessibilityService, - @IConfigurationService configurationService: IConfigurationService, - @IAccessibleViewService accessibleViewService: IAccessibleViewService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService, @ITextModelService protected readonly _textModelResolverService: ITextModelService, - @IChatService chatService: IChatService, - @IHoverService hoverService: IHoverService, - @IChatEntitlementService chatEntitlementService: IChatEntitlementService, - @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, + @IChatService private readonly _chatService: IChatService, + @IHoverService private readonly _hoverService: IHoverService, + @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, ) { - this.#options = options; - this.#contextKeyService = contextKeyService; - this.#keybindingService = keybindingService; - this.#accessibilityService = accessibilityService; - this.#configurationService = configurationService; - this.#accessibleViewService = accessibleViewService; - this.#chatService = chatService; - this.#hoverService = hoverService; - this.#chatEntitlementService = chatEntitlementService; - this.#markdownRendererService = markdownRendererService; - - this.scopedContextKeyService = this._store.add(contextKeyService.createScoped(this._elements.chatWidget)); + this.scopedContextKeyService = this._store.add(_contextKeyService.createScoped(this._elements.chatWidget)); const scopedInstaService = _instantiationService.createChild( new ServiceCollection([ IContextKeyService, @@ -156,7 +134,7 @@ export class InlineChatWidget { this._store ); - this.#chatWidget = scopedInstaService.createInstance( + this._chatWidget = scopedInstaService.createInstance( ChatWidget, location, { isInlineChat: true }, @@ -176,14 +154,14 @@ export class InlineChatWidget { if (emptyResponse) { return false; } - if (item.response.value.every(item => item.kind === 'textEditGroup' && this.#options.chatWidgetViewOptions?.rendererOptions?.renderTextEditsAsSummary?.(item.uri))) { + if (item.response.value.every(item => item.kind === 'textEditGroup' && _options.chatWidgetViewOptions?.rendererOptions?.renderTextEditsAsSummary?.(item.uri))) { return false; } return true; }, dndContainer: this._elements.root, defaultMode: ChatMode.Ask, - ...this.#options.chatWidgetViewOptions + ..._options.chatWidgetViewOptions }, { listForeground: inlineChatForeground, @@ -193,11 +171,11 @@ export class InlineChatWidget { resultEditorBackground: editorBackground } ); - this._elements.root.classList.toggle('in-zone-widget', !!this.#options.inZoneWidget); - this.#chatWidget.render(this._elements.chatWidget); + this._elements.root.classList.toggle('in-zone-widget', !!_options.inZoneWidget); + this._chatWidget.render(this._elements.chatWidget); this._elements.chatWidget.style.setProperty(asCssVariableName(chatRequestBackground), asCssVariable(inlineChatBackground)); - this.#chatWidget.setVisible(true); - this._store.add(this.#chatWidget); + this._chatWidget.setVisible(true); + this._store.add(this._chatWidget); const ctxResponse = ChatContextKeys.isResponse.bindTo(this.scopedContextKeyService); const ctxResponseVote = ChatContextKeys.responseVote.bindTo(this.scopedContextKeyService); @@ -206,10 +184,10 @@ export class InlineChatWidget { const ctxResponseErrorFiltered = ChatContextKeys.responseIsFiltered.bindTo(this.scopedContextKeyService); const viewModelStore = this._store.add(new DisposableStore()); - this._store.add(this.#chatWidget.onDidChangeViewModel(() => { + this._store.add(this._chatWidget.onDidChangeViewModel(() => { viewModelStore.clear(); - const viewModel = this.#chatWidget.viewModel; + const viewModel = this._chatWidget.viewModel; if (!viewModel) { return; } @@ -225,7 +203,7 @@ export class InlineChatWidget { viewModelStore.add(viewModel.onDidChange(() => { - this.#requestInProgress.set(viewModel.model.requestInProgress.get(), undefined); + this._requestInProgress.set(viewModel.model.requestInProgress.get(), undefined); const last = viewModel.getItems().at(-1); toolbar2.context = last; @@ -246,22 +224,22 @@ export class InlineChatWidget { })); // context keys - this.#ctxResponseFocused = CTX_INLINE_CHAT_RESPONSE_FOCUSED.bindTo(this.#contextKeyService); + this._ctxResponseFocused = CTX_INLINE_CHAT_RESPONSE_FOCUSED.bindTo(this._contextKeyService); const tracker = this._store.add(trackFocus(this.domNode)); - this._store.add(tracker.onDidBlur(() => this.#ctxResponseFocused.set(false))); - this._store.add(tracker.onDidFocus(() => this.#ctxResponseFocused.set(true))); + this._store.add(tracker.onDidBlur(() => this._ctxResponseFocused.set(false))); + this._store.add(tracker.onDidFocus(() => this._ctxResponseFocused.set(true))); - this.#ctxInputEditorFocused = CTX_INLINE_CHAT_FOCUSED.bindTo(this.#contextKeyService); - this._store.add(this.#chatWidget.inputEditor.onDidFocusEditorWidget(() => this.#ctxInputEditorFocused.set(true))); - this._store.add(this.#chatWidget.inputEditor.onDidBlurEditorWidget(() => this.#ctxInputEditorFocused.set(false))); + this._ctxInputEditorFocused = CTX_INLINE_CHAT_FOCUSED.bindTo(_contextKeyService); + this._store.add(this._chatWidget.inputEditor.onDidFocusEditorWidget(() => this._ctxInputEditorFocused.set(true))); + this._store.add(this._chatWidget.inputEditor.onDidBlurEditorWidget(() => this._ctxInputEditorFocused.set(false))); - const statusMenuId = this.#options.statusMenuId instanceof MenuId ? this.#options.statusMenuId : this.#options.statusMenuId.menu; + const statusMenuId = _options.statusMenuId instanceof MenuId ? _options.statusMenuId : _options.statusMenuId.menu; // BUTTON bar - const statusMenuOptions = this.#options.statusMenuId instanceof MenuId ? undefined : this.#options.statusMenuId.options; + const statusMenuOptions = _options.statusMenuId instanceof MenuId ? undefined : _options.statusMenuId.options; const statusButtonBar = scopedInstaService.createInstance(MenuWorkbenchButtonBar, this._elements.toolbar1, statusMenuId, { toolbarOptions: { primaryGroup: '0_main' }, - telemetrySource: this.#options.chatWidgetViewOptions?.menus?.telemetrySource, + telemetrySource: _options.chatWidgetViewOptions?.menus?.telemetrySource, menuOptions: { renderShortTitle: true }, ...statusMenuOptions, }); @@ -269,8 +247,8 @@ export class InlineChatWidget { this._store.add(statusButtonBar); // secondary toolbar - const toolbar2 = scopedInstaService.createInstance(MenuWorkbenchToolBar, this._elements.toolbar2, this.#options.secondaryMenuId ?? MenuId.for(''), { - telemetrySource: this.#options.chatWidgetViewOptions?.menus?.telemetrySource, + const toolbar2 = scopedInstaService.createInstance(MenuWorkbenchToolBar, this._elements.toolbar2, _options.secondaryMenuId ?? MenuId.for(''), { + telemetrySource: _options.chatWidgetViewOptions?.menus?.telemetrySource, menuOptions: { renderShortTitle: true, shouldForwardArgs: true }, actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { if (action instanceof MenuItemAction && action.item.id === MarkUnhelpfulActionId) { @@ -283,60 +261,60 @@ export class InlineChatWidget { this._store.add(toolbar2); - this._store.add(this.#configurationService.onDidChangeConfiguration(e => { + this._store.add(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AccessibilityVerbositySettingId.InlineChat)) { - this.#updateAriaLabel(); + this._updateAriaLabel(); } })); this._elements.root.tabIndex = 0; this._elements.statusLabel.tabIndex = 0; - this.#updateAriaLabel(); - this.#setupDisclaimer(); + this._updateAriaLabel(); + this._setupDisclaimer(); - this._store.add(this.#hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this._elements.statusLabel, () => { + this._store.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('element'), this._elements.statusLabel, () => { return this._elements.statusLabel.dataset['title']; })); - this._store.add(this.#chatService.onDidPerformUserAction(e => { - if (isEqual(e.sessionResource, this.#chatWidget.viewModel?.model.sessionResource) && e.action.kind === 'vote') { + this._store.add(this._chatService.onDidPerformUserAction(e => { + if (isEqual(e.sessionResource, this._chatWidget.viewModel?.model.sessionResource) && e.action.kind === 'vote') { this.updateStatus(localize('feedbackThanks', "Thank you for your feedback!"), { resetAfter: 1250 }); } })); } - #updateAriaLabel(): void { + private _updateAriaLabel(): void { - this._elements.root.ariaLabel = this.#accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.InlineChat); + this._elements.root.ariaLabel = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.InlineChat); - if (this.#accessibilityService.isScreenReaderOptimized()) { + if (this._accessibilityService.isScreenReaderOptimized()) { let label = defaultAriaLabel; - if (this.#configurationService.getValue(AccessibilityVerbositySettingId.InlineChat)) { - const kbLabel = this.#keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); + if (this._configurationService.getValue(AccessibilityVerbositySettingId.InlineChat)) { + const kbLabel = this._keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); label = kbLabel ? localize('inlineChat.accessibilityHelp', "Inline Chat Input, Use {0} for Inline Chat Accessibility Help.", kbLabel) : localize('inlineChat.accessibilityHelpNoKb', "Inline Chat Input, Run the Inline Chat Accessibility Help command for more information."); } - this.#chatWidget.inputEditor.updateOptions({ ariaLabel: label }); + this._chatWidget.inputEditor.updateOptions({ ariaLabel: label }); } } - #setupDisclaimer(): void { + private _setupDisclaimer(): void { const disposables = this._store.add(new DisposableStore()); this._store.add(autorun(reader => { disposables.clear(); reset(this._elements.disclaimerLabel); - const sentiment = this.#chatEntitlementService.sentimentObs.read(reader); - const anonymous = this.#chatEntitlementService.anonymousObs.read(reader); - const requestInProgress = this.#chatService.requestInProgressObs.read(reader); + const sentiment = this._chatEntitlementService.sentimentObs.read(reader); + const anonymous = this._chatEntitlementService.anonymousObs.read(reader); + const requestInProgress = this._chatService.requestInProgressObs.read(reader); const showDisclaimer = !sentiment.installed && anonymous && !requestInProgress; this._elements.disclaimerLabel.classList.toggle('hidden', !showDisclaimer); if (showDisclaimer) { - const renderedMarkdown = disposables.add(this.#markdownRendererService.render(new MarkdownString(localize({ key: 'termsDisclaimer', comment: ['{Locked="]({2})"}', '{Locked="]({3})"}'] }, "By continuing with {0} Copilot, you agree to {1}'s [Terms]({2}) and [Privacy Statement]({3})", product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.termsStatementUrl ?? '', product.defaultChatAgent?.privacyStatementUrl ?? ''), { isTrusted: true }))); + const renderedMarkdown = disposables.add(this._markdownRendererService.render(new MarkdownString(localize({ key: 'termsDisclaimer', comment: ['{Locked="]({2})"}', '{Locked="]({3})"}'] }, "By continuing with {0} Copilot, you agree to {1}'s [Terms]({2}) and [Privacy Statement]({3})", product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.termsStatementUrl ?? '', product.defaultChatAgent?.privacyStatementUrl ?? ''), { isTrusted: true }))); this._elements.disclaimerLabel.appendChild(renderedMarkdown.element); } @@ -353,20 +331,20 @@ export class InlineChatWidget { } get chatWidget(): ChatWidget { - return this.#chatWidget; + return this._chatWidget; } saveState() { - this.#chatWidget.saveState(); + this._chatWidget.saveState(); } layout(widgetDim: Dimension) { const contentHeight = this.contentHeight; - this.#isLayouting = true; + this._isLayouting = true; try { this._doLayout(widgetDim); } finally { - this.#isLayouting = false; + this._isLayouting = false; if (this.contentHeight !== contentHeight) { this._onDidChangeHeight.fire(); @@ -383,7 +361,7 @@ export class InlineChatWidget { this._elements.root.style.height = `${dimension.height - extraHeight}px`; this._elements.root.style.width = `${dimension.width}px`; - this.#chatWidget.layout( + this._chatWidget.layout( dimension.height - statusHeight - extraHeight, dimension.width ); @@ -394,7 +372,7 @@ export class InlineChatWidget { */ get contentHeight(): number { const data = { - chatWidgetContentHeight: this.#chatWidget.contentHeight, + chatWidgetContentHeight: this._chatWidget.contentHeight, statusHeight: getTotalHeight(this._elements.status), extraHeight: this._getExtraHeight() }; @@ -407,7 +385,7 @@ export class InlineChatWidget { // at least "maxWidgetHeight" high and at most the content height. let maxWidgetOutputHeight = 100; - for (const item of this.#chatWidget.viewModel?.getItems() ?? []) { + for (const item of this._chatWidget.viewModel?.getItems() ?? []) { if (isResponseVM(item) && item.response.value.some(r => r.kind === 'textEditGroup' && !r.state?.applied)) { maxWidgetOutputHeight = 270; break; @@ -415,29 +393,29 @@ export class InlineChatWidget { } let value = this.contentHeight; - value -= this.#chatWidget.contentHeight; - value += Math.min(this.#chatWidget.input.height.get() + maxWidgetOutputHeight, this.#chatWidget.contentHeight); + value -= this._chatWidget.contentHeight; + value += Math.min(this._chatWidget.input.height.get() + maxWidgetOutputHeight, this._chatWidget.contentHeight); return value; } protected _getExtraHeight(): number { - return this.#options.inZoneWidget ? 1 : (2 /*border*/ + 4 /*shadow*/); + return this._options.inZoneWidget ? 1 : (2 /*border*/ + 4 /*shadow*/); } get value(): string { - return this.#chatWidget.getInput(); + return this._chatWidget.getInput(); } set value(value: string) { - this.#chatWidget.setInput(value); + this._chatWidget.setInput(value); } selectAll() { - this.#chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1)); + this._chatWidget.inputEditor.setSelection(new Selection(1, 1, Number.MAX_SAFE_INTEGER, 1)); } set placeholder(value: string) { - this.#chatWidget.setInputPlaceholder(value); + this._chatWidget.setInputPlaceholder(value); } toggleStatus(show: boolean) { @@ -458,7 +436,7 @@ export class InlineChatWidget { } async getCodeBlockInfo(codeBlockIndex: number): Promise { - const { viewModel } = this.#chatWidget; + const { viewModel } = this._chatWidget; if (!viewModel) { return undefined; } @@ -505,18 +483,18 @@ export class InlineChatWidget { } get responseContent(): string | undefined { - const requests = this.#chatWidget.viewModel?.model.getRequests(); + const requests = this._chatWidget.viewModel?.model.getRequests(); return requests?.at(-1)?.response?.response.toString(); } getChatModel(): IChatModel | undefined { - return this.#chatWidget.viewModel?.model; + return this._chatWidget.viewModel?.model; } setChatModel(chatModel: IChatModel) { chatModel.inputModel.setState({ inputText: '', selections: [] }); - this.#chatWidget.setModel(chatModel); + this._chatWidget.setModel(chatModel); } updateInfo(message: string): void { @@ -555,8 +533,8 @@ export class InlineChatWidget { } reset() { - this.#chatWidget.attachmentModel.clear(true); - this.#chatWidget.saveState(); + this._chatWidget.attachmentModel.clear(true); + this._chatWidget.saveState(); reset(this._elements.statusLabel); this._elements.statusLabel.classList.toggle('hidden', true); @@ -569,7 +547,7 @@ export class InlineChatWidget { } focus() { - this.#chatWidget.focusInput(); + this._chatWidget.focusInput(); } hasFocus() { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts index 5f172c19672e8..21113b9d0dea2 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts @@ -28,7 +28,7 @@ import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; export class InlineChatZoneWidget extends ZoneWidget { - static readonly #options: IOptions = { + private static readonly _options: IOptions = { showFrame: true, frameWidth: 1, // frameColor: 'var(--vscode-inlineChat-border)', @@ -43,12 +43,9 @@ export class InlineChatZoneWidget extends ZoneWidget { readonly widget: EditorBasedInlineChatWidget; - readonly #ctxCursorPosition: IContextKey<'above' | 'below' | ''>; - #dimension?: Dimension; - #notebookEditor?: INotebookEditor; - - readonly #instaService: IInstantiationService; - #logService: ILogService; + private readonly _ctxCursorPosition: IContextKey<'above' | 'below' | ''>; + private _dimension?: Dimension; + private notebookEditor?: INotebookEditor; constructor( location: IChatWidgetLocationOptions, @@ -56,22 +53,20 @@ export class InlineChatZoneWidget extends ZoneWidget { editors: { editor: ICodeEditor; notebookEditor?: INotebookEditor }, /** @deprecated should go away with inline2 */ clearDelegate: () => Promise, - @IInstantiationService instaService: IInstantiationService, - @ILogService logService: ILogService, + @IInstantiationService private readonly _instaService: IInstantiationService, + @ILogService private _logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, ) { - super(editors.editor, InlineChatZoneWidget.#options); - this.#instaService = instaService; - this.#logService = logService; - this.#notebookEditor = editors.notebookEditor; + super(editors.editor, InlineChatZoneWidget._options); + this.notebookEditor = editors.notebookEditor; - this.#ctxCursorPosition = CTX_INLINE_CHAT_OUTER_CURSOR_POSITION.bindTo(contextKeyService); + this._ctxCursorPosition = CTX_INLINE_CHAT_OUTER_CURSOR_POSITION.bindTo(contextKeyService); this._disposables.add(toDisposable(() => { - this.#ctxCursorPosition.reset(); + this._ctxCursorPosition.reset(); })); - this.widget = this.#instaService.createInstance(EditorBasedInlineChatWidget, location, this.editor, { + this.widget = this._instaService.createInstance(EditorBasedInlineChatWidget, location, this.editor, { statusMenuId: { menu: MENU_INLINE_CHAT_WIDGET_STATUS, options: { @@ -110,14 +105,14 @@ export class InlineChatZoneWidget extends ZoneWidget { let revealFn: (() => void) | undefined; this._disposables.add(this.widget.chatWidget.onWillMaybeChangeHeight(() => { if (this.position) { - revealFn = this.#createZoneAndScrollRestoreFn(this.position); + revealFn = this._createZoneAndScrollRestoreFn(this.position); } })); this._disposables.add(this.widget.onDidChangeHeight(() => { if (this.position && !this._usesResizeHeight) { // only relayout when visible - revealFn ??= this.#createZoneAndScrollRestoreFn(this.position); - const height = this.#computeHeight(); + revealFn ??= this._createZoneAndScrollRestoreFn(this.position); + const height = this._computeHeight(); this._relayout(height.linesValue); revealFn?.(); revealFn = undefined; @@ -141,13 +136,13 @@ export class InlineChatZoneWidget extends ZoneWidget { // todo@jrieken listen ONLY when showing const updateCursorIsAboveContextKey = () => { if (!this.position || !this.editor.hasModel()) { - this.#ctxCursorPosition.reset(); + this._ctxCursorPosition.reset(); } else if (this.position.lineNumber === this.editor.getPosition().lineNumber) { - this.#ctxCursorPosition.set('above'); + this._ctxCursorPosition.set('above'); } else if (this.position.lineNumber + 1 === this.editor.getPosition().lineNumber) { - this.#ctxCursorPosition.set('below'); + this._ctxCursorPosition.set('below'); } else { - this.#ctxCursorPosition.reset(); + this._ctxCursorPosition.reset(); } }; this._disposables.add(this.editor.onDidChangeCursorPosition(e => updateCursorIsAboveContextKey())); @@ -164,19 +159,19 @@ export class InlineChatZoneWidget extends ZoneWidget { protected override _doLayout(heightInPixel: number): void { - this.#updatePadding(); + this._updatePadding(); const info = this.editor.getLayoutInfo(); const width = info.contentWidth - info.verticalScrollbarWidth; // width = Math.min(850, width); - this.#dimension = new Dimension(width, heightInPixel); - this.widget.layout(this.#dimension); + this._dimension = new Dimension(width, heightInPixel); + this.widget.layout(this._dimension); } - #computeHeight(): { linesValue: number; pixelsValue: number } { + private _computeHeight(): { linesValue: number; pixelsValue: number } { const chatContentHeight = this.widget.contentHeight; - const editorHeight = this.#notebookEditor?.getLayoutInfo().height ?? this.editor.getLayoutInfo().height; + const editorHeight = this.notebookEditor?.getLayoutInfo().height ?? this.editor.getLayoutInfo().height; const contentHeight = this._decoratingElementsHeight() + Math.min(chatContentHeight, Math.max(this.widget.minHeight, editorHeight * 0.42)); const heightInLines = contentHeight / this.editor.getOption(EditorOption.lineHeight); @@ -197,25 +192,25 @@ export class InlineChatZoneWidget extends ZoneWidget { } protected override _onWidth(_widthInPixel: number): void { - if (this.#dimension) { - this._doLayout(this.#dimension.height); + if (this._dimension) { + this._doLayout(this._dimension.height); } } override show(position: Position): void { assertType(this.container); - this.#updatePadding(); + this._updatePadding(); - const revealZone = this.#createZoneAndScrollRestoreFn(position); - super.show(position, this.#computeHeight().linesValue); + const revealZone = this._createZoneAndScrollRestoreFn(position); + super.show(position, this._computeHeight().linesValue); this.widget.chatWidget.setVisible(true); this.widget.focus(); revealZone(); } - #updatePadding() { + private _updatePadding() { assertType(this.container); const info = this.editor.getLayoutInfo(); @@ -231,12 +226,12 @@ export class InlineChatZoneWidget extends ZoneWidget { } override updatePositionAndHeight(position: Position): void { - const revealZone = this.#createZoneAndScrollRestoreFn(position); - super.updatePositionAndHeight(position, !this._usesResizeHeight ? this.#computeHeight().linesValue : undefined); + const revealZone = this._createZoneAndScrollRestoreFn(position); + super.updatePositionAndHeight(position, !this._usesResizeHeight ? this._computeHeight().linesValue : undefined); revealZone(); } - #createZoneAndScrollRestoreFn(position: Position): () => void { + private _createZoneAndScrollRestoreFn(position: Position): () => void { const scrollState = StableEditorBottomScrollState.capture(this.editor); @@ -247,7 +242,7 @@ export class InlineChatZoneWidget extends ZoneWidget { const scrollTop = this.editor.getScrollTop(); const lineTop = this.editor.getTopForLineNumber(lineNumber); - const zoneTop = lineTop - this.#computeHeight().pixelsValue; + const zoneTop = lineTop - this._computeHeight().pixelsValue; const editorHeight = this.editor.getLayoutInfo().height; const lineBottom = this.editor.getBottomForLineNumber(lineNumber); @@ -262,7 +257,7 @@ export class InlineChatZoneWidget extends ZoneWidget { } if (newScrollTop < scrollTop || forceScrollTop) { - this.#logService.trace('[IE] REVEAL zone', { zoneTop, lineTop, lineBottom, scrollTop, newScrollTop, forceScrollTop }); + this._logService.trace('[IE] REVEAL zone', { zoneTop, lineTop, lineBottom, scrollTop, newScrollTop, forceScrollTop }); this.editor.setScrollTop(newScrollTop, ScrollType.Immediate); } }; @@ -274,7 +269,7 @@ export class InlineChatZoneWidget extends ZoneWidget { override hide(): void { const scrollState = StableEditorBottomScrollState.capture(this.editor); - this.#ctxCursorPosition.reset(); + this._ctxCursorPosition.reset(); this.widget.chatWidget.setVisible(false); super.hide(); aria.status(localize('inlineChatClosed', 'Closed inline chat widget')); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts b/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts index 8e1573fdaed98..2a6ea9759da72 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/testWorkerService.ts @@ -20,49 +20,47 @@ import { DisposableStore, IDisposable } from '../../../../../base/common/lifecyc export class TestWorkerService extends mock() implements IDisposable { - readonly #store = new DisposableStore(); - readonly #worker = this.#store.add(new EditorWorker()); - readonly #modelService: IModelService; + private readonly _store = new DisposableStore(); + private readonly _worker = this._store.add(new EditorWorker()); - constructor(@IModelService modelService: IModelService) { + constructor(@IModelService private readonly _modelService: IModelService) { super(); - this.#modelService = modelService; } dispose(): void { - this.#store.dispose(); + this._store.dispose(); } override async computeMoreMinimalEdits(resource: URI, edits: TextEdit[] | null | undefined, pretty?: boolean | undefined): Promise { return undefined; } override async computeDiff(original: URI, modified: URI, options: IDocumentDiffProviderOptions, algorithm: DiffAlgorithmName): Promise { - await new Promise(resolve => disposableTimeout(() => resolve(), 0, this.#store)); - if (this.#store.isDisposed) { + await new Promise(resolve => disposableTimeout(() => resolve(), 0, this._store)); + if (this._store.isDisposed) { return null; } - const originalModel = this.#modelService.getModel(original); - const modifiedModel = this.#modelService.getModel(modified); + const originalModel = this._modelService.getModel(original); + const modifiedModel = this._modelService.getModel(modified); assertType(originalModel); assertType(modifiedModel); - this.#worker.$acceptNewModel({ + this._worker.$acceptNewModel({ url: originalModel.uri.toString(), versionId: originalModel.getVersionId(), lines: originalModel.getLinesContent(), EOL: originalModel.getEOL(), }); - this.#worker.$acceptNewModel({ + this._worker.$acceptNewModel({ url: modifiedModel.uri.toString(), versionId: modifiedModel.getVersionId(), lines: modifiedModel.getLinesContent(), EOL: modifiedModel.getEOL(), }); - const result = await this.#worker.$computeDiff(originalModel.uri.toString(), modifiedModel.uri.toString(), options, algorithm); + const result = await this._worker.$computeDiff(originalModel.uri.toString(), modifiedModel.uri.toString(), options, algorithm); if (!result) { return result; } From be9986cffdad13477ad7735dedcc7c546301409f Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 6 Mar 2026 18:00:56 +0100 Subject: [PATCH 13/28] Fixes problem with code coverage on windows --- .../vscode-selfhost-test-provider/src/testOutputScanner.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts index 296ed1e9f12be..09e9a2af6d2b5 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts @@ -545,6 +545,9 @@ export class SourceMapStore { } } + if (/^[a-zA-Z]:/.test(source) || source.startsWith('/')) { + return vscode.Uri.file(source); + } return vscode.Uri.parse(source); } From 77375f1dc78bb14f929de41726c330acb362dabf Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 6 Mar 2026 18:18:20 +0100 Subject: [PATCH 14/28] Ensures ColorId.DefaultBackground is set when no colors are registered. --- src/vs/editor/common/viewModel/minimapTokensColorTracker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts index 45473f77c4dec..72ea2bcd16bcc 100644 --- a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts +++ b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts @@ -38,7 +38,10 @@ export class MinimapTokensColorTracker extends Disposable { private _updateColorMap(): void { const colorMap = TokenizationRegistry.getColorMap(); if (!colorMap) { - this._colors = [RGBA8.Empty]; + this._colors = []; + for (let i = 0; i <= ColorId.DefaultBackground; i++) { + this._colors[i] = RGBA8.Empty; + } this._backgroundIsLight = true; return; } From 844d9b263b892c8c68e8b92260bca37b1af7c300 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:52:09 -0800 Subject: [PATCH 15/28] Always require `tooltips` for markdown command links Fixes #299657 Also gives the displayed text argument a clearer name --- src/vs/base/common/htmlContent.ts | 4 ++-- .../contrib/chat/browser/tools/languageModelToolsService.ts | 4 ++-- .../chatContentParts/chatDisabledClaudeHooksContentPart.ts | 3 ++- .../chatMcpServersInteractionContentPart.ts | 6 ++++-- .../toolInvocationParts/chatToolPartUtilities.ts | 4 ++-- src/vs/workbench/contrib/mcp/browser/mcpCommands.ts | 3 ++- src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts | 6 +++--- src/vs/workbench/contrib/mcp/browser/mcpServersView.ts | 2 +- 8 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/vs/base/common/htmlContent.ts b/src/vs/base/common/htmlContent.ts index 070279f045ae7..16049d7e6f731 100644 --- a/src/vs/base/common/htmlContent.ts +++ b/src/vs/base/common/htmlContent.ts @@ -210,9 +210,9 @@ export function createMarkdownLink(text: string, href: string, title?: string, e return `[${escapeTokens ? escapeMarkdownSyntaxTokens(text) : text}](${href}${title ? ` "${escapeMarkdownSyntaxTokens(title)}"` : ''})`; } -export function createMarkdownCommandLink(command: { title: string; id: string; arguments?: unknown[]; tooltip?: string }, escapeTokens = true): string { +export function createMarkdownCommandLink(command: { text: string; id: string; arguments?: unknown[]; tooltip: string }, escapeTokens = true): string { const uri = createCommandUri(command.id, ...(command.arguments || [])).toString(); - return createMarkdownLink(command.title, uri, command.tooltip, escapeTokens); + return createMarkdownLink(command.text, uri, command.tooltip, escapeTokens); } export function createCommandUri(commandId: string, ...commandArgs: unknown[]): URI { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 27b43d79cc7ca..f00d1d149b795 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -838,14 +838,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo ...prepared.confirmationMessages, title: localize('defaultToolConfirmation.title', 'Confirm tool execution'), message: localize('defaultToolConfirmation.message', 'Run the \'{0}\' tool?', fullReferenceName), - disclaimer: tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }), + disclaimer: tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ text: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval], tooltip: localize('openSettings.autoApproval.tooltip', 'Open settings to configure auto-approval') }, false)), { isTrusted: true }), allowAutoConfirm: false, }; } if (!isEligibleForAutoApproval && prepared?.confirmationMessages?.title) { // Always overwrite the disclaimer if not eligible for auto-approval - prepared.confirmationMessages.disclaimer = tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }); + prepared.confirmationMessages.disclaimer = tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ text: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval], tooltip: localize('openSettings.autoApproval.tooltip', 'Open settings to configure auto-approval') }, false)), { isTrusted: true }); } if (prepared?.confirmationMessages?.title) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts index 7939aaa309915..67b9be3d84d1c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts @@ -33,9 +33,10 @@ export class ChatDisabledClaudeHooksContentPart extends Disposable implements IC icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); const enableLink = createMarkdownCommandLink({ - title: localize('chat.disabledClaudeHooks.enableLink', "Enable"), + text: localize('chat.disabledClaudeHooks.enableLink', "Enable"), id: 'workbench.action.openSettings', arguments: [PromptsConfig.USE_CLAUDE_HOOKS], + tooltip: localize('chat.disabledClaudeHooks.enableLink.tooltip', "Open settings to enable Claude Code hooks"), }); const message = localize('chat.disabledClaudeHooks.message', "Claude Code hooks are available for this workspace. {0}", enableLink); const content = new MarkdownString(message, { isTrusted: true }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts index 101b84d981099..0fec722d8a211 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts @@ -105,16 +105,18 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements private createServerCommandLinks(servers: Array<{ id: string; label: string }>): string { return servers.map(s => createMarkdownCommandLink({ - title: '`' + escapeMarkdownSyntaxTokens(s.label) + '`', + text: '`' + escapeMarkdownSyntaxTokens(s.label) + '`', id: McpCommandIds.ServerOptions, arguments: [s.id], + tooltip: localize('mcp.server.options.tooltip', 'Show options for {0}', s.label), }, false)).join(', '); } private updateDetailedProgress(state: IAutostartResult): void { const skipText = createMarkdownCommandLink({ - title: localize('mcp.skip.link', 'Skip?'), + text: localize('mcp.skip.link', 'Skip?'), id: McpCommandIds.SkipCurrentAutostart, + tooltip: localize('mcp.skip.tooltip', 'Skip starting this MCP server'), }); let content: MarkdownString; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts index aa7a82177c6fc..0aba9ec84a9d7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts @@ -30,7 +30,7 @@ export function getApprovalMessageFromReason(reason: ConfirmedReason): IMarkdown let md: string; switch (reason.type) { case ToolConfirmKind.Setting: - md = localize('chat.autoapprove.setting', 'Auto approved by {0}', createMarkdownCommandLink({ title: '`' + reason.id + '`', id: 'workbench.action.openSettings', arguments: [reason.id] }, false)); + md = localize('chat.autoapprove.setting', 'Auto approved by {0}', createMarkdownCommandLink({ text: '`' + reason.id + '`', id: 'workbench.action.openSettings', arguments: [reason.id], tooltip: localize('openSettings.tooltip', 'Open settings') }, false)); break; case ToolConfirmKind.LmServicePerTool: md = reason.scope === 'session' @@ -38,7 +38,7 @@ export function getApprovalMessageFromReason(reason: ConfirmedReason): IMarkdown : reason.scope === 'workspace' ? localize('chat.autoapprove.lmServicePerTool.workspace', 'Auto approved for this workspace') : localize('chat.autoapprove.lmServicePerTool.profile', 'Auto approved for this profile'); - md += ' (' + createMarkdownCommandLink({ title: localize('edit', 'Edit'), id: 'workbench.action.chat.editToolApproval', arguments: [reason.scope] }) + ')'; + md += ' (' + createMarkdownCommandLink({ text: localize('edit', 'Edit'), id: 'workbench.action.chat.editToolApproval', arguments: [reason.scope], tooltip: localize('editToolApproval.tooltip', 'Edit tool approval settings') }) + ')'; break; case ToolConfirmKind.ConfirmationNotNeeded: if (reason.reason) { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 71fd9bcd6c820..204a7326b8876 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -550,9 +550,10 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo protected override getHoverContents({ state, servers } = displayedStateCurrent.get()): string | undefined | IManagedHoverTooltipHTMLElement { const link = (s: IMcpServer) => createMarkdownCommandLink({ - title: s.definition.label, + text: s.definition.label, id: McpCommandIds.ServerOptions, arguments: [s.definition.id], + tooltip: localize('mcp.server.options.tooltip', 'Show server options for {0}', s.definition.label), }); const single = servers.length === 1; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts index 254537d67c9a2..285e27f037057 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts @@ -388,9 +388,9 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib function pushAnnotation(savedId: string, offset: number, saved: IResolvedValue): InlayHint { const tooltip = new MarkdownString([ - createMarkdownCommandLink({ id: McpCommandIds.EditStoredInput, title: localize('edit', 'Edit'), arguments: [savedId, model.uri, mcpConfigurationSection, inConfig!.target] }), - createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clear', 'Clear'), arguments: [inConfig!.scope, savedId] }), - createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clearAll', 'Clear All'), arguments: [inConfig!.scope] }), + createMarkdownCommandLink({ id: McpCommandIds.EditStoredInput, text: localize('edit', 'Edit'), arguments: [savedId, model.uri, mcpConfigurationSection, inConfig!.target], tooltip: localize('edit.savedValue.tooltip', 'Edit saved value') }), + createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, text: localize('clear', 'Clear'), arguments: [inConfig!.scope, savedId], tooltip: localize('clear.savedValue.tooltip', 'Clear saved value') }), + createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, text: localize('clearAll', 'Clear All'), arguments: [inConfig!.scope], tooltip: localize('clearAll.savedValues.tooltip', 'Clear all saved values') }), ].join(' | '), { isTrusted: true }); const hint: InlayHint = { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts index 9e82f119e8830..864cc4f5f8cbe 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts @@ -270,7 +270,7 @@ export class McpServersListView extends AbstractExtensionsListView Date: Fri, 6 Mar 2026 09:58:10 -0800 Subject: [PATCH 16/28] [MCP_Sandboxing]: Notifying network domains that need access (#299701) * changes to ensure all the network requests are passed through proxy * changes to ensure all the network requests are passed through proxy --- .../contrib/mcp/common/mcpSandboxService.ts | 2 +- .../contrib/mcp/common/mcpServerConnection.ts | 23 ++---------- .../test/common/mcpServerConnection.test.ts | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts index 7f930eabb5433..4cfa41a6cdf35 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts @@ -276,7 +276,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService private async _getSandboxEnvVariables(baseEnv: McpServerTransportStdio['env'], tempDir: URI | undefined, rgPath: string | undefined, remoteAuthority?: string): Promise { let env: McpServerTransportStdio['env'] = { ...baseEnv }; if (tempDir) { - env = { ...env, TMPDIR: tempDir.path, SRT_DEBUG: 'true' }; + env = { ...env, TMPDIR: tempDir.path, SRT_DEBUG: 'true', NODE_USE_ENV_PROXY: '1' }; } if (rgPath) { env = { ...env, PATH: env['PATH'] ? `${env['PATH']}${await this._getPathDelimiter(remoteAuthority)}${dirname(rgPath)}` : dirname(rgPath) }; diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts index b67ba4f17a741..2b493d9cfc188 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts @@ -162,15 +162,7 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect }; } - if (/\b(?:EAI_AGAIN|ENOTFOUND)\b/i.test(message)) { - return { - kind: 'network', - message, - host: this._extractSandboxHost(message), - }; - } - - if (/(?:\b(?:EACCES|EPERM|ENOENT|fail(?:ed|ure)?)\b|not accessible)/i.test(message)) { + if (/(?:\b(?:EACCES|EPERM|ENOENT|EROFS|fail(?:ed|ure)?)\b|\bnot accessible\b|read only)/i.test(message)) { return { kind: 'filesystem', message, @@ -197,16 +189,7 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect } private _extractSandboxHost(value: string): string | undefined { - const deniedMatch = value.match(/No matching config rule, denying:\s+(.+)$/i); - const matchTarget = deniedMatch?.[1] ?? value; - const trimmed = matchTarget.trim().replace(/^["'`]+|["'`,.;]+$/g, ''); - if (!trimmed) { - return undefined; - } - - const withoutProtocol = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); - const firstToken = withoutProtocol.split(/[\s/]/, 1)[0] ?? ''; - const host = firstToken.replace(/:\d+$/, ''); - return host || undefined; + const match = value.match(/No matching config rule, denying:\s+(?[^:\s]+):\d+\.?$/i); + return match?.groups?.host; } } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts index 44f180efe2f57..490efd0940961 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts @@ -338,6 +338,42 @@ suite('Workbench - MCP - ServerConnection', () => { await timeout(10); }); + test('should emit a sandbox network block with the denied host', async () => { + const sandboxedDefinition: McpServerDefinition = { + ...serverDefinition, + sandboxEnabled: true, + }; + + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + sandboxedDefinition, + delegate, + sandboxedDefinition.launch, + new NullLogger(), + false, + store.add(new McpTaskManager()), + ); + store.add(connection); + + const sandboxBlock = Event.toPromise(connection.onPotentialSandboxBlock); + const startPromise = connection.start({}); + + transport.simulateLog('No matching config rule, denying: api.example.com:443.'); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + assert.deepStrictEqual(await sandboxBlock, { + kind: 'network', + message: 'No matching config rule, denying: api.example.com:443.', + host: 'api.example.com', + }); + + await startPromise; + + connection.dispose(); + await timeout(10); + }); + test('should correctly handle transitions to and from error state', async () => { // Create server connection const connection = instantiationService.createInstance( From 41c93525460b38533f59a9396bbd653ee6ca4c42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:02:12 +0000 Subject: [PATCH 17/28] fix: pass Codicon.vscode directly instead of registering a new icon Co-authored-by: mjbvz <12821956+mjbvz@users.noreply.github.com> --- .../workbench/contrib/update/browser/releaseNotesEditor.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts index 59967d3cd3985..92c942f9d5ecd 100644 --- a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts +++ b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @@ -20,7 +20,6 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { asTextOrError, IRequestService } from '../../../../platform/request/common/request.js'; -import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from '../../markdown/browser/markdownDocumentRenderer.js'; import { WebviewInput } from '../../webviewPanel/browser/webviewEditorInput.js'; import { IWebviewWorkbenchService } from '../../webviewPanel/browser/webviewWorkbenchService.js'; @@ -40,8 +39,6 @@ import { asWebviewUri } from '../../webview/common/webview.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -const ReleaseNotesEditorIcon = registerIcon('release-notes-view-icon', Codicon.vscode, nls.localize('releaseNotesViewIcon', 'Icon of the release notes editor.')); - export class ReleaseNotesManager extends Disposable { private readonly _simpleSettingRenderer: SimpleSettingRenderer; private readonly _releaseNotesCache = new Map>(); @@ -127,7 +124,7 @@ export class ReleaseNotesManager extends Disposable { }, 'releaseNotes', title, - ReleaseNotesEditorIcon, + Codicon.vscode, { group: ACTIVE_GROUP, preserveFocus: false }); const disposables = new DisposableStore(); From 1b0e9461dee82a4c456c543105f8c6a2db5137a6 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 6 Mar 2026 18:35:21 +0100 Subject: [PATCH 18/28] groups component explorer updates --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fc5cda5555b2d..0729661959724 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,11 @@ updates: allow: - dependency-name: "@vscode/component-explorer" - dependency-name: "@vscode/component-explorer-cli" + groups: + component-explorer: + patterns: + - "@vscode/component-explorer" + - "@vscode/component-explorer-cli" - package-ecosystem: "npm" directory: "/build/vite" schedule: @@ -22,3 +27,8 @@ updates: allow: - dependency-name: "@vscode/component-explorer" - dependency-name: "@vscode/component-explorer-vite-plugin" + groups: + component-explorer: + patterns: + - "@vscode/component-explorer" + - "@vscode/component-explorer-vite-plugin" From 140fced2733a71f802d89d5730c63729583f3790 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 6 Mar 2026 13:05:41 -0500 Subject: [PATCH 19/28] ensure deleting attachment works on windows (#299824) fixes #299733 --- .../contrib/chat/browser/attachments/chatAttachmentWidgets.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index 8a20bd7098282..a502dd82f1e2a 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -158,6 +158,8 @@ abstract class AbstractChatAttachmentWidget extends Disposable { })); this._register(dom.addStandardDisposableListener(this.element, dom.EventType.KEY_DOWN, e => { if (e.keyCode === KeyCode.Backspace || e.keyCode === KeyCode.Delete) { + e.preventDefault(); + e.stopPropagation(); this._onDidDelete.fire(e.browserEvent); } })); From b607547dea5b2be2feb7722e4a918755c566a567 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 6 Mar 2026 14:21:06 -0500 Subject: [PATCH 20/28] tweak wording of chat tip for clarity (#299832) fixes #299565 --- src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index 404ab0b82b7e3..6a93f4d568f2a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -349,7 +349,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( - localize('tip.subagents', "Ask the agent to work in parallel to complete large tasks faster.") + localize('tip.subagents', "Have another task to work on? Start a new session to run multiple agents at once.") ); }, when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), From abe7ae5449c8cab202f4cc8cd8a8f05936cf06b8 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:02:44 -0800 Subject: [PATCH 21/28] fix: include sessions built-in prompts in esbuild resource patterns (#299848) The new esbuild-based CI pipeline (core-ci) uses curated resource patterns in build/next/index.ts to copy non-JS assets into the bundle output. When built-in .prompt.md files were added for the sessions window, they were included in the legacy pipeline's vscodeResourceIncludes (build/gulpfile.vscode.ts) but not in the desktopResourcePatterns used by the esbuild pipeline. This caused the prompt files to be missing from release builds (out-vscode-min), even though they worked correctly when running from sources (where copyAllNonTsFiles copies everything). Add 'vs/sessions/prompts/*.prompt.md' to desktopResourcePatterns to match the existing entry in vscodeResourceIncludes. --- build/next/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build/next/index.ts b/build/next/index.ts index f3043f0fa1fb2..a77b98b5c635a 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -273,6 +273,9 @@ const desktopResourcePatterns = [ 'vs/workbench/services/extensionManagement/common/media/*.png', 'vs/workbench/browser/parts/editor/media/*.png', 'vs/workbench/contrib/debug/browser/media/*.png', + + // Sessions - built-in prompts + 'vs/sessions/prompts/*.prompt.md', ]; // Resources for server target (minimal - no UI) From c30864b3d0fa2049360c59c98b9d0c4fe8ac1de3 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:08:17 +0100 Subject: [PATCH 22/28] Sessions - initial implementation for git changes (#299855) * Sessions - initial implementation of repository changes * Deduplicate resources and fix badge --- .../changesView/browser/changesView.ts | 76 +++++++++++++++---- .../browser/mainThreadGitExtensionService.ts | 22 +++++- .../workbench/api/common/extHost.protocol.ts | 10 +++ .../api/common/extHostGitExtensionService.ts | 72 +++++++++++++++--- .../contrib/git/common/gitService.ts | 10 +++ 5 files changed, 163 insertions(+), 27 deletions(-) diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index da1e460a2970c..9cf004af32aab 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -13,9 +13,9 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableFromPromise, observableValue } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; -import { isEqual } from '../../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -58,6 +58,7 @@ import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/b import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; const $ = dom.$; @@ -101,6 +102,8 @@ interface IChangesFolderItem { interface IActiveSession { readonly resource: URI; readonly sessionType: string; + readonly repository: URI | undefined; + readonly worktree: URI | undefined; } type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; @@ -230,6 +233,7 @@ export class ChangesViewPane extends ViewPane { private readonly activeSession: IObservableWithChange; private readonly activeSessionFileCountObs: IObservableWithChange; private readonly activeSessionHasChangesObs: IObservableWithChange; + private readonly activeSessionRepositoryChangesObs: IObservableWithChange; get activeSessionHasChanges(): IObservable { return this.activeSessionHasChangesObs; @@ -257,6 +261,7 @@ export class ChangesViewPane extends ViewPane { @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, @ICodeReviewService private readonly codeReviewService: ICodeReviewService, + @IGitService private readonly gitService: IGitService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -278,16 +283,49 @@ export class ChangesViewPane extends ViewPane { return { resource: activeSession.resource, + repository: activeSession.repository, + worktree: activeSession.worktree, sessionType: getChatSessionType(activeSession.resource), }; }).recomputeInitiallyAndOnChange(this._store); + // Track active session repository changes + const repositoryObs = derived(reader => { + const activeSessionWorktree = this.activeSession.read(reader)?.worktree; + if (!activeSessionWorktree) { + return undefined; + } + + return observableFromPromise(this.gitService.openRepository(activeSessionWorktree)); + }); + + this.activeSessionRepositoryChangesObs = derived(reader => { + const repository = repositoryObs.read(reader)?.read(reader); + if (!repository) { + return undefined; + } + + const state = repository.value?.state.read(reader); + return (state?.workingTreeChanges ?? []).map(change => { + const isDeletion = change.modifiedUri === undefined; + const isAddition = change.originalUri === undefined; + return { + type: 'file', + uri: change.modifiedUri ?? change.uri, + originalUri: change.originalUri, + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + reviewCommentCount: 0, + linesAdded: 0, + linesRemoved: 0, + } satisfies IChangesFileItem; + }); + }); + this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); - // Setup badge tracking - this.registerBadgeTracking(); - // Set chatSessionType on the view's context key service so ViewTitle // menu items can use it in their `when` clauses. Update reactively // when the active session changes. @@ -298,14 +336,6 @@ export class ChangesViewPane extends ViewPane { })); } - private registerBadgeTracking(): void { - // Update badge when file count changes - this._register(autorun(reader => { - const fileCount = this.activeSessionFileCountObs.read(reader); - this.updateBadge(fileCount); - })); - } - private createActiveSessionFileCountObservable(): IObservableWithChange { const activeSessionResource = this.activeSession.map(a => a?.resource); @@ -532,13 +562,24 @@ export class ChangesViewPane extends ViewPane { const combinedEntriesObs = derived(reader => { const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - return [...editEntries, ...sessionFiles]; + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; + + const resources = new Set(); + const entries: IChangesFileItem[] = []; + for (const item of [...editEntries, ...sessionFiles, ...repositoryFiles]) { + if (!resources.has(item.uri.fsPath)) { + resources.add(item.uri.fsPath); + entries.push(item); + } + } + return entries.sort((a, b) => extUriBiasedIgnorePathCase.compare(a.uri, b.uri)); }); // Calculate stats from combined entries const topLevelStats = derived(reader => { const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -549,7 +590,7 @@ export class ChangesViewPane extends ViewPane { } const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; + const isSessionMenu = editEntries.length === 0 && (sessionFiles.length > 0 || repositoryFiles.length > 0); return { files, added, removed, isSessionMenu }; }); @@ -653,6 +694,11 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(!hasEntries, this.welcomeContainer!); })); + // Update badge when file count changes + this.renderDisposables.add(autorun(reader => { + this.updateBadge(topLevelStats.read(reader).files); + })); + // Update summary text (line counts only, file count is shown in badge) if (this.summaryContainer) { dom.clearNode(this.summaryContainer); diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts index 6ed0a6d0acdd0..ad414190cd61c 100644 --- a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { URI } from '../../../base/common/uri.js'; import { GitRepository } from '../../contrib/git/browser/gitService.js'; -import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType, GitRepositoryState, GitBranch, IGitRepository } from '../../contrib/git/common/gitService.js'; +import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType, GitRepositoryState, GitBranch, GitChange, IGitRepository } from '../../contrib/git/common/gitService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostGitExtensionShape, GitRefTypeDto, GitRepositoryStateDto, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js'; @@ -32,6 +32,26 @@ function toGitRepositoryState(dto: GitRepositoryStateDto | undefined): GitReposi ahead: dto.HEAD.ahead, behind: dto.HEAD.behind, } satisfies GitBranch : undefined, + mergeChanges: dto?.mergeChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + indexChanges: dto?.indexChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + workingTreeChanges: dto?.workingTreeChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + untrackedChanges: dto?.untrackedChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0bd60212242d7..be95402104732 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3627,8 +3627,18 @@ export interface GitRefDto { readonly revision: string; } +export interface GitChangeDto { + readonly uri: UriComponents; + readonly originalUri: UriComponents | undefined; + readonly modifiedUri: UriComponents | undefined; +} + export interface GitRepositoryStateDto { readonly HEAD?: GitBranchDto; + readonly mergeChanges: readonly GitChangeDto[]; + readonly indexChanges: readonly GitChangeDto[]; + readonly workingTreeChanges: readonly GitChangeDto[]; + readonly untrackedChanges: readonly GitChangeDto[]; } export interface GitBranchDto { diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 61a64e83bf375..6c2960f038377 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -11,7 +11,7 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostExtensionService } from './extHostExtensionService.js'; import { IExtHostRpcService } from './extHostRpcService.js'; -import { ExtHostGitExtensionShape, GitBranchDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; +import { ExtHostGitExtensionShape, GitBranchDto, GitChangeDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; import { ResourceMap } from '../../../base/common/map.js'; const GIT_EXTENSION_ID = 'vscode.git'; @@ -45,6 +45,52 @@ function toGitUpstreamRefDto(upstream: UpstreamRef): GitUpstreamRefDto { }; } +// Status values from the git extension's const enum Status +const enum GitStatus { + INDEX_ADDED = 1, + INDEX_DELETED = 2, + INDEX_RENAMED = 3, + MODIFIED = 5, + DELETED = 6, + UNTRACKED = 7, + INTENT_TO_ADD = 9, + INTENT_TO_RENAME = 10, +} + +function toGitChangeDto(change: Change): GitChangeDto { + switch (change.status) { + // Added: no original + case GitStatus.INDEX_ADDED: + case GitStatus.UNTRACKED: + case GitStatus.INTENT_TO_ADD: + return { uri: change.uri, originalUri: undefined, modifiedUri: change.uri }; + + // Deleted: no modified + case GitStatus.INDEX_DELETED: + case GitStatus.DELETED: + return { uri: change.uri, originalUri: change.uri, modifiedUri: undefined }; + + // Renamed: original is old name, modified is new name + case GitStatus.INDEX_RENAMED: + case GitStatus.INTENT_TO_RENAME: + return { uri: change.uri, originalUri: change.originalUri, modifiedUri: change.renameUri }; + + // Modified and everything else: both original and modified + default: + return { uri: change.uri, originalUri: change.originalUri, modifiedUri: change.uri }; + } +} + +function toGitRepositoryStateDto(state: RepositoryState): GitRepositoryStateDto { + return { + HEAD: state.HEAD ? toGitBranchDto(state.HEAD) : undefined, + mergeChanges: state.mergeChanges.map(toGitChangeDto), + indexChanges: state.indexChanges.map(toGitChangeDto), + workingTreeChanges: state.workingTreeChanges.map(toGitChangeDto), + untrackedChanges: state.untrackedChanges.map(toGitChangeDto), + }; +} + interface Repository { readonly rootUri: vscode.Uri; readonly state: RepositoryState; @@ -53,8 +99,19 @@ interface Repository { getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise; } +interface Change { + readonly uri: vscode.Uri; + readonly originalUri: vscode.Uri; + readonly renameUri: vscode.Uri | undefined; + readonly status: number; +} + interface RepositoryState { readonly HEAD: Branch | undefined; + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; readonly onDidChange: Event; } @@ -148,9 +205,7 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return { handle: existingHandle, rootUri: repository.rootUri, - state: { - HEAD: repository.state.HEAD ? toGitBranchDto(repository.state.HEAD) : undefined - } + state: toGitRepositoryStateDto(repository.state), }; } @@ -178,11 +233,7 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return { handle, rootUri: repository.rootUri, - state: { - HEAD: repository.state.HEAD - ? toGitBranchDto(repository.state.HEAD) - : undefined - } + state: toGitRepositoryStateDto(repository.state), }; } @@ -225,8 +276,7 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return undefined; } - const state = repository.state; - return { HEAD: state.HEAD ? toGitBranchDto(state.HEAD) : undefined }; + return toGitRepositoryStateDto(repository.state); } private async _ensureGitApi(): Promise { diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts index 353686d452ab5..2217f2200db10 100644 --- a/src/vs/workbench/contrib/git/common/gitService.ts +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -29,8 +29,18 @@ export interface GitRefQuery { readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; } +export interface GitChange { + readonly uri: URI; + readonly originalUri: URI | undefined; + readonly modifiedUri: URI | undefined; +} + export interface GitRepositoryState { readonly HEAD?: GitBranch; + readonly mergeChanges: readonly GitChange[]; + readonly indexChanges: readonly GitChange[]; + readonly workingTreeChanges: readonly GitChange[]; + readonly untrackedChanges: readonly GitChange[]; } export interface GitBranch extends GitRef { From 22933cea7b65c18ba29aa7f52937f13b22bd2eb5 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 6 Mar 2026 16:11:34 -0500 Subject: [PATCH 23/28] add `closeOnResult` for editor's find widget (#299865) fixes #264818 --- src/vs/editor/common/config/editorOptions.ts | 11 ++++ .../contrib/find/browser/findController.ts | 17 ++++++ .../find/test/browser/findController.test.ts | 61 +++++++++++++++++++ src/vs/monaco.d.ts | 4 ++ .../browser/editorFindAccessibilityHelp.ts | 1 + 5 files changed, 94 insertions(+) diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index bf7964361673f..287992d174498 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -1744,6 +1744,10 @@ export interface IEditorFindOptions { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls whether to close the Find Widget after an explicit find navigation command lands on a match. + */ + closeOnResult?: boolean; /** * @internal * Controls how the find widget search history should be stored @@ -1772,6 +1776,7 @@ class EditorFind extends BaseEditorOption(input.history, this.defaultValue.history, ['never', 'workspace']), replaceHistory: stringSet<'never' | 'workspace'>(input.replaceHistory, this.defaultValue.replaceHistory, ['never', 'workspace']), }; diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index ec2ee490e1980..3260daed640f6 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -725,11 +725,28 @@ async function matchFindAction(editor: ICodeEditor, next: boolean): Promise { + const previousSelection = controller.editor.getSelection(); const result = next ? controller.moveToNextMatch() : controller.moveToPrevMatch(); + + let landedOnMatch = false; if (result) { + const currentSelection = controller.editor.getSelection(); + if (!previousSelection && currentSelection) { + landedOnMatch = true; + } else if (previousSelection && currentSelection && !previousSelection.equalsSelection(currentSelection)) { + landedOnMatch = true; + } + } + + if (landedOnMatch) { controller.editor.pushUndoStop(); + if (shouldCloseOnResult && wasFindWidgetVisible && controller.isFindInputFocused()) { + controller.closeFindWidget(); + } return true; } return false; diff --git a/src/vs/editor/contrib/find/test/browser/findController.test.ts b/src/vs/editor/contrib/find/test/browser/findController.test.ts index ed0383148f97e..1823dd31e1ab9 100644 --- a/src/vs/editor/contrib/find/test/browser/findController.test.ts +++ b/src/vs/editor/contrib/find/test/browser/findController.test.ts @@ -292,6 +292,67 @@ suite('FindController', () => { }); }); + test('editor.find.closeOnResult: closes find widget when a match is found from explicit navigation', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'ABC', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'ABC' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.isRevealed, false); + findController.dispose(); + }); + }); + + test('editor.find.closeOnResult: keeps find widget open when no match is found', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'DEF', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'NO_MATCH' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.matchesCount, 0); + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + test('editor.find.closeOnResult: disabled keeps find widget open after navigation', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'ABC', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: false } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'ABC' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + test('issue #9043: Clear search scope when find widget is hidden', async () => { await withAsyncTestCodeEditor([ 'var x = (3 * 5)', diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index fc9c2da70f5d2..7d60866f3714e 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4255,6 +4255,10 @@ declare namespace monaco.editor { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls whether to close the Find Widget after an explicit find navigation command lands on a match. + */ + closeOnResult?: boolean; } export type GoToLocationValues = 'peek' | 'gotoAndPeek' | 'goto'; diff --git a/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts b/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts index 32b0152643dbf..c0cef1433a2d9 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts @@ -300,6 +300,7 @@ class EditorFindAccessibilityHelpProvider extends Disposable implements IAccessi content.push(localize('find.settingSeed', "- `editor.find.seedSearchStringFromSelection`: Controls when selection text is used to seed Find.")); content.push(localize('find.settingAutoSelection', "- `editor.find.autoFindInSelection`: Automatically enables Find in Selection based on selection type.")); content.push(localize('find.settingLoop', "- `editor.find.loop`: Wraps search at the beginning or end of the file.")); + content.push(localize('find.settingCloseOnResult', "- `editor.find.closeOnResult`: Closes the Find dialog after an explicit find navigation command lands on a match.")); content.push(localize('find.settingExtraSpace', "- `editor.find.addExtraSpaceOnTop`: Adds extra scroll space so matches are not hidden behind the Find dialog.")); content.push(localize('find.settingHistory', "- `editor.find.history`: Controls whether Find search history is stored.")); content.push(localize('find.settingOccurrences', "- `editor.occurrencesHighlight`: Highlights other occurrences of the current symbol.")); From b35cc3053cbe00f1e316507cbd015ed53a3e592c Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 6 Mar 2026 16:12:01 -0500 Subject: [PATCH 24/28] alert when an image is attached via paste (#299862) fix #299859 --- .../chat/browser/widget/input/editor/chatPasteProviders.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 eafcf7aa1c601..527e9e9becf0e 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 @@ -5,6 +5,7 @@ import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { createStringDataTransferItem, IDataTransferItem, IReadonlyVSDataTransfer, VSDataTransfer } from '../../../../../../../base/common/dataTransfer.js'; +import { alert } from '../../../../../../../base/browser/ui/aria/aria.js'; import { HierarchicalKind } from '../../../../../../../base/common/hierarchicalKind.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../../../base/common/marshalling.js'; @@ -23,7 +24,7 @@ import { IFileService } from '../../../../../../../platform/files/common/files.j import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; 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 { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, isImageVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; import { IChatWidgetService } from '../../../chat.js'; import { getDynamicVariablesForWidget } from '../../../attachments/chatVariables.js'; @@ -386,6 +387,7 @@ function createCustomPasteEdit(model: ITextModel, context: IChatRequestVariableE const label = context.length === 1 ? context[0].name : localize('pastedAttachment.multiple', '{0} and {1} more', context[0].name, context.length - 1); + const announceImageAttachment = context.length === 1 && isImageVariableEntry(context[0]); const customEdit = { resource: model.uri, @@ -403,6 +405,9 @@ function createCustomPasteEdit(model: ITextModel, context: IChatRequestVariableE throw new Error('No widget found for redo'); } widget.attachmentModel.addContext(...context); + if (announceImageAttachment) { + alert(localize('chat.pastedImageAttached', 'Attached image')); + } }, metadata: { needsConfirmation: false, From e842b429d2742ccdf0ad16363e79708230ac8237 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:15:59 -0800 Subject: [PATCH 25/28] Fix browser positioning issues (#299842) --- .../electron-browser/browserEditor.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index a9983e9e46800..a3af60577018d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -49,6 +49,7 @@ import { logBrowserOpen } from '../../../../platform/browserView/common/browserV import { URI } from '../../../../base/common/uri.js'; import { ChatConfiguration } from '../../chat/common/constants.js'; import { Event } from '../../../../base/common/event.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -273,7 +274,8 @@ export class BrowserEditor extends EditorPane { @IEditorService private readonly editorService: IEditorService, @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILayoutService private readonly layoutService: ILayoutService ) { super(BrowserEditor.ID, group, telemetryService, themeService, storageService); } @@ -378,12 +380,6 @@ export class BrowserEditor extends EditorPane { hasFocus: this._model?.focused ?? false, window: this._model?.focused ? this.window : undefined }))); - - // Automatically call layoutBrowserContainer() when the browser container changes size. - // Be careful to use `ResizeObserver` from the target window to avoid cross-window issues. - const resizeObserver = new this.window.ResizeObserver(() => this.layoutBrowserContainer()); - resizeObserver.observe(this._browserContainer); - this._register(toDisposable(() => resizeObserver.disconnect())); } override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { @@ -514,12 +510,11 @@ export class BrowserEditor extends EditorPane { if (targetWindowId === this.window.vscodeWindowId) { // Update CSS variable for size calculations this._browserContainerWrapper.style.setProperty('--zoom-factor', String(getZoomFactor(this.window))); - this.layoutBrowserContainer(); } })); this.updateErrorDisplay(); - this.layoutBrowserContainer(); + this.layout(); this.updateVisibility(); this.doScreenshot(); @@ -1168,21 +1163,28 @@ export class BrowserEditor extends EditorPane { } } - override layout(dimension: Dimension, _position?: IDomPosition): void { + override layout(dimension?: Dimension, _position?: IDomPosition): void { // Layout find widget if it exists - this._findWidget.rawValue?.layout(dimension.width); + if (dimension && this._findWidget.rawValue) { + this._findWidget.rawValue.layout(dimension.width); + } + + const whenContainerStylesLoaded = this.layoutService.whenContainerStylesLoaded(this.window); + if (whenContainerStylesLoaded) { + // In floating windows, we need to ensure that the + // container is ready for us to compute certain + // layout related properties. + whenContainerStylesLoaded.then(() => this.layoutBrowserContainer()); + } else { + this.layoutBrowserContainer(); + } } /** - * This should be called whenever .browser-container changes in size, or when - * there could be any elements, such as the command palette, overlapping with it. - * - * Note that we don't call layoutBrowserContainer() from layout() but instead rely on using a ResizeObserver and on - * making direct calls to it. This is because we have seen cases where the getBoundingClientRect() values of - * the .browser-container element are not correct during layout() calls, especially during "Move into New Window" - * and "Copy into New Window" operations into a different monitor. + * Recompute the layout of the browser container and update the model with the new bounds. + * This should generally only be called via layout() to ensure that the container is ready and all necessary styles are loaded. */ - layoutBrowserContainer(): void { + private layoutBrowserContainer(): void { if (this._model) { this.checkOverlays(); From 0747aea6921df56f3ef517f98c4cbac0550506ec Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 6 Mar 2026 13:16:49 -0800 Subject: [PATCH 26/28] chat: fix stale pending divider headers from persisting (#299868) When templates are reused for different tree items, the DOM content from pending dividers was not being cleaned up. This caused old 'Steering' or 'Queued' divider headers to persist visually even after they were no longer in the list. The fix checks if the previous element in a template was a pending divider, and if so, clears the templateData.value node when the template is reused for a new element. - Adds a check in clearRenderedParts() to clear templateData.value when the previous element was a pending divider - Ensures stale divider headers don't remain visible after pending requests are processed and removed from the queue Fixes https://github.com/microsoft/vscode/issues/299853 (Commit message generated by Copilot) --- .../workbench/contrib/chat/browser/widget/chatListRenderer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index ef9919d8f84a7..9e51d5a6f5c92 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -657,6 +657,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer Date: Fri, 6 Mar 2026 13:23:22 -0800 Subject: [PATCH 27/28] sessions: 'update from VS Code' (#299359) * refactor(AccountWidget): simplify update button logic and styling * feat(AccountWidget): enhance update button logic for embedded app scenarios * fix(AccountWidget): add missing line for clarity in onClick method * embedded app update hint * fix(AccountWidget): remove background image from update button when disabled * Update src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(AccountWidget): refine styles for disabled update button * Update src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(AccountWidget): embedded app update flow with dialog, close, and open VS Code - Show outlined 'Update Available' button when updates are disabled due to embedded app - On click, confirm dialog explains Sessions will close and VS Code will open - Opens VS Code via productService.urlProtocol with windowId=_blank (new empty window) - Closes Sessions window after launching VS Code - Uses secondary outlined button style (border, no fill) for hint state - Inject IOpenerService, IDialogService, INativeHostService - Remove simulation TODOs, use real updateService.state * refactor(update): detect updates in embedded app via canInstall flag - Add optional canInstall field to AvailableForDownload state - Darwin: embedded app runs normal init + scheduled checks via HTTP (no Electron autoUpdater events), sets AvailableForDownload(update, false) - Win32: embedded app skips platform setup, checks via HTTP, sets AvailableForDownload(update, false) when update found - Sessions UI: check canInstall === false for hint button + dialog - Remove DisablementReason.EmbeddedApp (no longer needed) - Non-embedded --sessions mode uses standard update flow unchanged * fix: update AccountWidget fixture with new constructor args * fix: use IHostService instead of INativeHostService for browser layer compliance --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/platform/update/common/update.ts | 5 +-- .../electron-main/updateService.darwin.ts | 24 +++++++---- .../electron-main/updateService.win32.ts | 13 +++++- .../browser/account.contribution.ts | 40 +++++++++++++++++++ .../browser/media/accountWidget.css | 11 +++++ .../test/browser/accountWidget.fixture.ts | 8 +++- 6 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index cbeb3a6088856..b5c2b121c646f 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -62,14 +62,13 @@ export const enum DisablementReason { MissingConfiguration, InvalidConfiguration, RunningAsAdmin, - EmbeddedApp, } export type Uninitialized = { type: StateType.Uninitialized }; export type Disabled = { type: StateType.Disabled; reason: DisablementReason }; export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string }; export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: boolean }; -export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; +export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate; canInstall?: boolean }; export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number }; @@ -83,7 +82,7 @@ export const State = { Disabled: (reason: DisablementReason): Disabled => ({ type: StateType.Disabled, reason }), Idle: (updateType: UpdateType, error?: string): Idle => ({ type: StateType.Idle, updateType, error }), CheckingForUpdates: (explicit: boolean): CheckingForUpdates => ({ type: StateType.CheckingForUpdates, explicit }), - AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }), + AvailableForDownload: (update: IUpdate, canInstall?: boolean): AvailableForDownload => ({ type: StateType.AvailableForDownload, update, canInstall }), Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }), Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }), Updating: (update: IUpdate, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, currentProgress, maxProgress }), diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index 842c676692495..317ae6408bf85 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -16,7 +16,7 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; +import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; import { INodeProcess } from '../../../base/common/platform.js'; @@ -68,13 +68,15 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau } protected override async initialize(): Promise { + await super.initialize(); + + // In the embedded app we still want to detect available updates via HTTP, + // but we must not wire up Electron's autoUpdater (which auto-downloads). if ((process as INodeProcess).isEmbeddedApp) { - this.setState(State.Disabled(DisablementReason.EmbeddedApp)); - this.logService.info('update#ctor - updates are disabled from embedded app'); + this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); return; } - await super.initialize(); this.onRawError(this.onError, this, this.disposables); this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); this.onRawUpdateAvailable(this.onUpdateAvailable, this, this.disposables); @@ -135,6 +137,13 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } + // In the embedded app, always check without triggering Electron's auto-download. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#doCheckForUpdates - embedded app: checking for update without auto-download'); + this.checkForUpdateNoDownload(url, /* canInstall */ false); + return; + } + // When connection is metered and this is not an explicit check, avoid electron call as to not to trigger auto-download. if (!explicit && this.meteredConnectionService.isConnectionMetered) { this.logService.info('update#doCheckForUpdates - checking for update without auto-download because connection is metered'); @@ -148,9 +157,10 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau /** * Manually check the update feed URL without triggering Electron's auto-download. - * Used when connection is metered to show update availability without downloading. + * Used when connection is metered or in the embedded app. + * @param canInstall When false, signals that the update cannot be installed from this app. */ - private async checkForUpdateNoDownload(url: string): Promise { + private async checkForUpdateNoDownload(url: string, canInstall?: boolean): Promise { const headers = getUpdateRequestHeaders(this.productService.version); this.logService.trace('update#checkForUpdateNoDownload - checking update server', { url, headers }); @@ -165,7 +175,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.setState(State.Idle(UpdateType.Archive)); } else { this.logService.trace('update#checkForUpdateNoDownload - update available', { version: update.version, productVersion: update.productVersion }); - this.setState(State.AvailableForDownload(update)); + this.setState(State.AvailableForDownload(update, canInstall)); } } catch (err) { this.logService.error('update#checkForUpdateNoDownload - failed to check for update', err); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 25535f2125222..7933d7f675be4 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -99,9 +99,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } protected override async initialize(): Promise { + // In the embedded app, skip win32-specific setup (cache paths, telemetry) + // but still run the base initialization to detect available updates. if ((process as INodeProcess).isEmbeddedApp) { - this.setState(State.Disabled(DisablementReason.EmbeddedApp)); - this.logService.info('update#ctor - updates are disabled from embedded app'); + this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); + await super.initialize(); return; } @@ -227,6 +229,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } + // In the embedded app, signal that an update exists but can't be installed here. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#doCheckForUpdates - embedded app: update available, skipping download'); + this.setState(State.AvailableForDownload(update, /* canInstall */ false)); + return Promise.resolve(null); + } + // When connection is metered and this is not an explicit check, // show update is available but don't start downloading if (!explicit && this.meteredConnectionService.isConnectionMetered) { diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 75afc5655941e..c4d28d533855d 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -26,6 +26,10 @@ import { Codicon } from '../../../../base/common/codicons.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 { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { URI } from '../../../../base/common/uri.js'; import { UpdateHoverWidget } from './updateHoverWidget.js'; // --- Account Menu Items --- // @@ -101,6 +105,9 @@ export class AccountWidget extends ActionViewItem { @IContextKeyService private readonly contextKeyService: IContextKeyService, @IHoverService private readonly hoverService: IHoverService, @IProductService private readonly productService: IProductService, + @IOpenerService private readonly openerService: IOpenerService, + @IDialogService private readonly dialogService: IDialogService, + @IHostService private readonly hostService: IHostService, ) { super(undefined, action, { ...options, icon: false, label: false }); this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); @@ -188,6 +195,19 @@ export class AccountWidget extends ActionViewItem { } const state = this.updateService.state; + + // In the embedded app, updates are detected but cannot be installed directly. + // Show a hint button to update via VS Code only when an update is actually available. + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.classList.remove('account-widget-update-button-ready'); + this.updateButton.element.classList.add('account-widget-update-button-hint'); + this.updateButton.enabled = true; + this.updateButton.label = localize('updateAvailable', "Update Available"); + this.updateButton.element.title = localize('updateInVSCodeHover', "Updates are managed by VS Code. Click to open VS Code."); + return; + } + if (this.shouldHideUpdateButton(state.type)) { this.clearUpdateButtonStyling(); this.updateButton.element.classList.add('hidden'); @@ -239,9 +259,29 @@ export class AccountWidget extends ActionViewItem { } private async update(): Promise { + const state = this.updateService.state; + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + const { confirmed } = await this.dialogService.confirm({ + message: localize('updateFromVSCode.title', "Update from VS Code"), + detail: localize('updateFromVSCode.detail', "This will close the Sessions app and open VS Code so you can install the update.\n\nLaunch Sessions again after the update is complete."), + primaryButton: localize('updateFromVSCode.open', "Close and Open VS Code"), + }); + if (confirmed) { + await this.openVSCode(); + await this.hostService.close(); + } + return; + } await this.updateService.quitAndInstall(); } + private async openVSCode(): Promise { + await this.openerService.open(URI.from({ + scheme: this.productService.urlProtocol, + query: 'windowId=_blank', + }), { openExternal: true }); + } + override onClick(): void { // Handled by custom click handlers diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index aeff16819c71b..3e852ea53ba47 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -67,6 +67,17 @@ color: var(--vscode-button-foreground) !important; } +/* Boxed hint style for embedded app update indicator — outlined, no fill */ +.account-widget-update .account-widget-update-button.account-widget-update-button-hint { + background-color: transparent !important; + color: var(--vscode-button-foreground) !important; + border: 1px solid var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-hint:hover { + background-color: color-mix(in srgb, var(--vscode-button-background) 20%, transparent) !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; } diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts index 26c7d3a822d54..143b89aad169b 100644 --- a/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts +++ b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts @@ -13,6 +13,9 @@ import { IContextMenuService } from '../../../../../platform/contextview/browser 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 { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { AccountWidget } from '../../browser/account.contribution.js'; @@ -83,7 +86,10 @@ function renderAccountWidget(ctx: ComponentFixtureContext, state: State, account 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); + const openerService = instantiationService.get(IOpenerService); + const dialogService = instantiationService.get(IDialogService); + const hostService = instantiationService.get(IHostService); + const widget = new AccountWidget(action, {}, mockAccountService, mockUpdateService, contextMenuService, menuService, contextKeyService, hoverService, productService, openerService, dialogService, hostService); ctx.disposableStore.add(widget); widget.render(ctx.container); } From f3680f6a81e375c4224cc3490fcae10b17552ac0 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Fri, 6 Mar 2026 16:38:40 -0500 Subject: [PATCH 28/28] Support rendering reserved output separately (#299867) * Support rendering reserved output separately * Fix some of the progress bar logic * Better handling for reserve --- .../api/browser/mainThreadChatAgents2.ts | 1 + .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostChatAgents2.ts | 1 + .../viewPane/chatContextUsageDetails.ts | 44 ++++++++++--- .../viewPane/chatContextUsageWidget.ts | 34 ++++++---- .../media/chatContextUsageDetails.css | 64 +++++++++++++++++++ .../chat/common/chatService/chatService.ts | 1 + ...ode.proposed.chatParticipantAdditions.d.ts | 6 ++ 8 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 9459b611a98b3..b851f3fe206e0 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -411,6 +411,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA kind: 'usage', promptTokens: progress.promptTokens, completionTokens: progress.completionTokens, + outputBuffer: progress.outputBuffer, promptTokenDetails: progress.promptTokenDetails }); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index be95402104732..1f3304a5d7586 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2528,6 +2528,7 @@ export interface IChatUsageDto { kind: 'usage'; promptTokens: number; completionTokens: number; + outputBuffer?: number; promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 5edee47784fee..d91ae3ce3f000 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -440,6 +440,7 @@ export class ChatAgentResponseStream { kind: 'usage', promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, + outputBuffer: usage.outputBuffer, promptTokenDetails: usage.promptTokenDetails }; _report(dto); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index 9c63561bb60fc..47fe2a59f2cb4 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -22,8 +22,10 @@ export interface IChatContextUsagePromptTokenDetail { export interface IChatContextUsageData { usedTokens: number; + completionTokens: number; totalContextWindow: number; percentage: number; + outputBufferPercentage?: number; promptTokenDetails?: readonly IChatContextUsagePromptTokenDetail[]; } @@ -39,6 +41,8 @@ export class ChatContextUsageDetails extends Disposable { private readonly percentageLabel: HTMLElement; private readonly tokenCountLabel: HTMLElement; private readonly progressFill: HTMLElement; + private readonly outputBufferFill: HTMLElement; + private readonly outputBufferLegend: HTMLElement; private readonly tokenDetailsContainer: HTMLElement; private readonly warningMessage: HTMLElement; private readonly actionsSection: HTMLElement; @@ -67,6 +71,14 @@ export class ChatContextUsageDetails extends Disposable { // Progress bar const progressBar = this.quotaItem.appendChild($('.quota-bar')); this.progressFill = progressBar.appendChild($('.quota-bit')); + this.outputBufferFill = progressBar.appendChild($('.quota-bit.output-buffer')); + + // Output buffer legend (shown only when outputBuffer is provided) + this.outputBufferLegend = this.quotaItem.appendChild($('.output-buffer-legend')); + this.outputBufferLegend.appendChild($('.output-buffer-swatch')); + const legendLabel = this.outputBufferLegend.appendChild($('span')); + legendLabel.textContent = localize('outputReserved', "Reserved for response"); + this.outputBufferLegend.style.display = 'none'; // Token details container (for category breakdown) this.tokenDetailsContainer = this.domNode.appendChild($('.token-details-container')); @@ -98,25 +110,39 @@ export class ChatContextUsageDetails extends Disposable { } update(data: IChatContextUsageData): void { - const { percentage, usedTokens, totalContextWindow, promptTokenDetails } = data; + const { percentage, usedTokens, totalContextWindow, outputBufferPercentage, promptTokenDetails } = data; - // Update token count and percentage + // Update token count and percentage — reflects actual usage only this.tokenCountLabel.textContent = localize( 'tokenCount', "{0} / {1} tokens", this.formatTokenCount(usedTokens, 1), this.formatTokenCount(totalContextWindow, 0) ); - this.percentageLabel.textContent = localize('quotaDisplay', "{0}%", percentage.toFixed(0)); - - // Update progress bar - this.progressFill.style.width = `${Math.min(100, percentage)}%`; + this.percentageLabel.textContent = localize('quotaDisplay', "{0}%", Math.min(100, percentage).toFixed(0)); + + // Progress bar: actual usage fill + remaining reserved output fill + const usageBarWidth = Math.max(0, Math.min(100, percentage)); + this.progressFill.style.width = `${usageBarWidth}%`; + + if (outputBufferPercentage !== undefined && outputBufferPercentage > 0) { + // Clamp so the reserve never overflows the bar + this.outputBufferFill.style.width = `${Math.max(0, Math.min(100 - usageBarWidth, outputBufferPercentage))}%`; + this.outputBufferFill.style.display = ''; + this.outputBufferLegend.style.display = ''; + } else { + this.outputBufferFill.style.width = '0'; + this.outputBufferFill.style.display = 'none'; + this.outputBufferLegend.style.display = 'none'; + } - // Update color classes based on usage level on the quota item + // Color classes based on total spoken-for percentage + // (actual usage + remaining reserve) + const effectivePercentage = percentage + (outputBufferPercentage ?? 0); this.quotaItem.classList.remove('warning', 'error'); - if (percentage >= 90) { + if (effectivePercentage >= 90) { this.quotaItem.classList.add('error'); - } else if (percentage >= 75) { + } else if (effectivePercentage >= 75) { this.quotaItem.classList.add('warning'); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index e051fbcfb3f8e..0bd56a8fe132d 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -274,32 +274,42 @@ export class ChatContextUsageWidget extends Disposable { } const promptTokens = usage.promptTokens; + const completionTokens = usage.completionTokens; const promptTokenDetails = usage.promptTokenDetails; + const outputBuffer = usage.outputBuffer; const totalContextWindow = maxInputTokens + maxOutputTokens; - const usedTokens = promptTokens + maxOutputTokens; - const percentage = Math.min(100, (usedTokens / totalContextWindow) * 100); + const usedTokens = promptTokens + completionTokens; + const percentage = (usedTokens / totalContextWindow) * 100; - this.render(percentage, usedTokens, totalContextWindow, promptTokenDetails); + // Remaining reserve = whatever the model reserved minus what completions + // have already consumed. Once completions exceed the reserve, it drops to 0. + const outputBufferPercentage = outputBuffer !== undefined + ? (Math.max(0, outputBuffer - completionTokens) / totalContextWindow) * 100 + : undefined; + + this.render(percentage, completionTokens, usedTokens, totalContextWindow, outputBufferPercentage, promptTokenDetails); this.show(); } - private render(percentage: number, usedTokens: number, totalContextWindow: number, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { + private render(percentage: number, completionTokens: number, usedTokens: number, totalContextWindow: number, outputBufferPercentage: number | undefined, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { // Store current data for use in details popup - this.currentData = { usedTokens, totalContextWindow, percentage, promptTokenDetails }; + this.currentData = { usedTokens, completionTokens, totalContextWindow, percentage, outputBufferPercentage, promptTokenDetails }; - // Update pie chart progress - this.progressIndicator.setProgress(percentage); + // Pie chart shows actual usage + remaining reserve so the user can see + // how much of the context window is spoken for. + this.progressIndicator.setProgress(percentage + (outputBufferPercentage ?? 0)); - // Update percentage label and aria-label - const roundedPercentage = Math.round(percentage); + // Update percentage label and aria-label (clamp display to 100) + const roundedPercentage = Math.min(100, Math.round(percentage)); this.percentageLabel.textContent = `${roundedPercentage}%`; this.domNode.setAttribute('aria-label', localize('contextUsagePercentageLabel', "Context window usage: {0}%", roundedPercentage)); - // Update color based on usage level + // Color based on total spoken-for percentage (usage + remaining reserve) + const effectivePercentage = percentage + (outputBufferPercentage ?? 0); this.domNode.classList.remove('warning', 'error'); - if (percentage >= 90) { + if (effectivePercentage >= 90) { this.domNode.classList.add('error'); - } else if (percentage >= 75) { + } else if (effectivePercentage >= 75) { this.domNode.classList.add('warning'); } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css index 21dd9bcb7d4fc..53344c162f30c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css @@ -52,6 +52,7 @@ border-radius: 4px; border: 1px solid var(--vscode-gauge-border); margin: 4px 0; + display: flex; } .chat-context-usage-details .quota-indicator .quota-bar .quota-bit { @@ -61,6 +62,45 @@ transition: width 0.3s ease; } +.chat-context-usage-details .quota-indicator .quota-bar .quota-bit.output-buffer { + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-foreground), + var(--vscode-gauge-foreground) 2px, + transparent 2px, + transparent 4px + ); + border-radius: 0 4px 4px 0; +} + +.chat-context-usage-details .quota-indicator .quota-bar .quota-bit:not(.output-buffer):has(+ .quota-bit.output-buffer:not([style*="display: none"])) { + border-radius: 4px 0 0 4px; +} + +/* Output buffer legend */ +.chat-context-usage-details .quota-indicator .output-buffer-legend { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.chat-context-usage-details .quota-indicator .output-buffer-legend .output-buffer-swatch { + width: 12px; + height: 8px; + border-radius: 2px; + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-foreground), + var(--vscode-gauge-foreground) 2px, + transparent 2px, + transparent 4px + ); + flex-shrink: 0; +} + .chat-context-usage-details .quota-indicator.warning .quota-bar { background-color: var(--vscode-gauge-warningBackground); } @@ -69,6 +109,16 @@ background-color: var(--vscode-gauge-warningForeground); } +.chat-context-usage-details .quota-indicator.warning .quota-bar .quota-bit.output-buffer { + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-warningForeground), + var(--vscode-gauge-warningForeground) 2px, + transparent 2px, + transparent 4px + ); +} + .chat-context-usage-details .quota-indicator.error .quota-bar { background-color: var(--vscode-gauge-errorBackground); } @@ -77,6 +127,16 @@ background-color: var(--vscode-gauge-errorForeground); } +.chat-context-usage-details .quota-indicator.error .quota-bar .quota-bit.output-buffer { + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-errorForeground), + var(--vscode-gauge-errorForeground) 2px, + transparent 2px, + transparent 4px + ); +} + /* Description / warning text — matching ChatStatusDashboard */ .chat-context-usage-details div.description { font-size: 11px; @@ -100,6 +160,10 @@ font-weight: 600; } +.chat-context-usage-details .token-category:first-child .token-category-header { + margin-top: 8px; +} + .chat-context-usage-details .token-detail-item { display: flex; justify-content: space-between; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 4d1fd79be9f59..b33bde89eb816 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -151,6 +151,7 @@ export interface IChatUsagePromptTokenDetail { export interface IChatUsage { promptTokens: number; completionTokens: number; + outputBuffer?: number; promptTokenDetails?: readonly IChatUsagePromptTokenDetail[]; kind: 'usage'; } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 9143c72c08df2..286b87dd85cc7 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -839,6 +839,12 @@ declare module 'vscode' { */ readonly completionTokens: number; + /** + * The number of tokens reserved for the response. + * This is rendered specially in the UI to indicate that these tokens aren't used but are reserved. + */ + readonly outputBuffer?: number; + /** * Optional breakdown of prompt token usage by category and label. * If the percentages do not sum to 100%, the remaining will be shown as "Uncategorized".