From d366fa3069ad4edf5e9e575727f221361d614c68 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 30 Jan 2026 09:20:01 +0000 Subject: [PATCH 001/183] fix: detect-child-process-23 --- extensions/grunt/src/main.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/grunt/src/main.ts b/extensions/grunt/src/main.ts index b94b00c4462..9c2fee82b50 100644 --- a/extensions/grunt/src/main.ts +++ b/extensions/grunt/src/main.ts @@ -18,9 +18,9 @@ function exists(file: string): Promise { }); } -function exec(command: string, options: cp.ExecOptions): Promise<{ stdout: string; stderr: string }> { +function exec(command: string, args: string[], options: cp.ExecFileOptions): Promise<{ stdout: string; stderr: string }> { return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - cp.exec(command, options, (error, stdout, stderr) => { + cp.execFile(command, args, options, (error, stdout, stderr) => { if (error) { reject({ error, stdout, stderr }); } @@ -143,9 +143,9 @@ class FolderDetector { return emptyTasks; } - const commandLine = `${await this._gruntCommand} --help --no-color`; + const gruntCommand = await this._gruntCommand; try { - const { stdout, stderr } = await exec(commandLine, { cwd: rootPath }); + const { stdout, stderr } = await exec(gruntCommand, ['--help', '--no-color'], { cwd: rootPath }); if (stderr) { getOutputChannel().appendLine(stderr); showError(); From c3e4504edd9e01e6d39bfebebe6a84c15ef896a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:15:56 +0000 Subject: [PATCH 002/183] Initial plan From b857fb3067afe3e6ad25acf68b775659dcfe9a97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:22:34 +0000 Subject: [PATCH 003/183] Update renameTool and usagesTool: skip registration when no providers, add language names to userDescription Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../contrib/chat/browser/tools/renameTool.ts | 32 ++++++++++++------- .../contrib/chat/browser/tools/usagesTool.ts | 23 +++++++++---- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts index fef06150ff0..41b158c96aa 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -15,6 +15,7 @@ import { TextEdit } from '../../../../../editor/common/languages.js'; import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { rename } from '../../../../../editor/contrib/rename/browser/rename.js'; import { localize } from '../../../../../nls.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -53,6 +54,7 @@ export class RenameTool extends Disposable implements IToolImpl { constructor( @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ILanguageService private readonly _languageService: ILanguageService, @ITextModelService private readonly _textModelService: ITextModelService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IChatService private readonly _chatService: IChatService, @@ -67,26 +69,31 @@ export class RenameTool extends Disposable implements IToolImpl { )((() => this._onDidUpdateToolData.fire()))); } - getToolData(): IToolData { + getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.renameProvider.registeredLanguageIds; - let modelDescription = BaseModelDescription; - if (languageIds.has('*')) { - modelDescription += '\n\nSupported for all languages.'; - } else if (languageIds.size > 0) { - const sorted = [...languageIds].sort(); - modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; - } else { - modelDescription += '\n\nNo languages currently have rename providers registered.'; + if (!languageIds.has('*') && languageIds.size === 0) { + return undefined; } + let modelDescription = BaseModelDescription; + let userDescription: string; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + userDescription = localize('tool.rename.userDescription', 'Rename a symbol across the workspace'); + } else { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + const niceNames = sorted.map(id => this._languageService.getLanguageName(id) ?? id); + userDescription = localize('tool.rename.userDescriptionWithLanguages', 'Rename a symbol across the workspace ({0})', niceNames.join(', ')); + } return { id: RenameToolId, toolReferenceName: 'rename', canBeReferencedInPrompt: false, icon: ThemeIcon.fromId(Codicon.rename.id), displayName: localize('tool.rename.displayName', 'Rename Symbol'), - userDescription: localize('tool.rename.userDescription', 'Rename a symbol across the workspace'), + userDescription, modelDescription, source: ToolDataSource.Internal, when: ContextKeyExpr.has('config.chat.tools.renameTool.enabled'), @@ -251,9 +258,12 @@ export class RenameToolContribution extends Disposable implements IWorkbenchCont let registration: IDisposable | undefined; const registerRenameTool = () => { registration?.dispose(); + registration = undefined; toolsService.flushToolUpdates(); const toolData = renameTool.getToolData(); - registration = toolsService.registerTool(toolData, renameTool); + if (toolData) { + registration = toolsService.registerTool(toolData, renameTool); + } }; registerRenameTool(); this._store.add(renameTool.onDidUpdateToolData(registerRenameTool)); diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts index 075977bc799..99420104957 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -18,6 +18,7 @@ import { Location, LocationLink } from '../../../../../editor/common/languages.j import { IModelService } from '../../../../../editor/common/services/model.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; import { localize } from '../../../../../nls.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -50,6 +51,7 @@ export class UsagesTool extends Disposable implements IToolImpl { constructor( @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ILanguageService private readonly _languageService: ILanguageService, @IModelService private readonly _modelService: IModelService, @ISearchService private readonly _searchService: ISearchService, @ITextModelService private readonly _textModelService: ITextModelService, @@ -64,17 +66,23 @@ export class UsagesTool extends Disposable implements IToolImpl { )((() => this._onDidUpdateToolData.fire()))); } - getToolData(): IToolData { + getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds; + if (!languageIds.has('*') && languageIds.size === 0) { + return undefined; + } + let modelDescription = BaseModelDescription; + let userDescription: string; if (languageIds.has('*')) { modelDescription += '\n\nSupported for all languages.'; - } else if (languageIds.size > 0) { + userDescription = localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'); + } else { const sorted = [...languageIds].sort(); modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; - } else { - modelDescription += '\n\nNo languages currently have reference providers registered.'; + const niceNames = sorted.map(id => this._languageService.getLanguageName(id) ?? id); + userDescription = localize('tool.usages.userDescriptionWithLanguages', 'Find references, definitions, and implementations of a symbol ({0})', niceNames.join(', ')); } return { @@ -83,7 +91,7 @@ export class UsagesTool extends Disposable implements IToolImpl { canBeReferencedInPrompt: false, icon: ThemeIcon.fromId(Codicon.references.id), displayName: localize('tool.usages.displayName', 'List Code Usages'), - userDescription: localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'), + userDescription, modelDescription, source: ToolDataSource.Internal, when: ContextKeyExpr.has('config.chat.tools.usagesTool.enabled'), @@ -320,9 +328,12 @@ export class UsagesToolContribution extends Disposable implements IWorkbenchCont let registration: IDisposable | undefined; const registerUsagesTool = () => { registration?.dispose(); + registration = undefined; toolsService.flushToolUpdates(); const toolData = usagesTool.getToolData(); - registration = toolsService.registerTool(toolData, usagesTool); + if (toolData) { + registration = toolsService.registerTool(toolData, usagesTool); + } }; registerUsagesTool(); this._store.add(usagesTool.onDidUpdateToolData(registerUsagesTool)); From 1026d57b9311f7e71012611c5da792f90d2f38ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:36:33 +0000 Subject: [PATCH 004/183] Simplify empty-set check: size === 0 is sufficient Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- src/vs/workbench/contrib/chat/browser/tools/renameTool.ts | 2 +- src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts index 41b158c96aa..0ddb27fa1ff 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -72,7 +72,7 @@ export class RenameTool extends Disposable implements IToolImpl { getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.renameProvider.registeredLanguageIds; - if (!languageIds.has('*') && languageIds.size === 0) { + if (languageIds.size === 0) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts index 99420104957..daa5a2bd8fe 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -69,7 +69,7 @@ export class UsagesTool extends Disposable implements IToolImpl { getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds; - if (!languageIds.has('*') && languageIds.size === 0) { + if (languageIds.size === 0) { return undefined; } From 6726c61d4fe5a5a2dc8cb7a1b0924e48cff64fb4 Mon Sep 17 00:00:00 2001 From: kbhujbal Date: Thu, 26 Feb 2026 00:05:27 -0800 Subject: [PATCH 005/183] Fix code quality issues: error logging and JSDoc typo - Replace `console.log(err)` with `console.error(err)` in catch blocks in domLineBreaksComputer.ts and sharedWebContentExtractorService.ts - Remove redundant `console.log` that duplicates `logService.info()` in remote.ts - Fix JSDoc typo "Returns of" -> "Returns if" in offsetRange.ts Co-Authored-By: Claude Opus 4.6 --- src/vs/editor/browser/view/domLineBreaksComputer.ts | 2 +- src/vs/editor/common/core/ranges/offsetRange.ts | 2 +- .../node/sharedWebContentExtractorService.ts | 2 +- src/vs/workbench/contrib/remote/browser/remote.ts | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/vs/editor/browser/view/domLineBreaksComputer.ts b/src/vs/editor/browser/view/domLineBreaksComputer.ts index 881275f34af..ae200f2c672 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -306,7 +306,7 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: try { discoverBreaks(range, spans, charOffsets, 0, null, lineContent.length - 1, null, breakOffsets); } catch (err) { - console.log(err); + console.error(err); return null; } diff --git a/src/vs/editor/common/core/ranges/offsetRange.ts b/src/vs/editor/common/core/ranges/offsetRange.ts index e279b382078..72d116282e2 100644 --- a/src/vs/editor/common/core/ranges/offsetRange.ts +++ b/src/vs/editor/common/core/ranges/offsetRange.ts @@ -257,7 +257,7 @@ export class OffsetRangeSet { } /** - * Returns of there is a value that is contained in this instance and the given range. + * Returns if there is a value that is contained in this instance and the given range. */ public intersectsStrict(other: OffsetRange): boolean { // TODO use binary search diff --git a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts index ae70e341c0c..f5d2edb2796 100644 --- a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts +++ b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts @@ -31,7 +31,7 @@ export class SharedWebContentExtractorService implements ISharedWebContentExtrac const content = VSBuffer.wrap(await (response as unknown as { bytes: () => Promise> } /* workaround https://github.com/microsoft/TypeScript/issues/61826 */).bytes()); return content; } catch (err) { - console.log(err); + console.error(err); return undefined; } } diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index 530b0c4ae0f..2d7e4bb96ef 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -1003,7 +1003,6 @@ export class RemoteAgentConnectionStatusListener extends Disposable implements I if (e.handled) { logService.info(`Error handled: Not showing a notification for the error.`); - console.log(`Error handled: Not showing a notification for the error.`); } else if (!this._reloadWindowShown) { this._reloadWindowShown = true; dialogService.confirm({ From 5d91224f5404fbde90b53d5848c2713c0cad008b Mon Sep 17 00:00:00 2001 From: Isidor Date: Fri, 27 Feb 2026 18:41:16 +0100 Subject: [PATCH 006/183] fixes vscode-internalbacklog#6911 --- .../browser/telemetry/editSourceTrackingImpl.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 2a2ae1cc5d6..55fb2fdf16f 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -212,6 +212,8 @@ class TrackedDocumentInfo extends Disposable { trigger: EditTelemetryTrigger; languageId: string; statsUuid: string; + conversationId: string | undefined; + requestId: string | undefined; modifiedCount: number; deltaModifiedCount: number; totalModifiedCount: number; @@ -229,6 +231,8 @@ class TrackedDocumentInfo extends Disposable { languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; + conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; @@ -248,6 +252,8 @@ class TrackedDocumentInfo extends Disposable { trigger, languageId: this._doc.document.languageId.get(), statsUuid: statsUuid, + conversationId: repr.props.$$sessionId, + requestId: repr.props.$$requestId, modifiedCount: value, deltaModifiedCount: deltaModifiedCount, totalModifiedCount: data.totalModifiedCharactersInFinalState, From 35f486e711d8d0ada43659b9a9aad0f5dc12a08e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:44:48 +0000 Subject: [PATCH 007/183] Fix compile errors in renameTool.test.ts and usagesTool.test.ts Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../chat/test/browser/tools/renameTool.test.ts | 16 ++++++++++------ .../chat/test/browser/tools/usagesTool.test.ts | 17 ++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts index 42226127e20..fabf66ac855 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts @@ -12,10 +12,11 @@ import { RenameProvider, WorkspaceEdit, Rejection } from '../../../../../../edit import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IBulkEditService, IBulkEditResult } from '../../../../../../editor/browser/services/bulkEditService.js'; -import { RenameTool, RenameToolId } from '../../../browser/tools/renameTool.js'; +import { RenameTool } from '../../../browser/tools/renameTool.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -100,9 +101,14 @@ suite('RenameTool', () => { const noopCountTokens = async () => 0; const noopProgress: ToolProgress = { report() { } }; + function createMockLanguageService(): ILanguageService { + return { getLanguageName: (id: string) => id } as unknown as ILanguageService; + } + function createTool(textModelService: ITextModelService, options?: { bulkEditService?: IBulkEditService }): RenameTool { return new RenameTool( langFeatures, + createMockLanguageService(), textModelService, createMockWorkspaceService(), createMockChatService(), @@ -124,9 +130,7 @@ suite('RenameTool', () => { test('reports no providers when none registered', () => { const tool = disposables.add(createTool(createMockTextModelService(null!))); - const data = tool.getToolData(); - assert.strictEqual(data.id, RenameToolId); - assert.ok(data.modelDescription.includes('No languages currently have rename providers')); + assert.strictEqual(tool.getToolData(), undefined); }); test('lists registered language ids', () => { @@ -136,7 +140,7 @@ suite('RenameTool', () => { provideRenameEdits: () => ({ edits: [] }), })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('typescript')); + assert.ok(data?.modelDescription.includes('typescript')); }); test('reports all languages for wildcard', () => { @@ -145,7 +149,7 @@ suite('RenameTool', () => { provideRenameEdits: () => ({ edits: [] }), })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('all languages')); + assert.ok(data?.modelDescription.includes('all languages')); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts index e0e20ec03f6..28c14f3b1ad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts @@ -13,10 +13,11 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js'; -import { UsagesTool, UsagesToolId } from '../../../browser/tools/usagesTool.js'; +import { UsagesTool } from '../../../browser/tools/usagesTool.js'; import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -91,8 +92,12 @@ suite('UsagesTool', () => { const noopCountTokens = async () => 0; const noopProgress: ToolProgress = { report() { } }; + function createMockLanguageService(): ILanguageService { + return { getLanguageName: (id: string) => id } as unknown as ILanguageService; + } + function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool { - return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); + return new UsagesTool(langFeatures, createMockLanguageService(), options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); } setup(() => { @@ -109,9 +114,7 @@ suite('UsagesTool', () => { test('reports no providers when none registered', () => { const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); - const data = tool.getToolData(); - assert.strictEqual(data.id, UsagesToolId); - assert.ok(data.modelDescription.includes('No languages currently have reference providers')); + assert.strictEqual(tool.getToolData(), undefined); }); test('lists registered language ids', () => { @@ -119,14 +122,14 @@ suite('UsagesTool', () => { const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('typescript')); + assert.ok(data?.modelDescription.includes('typescript')); }); test('reports all languages for wildcard', () => { const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); disposables.add(langFeatures.referenceProvider.register('*', { provideReferences: () => [] })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('all languages')); + assert.ok(data?.modelDescription.includes('all languages')); }); }); From 81a49feaf7a6572152eb909d409fd245eb768598 Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Fri, 6 Mar 2026 12:05:19 -0800 Subject: [PATCH 008/183] Compact command center + inline agent status when both settings enabled When both chat.unifiedAgentsBar.enabled and chat.agentsControl.enabled are true: - Command center shows left-aligned workspace title, no search icon - Agent status badge sections render inline inside the pill (not separate) - Section order is reversed: [active, unread, sparkle] (populating inward) - Hover only applies to input area, not badge sections When either setting is off, behavior is unchanged. --- .../parts/titlebar/commandCenterControl.ts | 17 ++- .../parts/titlebar/media/titlebarpart.css | 19 +++ .../experiments/agentTitleBarStatusWidget.ts | 121 +++++++++++++----- .../media/agenttitlebarstatuswidget.css | 39 +++++- 4 files changed, 159 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index 2f28eca7419..5ec87b8e52f 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -22,6 +22,7 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { WindowTitle } from './windowTitle.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class CommandCenterControl { @@ -86,6 +87,7 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { @IKeybindingService private _keybindingService: IKeybindingService, @IInstantiationService private _instaService: IInstantiationService, @IEditorGroupsService private _editorGroupService: IEditorGroupsService, + @IConfigurationService private _configurationService: IConfigurationService, ) { super(undefined, _submenu.actions.find(action => action.id === 'workbench.action.quickOpenWithModes') ?? _submenu.actions[0], options); this._hoverDelegate = options.hoverDelegate ?? getDefaultHoverDelegate('mouse'); @@ -143,9 +145,16 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { container.classList.toggle('command-center-quick-pick'); container.role = 'button'; container.setAttribute('aria-description', this.getTooltip()); + + // When both unified agents bar and agent status are enabled, + // hide search icon and left-align the label + const isCompactMode = that._configurationService.getValue('chat.unifiedAgentsBar.enabled') === true + && that._configurationService.getValue('chat.agentsControl.enabled') === true; + container.classList.toggle('compact-mode', isCompactMode); + const action = this.action; - // icon (search) + // icon (search) - hidden in compact mode const searchIcon = document.createElement('span'); searchIcon.ariaHidden = 'true'; searchIcon.className = action.class ?? ''; @@ -156,7 +165,11 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { const labelElement = document.createElement('span'); labelElement.classList.add('search-label'); labelElement.textContent = label; - reset(container, searchIcon, labelElement); + if (isCompactMode) { + reset(container, labelElement); + } else { + reset(container, searchIcon, labelElement); + } const hover = this._store.add(that._hoverService.setupManagedHover(that._hoverDelegate, container, this.getTooltip())); diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index a2c1090f9d1..c48ac39255b 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -204,6 +204,25 @@ text-overflow: ellipsis; } +/* Compact mode: left-aligned label, no icon, full width */ +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center .action-item.command-center-quick-pick.compact-mode { + margin: auto auto auto 0; + padding-left: 8px; + flex: 1; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar > .monaco-action-bar > .actions-container { + margin: 0; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar { + flex: 1; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar > .monaco-action-bar { + width: 100%; +} + .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center.multiple { justify-content: flex-start; padding: 0 12px; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 86ec453b950..e7b12dcc3f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -406,6 +406,10 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._firstFocusableElement = pill; this._container.appendChild(pill); + // When agent status is also enabled, use compact mode (no icon, left-aligned label) + const isCompactMode = this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; + pill.classList.toggle('compact-mode', isCompactMode); + // Left icon container (sparkle by default, report+count when attention needed, search on hover) const leftIcon = $('span.agent-status-left-icon'); if (hasAttentionNeeded) { @@ -418,53 +422,69 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } else { reset(leftIcon, renderIcon(Codicon.searchSparkle)); } - pill.appendChild(leftIcon); + if (!isCompactMode) { + pill.appendChild(leftIcon); + } - // Label (workspace name by default, placeholder on hover) - // Show attention progress or default label + // Input area wrapper - hover only activates here, not on badge sections + const inputArea = $('div.agent-status-input-area'); + pill.appendChild(inputArea); + + // Label - always shows workspace name in compact mode const label = $('span.agent-status-label'); const { session: attentionSession, progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); this._displayedSession = attentionSession; - const defaultLabel = progressText ?? this._getLabel(); + const defaultLabel = isCompactMode ? this._getLabel() : (progressText ?? this._getLabel()); - if (progressText) { + if (!isCompactMode && progressText) { label.classList.add('has-progress'); } const hoverLabel = localize('askAnythingPlaceholder', "Ask anything or describe what to build"); label.textContent = defaultLabel; - pill.appendChild(label); + inputArea.appendChild(label); - // Send icon (hidden by default, shown on hover - only when not showing attention message) - const sendIcon = $('span.agent-status-send'); - reset(sendIcon, renderIcon(Codicon.send)); - sendIcon.classList.add('hidden'); - pill.appendChild(sendIcon); - - // Hover behavior - swap icon and label (only when showing default state). - // When progressText is defined (e.g. sessions need attention), keep the attention/progress - // message visible and do not replace it with the generic placeholder on hover. - if (!progressText) { - disposables.add(addDisposableListener(pill, EventType.MOUSE_ENTER, () => { + if (isCompactMode) { + // Compact mode: hover resets icon state but keeps workspace name + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_ENTER, () => { reset(leftIcon, renderIcon(Codicon.searchSparkle)); leftIcon.classList.remove('has-attention'); - label.textContent = hoverLabel; label.classList.remove('has-progress'); - sendIcon.classList.remove('hidden'); })); - disposables.add(addDisposableListener(pill, EventType.MOUSE_LEAVE, () => { + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_LEAVE, () => { reset(leftIcon, renderIcon(Codicon.searchSparkle)); - label.textContent = defaultLabel; - sendIcon.classList.add('hidden'); })); + } else { + // Send icon (hidden by default, shown on hover - only when not showing attention message) + const sendIcon = $('span.agent-status-send'); + reset(sendIcon, renderIcon(Codicon.send)); + sendIcon.classList.add('hidden'); + inputArea.appendChild(sendIcon); + + // Hover behavior - swap icon and label (only when showing default state). + if (!progressText) { + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_ENTER, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + leftIcon.classList.remove('has-attention'); + label.textContent = hoverLabel; + label.classList.remove('has-progress'); + sendIcon.classList.remove('hidden'); + })); + + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_LEAVE, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + label.textContent = defaultLabel; + sendIcon.classList.add('hidden'); + })); + } } - // Setup hover tooltip + // Setup hover tooltip on input area const hoverDelegate = getDefaultHoverDelegate('mouse'); - disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, () => { + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, inputArea, () => { if (this._displayedSession) { return localize('openSessionTooltip', "Open session: {0}", this._displayedSession.label); } @@ -490,9 +510,10 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } })); - // Status badge (separate rectangle on right) - only when Agent Status is enabled + // Status badge - only when Agent Status is enabled + // In compact mode, render inline within the pill instead of as a separate badge if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); + this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions, isCompactMode ? pill : undefined); } } @@ -715,7 +736,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * Shows split UI with sparkle icon on left, then unread, needs-input, and active indicators. * Always renders the sparkle icon section. */ - private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[], attentionNeededSessions: IAgentSession[]): void { + private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[], attentionNeededSessions: IAgentSession[], inlineContainer?: HTMLElement): void { if (!this._container) { return; } @@ -727,8 +748,15 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Auto-clear filter if the filtered category becomes empty if this window applied it this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions); - const badge = $('div.agent-status-badge'); - this._container.appendChild(badge); + // When inlineContainer is provided, render sections directly into it (compact mode) + // Otherwise, create a separate badge container + let badge: HTMLElement; + if (inlineContainer) { + badge = inlineContainer; + } else { + badge = $('div.agent-status-badge'); + this._container.appendChild(badge); + } // Sparkle dropdown button section (always visible on left) - proper button with dropdown menu const sparkleContainer = $('span.agent-status-badge-section.sparkle'); @@ -736,7 +764,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { if (!this._firstFocusableElement) { this._firstFocusableElement = sparkleContainer; } - badge.appendChild(sparkleContainer); // Get menu actions for dropdown with proper group separators const menuActions: IAction[] = Separator.join(...this._chatTitleBarMenu.getActions({ shouldForwardArgs: true }).map(([, actions]) => actions)); @@ -812,10 +839,26 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Only show status indicators if chat.viewSessions.enabled is true const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; + // When both unified agents bar and agent status are enabled, show status indicators + // before the sparkle button: [active, unread, sparkle] (populating inward) + // Otherwise, keep original order: [sparkle, unread, active] + const unifiedAgentsBarEnabled = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + const agentStatusEnabled = this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; + const reverseOrder = unifiedAgentsBarEnabled && agentStatusEnabled; + + if (!reverseOrder) { + // Original order: sparkle first + badge.appendChild(sparkleContainer); + } + + // Build status sections but don't append yet - we need to control order + let unreadSection: HTMLElement | undefined; + let activeSection: HTMLElement | undefined; + // Unread section (blue dot + count) if (viewSessionsEnabled && hasUnreadSessions && this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY) { const { isFilteredToUnread } = this._getCurrentFilterState(); - const unreadSection = $('span.agent-status-badge-section.unread'); + unreadSection = $('span.agent-status-badge-section.unread'); if (isFilteredToUnread) { unreadSection.classList.add('filtered'); } @@ -827,7 +870,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const unreadCount = $('span.agent-status-text'); unreadCount.textContent = String(unreadSessions.length); unreadSection.appendChild(unreadCount); - badge.appendChild(unreadSection); // Click handler - filter to unread sessions disposables.add(addDisposableListener(unreadSection, EventType.CLICK, (e) => { @@ -854,7 +896,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // otherwise shows "in progress" state. This is a single section that transforms based on state. if (viewSessionsEnabled && hasActiveSessions) { const { isFilteredToInProgress } = this._getCurrentFilterState(); - const activeSection = $('span.agent-status-badge-section.active'); + activeSection = $('span.agent-status-badge-section.active'); if (hasAttentionNeeded) { activeSection.classList.add('needs-input'); } @@ -871,7 +913,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Show needs-input count when attention needed, otherwise total active count statusCount.textContent = String(hasAttentionNeeded ? attentionNeededSessions.length : activeSessions.length); activeSection.appendChild(statusCount); - badge.appendChild(activeSection); // Click handler - filter to in-progress sessions disposables.add(addDisposableListener(activeSection, EventType.CLICK, (e) => { @@ -898,6 +939,18 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(this.hoverService.setupManagedHover(hoverDelegate, activeSection, activeTooltip)); } + // Append status sections in the correct order + if (reverseOrder) { + // [active, unread, sparkle] — populates inward + if (activeSection) { badge.appendChild(activeSection); } + if (unreadSection) { badge.appendChild(unreadSection); } + badge.appendChild(sparkleContainer); + } else { + // Original: [sparkle (already appended), unread, active] + if (unreadSection) { badge.appendChild(unreadSection); } + if (activeSection) { badge.appendChild(activeSection); } + } + } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index 2da5d3bb06a..45ef1d846fc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -95,13 +95,50 @@ color: var(--vscode-commandCenter-activeForeground); } -/* Label */ +/* Label - styled as placeholder text */ .agent-status-label { flex: 1; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + opacity: 0.6; +} + +/* Compact mode: left-aligned label, no icon */ +.agent-status-pill.compact-mode .agent-status-label { + text-align: left; +} + +/* Compact mode: inline status sections inside the pill */ +.agent-status-pill.compact-mode .agent-status-badge-section { + flex-shrink: 0; +} + +/* First badge section in compact mode also gets a left separator */ +.agent-status-pill.compact-mode .agent-status-badge-section:first-of-type::before { + content: ''; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 1px; + background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); +} + +/* Input hover target - only this area triggers hover, not badge sections */ +.agent-status-pill .agent-status-input-area { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + overflow: hidden; + cursor: pointer; + gap: 6px; +} + +.agent-status-pill .agent-status-input-area:hover { + background-color: var(--vscode-commandCenter-activeBackground); } .agent-status-label.has-progress { From f6ce69d5064a60d34f844ecdd78149921ce28f32 Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Tue, 10 Mar 2026 15:35:00 -0700 Subject: [PATCH 009/183] debug edgecase --- .../parts/titlebar/commandCenterControl.ts | 7 +- .../agentSessionProjectionActions.ts | 58 +++++++++---- .../agentSessionsExperiments.contribution.ts | 15 ++-- .../experiments/agentTitleBarStatusWidget.ts | 81 +++++++++++-------- .../media/agenttitlebarstatuswidget.css | 21 +++-- .../contrib/chat/browser/chat.contribution.ts | 10 ++- .../contrib/chat/common/constants.ts | 21 +++++ 7 files changed, 144 insertions(+), 69 deletions(-) diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index 5ec87b8e52f..c0ac9cacf34 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -146,10 +146,9 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { container.role = 'button'; container.setAttribute('aria-description', this.getTooltip()); - // When both unified agents bar and agent status are enabled, - // hide search icon and left-align the label - const isCompactMode = that._configurationService.getValue('chat.unifiedAgentsBar.enabled') === true - && that._configurationService.getValue('chat.agentsControl.enabled') === true; + // When agent control mode is 'compact', hide search icon and left-align the label + const agentControlValue = that._configurationService.getValue('chat.agentsControl.enabled'); + const isCompactMode = agentControlValue === 'compact'; container.classList.toggle('compact-mode', isCompactMode); const action = this.action; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index a5e80a2aa79..d4096e5c3e4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize, localize2 } from '../../../../../../nls.js'; -import { Action2 } from '../../../../../../platform/actions/common/actions.js'; +import { Action2, MenuId } from '../../../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; @@ -17,7 +17,8 @@ import { CHAT_CATEGORY } from '../../actions/chatActions.js'; import { ToggleTitleBarConfigAction } from '../../../../../browser/parts/titlebar/titlebarActions.js'; import { IsCompactTitleBarContext } from '../../../../../common/contextkeys.js'; import { inAgentSessionProjection } from './agentSessionProjection.js'; -import { ChatConfiguration } from '../../../common/constants.js'; +import { ChatConfiguration, getAgentControlMode } from '../../../common/constants.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; //#region Enter Agent Session Projection @@ -92,19 +93,48 @@ export class ExitAgentSessionProjectionAction extends Action2 { //#region Toggle Agent Status -export class ToggleAgentStatusAction extends ToggleTitleBarConfigAction { +export class ToggleAgentStatusAction extends Action2 { constructor() { - super( - ChatConfiguration.AgentStatusEnabled, - localize('toggle.agentStatus', 'Agent Status'), - localize('toggle.agentStatusDescription', "Toggle visibility of the Agent Status in title bar"), 6, - ContextKeyExpr.and( - ChatContextKeys.enabled, - IsCompactTitleBarContext.negate(), - ChatContextKeys.supported, - ContextKeyExpr.has('config.window.commandCenter') - ) - ); + super({ + id: `toggle.${ChatConfiguration.AgentStatusEnabled}`, + title: localize('toggle.agentStatus', 'Agent Status'), + metadata: { description: localize('toggle.agentStatusDescription', "Toggle visibility of the Agent Status in title bar") }, + toggled: ContextKeyExpr.and( + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden + ), + menu: [ + { + id: MenuId.TitleBarContext, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + IsCompactTitleBarContext.negate(), + ChatContextKeys.supported, + ContextKeyExpr.has('config.window.commandCenter') + ), + order: 6, + group: '2_config' + }, + { + id: MenuId.TitleBarTitleContext, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + IsCompactTitleBarContext.negate(), + ChatContextKeys.supported, + ContextKeyExpr.has('config.window.commandCenter') + ), + order: 6, + group: '2_config' + } + ] + }); + } + + run(accessor: ServicesAccessor): void { + const configService = accessor.get(IConfigurationService); + const mode = getAgentControlMode(configService.getValue(ChatConfiguration.AgentStatusEnabled)); + // Toggle between 'compact' (default) and 'hidden' + configService.updateValue(ChatConfiguration.AgentStatusEnabled, mode === 'hidden' ? 'compact' : 'hidden'); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 07fec9b6d17..327c1ab8b4f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -251,10 +251,8 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { icon: Codicon.chatSparkle, when: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), - ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedAgentsBar}`) - ) + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden ), order: 10002 // to the right of the chat button }); @@ -271,7 +269,8 @@ MenuRegistry.appendMenuItem(MenuId.TitleBar, { ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabled.negate() ), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden ContextKeyExpr.has('config.window.commandCenter').negate(), ), order: 1 @@ -285,10 +284,8 @@ MenuRegistry.appendMenuItem(MenuId.AgentsTitleBarControlMenu, { }, when: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), - ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedAgentsBar}`) - ) + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden ), group: 'a_open', order: 1 diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index e7b12dcc3f5..e9e098333a1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -43,7 +43,7 @@ import { IActionViewItemService } from '../../../../../../platform/actions/brows import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { LayoutSettings } from '../../../../../services/layout/browser/layoutService.js'; -import { ChatConfiguration } from '../../../common/constants.js'; +import { ChatConfiguration, getAgentControlMode } from '../../../common/constants.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetService } from '../../chat.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -287,9 +287,10 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Get current filter state for state key const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); - // Check which settings are enabled (these are independent settings) - const unifiedAgentsBarEnabled = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; - const agentStatusEnabled = this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; + // Check which settings are enabled + const agentControlMode = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); + const unifiedAgentsBarEnabled = agentControlMode === 'compact'; + const agentStatusEnabled = agentControlMode !== 'hidden'; const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; // Build state key for comparison @@ -392,9 +393,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const { activeSessions, unreadSessions, attentionNeededSessions, hasAttentionNeeded } = this._getSessionStats(); - // Render command center items (like debug toolbar) FIRST - to the left - this._renderCommandCenterToolbar(disposables); - // Create pill const pill = $('div.agent-status-pill.chat-input-mode'); if (hasAttentionNeeded) { @@ -406,8 +404,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._firstFocusableElement = pill; this._container.appendChild(pill); - // When agent status is also enabled, use compact mode (no icon, left-aligned label) - const isCompactMode = this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; + // Render command center items (like debug toolbar) inside the pill + this._renderCommandCenterToolbar(disposables, pill); + + // Compact mode is always true when rendering chat input mode (caller already checked for compact) + const isCompactMode = true; pill.classList.toggle('compact-mode', isCompactMode); // Left icon container (sparkle by default, report+count when attention needed, search on hover) @@ -510,11 +511,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } })); - // Status badge - only when Agent Status is enabled - // In compact mode, render inline within the pill instead of as a separate badge - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions, isCompactMode ? pill : undefined); - } + // In compact mode, render status badge inline within the pill + this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions, pill); } private _renderSessionMode(disposables: DisposableStore): void { @@ -558,8 +556,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.CLICK, exitHandler)); disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, exitHandler)); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { + // Status badge (separate rectangle on right) + const agentControlMode = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); + if (agentControlMode !== 'hidden') { this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } } @@ -609,8 +608,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.CLICK, enterHandler)); disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, enterHandler)); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { + // Status badge (separate rectangle on right) + const agentControlModeForReady = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); + if (agentControlModeForReady !== 'hidden') { this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } } @@ -639,8 +639,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * Filters out the quick open action since we provide our own search UI. * Adds a dot separator after the toolbar if content was rendered. */ - private _renderCommandCenterToolbar(disposables: DisposableStore): void { - if (!this._container) { + private _renderCommandCenterToolbar(disposables: DisposableStore, parent?: HTMLElement): void { + const container = parent ?? this._container; + if (!container) { return; } @@ -668,7 +669,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const hoverDelegate = getDefaultHoverDelegate('mouse'); const toolbarContainer = $('div.agent-status-command-center-toolbar'); - this._container.appendChild(toolbarContainer); + container.appendChild(toolbarContainer); const toolbar = this.instantiationService.createInstance(WorkbenchToolBar, toolbarContainer, { hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -681,10 +682,17 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { toolbar.setActions(allActions); - // Add dot separator after the toolbar (matching command center style) - const separator = renderIcon(Codicon.circleSmallFilled); - separator.classList.add('agent-status-separator'); - this._container.appendChild(separator); + // Add separator after the toolbar + if (parent) { + // Inside pill (compact mode): use a vertical line separator + const separator = $('span.agent-status-line-separator'); + container.appendChild(separator); + } else { + // Outside pill: use dot separator (matching command center style) + const separator = renderIcon(Codicon.circleSmallFilled); + separator.classList.add('agent-status-separator'); + container.appendChild(separator); + } } /** @@ -842,9 +850,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // When both unified agents bar and agent status are enabled, show status indicators // before the sparkle button: [active, unread, sparkle] (populating inward) // Otherwise, keep original order: [sparkle, unread, active] - const unifiedAgentsBarEnabled = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; - const agentStatusEnabled = this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; - const reverseOrder = unifiedAgentsBarEnabled && agentStatusEnabled; + const agentControlModeForBadge = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); + const reverseOrder = agentControlModeForBadge === 'compact'; if (!reverseOrder) { // Original order: sparkle first @@ -941,6 +948,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Append status sections in the correct order if (reverseOrder) { + // Add line separator before badge sections when inline in compact mode + if (inlineContainer) { + const badgeSeparator = $('span.agent-status-badge-separator'); + badge.appendChild(badgeSeparator); + } // [active, unread, sparkle] — populates inward if (activeSection) { badge.appendChild(activeSection); } if (unreadSection) { badge.appendChild(unreadSection); } @@ -1226,7 +1238,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { source: 'pill', action: 'quickAccess', }); - this.commandService.executeCommand(UNIFIED_QUICK_ACCESS_ACTION_ID); + // Use unified quick access only if that separate setting is enabled, otherwise use normal quick open + const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); } } @@ -1285,7 +1299,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } if (!label) { - label = localize('agentStatusWidget.askAnything', "Ask anything..."); + label = localize('agentStatusWidget.search', "Search"); } // Apply prefix and suffix decorations @@ -1349,11 +1363,12 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben }, undefined)); // Add/remove CSS classes on workbench based on settings - // Force enable command center and disable chat controls when agent status or unified agents bar is enabled + // Force enable command center and disable chat controls when agent status is enabled const updateClass = () => { const commandCenterEnabled = configurationService.getValue(LayoutSettings.COMMAND_CENTER) === true; - const enabled = configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true && commandCenterEnabled; - const enhanced = configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true && commandCenterEnabled; + const mode = getAgentControlMode(configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); + const enabled = mode !== 'hidden' && commandCenterEnabled; + const enhanced = mode === 'compact' && commandCenterEnabled; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); mainWindow.document.body.classList.toggle('unified-agents-bar', enhanced); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index 45ef1d846fc..7f9a694585e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -115,15 +115,13 @@ flex-shrink: 0; } -/* First badge section in compact mode also gets a left separator */ -.agent-status-pill.compact-mode .agent-status-badge-section:first-of-type::before { - content: ''; - position: absolute; - left: 0; - top: 4px; - bottom: 4px; +/* Vertical line separator before badge sections in compact mode */ +.agent-status-badge-separator { width: 1px; + align-self: stretch; + margin: 4px 0; background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); + flex-shrink: 0; } /* Input hover target - only this area triggers hover, not badge sections */ @@ -249,6 +247,15 @@ align-items: center; } +/* Vertical line separator used inside the pill in compact+debug mode */ +.agent-status-line-separator { + width: 1px; + align-self: stretch; + margin: 4px 6px; + background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); + flex-shrink: 0; +} + /* Status Badge */ .agent-status-badge { display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 4fbdb207fa0..e1fa7d048a8 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -229,9 +229,15 @@ configurationRegistry.registerConfiguration({ default: 0 }, [ChatConfiguration.AgentStatusEnabled]: { - type: 'boolean', + type: 'string', + enum: ['hidden', 'badge', 'compact'], + enumDescriptions: [ + nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), + nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), + nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), + ], markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the 'Agent Status' indicator is shown in the title bar command center. Enabling this setting will automatically enable {0}. The unread/in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), - default: true, + default: 'compact', tags: ['experimental'] }, [ChatConfiguration.UnifiedAgentsBar]: { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 1cc89241a27..f11b76a06f7 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -8,6 +8,27 @@ import { IChatSessionsService } from './chatSessionsService.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +export type AgentControlMode = 'hidden' | 'badge' | 'compact'; + +/** + * Resolves the agent control mode from the configuration, handling backward + * compatibility with the old boolean value. + */ +export function getAgentControlMode(value: unknown): AgentControlMode { + if (value === false || value === 'hidden') { + return 'hidden'; + } + if (value === true || value === 'badge') { + // true = old boolean default, preserve badge-only behavior + return 'badge'; + } + if (value === 'compact') { + return 'compact'; + } + // New installs get the string default 'compact' from the setting definition + return 'compact'; +} + export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', PluginsEnabled = 'chat.plugins.enabled', From 7fffb1b10a3e9508dd1b811e02a55f29bf4c4c7e Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Tue, 10 Mar 2026 15:54:05 -0700 Subject: [PATCH 010/183] adjusting spacing --- .../experiments/agentTitleBarStatusWidget.ts | 70 ++++++++++++------- .../media/agenttitlebarstatuswidget.css | 45 +++++++++--- 2 files changed, 78 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index e9e098333a1..0599c0f51ca 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -861,6 +861,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Build status sections but don't append yet - we need to control order let unreadSection: HTMLElement | undefined; let activeSection: HTMLElement | undefined; + let needsInputSection: HTMLElement | undefined; // Unread section (blue dot + count) if (viewSessionsEnabled && hasUnreadSessions && this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY) { @@ -899,29 +900,54 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(this.hoverService.setupManagedHover(hoverDelegate, unreadSection, unreadTooltip)); } - // In-progress/Needs-input section - shows "needs input" state when any session needs attention, - // otherwise shows "in progress" state. This is a single section that transforms based on state. - if (viewSessionsEnabled && hasActiveSessions) { + // Needs-input section - shows sessions requiring user attention (approval/confirmation/input) + if (viewSessionsEnabled && hasAttentionNeeded) { + needsInputSection = $('span.agent-status-badge-section.active.needs-input'); + needsInputSection.setAttribute('role', 'button'); + needsInputSection.tabIndex = 0; + const needsInputIcon = $('span.agent-status-icon'); + reset(needsInputIcon, renderIcon(Codicon.report)); + needsInputSection.appendChild(needsInputIcon); + const needsInputCount = $('span.agent-status-text'); + needsInputCount.textContent = String(attentionNeededSessions.length); + needsInputSection.appendChild(needsInputCount); + + disposables.add(addDisposableListener(needsInputSection, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('inProgress'); + })); + disposables.add(addDisposableListener(needsInputSection, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('inProgress'); + } + })); + + const needsInputTooltip = attentionNeededSessions.length === 1 + ? localize('needsInputSessionsTooltip1', "{0} session needs input", attentionNeededSessions.length) + : localize('needsInputSessionsTooltip', "{0} sessions need input", attentionNeededSessions.length); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, needsInputSection, needsInputTooltip)); + } + + // In-progress section - shows sessions that are actively running (excludes needs-input) + const inProgressOnly = activeSessions.filter(s => s.status !== AgentSessionStatus.NeedsInput); + if (viewSessionsEnabled && inProgressOnly.length > 0) { const { isFilteredToInProgress } = this._getCurrentFilterState(); activeSection = $('span.agent-status-badge-section.active'); - if (hasAttentionNeeded) { - activeSection.classList.add('needs-input'); - } if (isFilteredToInProgress) { activeSection.classList.add('filtered'); } activeSection.setAttribute('role', 'button'); activeSection.tabIndex = 0; const statusIcon = $('span.agent-status-icon'); - // Show report icon when needs input, otherwise session-in-progress icon - reset(statusIcon, renderIcon(hasAttentionNeeded ? Codicon.report : Codicon.sessionInProgress)); + reset(statusIcon, renderIcon(Codicon.sessionInProgress)); activeSection.appendChild(statusIcon); const statusCount = $('span.agent-status-text'); - // Show needs-input count when attention needed, otherwise total active count - statusCount.textContent = String(hasAttentionNeeded ? attentionNeededSessions.length : activeSessions.length); + statusCount.textContent = String(inProgressOnly.length); activeSection.appendChild(statusCount); - // Click handler - filter to in-progress sessions disposables.add(addDisposableListener(activeSection, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); @@ -935,32 +961,24 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } })); - // Hover tooltip - different message based on state - const activeTooltip = hasAttentionNeeded - ? (attentionNeededSessions.length === 1 - ? localize('needsInputSessionsTooltip1', "{0} session needs input", attentionNeededSessions.length) - : localize('needsInputSessionsTooltip', "{0} sessions need input", attentionNeededSessions.length)) - : (activeSessions.length === 1 - ? localize('activeSessionsTooltip1', "{0} session in progress", activeSessions.length) - : localize('activeSessionsTooltip', "{0} sessions in progress", activeSessions.length)); + const activeTooltip = inProgressOnly.length === 1 + ? localize('activeSessionsTooltip1', "{0} session in progress", inProgressOnly.length) + : localize('activeSessionsTooltip', "{0} sessions in progress", inProgressOnly.length); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, activeSection, activeTooltip)); } // Append status sections in the correct order if (reverseOrder) { - // Add line separator before badge sections when inline in compact mode - if (inlineContainer) { - const badgeSeparator = $('span.agent-status-badge-separator'); - badge.appendChild(badgeSeparator); - } - // [active, unread, sparkle] — populates inward + // [needs-input, active, unread, sparkle] — populates inward + if (needsInputSection) { badge.appendChild(needsInputSection); } if (activeSection) { badge.appendChild(activeSection); } if (unreadSection) { badge.appendChild(unreadSection); } badge.appendChild(sparkleContainer); } else { - // Original: [sparkle (already appended), unread, active] + // Original: [sparkle (already appended), unread, active, needs-input] if (unreadSection) { badge.appendChild(unreadSection); } if (activeSection) { badge.appendChild(activeSection); } + if (needsInputSection) { badge.appendChild(needsInputSection); } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index 7f9a694585e..773066b87c7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -65,6 +65,18 @@ border-color: var(--vscode-commandCenter-activeBorder, transparent); } +/* Compact mode: pill hover is handled by individual sections, not the whole pill */ +.agent-status-pill.compact-mode { + padding: 0; + gap: 0; + background-color: transparent; +} + +.agent-status-pill.compact-mode:hover { + background-color: transparent; + border-color: var(--vscode-commandCenter-border, transparent); +} + .agent-status-pill.chat-input-mode { cursor: pointer; } @@ -115,15 +127,6 @@ flex-shrink: 0; } -/* Vertical line separator before badge sections in compact mode */ -.agent-status-badge-separator { - width: 1px; - align-self: stretch; - margin: 4px 0; - background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); - flex-shrink: 0; -} - /* Input hover target - only this area triggers hover, not badge sections */ .agent-status-pill .agent-status-input-area { display: flex; @@ -133,6 +136,15 @@ overflow: hidden; cursor: pointer; gap: 6px; + border-radius: 5px 0 0 5px; + height: 100%; + padding: 0 10px; + background-color: var(--vscode-agentStatusIndicator-background); +} + +/* When preceded by a toolbar/separator, remove left border-radius */ +.agent-status-line-separator + .agent-status-input-area { + border-radius: 0; } .agent-status-pill .agent-status-input-area:hover { @@ -238,6 +250,9 @@ display: flex; align-items: center; -webkit-app-region: no-drag; + height: 100%; + background-color: var(--vscode-agentStatusIndicator-background); + border-radius: 5px 0 0 5px; } .agent-status-separator { @@ -251,9 +266,10 @@ .agent-status-line-separator { width: 1px; align-self: stretch; - margin: 4px 6px; + margin: 4px 0; background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); flex-shrink: 0; + pointer-events: none; } /* Status Badge */ @@ -279,6 +295,7 @@ height: 100%; position: relative; cursor: pointer; + background-color: var(--vscode-agentStatusIndicator-background); } .agent-status-badge-section:first-child { border-radius: 5px 0 0 5px; } @@ -325,7 +342,8 @@ } /* Separator between sections */ -.agent-status-badge-section + .agent-status-badge-section::before { +.agent-status-badge-section + .agent-status-badge-section::before, +.agent-status-input-area + .agent-status-badge-section::before { content: ''; position: absolute; left: 0; @@ -365,6 +383,11 @@ border-radius: 5px 0 0 5px; } +/* In compact mode, no left radius on sparkle - it sits flush next to other sections */ +.agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { + border-radius: 0; +} + .agent-status-badge-section.sparkle .dropdown-action-container { width: 18px; padding: 0; From 60b9060780ed0a307f4f040930dc155d28626066 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:27:04 +0000 Subject: [PATCH 011/183] Initial plan From a09dc298de245b92d3e692dc6324ccd47c2bb07f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:35:39 +0000 Subject: [PATCH 012/183] Address review feedback: fix order comment, needsInput filtered state, toggle badge preservation, and description updates Co-authored-by: eli-w-king <201316543+eli-w-king@users.noreply.github.com> --- .../experiments/agentSessionProjectionActions.ts | 11 ++++++++--- .../experiments/agentTitleBarStatusWidget.ts | 10 +++++++--- .../contrib/chat/browser/chat.contribution.ts | 2 +- src/vs/workbench/contrib/chat/common/constants.ts | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index d4096e5c3e4..9d6d0f6c9a1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -132,9 +132,14 @@ export class ToggleAgentStatusAction extends Action2 { run(accessor: ServicesAccessor): void { const configService = accessor.get(IConfigurationService); - const mode = getAgentControlMode(configService.getValue(ChatConfiguration.AgentStatusEnabled)); - // Toggle between 'compact' (default) and 'hidden' - configService.updateValue(ChatConfiguration.AgentStatusEnabled, mode === 'hidden' ? 'compact' : 'hidden'); + const rawValue = configService.getValue(ChatConfiguration.AgentStatusEnabled); + const mode = getAgentControlMode(rawValue); + if (mode === 'hidden') { + // Restore to previous non-hidden mode; if the raw value was 'badge', restore 'badge', otherwise use 'compact' + configService.updateValue(ChatConfiguration.AgentStatusEnabled, rawValue === 'badge' ? 'badge' : 'compact'); + } else { + configService.updateValue(ChatConfiguration.AgentStatusEnabled, 'hidden'); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 0599c0f51ca..81bacf1555c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -847,9 +847,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Only show status indicators if chat.viewSessions.enabled is true const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; - // When both unified agents bar and agent status are enabled, show status indicators - // before the sparkle button: [active, unread, sparkle] (populating inward) - // Otherwise, keep original order: [sparkle, unread, active] + // When compact mode is active, show status indicators before the sparkle button: + // [needs-input, active, unread, sparkle] (populating inward) + // Otherwise, keep original order: [sparkle, unread, active, needs-input] const agentControlModeForBadge = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); const reverseOrder = agentControlModeForBadge === 'compact'; @@ -902,7 +902,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Needs-input section - shows sessions requiring user attention (approval/confirmation/input) if (viewSessionsEnabled && hasAttentionNeeded) { + const { isFilteredToInProgress } = this._getCurrentFilterState(); needsInputSection = $('span.agent-status-badge-section.active.needs-input'); + if (isFilteredToInProgress) { + needsInputSection.classList.add('filtered'); + } needsInputSection.setAttribute('role', 'button'); needsInputSection.tabIndex = 0; const needsInputIcon = $('span.agent-status-icon'); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index e1fa7d048a8..5323d639d3a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -236,7 +236,7 @@ configurationRegistry.registerConfiguration({ nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), ], - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the 'Agent Status' indicator is shown in the title bar command center. Enabling this setting will automatically enable {0}. The unread/in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), default: 'compact', tags: ['experimental'] }, diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index f11b76a06f7..7bfe9f7f620 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -25,7 +25,7 @@ export function getAgentControlMode(value: unknown): AgentControlMode { if (value === 'compact') { return 'compact'; } - // New installs get the string default 'compact' from the setting definition + // Fallback to the configuration schema default 'compact' for any other or missing value return 'compact'; } From 78b7246cc2a8e765b7dc7413eb186bebef28778e Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Wed, 11 Mar 2026 11:28:39 -0700 Subject: [PATCH 013/183] keyboard accessible --- src/vs/workbench/browser/style.ts | 2 +- .../experiments/agentTitleBarStatusWidget.ts | 186 +++++++++++------- .../media/agenttitlebarstatuswidget.css | 13 +- 3 files changed, 123 insertions(+), 78 deletions(-) diff --git a/src/vs/workbench/browser/style.ts b/src/vs/workbench/browser/style.ts index 9250ef3f280..6cec1192f1e 100644 --- a/src/vs/workbench/browser/style.ts +++ b/src/vs/workbench/browser/style.ts @@ -16,7 +16,7 @@ registerThemingParticipant((theme, collector) => { // Background (helps for subpixel-antialiasing on Windows) const workbenchBackground = WORKBENCH_BACKGROUND(theme); - collector.addRule(`.monaco-workbench { background-color: ${workbenchBackground}; }`); + collector.scraddRule(`.monaco-workbench { background-color: ${workbenchBackground}; }`); // Selection (do NOT remove - https://github.com/microsoft/vscode/issues/169662) const windowSelectionBackground = theme.getColor(selectionBackground); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 81bacf1555c..618399c1c51 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -62,7 +62,7 @@ type AgentStatusClickAction = | 'exitProjection'; type AgentStatusClickEvent = { - source: 'pill' | 'sparkle' | 'unread' | 'inProgress'; + source: 'pill' | 'sparkle' | 'unread' | 'inProgress' | 'needsInput'; action: AgentStatusClickAction; }; @@ -110,12 +110,13 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Guard to prevent re-entrant rendering */ private _isRendering = false; - /** First focusable element for keyboard navigation */ - private _firstFocusableElement: HTMLElement | undefined; + /** Roving tabindex elements for keyboard navigation */ + private _rovingElements: HTMLElement[] = []; + private _rovingIndex: number = 0; /** Tracks if this window applied a badge filter (unread/inProgress), so we only auto-clear our own filters */ // TODO: This is imperfect. Targetted fix for vscode#290863. We should revisit storing filter state per-window to avoid this - private _badgeFilterAppliedByThisWindow: 'unread' | 'inProgress' | null = null; + private _badgeFilterAppliedByThisWindow: 'unread' | 'inProgress' | 'needsInput' | null = null; /** Reusable menu for CommandCenterCenter items (e.g., debug toolbar) */ private readonly _commandCenterMenu; @@ -223,6 +224,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { super.render(container); this._container = container; container.classList.add('agent-status-container'); + container.setAttribute('role', 'toolbar'); // Container should not be focusable - inner elements handle focus container.tabIndex = -1; @@ -237,8 +239,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } override focus(): void { - // Focus the first focusable child instead - this._firstFocusableElement?.focus(); + this._rovingElements[this._rovingIndex]?.focus(); } override blur(): void { @@ -285,7 +286,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const label = this._getLabel(); // Get current filter state for state key - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); // Check which settings are enabled const agentControlMode = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); @@ -304,6 +305,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { label, isFilteredToUnread, isFilteredToInProgress, + isFilteredToNeedsInput, unifiedAgentsBarEnabled, agentStatusEnabled, viewSessionsEnabled, @@ -318,9 +320,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Clear existing content reset(this._container); - // Clear previous disposables and focusable element for dynamic content + // Clear previous disposables and roving elements for dynamic content this._dynamicDisposables.clear(); - this._firstFocusableElement = undefined; + this._rovingElements = []; if (this.agentTitleBarStatusService.mode === AgentStatusMode.Session) { // Agent Session Projection mode - show session title + close button @@ -336,11 +338,55 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._renderBadgeOnlyMode(this._dynamicDisposables); } // If neither setting is enabled, nothing is rendered (container is already cleared) + + // Setup roving tabindex for keyboard navigation + this._setupRovingTabIndex(this._dynamicDisposables); } finally { this._isRendering = false; } } + /** + * Setup roving tabindex for arrow key navigation between interactive elements. + * Auto-discovers elements with role="button" in DOM order. + */ + private _setupRovingTabIndex(disposables: DisposableStore): void { + if (!this._container || this._rovingElements.length === 0) { + return; + } + + if (this._rovingIndex >= this._rovingElements.length) { + this._rovingIndex = 0; + } + for (let i = 0; i < this._rovingElements.length; i++) { + this._rovingElements[i].tabIndex = i === this._rovingIndex ? 0 : -1; + } + + disposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e) => { + const index = this._rovingElements.findIndex(el => el === e.target || el.contains(e.target as Node)); + if (index === -1) { + return; + } + + let nextIndex: number | undefined; + switch (e.key) { + case 'ArrowRight': nextIndex = (index + 1) % this._rovingElements.length; break; + case 'ArrowLeft': nextIndex = (index - 1 + this._rovingElements.length) % this._rovingElements.length; break; + case 'Home': nextIndex = 0; break; + case 'End': nextIndex = this._rovingElements.length - 1; break; + } + + if (nextIndex !== undefined && nextIndex !== index) { + e.preventDefault(); + e.stopPropagation(); + this._rovingElements[index].tabIndex = -1; + this._rovingElements[nextIndex].tabIndex = 0; + this._rovingElements[nextIndex].focus(); + this._rovingIndex = nextIndex; + } + })); + } + // #region Session Statistics /** @@ -398,10 +444,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { if (hasAttentionNeeded) { pill.classList.add('needs-attention'); } - pill.setAttribute('role', 'button'); - pill.setAttribute('aria-label', localize('openQuickAccess', "Open Quick Access")); - pill.tabIndex = 0; - this._firstFocusableElement = pill; this._container.appendChild(pill); // Render command center items (like debug toolbar) inside the pill @@ -429,6 +471,10 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Input area wrapper - hover only activates here, not on badge sections const inputArea = $('div.agent-status-input-area'); + inputArea.setAttribute('role', 'button'); + inputArea.setAttribute('aria-label', localize('openQuickAccess', "Open Quick Access")); + inputArea.tabIndex = 0; + this._rovingElements.push(inputArea); pill.appendChild(inputArea); // Label - always shows workspace name in compact mode @@ -496,14 +542,14 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { })); // Click handler - open displayed session if showing progress, otherwise open unified quick access - disposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + disposables.add(addDisposableListener(inputArea, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); this._handlePillClick(); })); // Keyboard handler - disposables.add(addDisposableListener(pill, EventType.KEY_DOWN, (e) => { + disposables.add(addDisposableListener(inputArea, EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); @@ -709,9 +755,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { searchButton.setAttribute('role', 'button'); searchButton.setAttribute('aria-label', localize('openQuickOpen', "Open Quick Open")); searchButton.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = searchButton; - } + this._rovingElements.push(searchButton); container.appendChild(searchButton); // Setup hover @@ -754,7 +798,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const hasAttentionNeeded = attentionNeededSessions.length > 0; // Auto-clear filter if the filtered category becomes empty if this window applied it - this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions); + this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions, hasAttentionNeeded); // When inlineContainer is provided, render sections directly into it (compact mode) // Otherwise, create a separate badge container @@ -769,9 +813,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Sparkle dropdown button section (always visible on left) - proper button with dropdown menu const sparkleContainer = $('span.agent-status-badge-section.sparkle'); sparkleContainer.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = sparkleContainer; - } // Get menu actions for dropdown with proper group separators const menuActions: IAction[] = Separator.join(...this._chatTitleBarMenu.getActions({ shouldForwardArgs: true }).map(([, actions]) => actions)); @@ -902,9 +943,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Needs-input section - shows sessions requiring user attention (approval/confirmation/input) if (viewSessionsEnabled && hasAttentionNeeded) { - const { isFilteredToInProgress } = this._getCurrentFilterState(); + const { isFilteredToNeedsInput } = this._getCurrentFilterState(); needsInputSection = $('span.agent-status-badge-section.active.needs-input'); - if (isFilteredToInProgress) { + if (isFilteredToNeedsInput) { needsInputSection.classList.add('filtered'); } needsInputSection.setAttribute('role', 'button'); @@ -919,13 +960,13 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(needsInputSection, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); - this._openSessionsWithFilter('inProgress'); + this._openSessionsWithFilter('needsInput'); })); disposables.add(addDisposableListener(needsInputSection, EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this._openSessionsWithFilter('inProgress'); + this._openSessionsWithFilter('needsInput'); } })); @@ -971,18 +1012,20 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(this.hoverService.setupManagedHover(hoverDelegate, activeSection, activeTooltip)); } - // Append status sections in the correct order + // Append status sections in the correct order and register for roving tabindex if (reverseOrder) { // [needs-input, active, unread, sparkle] — populates inward - if (needsInputSection) { badge.appendChild(needsInputSection); } - if (activeSection) { badge.appendChild(activeSection); } - if (unreadSection) { badge.appendChild(unreadSection); } + if (needsInputSection) { badge.appendChild(needsInputSection); this._rovingElements.push(needsInputSection); } + if (activeSection) { badge.appendChild(activeSection); this._rovingElements.push(activeSection); } + if (unreadSection) { badge.appendChild(unreadSection); this._rovingElements.push(unreadSection); } badge.appendChild(sparkleContainer); + this._rovingElements.push(sparkleContainer); } else { // Original: [sparkle (already appended), unread, active, needs-input] - if (unreadSection) { badge.appendChild(unreadSection); } - if (activeSection) { badge.appendChild(activeSection); } - if (needsInputSection) { badge.appendChild(needsInputSection); } + this._rovingElements.push(sparkleContainer); + if (unreadSection) { badge.appendChild(unreadSection); this._rovingElements.push(unreadSection); } + if (activeSection) { badge.appendChild(activeSection); this._rovingElements.push(activeSection); } + if (needsInputSection) { badge.appendChild(needsInputSection); this._rovingElements.push(needsInputSection); } } } @@ -992,31 +1035,35 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * For example, if filtered to "unread" but no unread sessions exist, restore user's previous filter. * Only auto-clears if THIS window applied the badge filter to avoid cross-window interference. */ - private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean): void { + private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean, hasAttentionNeeded: boolean): void { // Only auto-clear if this window applied the badge filter // This prevents Window B from clearing filters that Window A set if (this._badgeFilterAppliedByThisWindow === 'unread' && !hasUnreadSessions) { this._restoreUserFilter(); } else if (this._badgeFilterAppliedByThisWindow === 'inProgress' && !hasActiveSessions) { this._restoreUserFilter(); + } else if (this._badgeFilterAppliedByThisWindow === 'needsInput' && !hasAttentionNeeded) { + this._restoreUserFilter(); } } /** * Get the current filter state from storage. */ - private _getCurrentFilterState(): { isFilteredToUnread: boolean; isFilteredToInProgress: boolean } { + private _getCurrentFilterState(): { isFilteredToUnread: boolean; isFilteredToInProgress: boolean; isFilteredToNeedsInput: boolean } { const filter = this._getStoredFilter(); if (!filter) { - return { isFilteredToUnread: false, isFilteredToInProgress: false }; + return { isFilteredToUnread: false, isFilteredToInProgress: false, isFilteredToNeedsInput: false }; } // Detect if filtered to unread (read=true excludes read sessions, leaving only unread) const isFilteredToUnread = filter.read === true && filter.states.length === 0; - // Detect if filtered to in-progress (2 excluded states = Completed + Failed) - const isFilteredToInProgress = filter.states?.length === 2 && filter.read === false; + // Detect if filtered to in-progress only (3 excluded states including NeedsInput) + const isFilteredToInProgress = filter.states?.length === 3 && filter.states.includes(AgentSessionStatus.NeedsInput) && filter.read === false; + // Detect if filtered to needs-input only (3 excluded states including InProgress) + const isFilteredToNeedsInput = filter.states?.length === 3 && filter.states.includes(AgentSessionStatus.InProgress) && filter.read === false; - return { isFilteredToUnread, isFilteredToInProgress }; + return { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput }; } /** @@ -1059,11 +1106,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * This preserves the original user filter when switching between badge filters. */ private _saveUserFilter(): void { - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); // Don't overwrite the saved filter if we're already in a badge-filtered state // The previous user filter should already be saved - if (isFilteredToUnread || isFilteredToInProgress) { + if (isFilteredToUnread || isFilteredToInProgress || isFilteredToNeedsInput) { return; } @@ -1099,56 +1146,54 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** * Opens the agent sessions view with a specific filter applied, or restores previous filter if already applied. * Preserves session type (provider) filters while toggling only status filters. - * @param filterType 'unread' to show only unread sessions, 'inProgress' to show only in-progress sessions */ - private _openSessionsWithFilter(filterType: 'unread' | 'inProgress'): void { - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + private _openSessionsWithFilter(filterType: 'unread' | 'inProgress' | 'needsInput'): void { + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); const currentFilter = this._getStoredFilter(); // Preserve existing provider filters (session type filters like Local, Background, etc.) const preservedProviders = currentFilter?.providers ?? []; // Log telemetry for filter button clicks - const isToggleOff = (filterType === 'unread' && isFilteredToUnread) || (filterType === 'inProgress' && isFilteredToInProgress); + const isToggleOff = (filterType === 'unread' && isFilteredToUnread) + || (filterType === 'inProgress' && isFilteredToInProgress) + || (filterType === 'needsInput' && isFilteredToNeedsInput); this.telemetryService.publicLog2('agentStatusWidget.click', { source: filterType, action: isToggleOff ? 'clearFilter' : 'applyFilter', }); - // Toggle filter based on current state - if (filterType === 'unread') { - if (isFilteredToUnread) { - // Already filtered to unread - restore user's previous filter - this._restoreUserFilter(); - } else { - // Save current filter before applying our own - this._saveUserFilter(); - // Exclude read sessions to show only unread, preserving provider filters + // Check if already filtered to this type — toggle off + if (isToggleOff) { + this._restoreUserFilter(); + } else { + // Save current filter before applying our own + this._saveUserFilter(); + + if (filterType === 'unread') { this._storeFilter({ providers: preservedProviders, states: [], archived: true, read: true }); - // Track that this window applied the badge filter - this._badgeFilterAppliedByThisWindow = 'unread'; - } - } else { - if (isFilteredToInProgress) { - // Already filtered to in-progress - restore user's previous filter - this._restoreUserFilter(); - } else { - // Save current filter before applying our own - this._saveUserFilter(); - // Exclude Completed and Failed to show InProgress and NeedsInput, preserving provider filters + } else if (filterType === 'inProgress') { + // Exclude Completed, Failed, and NeedsInput — show only InProgress this._storeFilter({ providers: preservedProviders, - states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed], + states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed, AgentSessionStatus.NeedsInput], + archived: true, + read: false + }); + } else { + // Exclude Completed, Failed, and InProgress — show only NeedsInput + this._storeFilter({ + providers: preservedProviders, + states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed, AgentSessionStatus.InProgress], archived: true, read: false }); - // Track that this window applied the badge filter - this._badgeFilterAppliedByThisWindow = 'inProgress'; } + this._badgeFilterAppliedByThisWindow = filterType; } // Open the sessions view @@ -1164,6 +1209,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { escButton.setAttribute('role', 'button'); escButton.setAttribute('aria-label', localize('exitAgentSessionProjection', "Exit Agent Session Projection")); escButton.tabIndex = 0; + this._rovingElements.push(escButton); parent.appendChild(escButton); // Setup hover @@ -1204,9 +1250,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { enterButton.setAttribute('role', 'button'); enterButton.setAttribute('aria-label', localize('enterAgentSessionProjection', "Enter Agent Session Projection")); enterButton.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = enterButton; - } + this._rovingElements.push(enterButton); parent.appendChild(enterButton); // Setup hover diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index 773066b87c7..adb9387bdeb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -81,11 +81,6 @@ cursor: pointer; } -.agent-status-pill.chat-input-mode:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - .agent-status-pill.session-mode, .agent-status-pill.session-ready-mode { padding: 0 12px; @@ -151,6 +146,11 @@ background-color: var(--vscode-commandCenter-activeBackground); } +.agent-status-pill .agent-status-input-area:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .agent-status-label.has-progress { animation: agentStatusFadeIn 0.3s ease-out; } @@ -379,13 +379,14 @@ } .agent-status-badge-section.sparkle .action-container { - padding: 0 4px; + padding: 0 5px; border-radius: 5px 0 0 5px; } /* In compact mode, no left radius on sparkle - it sits flush next to other sections */ .agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { border-radius: 0; + padding: 0 5px 0 6px; } .agent-status-badge-section.sparkle .dropdown-action-container { From 0e2826fd44e7e7e067a4c107a2ba42370c556eaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:19:53 +0000 Subject: [PATCH 014/183] Initial plan From 4a20f5993174b0e2e79270b515c522da73fb8612 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:27:14 +0000 Subject: [PATCH 015/183] Address review feedback: typo fix, schema backward compat, a11y improvements, CSS cursor scoping, simplified toggle Co-authored-by: eli-w-king <201316543+eli-w-king@users.noreply.github.com> --- src/vs/workbench/browser/style.ts | 2 +- .../experiments/agentSessionProjectionActions.ts | 7 +++---- .../experiments/agentTitleBarStatusWidget.ts | 3 ++- .../experiments/media/agenttitlebarstatuswidget.css | 6 +++++- .../workbench/contrib/chat/browser/chat.contribution.ts | 8 +++++--- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/browser/style.ts b/src/vs/workbench/browser/style.ts index 6cec1192f1e..9250ef3f280 100644 --- a/src/vs/workbench/browser/style.ts +++ b/src/vs/workbench/browser/style.ts @@ -16,7 +16,7 @@ registerThemingParticipant((theme, collector) => { // Background (helps for subpixel-antialiasing on Windows) const workbenchBackground = WORKBENCH_BACKGROUND(theme); - collector.scraddRule(`.monaco-workbench { background-color: ${workbenchBackground}; }`); + collector.addRule(`.monaco-workbench { background-color: ${workbenchBackground}; }`); // Selection (do NOT remove - https://github.com/microsoft/vscode/issues/169662) const windowSelectionBackground = theme.getColor(selectionBackground); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index 9d6d0f6c9a1..1a79bca6f6e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -132,11 +132,10 @@ export class ToggleAgentStatusAction extends Action2 { run(accessor: ServicesAccessor): void { const configService = accessor.get(IConfigurationService); - const rawValue = configService.getValue(ChatConfiguration.AgentStatusEnabled); - const mode = getAgentControlMode(rawValue); + const mode = getAgentControlMode(configService.getValue(ChatConfiguration.AgentStatusEnabled)); if (mode === 'hidden') { - // Restore to previous non-hidden mode; if the raw value was 'badge', restore 'badge', otherwise use 'compact' - configService.updateValue(ChatConfiguration.AgentStatusEnabled, rawValue === 'badge' ? 'badge' : 'compact'); + // When currently hidden, restore to compact mode + configService.updateValue(ChatConfiguration.AgentStatusEnabled, 'compact'); } else { configService.updateValue(ChatConfiguration.AgentStatusEnabled, 'hidden'); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 618399c1c51..4a02060424a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -225,6 +225,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._container = container; container.classList.add('agent-status-container'); container.setAttribute('role', 'toolbar'); + container.setAttribute('aria-label', localize('agentStatusToolbarLabel', "Agent Status")); // Container should not be focusable - inner elements handle focus container.tabIndex = -1; @@ -348,7 +349,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** * Setup roving tabindex for arrow key navigation between interactive elements. - * Auto-discovers elements with role="button" in DOM order. + * Uses the elements registered in `this._rovingElements` in their existing order. */ private _setupRovingTabIndex(disposables: DisposableStore): void { if (!this._container || this._rovingElements.length === 0) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index adb9387bdeb..620950ec921 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -77,7 +77,11 @@ border-color: var(--vscode-commandCenter-border, transparent); } -.agent-status-pill.chat-input-mode { +.agent-status-pill.chat-input-mode:not(.compact-mode) { + cursor: pointer; +} + +.agent-status-pill.chat-input-mode .agent-status-input-area { cursor: pointer; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d43059fd1aa..b416e888ec6 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -232,14 +232,16 @@ configurationRegistry.registerConfiguration({ default: 0 }, [ChatConfiguration.AgentStatusEnabled]: { - type: 'string', - enum: ['hidden', 'badge', 'compact'], + type: ['string', 'boolean'], + enum: ['hidden', 'badge', 'compact', true, false], enumDescriptions: [ nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), + nls.localize('chat.agentsControl.trueDeprecated', "Deprecated: treated as 'badge'. Use 'badge' or 'compact' instead."), + nls.localize('chat.agentsControl.falseDeprecated', "Deprecated: treated as 'hidden'. Use 'hidden' instead."), ], - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled. Boolean values are deprecated in favor of the string values.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), default: 'compact', tags: ['experimental'] }, From 4ad91d5092d58c1856a8b23a6350fd8fb11f6f84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:58:24 +0000 Subject: [PATCH 016/183] Fix arrow key navigation: prevent DropdownWithPrimaryActionViewItem from consuming ArrowLeft/ArrowRight in sparkle section Co-authored-by: eli-w-king <201316543+eli-w-king@users.noreply.github.com> --- .../experiments/agentTitleBarStatusWidget.ts | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 4a02060424a..84ed6a2d5cc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -369,25 +369,39 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { return; } - let nextIndex: number | undefined; - switch (e.key) { - case 'ArrowRight': nextIndex = (index + 1) % this._rovingElements.length; break; - case 'ArrowLeft': nextIndex = (index - 1 + this._rovingElements.length) % this._rovingElements.length; break; - case 'Home': nextIndex = 0; break; - case 'End': nextIndex = this._rovingElements.length - 1; break; - } - + const nextIndex = this._getNextRovingIndex(index, e.key); if (nextIndex !== undefined && nextIndex !== index) { e.preventDefault(); e.stopPropagation(); - this._rovingElements[index].tabIndex = -1; - this._rovingElements[nextIndex].tabIndex = 0; - this._rovingElements[nextIndex].focus(); - this._rovingIndex = nextIndex; + this._moveRovingFocus(index, nextIndex); } })); } + /** + * Moves roving focus from `currentIndex` to `nextIndex`, updating tabIndex and focusing the element. + */ + private _moveRovingFocus(currentIndex: number, nextIndex: number): void { + this._rovingElements[currentIndex].tabIndex = -1; + this._rovingElements[nextIndex].tabIndex = 0; + this._rovingElements[nextIndex].focus(); + this._rovingIndex = nextIndex; + } + + /** + * Returns the next roving index for the given key, or `undefined` if no navigation should occur. + */ + private _getNextRovingIndex(currentIndex: number, key: string): number | undefined { + const len = this._rovingElements.length; + switch (key) { + case 'ArrowRight': return (currentIndex + 1) % len; + case 'ArrowLeft': return (currentIndex - 1 + len) % len; + case 'Home': return 0; + case 'End': return len - 1; + default: return undefined; + } + } + // #region Session Statistics /** @@ -869,6 +883,23 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { sparkleDropdown.render(sparkleContainer); disposables.add(sparkleDropdown); + // Capture-phase listener for ArrowLeft/ArrowRight/Home/End to prevent DropdownWithPrimaryActionViewItem + // from consuming these keys internally. This ensures the outer roving tabindex handles navigation. + disposables.add(addDisposableListener(sparkleContainer, EventType.KEY_DOWN, (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') { + const idx = this._rovingElements.indexOf(sparkleContainer); + if (idx === -1) { + return; + } + const nextIndex = this._getNextRovingIndex(idx, e.key); + if (nextIndex !== undefined && nextIndex !== idx) { + e.preventDefault(); + e.stopImmediatePropagation(); + this._moveRovingFocus(idx, nextIndex); + } + } + }, true /* useCapture */)); + // Add keyboard handler for Enter/Space on the sparkle container disposables.add(addDisposableListener(sparkleContainer, EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { From 01e9a2b8ddb824bb59136738aa3bbf6e6075bb41 Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Wed, 11 Mar 2026 15:02:00 -0700 Subject: [PATCH 017/183] updated default to compact --- .../experiments/agentSessionProjectionActions.ts | 5 +---- .../experiments/agentSessionsExperiments.contribution.ts | 2 -- .../workbench/contrib/chat/browser/chat.contribution.ts | 8 +++----- src/vs/workbench/contrib/chat/common/constants.ts | 2 -- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index 1a79bca6f6e..412729cbd9d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -99,10 +99,7 @@ export class ToggleAgentStatusAction extends Action2 { id: `toggle.${ChatConfiguration.AgentStatusEnabled}`, title: localize('toggle.agentStatus', 'Agent Status'), metadata: { description: localize('toggle.agentStatusDescription', "Toggle visibility of the Agent Status in title bar") }, - toggled: ContextKeyExpr.and( - ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden - ), + toggled: ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), menu: [ { id: MenuId.TitleBarContext, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 327c1ab8b4f..38945fbb466 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -252,7 +252,6 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { when: ContextKeyExpr.and( ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden ), order: 10002 // to the right of the chat button }); @@ -270,7 +269,6 @@ MenuRegistry.appendMenuItem(MenuId.TitleBar, { ChatContextKeys.Setup.disabled.negate() ), ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden ContextKeyExpr.has('config.window.commandCenter').negate(), ), order: 1 diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index b416e888ec6..d43059fd1aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -232,16 +232,14 @@ configurationRegistry.registerConfiguration({ default: 0 }, [ChatConfiguration.AgentStatusEnabled]: { - type: ['string', 'boolean'], - enum: ['hidden', 'badge', 'compact', true, false], + type: 'string', + enum: ['hidden', 'badge', 'compact'], enumDescriptions: [ nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), - nls.localize('chat.agentsControl.trueDeprecated', "Deprecated: treated as 'badge'. Use 'badge' or 'compact' instead."), - nls.localize('chat.agentsControl.falseDeprecated', "Deprecated: treated as 'hidden'. Use 'hidden' instead."), ], - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled. Boolean values are deprecated in favor of the string values.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), default: 'compact', tags: ['experimental'] }, diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 7bfe9f7f620..b857c78266d 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -19,13 +19,11 @@ export function getAgentControlMode(value: unknown): AgentControlMode { return 'hidden'; } if (value === true || value === 'badge') { - // true = old boolean default, preserve badge-only behavior return 'badge'; } if (value === 'compact') { return 'compact'; } - // Fallback to the configuration schema default 'compact' for any other or missing value return 'compact'; } From 94b36177e9c37fc05cb0c944c5d292a57c13535c Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:53:57 -0700 Subject: [PATCH 018/183] Use markdownDescription for theme properties --- .../workbench/services/themes/common/themeExtensionPoints.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts index aa8af78316c..8ab5a367a79 100644 --- a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts +++ b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts @@ -39,11 +39,11 @@ export function registerColorThemeExtensionPoint() { type: 'string' }, uiTheme: { - description: nls.localize('vscode.extension.contributes.themes.uiTheme', 'Base theme defining the colors around the editor: \'vs\' is the light color theme, \'vs-dark\' is the dark color theme. \'hc-black\' is the dark high contrast theme, \'hc-light\' is the light high contrast theme.'), + markdownDescription: nls.localize('vscode.extension.contributes.themes.uiTheme', 'Base theme defining the colors around the editor: `vs` is the light color theme, `vs-dark` is the dark color theme. `hc-black` is the dark high contrast theme, `hc-light` is the light high contrast theme.'), enum: [ThemeTypeSelector.VS, ThemeTypeSelector.VS_DARK, ThemeTypeSelector.HC_BLACK, ThemeTypeSelector.HC_LIGHT] }, path: { - description: nls.localize('vscode.extension.contributes.themes.path', 'Path of the tmTheme file. The path is relative to the extension folder and is typically \'./colorthemes/awesome-color-theme.json\'.'), + markdownDescription: nls.localize('vscode.extension.contributes.themes.path', 'Path of the tmTheme file. The path is relative to the extension folder and is typically `./colorthemes/awesome-color-theme.json`.'), type: 'string' } }, From f3b054495066ddeebb7a9842bc029e249e87e417 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 18 Mar 2026 14:15:29 -0700 Subject: [PATCH 019/183] agentHost: 9728-style protected resource auth This commit implement RFC 9728/6750-inspired authentication for the agent host. This is a working prototype, although before merging this I'll pull this stuff up to the protocol itself rather than being extension methods. --- .../platform/agentHost/common/agentService.ts | 79 ++- .../agentHost/common/state/sessionProtocol.ts | 4 +- .../electron-browser/agentHostService.ts | 8 +- .../remoteAgentHostProtocolClient.ts | 16 +- .../platform/agentHost/node/agentHostMain.ts | 6 + .../platform/agentHost/node/agentService.ts | 26 +- .../agentHost/node/agentSideEffects.ts | 20 +- .../agentHost/node/copilot/copilotAgent.ts | 24 +- .../agentHost/node/protocolServerHandler.ts | 79 ++- src/vs/platform/agentHost/test/auth-rework.md | 454 ++++++++++++++++++ .../platform/agentHost/test/node/mockAgent.ts | 22 + .../test/node/protocolServerHandler.test.ts | 2 + .../browser/remoteAgentHost.contribution.ts | 94 +++- .../agentHost/agentHostChatContribution.ts | 108 ++++- .../agentHost/agentHostSessionHandler.ts | 49 +- 15 files changed, 930 insertions(+), 61 deletions(-) create mode 100644 src/vs/platform/agentHost/test/auth-rework.md diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 9907d4e4da8..997cd916c16 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; @@ -39,10 +40,55 @@ export interface IAgentDescriptor { readonly provider: AgentProvider; readonly displayName: string; readonly description: string; - /** Whether the renderer should push a GitHub auth token for this agent. */ + /** + * Whether the renderer should push a GitHub auth token for this agent. + * @deprecated Use {@link IResourceMetadata.resources} from {@link IAgentService.getResourceMetadata} instead. + */ readonly requiresAuth: boolean; } +// ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Uses {@link IAuthorizationProtectedResourceMetadata} from RFC 9728 + * to describe auth requirements, enabling clients to resolve tokens + * using the standard VS Code authentication service. + * + * Returned from the server via {@link IAgentService.getResourceMetadata}. + */ +export interface IResourceMetadata { + /** + * Protected resources the agent host requires authentication for. + * Each entry uses the standard RFC 9728 shape so clients can resolve + * tokens via {@link IAuthenticationService.getOrActivateProviderIdForServer}. + */ + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 section 2.1). + */ +export interface IAuthenticateParams { + /** + * The `resource` identifier from the server's + * {@link IAuthorizationProtectedResourceMetadata} that this token targets. + */ + readonly resource: string; + + /** The bearer token value (RFC 6750). */ + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +export interface IAuthenticateResult { + /** Whether the token was accepted. */ + readonly authenticated: boolean; +} + export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: string; @@ -300,9 +346,21 @@ export interface IAgent { /** List persisted sessions from this provider. */ listSessions(): Promise; - /** Set the authentication token for this provider. */ + /** + * Set the authentication token for this provider. + * @deprecated Use {@link authenticate} instead. + */ setAuthToken(token: string): Promise; + /** Declare protected resources this agent requires auth for (RFC 9728). */ + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + + /** + * Authenticate for a specific resource. Returns true if accepted. + * The `resource` matches {@link IAuthorizationProtectedResourceMetadata.resource}. + */ + authenticate(resource: string, token: string): Promise; + /** Gracefully shut down all sessions. */ shutdown(): Promise; @@ -328,9 +386,24 @@ export interface IAgentService { /** Discover available agent backends from the agent host. */ listAgents(): Promise; - /** Set the GitHub auth token used by the Copilot SDK. */ + /** + * Set the GitHub auth token used by the Copilot SDK. + * @deprecated Use {@link authenticate} instead. + */ setAuthToken(token: string): Promise; + /** + * Retrieve the resource metadata describing auth requirements. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ + getResourceMetadata(): Promise; + + /** + * Authenticate with the server using a specific auth scheme. + * Analogous to sending `Authorization: Bearer ` (RFC 6750). + */ + authenticate(params: IAuthenticateParams): Promise; + /** * Refresh the model list from all providers, publishing updated * agents (with models) to root state via `root/agentsChanged`. diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index e49688b0f44..deca30524f4 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -80,6 +80,7 @@ export const AHP_SESSION_ALREADY_EXISTS = -32003 as const; export const AHP_TURN_IN_PROGRESS = -32004 as const; export const AHP_UNSUPPORTED_PROTOCOL_VERSION = -32005 as const; export const AHP_CONTENT_NOT_FOUND = -32006 as const; +export const AHP_AUTH_REQUIRED = -32007 as const; // ---- Type guards ----------------------------------------------------------- @@ -101,9 +102,10 @@ export function isJsonRpcResponse(msg: IProtocolMessage): msg is IAhpSuccessResp /** * Error with a JSON-RPC error code for protocol-level failures. + * Optionally carries a `data` payload for structured error details. */ export class ProtocolError extends Error { - constructor(readonly code: number, message: string) { + constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); } } diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index d0667c84b85..358b5f91eea 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -13,7 +13,7 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js' import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -86,6 +86,12 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { setAuthToken(token: string): Promise { return this._proxy.setAuthToken(token); } + getResourceMetadata(): Promise { + return this._proxy.getResourceMetadata(); + } + authenticate(params: IAuthenticateParams): Promise { + return this._proxy.authenticate(params); + } listAgents(): Promise { return this._proxy.listAgents(); } diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index c05b080a7a4..2a151d2a211 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -14,7 +14,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -134,6 +134,20 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._sendExtensionNotification('setAuthToken', { token }); } + /** + * Retrieve the server's resource metadata describing auth requirements. + */ + async getResourceMetadata(): Promise { + return await this._sendExtensionRequest('getResourceMetadata') as IResourceMetadata; + } + + /** + * Authenticate with the remote agent host using a specific scheme. + */ + async authenticate(params: IAuthenticateParams): Promise { + return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult; + } + /** * Refresh the model list from all providers on the remote host. */ diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 498155d598b..e81ae6a408b 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -150,6 +150,12 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog handleSetAuthToken(token) { agentService.setAuthToken(token); }, + handleGetResourceMetadata() { + return agentService.getResourceMetadataSync(); + }, + async handleAuthenticate(params) { + return agentService.authenticate(params); + }, handleBrowseDirectory(uri) { return agentService.browseDirectory(URI.parse(uri)); }, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8c1fce8fdd3..86991c636e4 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -9,7 +9,7 @@ import { observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { IFileService } from '../../files/common/files.js'; -import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor } from '../common/agentService.js'; +import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor, IResourceMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; @@ -98,6 +98,30 @@ export class AgentService extends Disposable implements IAgentService { await Promise.all(promises); } + async getResourceMetadata(): Promise { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + getResourceMetadataSync(): IResourceMetadata { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + async authenticate(params: IAuthenticateParams): Promise { + this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); + for (const provider of this._providers.values()) { + const resources = provider.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await provider.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } + } + return { authenticated: false }; + } + // ---- session management ------------------------------------------------- async listSessions(): Promise { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 73adeba5a01..be6216f1f2b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import * as os from 'os'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgent, IAgentAttachment } from '../common/agentService.js'; +import { IAgent, IAgentAttachment, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { ISessionAction } from '../common/state/sessionActions.js'; import { IBrowseDirectoryResult, ICreateSessionParams, AHP_PROVIDER_NOT_FOUND, JSON_RPC_INTERNAL_ERROR, ProtocolError, IDirectoryEntry } from '../common/state/sessionProtocol.js'; import { @@ -236,6 +236,24 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } } + handleGetResourceMetadata(): IResourceMetadata { + const resources = this._options.agents.get().flatMap(a => a.getProtectedResources()); + return { resources }; + } + + async handleAuthenticate(params: IAuthenticateParams): Promise { + for (const agent of this._options.agents.get()) { + const resources = agent.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await agent.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } + } + return { authenticated: false }; + } + async handleBrowseDirectory(uri: ProtocolURI): Promise { let stat; try { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6f8ef0cd5b9..43207af6df5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4,18 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk'; +import { rgPath } from '@vscode/ripgrep'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; -import { rgPath } from '@vscode/ripgrep'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../log/common/log.js'; -import { IAgentCreateSessionConfig, IAgentModelInfo, IAgentProgressEvent, IAgentMessageEvent, IAgent, IAgentSessionMetadata, IAgentToolStartEvent, IAgentToolCompleteEvent, AgentSession, IAgentDescriptor, IAgentAttachment } from '../../common/agentService.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; function tryStringify(value: unknown): string | undefined { try { @@ -62,6 +63,15 @@ export class CopilotAgent extends Disposable implements IAgent { }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return [{ + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + }]; + } + async setAuthToken(token: string): Promise { const tokenChanged = this._githubToken !== token; this._githubToken = token; @@ -75,6 +85,14 @@ export class CopilotAgent extends Disposable implements IAgent { } } + async authenticate(resource: string, token: string): Promise { + if (resource !== 'https://api.github.com') { + return false; + } + await this.setAuthToken(token); + return true; + } + // ---- client lifecycle --------------------------------------------------- private async _ensureClient(): Promise { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index a2fb3c2d748..f8f747ff0a4 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; +import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { ICommandMap } from '../common/state/protocol/messages.js'; import { IActionEnvelope, INotification, isSessionAction, type ISessionAction } from '../common/state/sessionActions.js'; import { isActionKnownToVersion, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -37,8 +38,8 @@ function jsonRpcSuccess(id: number, result: unknown): IJsonRpcResponse { } /** Build a JSON-RPC error response suitable for transport.send(). */ -function jsonRpcError(id: number, code: number, message: string): IJsonRpcResponse { - return { jsonrpc: '2.0', id, error: { code, message } }; +function jsonRpcError(id: number, code: number, message: string, data?: unknown): IJsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; } /** @@ -336,23 +337,59 @@ export class ProtocolServerHandler extends Disposable { private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { const handler = this._requestHandlers.hasOwnProperty(method) ? this._requestHandlers[method as RequestMethod] : undefined; - if (!handler) { - client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + if (handler) { + (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { + this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; + const data = err instanceof ProtocolError ? err.data : undefined; + const message = err instanceof ProtocolError + ? err.message + : err instanceof Error && err.stack + ? err.stack + : String(err?.message ?? err); + client.transport.send(jsonRpcError(id, code, message, data)); + }); return; } - (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { - this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); - client.transport.send(jsonRpcSuccess(id, result ?? null)); - }).catch(err => { - this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof ProtocolError - ? err.message - : err instanceof Error && err.stack - ? err.stack - : String(err?.message ?? err); - client.transport.send(jsonRpcError(id, code, message)); - }); + + // VS Code extension methods (not in the typed protocol maps yet) + const extensionResult = this._handleExtensionRequest(method, params); + if (extensionResult) { + extensionResult.then(result => { + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Extension request '${method}' failed`, err); + client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, String(err?.message ?? err))); + }); + return; + } + + client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + } + + /** + * Handle VS Code extension methods that are not yet part of the typed + * protocol. Returns a Promise if the method was recognized, undefined + * otherwise. + */ + private _handleExtensionRequest(method: string, params: unknown): Promise | undefined { + switch (method) { + case 'getResourceMetadata': + return Promise.resolve(this._sideEffectHandler.handleGetResourceMetadata()); + case 'authenticate': + return this._sideEffectHandler.handleAuthenticate(params as IAuthenticateParams); + case 'refreshModels': + return this._sideEffectHandler.handleRefreshModels?.() ?? Promise.resolve(null); + case 'listAgents': + return Promise.resolve(this._sideEffectHandler.handleListAgents?.() ?? []); + case 'shutdown': + return this._sideEffectHandler.handleShutdown?.() ?? Promise.resolve(null); + default: + return undefined; + } } // ---- Broadcasting ------------------------------------------------------- @@ -408,7 +445,15 @@ export interface IProtocolSideEffectHandler { handleDisposeSession(session: URI): void; handleListSessions(): Promise; handleSetAuthToken(token: string): void; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; /** Returns the server's default browsing directory, if available. */ getDefaultDirectory?(): URI; + /** Refresh models from all providers (VS Code extension method). */ + handleRefreshModels?(): Promise; + /** List agent descriptors (VS Code extension method). */ + handleListAgents?(): IAgentDescriptor[]; + /** Shut down all providers (VS Code extension method). */ + handleShutdown?(): Promise; } diff --git a/src/vs/platform/agentHost/test/auth-rework.md b/src/vs/platform/agentHost/test/auth-rework.md new file mode 100644 index 00000000000..eb1f67709bc --- /dev/null +++ b/src/vs/platform/agentHost/test/auth-rework.md @@ -0,0 +1,454 @@ +# Auth Rework: Standards-Based Authentication for the Agent Host Protocol + +## Problem + +The current authentication mechanism is imperative and VS Code-specific: + +1. The renderer discovers agents via `listAgents()` and checks `IAgentDescriptor.requiresAuth`. +2. It obtains a GitHub OAuth token from VS Code's built-in authentication service. +3. It pushes the token via `setAuthToken(token)` — a fire-and-forget JSON-RPC notification. +4. The agent host fans the token out to all registered `IAgent` providers. + +This couples the agent host to VS Code internals. An external client (CLI tool, web app, another editor) connecting over WebSocket has no way to know _what_ authentication is required, _where_ to get a token, or _what scopes_ are needed. The client must have out-of-band knowledge that "this server needs a GitHub OAuth token." + +## Design Goals + +- **Self-describing**: The server declares its auth requirements so arbitrary clients can discover them without prior knowledge of the server's internals. +- **Standards-aligned**: Use the semantics and vocabulary of RFC 6750 (Bearer Token Usage) and RFC 9728 (OAuth 2.0 Protected Resource Metadata) adapted for JSON-RPC. +- **Challenge-on-failure**: When auth is missing or invalid, the server responds with a structured challenge (like `WWW-Authenticate`) that tells the client exactly what to do. +- **Transport-agnostic**: Works over WebSocket JSON-RPC and MessagePort IPC alike. +- **Multi-provider**: Supports multiple independent auth requirements (e.g. GitHub + a future enterprise IdP) each with their own scopes and authorization servers. +- **Non-breaking migration**: Can coexist with `setAuthToken` during a transition period. + +## Relevant Standards + +### RFC 6750 — Bearer Token Usage + +Defines how bearer tokens are transmitted (`Authorization: Bearer `) and how servers challenge clients when auth is missing or invalid: + +``` +WWW-Authenticate: Bearer realm="example", + error="invalid_token", + error_description="The access token expired" +``` + +Key error codes: `invalid_request`, `invalid_token`, `insufficient_scope`. + +### RFC 9728 — OAuth 2.0 Protected Resource Metadata + +Defines a metadata document that a protected resource publishes to describe itself: + +```json +{ + "resource": "https://resource.example.com", + "authorization_servers": ["https://as.example.com"], + "scopes_supported": ["profile", "email"], + "bearer_methods_supported": ["header"] +} +``` + +Clients discover this metadata either via a well-known URL or via the `resource_metadata` parameter in a `WWW-Authenticate` challenge. This tells the client _where_ to get a token and _what scopes_ to request. + +## Proposed Design + +### Overview + +The authentication flow has three phases, mirroring the HTTP flow from RFC 9728 §5: + +``` +┌─────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Client │ │ Agent Host │ │ Authorization │ +│ │ │ (Server) │ │ Server │ +└────┬─────┘ └──────┬───────┘ └────────┬────────┘ + │ │ │ + │ 1. initialize │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 2. initialize result │ │ + │ { auth: [{ scheme, resource, │ │ + │ authorization_servers, │ │ + │ scopes_supported }] } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 3. Obtain token from AS │ │ + │ ─────────────────────────────────────────────────────────────────> │ + │ │ │ + │ 4. Token │ │ + │ <───────────────────────────────────────────────────────────────── │ + │ │ │ + │ 5. authenticate { scheme, token } │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 6. { authenticated: true } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 7. createSession / other commands │ │ + │ ───────────────────────────────────> │ │ +``` + +### Phase 1: Discovery (in `initialize` response) + +The `initialize` result is extended with a `resourceMetadata` field, modeled on RFC 9728 §2: + +```typescript +interface IInitializeResult { + protocolVersion: number; + serverSeq: number; + snapshots: ISnapshot[]; + defaultDirectory?: URI; + + /** RFC 9728-style resource metadata describing auth requirements. */ + resourceMetadata?: IResourceMetadata; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ +interface IResourceMetadata { + /** + * Identifier for this resource (the agent host). + * Analogous to RFC 9728 `resource`. + */ + resource: string; + + /** + * Independent auth requirements. Each entry describes one + * authentication scheme the server accepts. A client must + * satisfy at least one to use authenticated features. + */ + authSchemes: IAuthScheme[]; +} + +/** + * A single authentication scheme the server accepts. + */ +interface IAuthScheme { + /** + * The auth scheme name. Initially only "bearer" (RFC 6750). + * Future schemes (e.g. "dpop", "device_code") can be added. + */ + scheme: 'bearer'; + + /** + * An opaque identifier for this auth requirement, used to + * correlate `authenticate` calls and challenges. Allows the + * server to require multiple independent tokens (e.g. one + * per agent provider). + * + * Example: "github" for GitHub Copilot auth. + */ + id: string; + + /** + * Human-readable label for display in auth UIs. + * Analogous to RFC 9728 `resource_name`. + */ + label: string; + + /** + * Authorization server issuer identifiers (RFC 8414). + * Tells the client where to obtain tokens. + * Analogous to RFC 9728 `authorization_servers`. + * + * Example: ["https://github.com/login/oauth"] + */ + authorizationServers: string[]; + + /** + * OAuth scopes the server needs. + * Analogous to RFC 9728 `scopes_supported`. + * + * Example: ["read:user", "user:email", "repo", "workflow"] + */ + scopesSupported?: string[]; + + /** + * Whether this auth requirement is mandatory for any + * functionality, or only for specific agents/features. + */ + required?: boolean; +} +``` + +**Why in `initialize`?** RFC 9728 publishes metadata at a well-known URL. In our JSON-RPC world, the `initialize` handshake _is_ the well-known endpoint — it's the first thing every client calls, and it's already where we exchange capabilities. This avoids an extra round-trip and keeps the discovery atomic. + +### Phase 2: Token Delivery (`authenticate` command) + +Replace the fire-and-forget `setAuthToken` notification with a proper JSON-RPC **request** so the client gets confirmation: + +```typescript +/** + * Client → Server request to authenticate. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 §2.1). + */ +interface IAuthenticateParams { + /** + * The auth scheme identifier from the server's resourceMetadata. + * Correlates to IAuthScheme.id. + */ + schemeId: string; + + /** The scheme type (initially always "bearer"). */ + scheme: 'bearer'; + + /** The bearer token value (RFC 6750). */ + token: string; +} + +interface IAuthenticateResult { + /** Whether the token was accepted. */ + authenticated: boolean; +} +``` + +This is a **request** (not a notification) so: +- The client knows immediately if the token was accepted or rejected. +- The server can validate the token before returning success. +- Errors use structured challenges (see Phase 3). + +The client can call `authenticate` multiple times (e.g. when a token is refreshed), and can authenticate for multiple scheme IDs independently. + +### Phase 3: Challenges on Failure + +When a command fails because authentication is missing or invalid, the server returns a JSON-RPC error with structured challenge data in the `data` field, modeled on RFC 6750 §3: + +```typescript +/** + * JSON-RPC error data for authentication failures. + * Modeled on RFC 6750 WWW-Authenticate challenge parameters. + */ +interface IAuthChallenge { + /** The scheme ID that needs (re-)authentication. */ + schemeId: string; + + /** RFC 6750 §3.1 error code. */ + error: 'invalid_request' | 'invalid_token' | 'insufficient_scope'; + + /** Human-readable error description (RFC 6750 §3 error_description). */ + errorDescription?: string; + + /** Required scopes, if the error is insufficient_scope (RFC 6750 §3 scope). */ + scope?: string; +} +``` + +This is returned as the `data` payload of a JSON-RPC error response: + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32010, + "message": "Authentication required", + "data": { + "challenges": [ + { + "schemeId": "github", + "error": "invalid_token", + "errorDescription": "The access token expired" + } + ] + } + } +} +``` + +A dedicated error code (e.g. `-32010 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. + +### Phase 4: Auth State Notifications + +The server pushes auth state changes via notifications so clients know when auth expires or the required scopes change: + +```typescript +/** + * Server → Client notification when auth state changes. + */ +interface IAuthStateNotification { + type: 'notify/authRequired'; + + /** The scheme ID whose auth state changed. */ + schemeId: string; + + /** The new state. */ + state: 'authenticated' | 'expired' | 'revoked' | 'required'; + + /** Optional challenge with details (e.g. new scopes needed). */ + challenge?: IAuthChallenge; +} +``` + +This replaces the implicit "push a token whenever you see an account change" model with an explicit server-driven signal. + +## Concrete Example: GitHub Copilot Auth + +### Server-side (CopilotAgent) + +When the Copilot agent registers, it publishes an auth scheme: + +```typescript +// In CopilotAgent.getAuthSchemes(): +[{ + scheme: 'bearer', + id: 'github', + label: 'GitHub', + authorizationServers: ['https://github.com/login/oauth'], + scopesSupported: ['read:user', 'user:email'], + required: true, +}] +``` + +The agent host aggregates auth schemes from all agents into `IInitializeResult.resourceMetadata`. + +### Client-side (VS Code renderer) + +```typescript +// After initialize: +const metadata = initResult.resourceMetadata; +if (metadata) { + for (const scheme of metadata.authSchemes) { + if (scheme.scheme === 'bearer' && scheme.authorizationServers.some( + as => as.includes('github.com') + )) { + // We know how to handle GitHub auth + const token = await this._getGitHubToken(scheme.scopesSupported); + await agentHostService.authenticate({ + schemeId: scheme.id, + scheme: 'bearer', + token, + }); + } + } +} +``` + +### Client-side (generic external client) + +A CLI tool connecting over WebSocket: + +```typescript +const ws = new WebSocket('ws://localhost:3000'); +const initResult = await rpc.request('initialize', { protocolVersion: 1, clientId: 'cli-1' }); + +for (const scheme of initResult.resourceMetadata?.authSchemes ?? []) { + if (scheme.scheme === 'bearer') { + console.log(`Auth required: ${scheme.label}`); + console.log(`Get a token from: ${scheme.authorizationServers[0]}`); + console.log(`Scopes: ${scheme.scopesSupported?.join(', ')}`); + + // Client can use any OAuth library to get the token + const token = await doOAuthFlow(scheme.authorizationServers[0], scheme.scopesSupported); + await rpc.request('authenticate', { schemeId: scheme.id, scheme: 'bearer', token }); + } +} +``` + +## Protocol Changes Summary + +### New JSON-RPC request: `authenticate` + +| Direction | Type | Params | Result | +|---|---|---|---| +| Client → Server | Request | `IAuthenticateParams` | `IAuthenticateResult` | + +### New JSON-RPC error code + +| Code | Name | When | +|---|---|---| +| `-32010` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | + +### Extended: `initialize` result + +| Field | Type | Description | +|---|---|---| +| `resourceMetadata` | `IResourceMetadata` | Optional. Auth and resource information. | + +### New notification + +| Type | Direction | When | +|---|---|---| +| `notify/authRequired` | Server → Client | Auth state changed (expired, revoked, new requirements) | + +### Deprecated + +| Item | Replacement | Migration | +|---|---|---| +| `setAuthToken` notification | `authenticate` request | Keep accepting `setAuthToken` for one version, log deprecation | +| `IAgentDescriptor.requiresAuth` | `IResourceMetadata.authSchemes` | Derive from `authSchemes` during transition | + +## Interface Changes in `agentService.ts` + +### `IAgentService` + +```diff + interface IAgentService { +- setAuthToken(token: string): Promise; ++ authenticate(params: IAuthenticateParams): Promise; + } +``` + +### `IAgent` + +```diff + interface IAgent { +- setAuthToken(token: string): Promise; ++ /** Declare auth schemes this agent requires. */ ++ getAuthSchemes(): IAuthScheme[]; ++ /** Authenticate with a specific scheme. Returns true if accepted. */ ++ authenticate(schemeId: string, token: string): Promise; + } +``` + +### `IAgentDescriptor` + +```diff + interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; +- readonly requiresAuth: boolean; + } +``` + +`requiresAuth` is removed — clients discover auth requirements from `IResourceMetadata` instead of per-agent descriptors. + +## Design Decisions + +### Why not `WWW-Authenticate` headers literally? + +We're not using HTTP. Embedding RFC 6750's string-encoded header format in JSON-RPC would be awkward. Instead, we use JSON-native equivalents with the same semantics: `IAuthChallenge` mirrors the `WWW-Authenticate` parameters, and `IResourceMetadata` mirrors RFC 9728's metadata document. + +### Why in `initialize` and not a separate `getResourceMetadata` command? + +Fewer round-trips. Every client calls `initialize` first — embedding auth requirements there means the client knows what auth is needed from the very first response. A separate command would add latency and complexity for zero benefit, since the metadata is small and always needed. + +### Why `schemeId` and not just the `scheme` name? + +A server might need multiple bearer tokens from different authorization servers (e.g. GitHub + an enterprise IdP). The `schemeId` lets the client and server correlate tokens to specific requirements. It also makes `authenticate` calls idempotent and unambiguous. + +### Why a request instead of a notification for `authenticate`? + +The current `setAuthToken` is fire-and-forget — the client has no idea if the token was accepted, expired, or for the wrong provider. Making `authenticate` a request with a response lets the client react immediately (retry with different scopes, prompt the user, etc.). + +### What about Device Code / OAuth flows that the server drives? + +This proposal covers the "client already has a token" case (RFC 6750 bearer). For server-driven flows (device code, authorization code with redirect), the `authorizationServers` metadata tells the client which AS to talk to. The actual OAuth flow is client-side — the server just declares requirements. + +A future extension could add an `IAuthScheme` with `scheme: 'device_code'` that includes a device authorization endpoint, letting the server guide the client through a device flow. This is out of scope for the initial implementation. + +## Migration Plan + +1. **Phase A**: Add `resourceMetadata` to `IInitializeResult` and the `authenticate` command. Keep `setAuthToken` working as-is. +2. **Phase B**: Update VS Code renderer to use `authenticate` instead of `setAuthToken`. External clients can start using the new flow. +3. **Phase C**: Remove `setAuthToken`, `requiresAuth`, and the old imperative push model. Bump protocol version. + +## Open Questions + +1. **Token validation**: Should the server validate tokens eagerly on `authenticate` (e.g. call a GitHub API endpoint), or defer validation to when a command actually needs it? Eager validation gives better error messages; deferred is simpler and avoids extra network calls. + +2. **Per-agent vs. global auth**: The current design has one `resourceMetadata` for the whole server. Should auth schemes be per-agent-provider instead? Per-agent gives finer control (e.g. "Copilot needs GitHub, MockAgent needs nothing") but complicates the protocol. The current proposal uses global metadata with `schemeId` correlation, which the server can internally route to the right agent. + +3. **Token refresh**: Should the server expose token expiry information so clients can proactively refresh, or rely on `notify/authRequired` to signal when a refresh is needed? Proactive refresh avoids interruptions but requires the server to parse tokens (which it shouldn't have to for opaque tokens). + +4. **Multiple tokens**: Can a client authenticate multiple scheme IDs simultaneously? (Proposed: yes.) Can multiple clients each send their own token? (Proposed: yes, last-writer-wins per schemeId, which matches current behavior.) diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 6ddf3ac28c3..c7aabaf8e79 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -5,6 +5,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; /** @@ -24,6 +25,7 @@ export class MockAgent implements IAgent { readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; readonly changeModelCalls: { session: URI; model: string }[] = []; + readonly authenticateCalls: { resource: string; token: string }[] = []; constructor(readonly id: AgentProvider = 'mock') { } @@ -31,6 +33,13 @@ export class MockAgent implements IAgent { return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + if (this.id === 'copilot') { + return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }]; + } + return []; + } + async listModels(): Promise { return [{ provider: this.id, id: `${this.id}-model`, name: `${this.id} Model`, maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -75,6 +84,11 @@ export class MockAgent implements IAgent { this.setAuthTokenCalls.push(token); } + async authenticate(resource: string, token: string): Promise { + this.authenticateCalls.push({ resource, token }); + return true; + } + async shutdown(): Promise { } fireProgress(event: IAgentProgressEvent): void { @@ -104,6 +118,10 @@ export class ScriptedMockAgent implements IAgent { return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return []; + } + async listModels(): Promise { return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -225,6 +243,10 @@ export class ScriptedMockAgent implements IAgent { async setAuthToken(_token: string): Promise { } + async authenticate(_resource: string, _token: string): Promise { + return true; + } + async shutdown(): Promise { } dispose(): void { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3c6a3b83fed..74591f923ba 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -75,6 +75,8 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { handleDisposeSession(_session: string): void { } async handleListSessions(): Promise { return []; } handleSetAuthToken(_token: string): void { } + handleGetResourceMetadata() { return { resources: [] }; } + async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; } async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> { this.browsedUris.push(URI.parse(uri)); const error = this.browseErrors.get(uri); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index e4eca99f515..a7cd17bbeb2 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -179,8 +179,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._logService.error(`[RemoteAgentHost] Failed to subscribe to root state for ${address}`, err); }); - // Push auth token to this new connection - this._pushAuthToken(connection); + // Authenticate with this new connection + this._authenticateWithConnection(connection); } private _handleRootStateChange(address: string, connection: IAgentConnection, rootState: IRootState): void { @@ -282,6 +282,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, + resolveAuthentication: () => this._resolveAuthenticationInteractively(connection), })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -302,26 +303,93 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc for (const address of this._connections.keys()) { const connection = this._remoteAgentHostService.getConnection(address); if (connection) { - this._pushAuthToken(connection); + this._authenticateWithConnection(connection); } } } - private async _pushAuthToken(connection: IAgentConnection): Promise { + /** + * Discover auth requirements from the connection's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithConnection(connection: IAgentConnection): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + const token = await this._resolveTokenForResource(resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); + await connection.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Failed to authenticate with connection', err); + } + } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + this._logService.trace(`[RemoteAgentHost] No auth provider found for server: ${server}`); + continue; } - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await connection.setAuthToken(session.accessToken); + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + // Fall back to any session from the provider + const anySessions = await this._authenticationService.getSessions(providerId); + if (anySessions.length > 0) { + return anySessions[0].accessToken; } - } catch { - // best-effort } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(connection: IAgentConnection): Promise { + try { + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + continue; + } + + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await connection.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Interactive authentication failed', err); + } + return false; } private _traceIpc(address: string, method: string, data?: unknown): void { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 28130358707..e860b721fbc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -219,6 +219,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr fullName: agent.displayName, description: agent.description, connection: this._agentHostService, + resolveAuthentication: () => this._resolveAuthenticationInteractively(), })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -233,27 +234,104 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); // Push auth token and refresh models from server - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); store.add(this._authenticationService.onDidChangeSessions(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); } - private async _pushAuthToken(): Promise { + /** + * Discover auth requirements from the server's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithServer(): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await this._agentHostService.getResourceMetadata(); + this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`); + for (const resource of metadata.resources) { + const token = await this._resolveTokenForResource(resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); + await this._agentHostService.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); + } } - - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await this._agentHostService.setAuthToken(session.accessToken); - } - } catch { - // best-effort + } catch (err) { + this._logService.error('[AgentHost] Failed to authenticate with server', err); } } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + this._logService.trace(`[AgentHost] No auth provider found for server: ${server}`); + continue; + } + this._logService.trace(`[AgentHost] Resolved auth provider '${providerId}' for server: ${server}`); + + // Try with the declared scopes first, then fall back to empty scopes + // (the provider may have sessions with broader scopes). + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + // Fall back to any session from the provider + const anySessions = await this._authenticationService.getSessions(providerId); + if (anySessions.length > 0) { + this._logService.trace(`[AgentHost] Using session with broader scopes from provider '${providerId}'`); + return anySessions[0].accessToken; + } + + this._logService.trace(`[AgentHost] No sessions found for provider '${providerId}'`); + } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Fetches resource metadata, resolves the auth provider, creates a session + * (which triggers the login UI), and pushes the token to the server. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(): Promise { + try { + const metadata = await this._agentHostService.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + if (!providerId) { + continue; + } + + // createSession will show the login UI if no session exists + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await this._agentHostService.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[AgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[AgentHost] Interactive authentication failed', err); + } + return false; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index a1ddeacc81b..d2edaccb893 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -96,6 +96,12 @@ export interface IAgentHostSessionHandlerConfig { * If not provided, falls back to the first workspace folder. */ readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; + /** + * Optional callback invoked when the server rejects an operation because + * authentication is required. Should trigger interactive authentication + * and return true if the user authenticated successfully. + */ + readonly resolveAuthentication?: () => Promise; } export class AgentHostSessionHandler extends Disposable implements IChatSessionContentProvider { @@ -442,11 +448,33 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ?? this._workspaceContextService.getWorkspace().folders[0]?.uri.fsPath; this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}`); - const session = await this._config.connection.createSession({ - model: rawModelId, - provider: this._config.provider, - workingDirectory, - }); + + let session: URI; + try { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } catch (err) { + // If authentication is required, try to resolve it and retry once + if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) { + this._logService.info('[AgentHost] Authentication required, prompting user...'); + const authenticated = await this._config.resolveAuthentication(); + if (authenticated) { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } else { + throw new Error('Authentication is required to start a session. Please sign in and try again.'); + } + } else { + throw err; + } + } + this._logService.trace(`[AgentHost] Created session: ${session.toString()}`); // Subscribe to the new session's state @@ -460,6 +488,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return session; } + /** + * Check if an error is an "authentication required" error. + * Works across both ProxyChannel (message-only) and WebSocket (structured) paths. + */ + private _isAuthRequiredError(err: unknown): boolean { + if (err instanceof Error && err.message.includes('Authentication required')) { + return true; + } + return false; + } + /** * Extracts the raw model id from a language-model service identifier. * E.g. "agent-host-copilot:claude-sonnet-4-20250514" → "claude-sonnet-4-20250514". From e3547da62ce4039d53eb236a7b31181220845a90 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 18 Mar 2026 14:25:42 -0700 Subject: [PATCH 020/183] cleanup old setAuthToken --- src/vs/platform/agentHost/common/agentService.ts | 12 ------------ .../agentHost/electron-browser/agentHostService.ts | 3 --- .../remoteAgentHostProtocolClient.ts | 14 -------------- src/vs/platform/agentHost/node/agentHostMain.ts | 4 +--- src/vs/platform/agentHost/node/agentService.ts | 9 --------- src/vs/platform/agentHost/node/agentSideEffects.ts | 8 -------- .../agentHost/node/copilot/copilotAgent.ts | 12 ++++-------- .../agentHost/node/protocolServerHandler.ts | 11 ----------- .../agentHost/test/node/agentService.test.ts | 14 -------------- src/vs/platform/agentHost/test/node/mockAgent.ts | 8 +------- .../test/node/protocolServerHandler.test.ts | 1 - .../agentHostChatContribution.test.ts | 2 -- 12 files changed, 6 insertions(+), 92 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 997cd916c16..54a9022f747 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -346,12 +346,6 @@ export interface IAgent { /** List persisted sessions from this provider. */ listSessions(): Promise; - /** - * Set the authentication token for this provider. - * @deprecated Use {@link authenticate} instead. - */ - setAuthToken(token: string): Promise; - /** Declare protected resources this agent requires auth for (RFC 9728). */ getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; @@ -386,12 +380,6 @@ export interface IAgentService { /** Discover available agent backends from the agent host. */ listAgents(): Promise; - /** - * Set the GitHub auth token used by the Copilot SDK. - * @deprecated Use {@link authenticate} instead. - */ - setAuthToken(token: string): Promise; - /** * Retrieve the resource metadata describing auth requirements. * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 358b5f91eea..da706baae2f 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -83,9 +83,6 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- - setAuthToken(token: string): Promise { - return this._proxy.setAuthToken(token); - } getResourceMetadata(): Promise { return this._proxy.getResourceMetadata(); } diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index 2a151d2a211..bbe0e722662 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -127,13 +127,6 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return session; } - /** - * Push a GitHub auth token to the remote agent host. - */ - async setAuthToken(token: string): Promise { - this._sendExtensionNotification('setAuthToken', { token }); - } - /** * Retrieve the server's resource metadata describing auth requirements. */ @@ -241,13 +234,6 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._transport.send({ jsonrpc: '2.0' as const, method, params } as IProtocolMessage); } - /** Send a JSON-RPC notification for a VS Code extension method (not in the protocol spec). */ - private _sendExtensionNotification(method: string, params?: unknown): void { - // Cast: extension methods aren't in the typed protocol maps yet - // eslint-disable-next-line local/code-no-dangerous-type-assertions - this._transport.send({ jsonrpc: '2.0', method, params } as unknown as IJsonRpcResponse); - } - /** Send a typed JSON-RPC request for a protocol-defined method. */ private _sendRequest(method: M, params: ICommandMap[M]['params']): Promise { const id = this._nextRequestId++; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index e81ae6a408b..69cd971c22e 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -147,9 +147,7 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog modifiedAt: s.modifiedTime, })); }, - handleSetAuthToken(token) { - agentService.setAuthToken(token); - }, + handleGetResourceMetadata() { return agentService.getResourceMetadataSync(); }, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 86991c636e4..fe1acfa846f 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -89,15 +89,6 @@ export class AgentService extends Disposable implements IAgentService { return [...this._providers.values()].map(p => p.getDescriptor()); } - async setAuthToken(token: string): Promise { - this._logService.trace('[AgentService] setAuthToken called'); - const promises: Promise[] = []; - for (const provider of this._providers.values()) { - promises.push(provider.setAuthToken(token)); - } - await Promise.all(promises); - } - async getResourceMetadata(): Promise { const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); return { resources }; diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index be6216f1f2b..d202bc421b9 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -228,14 +228,6 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH return allSessions; } - handleSetAuthToken(token: string): void { - for (const agent of this._options.agents.get()) { - agent.setAuthToken(token).catch(err => { - this._logService.error('[AgentSideEffects] setAuthToken failed', err); - }); - } - } - handleGetResourceMetadata(): IResourceMetadata { const resources = this._options.agents.get().flatMap(a => a.getProtectedResources()); return { resources }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 43207af6df5..39c2172655c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -72,7 +72,10 @@ export class CopilotAgent extends Disposable implements IAgent { }]; } - async setAuthToken(token: string): Promise { + async authenticate(resource: string, token: string): Promise { + if (resource !== 'https://api.github.com') { + return false; + } const tokenChanged = this._githubToken !== token; this._githubToken = token; this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); @@ -83,13 +86,6 @@ export class CopilotAgent extends Disposable implements IAgent { this._clientStarting = undefined; await client.stop(); } - } - - async authenticate(resource: string, token: string): Promise { - if (resource !== 'https://api.github.com') { - return false; - } - await this.setAuthToken(token); return true; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index f8f747ff0a4..e655008bdd5 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -22,7 +22,6 @@ import { type IInitializeParams, type IJsonRpcResponse, type IReconnectParams, - type ISetAuthTokenParams, type IStateSnapshot, } from '../common/state/sessionProtocol.js'; import { ROOT_STATE_URI, type ISessionSummary, type URI } from '../common/state/sessionState.js'; @@ -161,15 +160,6 @@ export class ProtocolServerHandler extends Disposable { this._sideEffectHandler.handleAction(action); } break; - default: { - // VS Code extension: setAuthToken (not part of the protocol spec) - const method = msg.method as string; - if (method === 'setAuthToken') { - const p = msg.params as unknown as ISetAuthTokenParams; - this._sideEffectHandler.handleSetAuthToken(p.token); - } - break; - } } } // Responses from the client (if any) are ignored on the server side. @@ -444,7 +434,6 @@ export interface IProtocolSideEffectHandler { handleCreateSession(command: ICreateSessionParams): Promise; handleDisposeSession(session: URI): void; handleListSessions(): Promise; - handleSetAuthToken(token: string): void; handleGetResourceMetadata(): IResourceMetadata; handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 8754a80bb71..71408589a3c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -126,20 +126,6 @@ suite('AgentService (node dispatcher)', () => { }); }); - // ---- setAuthToken --------------------------------------------------- - - suite('setAuthToken', () => { - - test('broadcasts token to all registered providers', async () => { - service.registerProvider(copilotAgent); - - await service.setAuthToken('my-token'); - - assert.strictEqual(copilotAgent.setAuthTokenCalls.length, 1); - assert.strictEqual(copilotAgent.setAuthTokenCalls[0], 'my-token'); - }); - }); - // ---- listSessions / listModels -------------------------------------- suite('aggregation', () => { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index c7aabaf8e79..391f4fed97b 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -19,7 +19,7 @@ export class MockAgent implements IAgent { private readonly _sessions = new Map(); private _nextId = 1; - readonly setAuthTokenCalls: string[] = []; + readonly sendMessageCalls: { session: URI; prompt: string }[] = []; readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; @@ -80,10 +80,6 @@ export class MockAgent implements IAgent { this.changeModelCalls.push({ session, model }); } - async setAuthToken(token: string): Promise { - this.setAuthTokenCalls.push(token); - } - async authenticate(resource: string, token: string): Promise { this.authenticateCalls.push({ resource, token }); return true; @@ -241,8 +237,6 @@ export class ScriptedMockAgent implements IAgent { } } - async setAuthToken(_token: string): Promise { } - async authenticate(_resource: string, _token: string): Promise { return true; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 74591f923ba..80d6c16fca5 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -74,7 +74,6 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { async handleCreateSession(_command: ICreateSessionParams): Promise { /* session created via state manager */ } handleDisposeSession(_session: string): void { } async handleListSessions(): Promise { return []; } - handleSetAuthToken(_token: string): void { } handleGetResourceMetadata() { return { resources: [] }; } async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; } async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 36256611e93..cc2efa6519d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -49,8 +49,6 @@ class MockAgentHostService extends mock() { public createSessionCalls: IAgentCreateSessionConfig[] = []; public agents = [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', requiresAuth: true }]; - override async setAuthToken(_token: string): Promise { } - override async listSessions(): Promise { return [...this._sessions.values()]; } From c5fc3c07b30f0d5e1078caeffaee9123b8dea9a7 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 18 Mar 2026 14:29:03 -0700 Subject: [PATCH 021/183] tests --- .../agentHost/test/node/agentService.test.ts | 48 +++++++++++++++++++ .../test/node/agentSideEffects.test.ts | 39 +++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 71408589a3c..5799bfeb413 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -155,6 +155,54 @@ suite('AgentService (node dispatcher)', () => { }); }); + // ---- getResourceMetadata -------------------------------------------- + + suite('getResourceMetadata', () => { + + test('aggregates protected resources from all providers', async () => { + service.registerProvider(copilotAgent); + + const mockAgent = new MockAgent('other'); + disposables.add(toDisposable(() => mockAgent.dispose())); + service.registerProvider(mockAgent); + + const metadata = await service.getResourceMetadata(); + // copilot agent returns one resource (https://api.github.com), + // generic MockAgent('other') returns empty + assert.deepStrictEqual(metadata, { + resources: [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }], + }); + }); + + test('returns empty resources when no providers registered', async () => { + const metadata = await service.getResourceMetadata(); + assert.deepStrictEqual(metadata, { resources: [] }); + }); + }); + + // ---- authenticate --------------------------------------------------- + + suite('authenticate', () => { + + test('routes token to provider matching the resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' }); + + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]); + }); + + test('returns not authenticated for unknown resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' }); + + assert.deepStrictEqual(result, { authenticated: false }); + assert.strictEqual(copilotAgent.authenticateCalls.length, 0); + }); + }); + // ---- shutdown ------------------------------------------------------- suite('shutdown', () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index fc1772e5d37..cb1176e7811 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -322,4 +322,43 @@ suite('AgentSideEffects', () => { assert.ok(action, 'should dispatch root/agentsChanged'); }); }); + + // ---- handleGetResourceMetadata / handleAuthenticate ----------------- + + suite('auth', () => { + + test('handleGetResourceMetadata aggregates resources from agents', () => { + agentList.set([agent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 0, 'mock agent has no protected resources'); + }); + + test('handleGetResourceMetadata returns resources when agent declares them', () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 1); + assert.strictEqual(metadata.resources[0].resource, 'https://api.github.com'); + }); + + test('handleAuthenticate returns authenticated for matching resource', async () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://api.github.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'test-token' }]); + }); + + test('handleAuthenticate returns not authenticated for non-matching resource', async () => { + agentList.set([agent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://unknown.example.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: false }); + }); + }); }); From 3af163a8c8f9b52dd7a36a18a89ecbab5bfa14ca Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 18 Mar 2026 14:54:30 -0700 Subject: [PATCH 022/183] comments --- .../platform/agentHost/common/agentService.ts | 5 +- .../agentHost/node/protocolServerHandler.ts | 28 ++++++----- src/vs/platform/agentHost/test/auth-rework.md | 6 +-- .../test/node/protocolServerHandler.test.ts | 46 +++++++++++++++++++ .../browser/remoteAgentHost.contribution.ts | 22 ++++----- .../agentHost/agentHostChatContribution.ts | 19 +++----- .../agentHost/agentHostSessionHandler.ts | 11 ++++- 7 files changed, 89 insertions(+), 48 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 14deb197fa3..7cbe07bdd7e 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -388,8 +388,9 @@ export interface IAgentService { getResourceMetadata(): Promise; /** - * Authenticate with the server using a specific auth scheme. - * Analogous to sending `Authorization: Bearer ` (RFC 6750). + * Authenticate for a protected resource on the server. + * The {@link IAuthenticateParams.resource} must match a resource from + * {@link getResourceMetadata}. Analogous to RFC 6750 bearer token delivery. */ authenticate(params: IAuthenticateParams): Promise; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index e655008bdd5..ef47a34c59f 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -41,6 +41,15 @@ function jsonRpcError(id: number, code: number, message: string, data?: unknown) return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; } +/** Build a JSON-RPC error response from an unknown thrown value, preserving {@link ProtocolError} fields. */ +function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { + if (err instanceof ProtocolError) { + return jsonRpcError(id, err.code, err.message, err.data); + } + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); +} + /** * Methods handled by the request dispatcher. Excludes `initialize` and * `reconnect` which are handled during the handshake phase. @@ -119,9 +128,7 @@ export class ProtocolServerHandler extends Disposable { client = result.client; transport.send(jsonRpcSuccess(msg.id, result.response)); } catch (err) { - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof Error ? err.message : String(err); - transport.send(jsonRpcError(msg.id, code, message)); + transport.send(jsonRpcErrorFrom(msg.id, err)); } return; } @@ -131,9 +138,7 @@ export class ProtocolServerHandler extends Disposable { client = result.client; transport.send(jsonRpcSuccess(msg.id, result.response)); } catch (err) { - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof Error ? err.message : String(err); - transport.send(jsonRpcError(msg.id, code, message)); + transport.send(jsonRpcErrorFrom(msg.id, err)); } return; } @@ -333,14 +338,7 @@ export class ProtocolServerHandler extends Disposable { client.transport.send(jsonRpcSuccess(id, result ?? null)); }).catch(err => { this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const data = err instanceof ProtocolError ? err.data : undefined; - const message = err instanceof ProtocolError - ? err.message - : err instanceof Error && err.stack - ? err.stack - : String(err?.message ?? err); - client.transport.send(jsonRpcError(id, code, message, data)); + client.transport.send(jsonRpcErrorFrom(id, err)); }); return; } @@ -352,7 +350,7 @@ export class ProtocolServerHandler extends Disposable { client.transport.send(jsonRpcSuccess(id, result ?? null)); }).catch(err => { this._logService.error(`[ProtocolServer] Extension request '${method}' failed`, err); - client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, String(err?.message ?? err))); + client.transport.send(jsonRpcErrorFrom(id, err)); }); return; } diff --git a/src/vs/platform/agentHost/test/auth-rework.md b/src/vs/platform/agentHost/test/auth-rework.md index eb1f67709bc..4533c3b4e59 100644 --- a/src/vs/platform/agentHost/test/auth-rework.md +++ b/src/vs/platform/agentHost/test/auth-rework.md @@ -240,7 +240,7 @@ This is returned as the `data` payload of a JSON-RPC error response: "jsonrpc": "2.0", "id": 5, "error": { - "code": -32010, + "code": -32007, "message": "Authentication required", "data": { "challenges": [ @@ -255,7 +255,7 @@ This is returned as the `data` payload of a JSON-RPC error response: } ``` -A dedicated error code (e.g. `-32010 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. +A dedicated error code (`-32007 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. ### Phase 4: Auth State Notifications @@ -356,7 +356,7 @@ for (const scheme of initResult.resourceMetadata?.authSchemes ?? []) { | Code | Name | When | |---|---|---| -| `-32010` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | +| `-32007` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | ### Extended: `initialize` result diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 23be5c8c3fa..3eac4562547 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -381,4 +381,50 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(resp.error!.code, JSON_RPC_INTERNAL_ERROR); assert.match(resp.error!.message, /Directory not found/); }); + + // ---- Extension methods: auth ---------------------------------------- + + test('getResourceMetadata returns resource metadata via extension request', async () => { + const transport = connectClient('client-metadata'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'getResourceMetadata')); + const resp = await responsePromise as { result?: { resources: unknown[] } }; + + assert.ok(resp?.result); + assert.ok(Array.isArray(resp.result!.resources)); + }); + + test('authenticate returns result via extension request', async () => { + const transport = connectClient('client-auth'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' })); + const resp = await responsePromise as { result?: { authenticated: boolean } }; + + assert.ok(resp?.result); + assert.strictEqual(resp.result!.authenticated, true); + }); + + test('extension request preserves ProtocolError code and data', async () => { + // Override handleAuthenticate to throw a ProtocolError with data + const origHandler = sideEffects.handleAuthenticate; + sideEffects.handleAuthenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); }; + + const transport = connectClient('client-auth-error'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'test', token: 'bad' })); + const resp = await responsePromise as { error?: { code: number; message: string; data?: unknown } }; + + assert.ok(resp?.error); + assert.strictEqual(resp.error!.code, -32007); + assert.strictEqual(resp.error!.message, 'Auth required'); + assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' }); + + sideEffects.handleAuthenticate = origHandler; + }); }); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 870428fda7e..92941d06af8 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -103,8 +103,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc })); // Push auth token whenever the default account or sessions change - this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._pushAuthTokenToAll())); - this._register(this._authenticationService.onDidChangeSessions(() => this._pushAuthTokenToAll())); + this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._authenticateAllConnections())); + this._register(this._authenticationService.onDidChangeSessions(() => this._authenticateAllConnections())); // Initial setup for already-connected remotes this._reconcileConnections(); @@ -300,7 +300,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._logService.info(`[RemoteAgentHost] Registered agent ${agent.provider} from ${address} as ${sessionType}`); } - private _pushAuthTokenToAll(): void { + private _authenticateAllConnections(): void { for (const address of this._connections.keys()) { const connection = this._remoteAgentHostService.getConnection(address); if (connection) { @@ -318,7 +318,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc try { const metadata = await connection.getResourceMetadata(); for (const resource of metadata.resources) { - const token = await this._resolveTokenForResource(resource.authorization_servers ?? [], resource.scopes_supported ?? []); + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); if (token) { this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); await connection.authenticate({ resource: resource.resource, token }); @@ -335,10 +336,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * Resolve a bearer token for a set of authorization servers using the * standard VS Code authentication service provider resolution. */ - private async _resolveTokenForResource(authorizationServers: readonly string[], scopes: readonly string[]): Promise { + private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { for (const server of authorizationServers) { const serverUri = URI.parse(server); - const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); if (!providerId) { this._logService.trace(`[RemoteAgentHost] No auth provider found for server: ${server}`); continue; @@ -348,12 +349,6 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc if (sessions.length > 0) { return sessions[0].accessToken; } - - // Fall back to any session from the provider - const anySessions = await this._authenticationService.getSessions(providerId); - if (anySessions.length > 0) { - return anySessions[0].accessToken; - } } return undefined; } @@ -368,7 +363,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc for (const resource of metadata.resources) { for (const server of resource.authorization_servers ?? []) { const serverUri = URI.parse(server); - const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + const resourceUri = URI.parse(resource.resource); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); if (!providerId) { continue; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 92495b2b93a..c87e45bead2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -251,7 +251,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr const metadata = await this._agentHostService.getResourceMetadata(); this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`); for (const resource of metadata.resources) { - const token = await this._resolveTokenForResource(resource.authorization_servers ?? [], resource.scopes_supported ?? []); + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); if (token) { this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); await this._agentHostService.authenticate({ resource: resource.resource, token }); @@ -268,30 +269,21 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr * Resolve a bearer token for a set of authorization servers using the * standard VS Code authentication service provider resolution. */ - private async _resolveTokenForResource(authorizationServers: readonly string[], scopes: readonly string[]): Promise { + private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { for (const server of authorizationServers) { const serverUri = URI.parse(server); - const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); if (!providerId) { this._logService.trace(`[AgentHost] No auth provider found for server: ${server}`); continue; } this._logService.trace(`[AgentHost] Resolved auth provider '${providerId}' for server: ${server}`); - // Try with the declared scopes first, then fall back to empty scopes - // (the provider may have sessions with broader scopes). const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); if (sessions.length > 0) { return sessions[0].accessToken; } - // Fall back to any session from the provider - const anySessions = await this._authenticationService.getSessions(providerId); - if (anySessions.length > 0) { - this._logService.trace(`[AgentHost] Using session with broader scopes from provider '${providerId}'`); - return anySessions[0].accessToken; - } - this._logService.trace(`[AgentHost] No sessions found for provider '${providerId}'`); } return undefined; @@ -309,7 +301,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr for (const resource of metadata.resources) { for (const server of resource.authorization_servers ?? []) { const serverUri = URI.parse(server); - const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri); + const resourceUri = URI.parse(resource.resource); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); if (!providerId) { continue; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index a3f7c47ec5f..c12d76c2766 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../nls.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -17,6 +18,7 @@ import { IProductService } from '../../../../../../platform/product/common/produ import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentAttachment, AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ActionType, isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { AttachmentType, ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -468,7 +470,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC workingDirectory, }); } else { - throw new Error('Authentication is required to start a session. Please sign in and try again.'); + throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again.")); } } else { throw err; @@ -490,9 +492,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** * Check if an error is an "authentication required" error. - * Works across both ProxyChannel (message-only) and WebSocket (structured) paths. + * Checks for the AHP_AUTH_REQUIRED error code when available, + * with a message-based fallback for transports that don't preserve + * structured error codes (e.g. ProxyChannel). */ private _isAuthRequiredError(err: unknown): boolean { + if (err instanceof ProtocolError && err.code === AHP_AUTH_REQUIRED) { + return true; + } if (err instanceof Error && err.message.includes('Authentication required')) { return true; } From 2bfd66adc038dd866d62969e699177555b56ffe8 Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Wed, 18 Mar 2026 16:27:59 -0700 Subject: [PATCH 023/183] fixed window.title --- .../experiments/agentTitleBarStatusWidget.ts | 60 +++++++------------ 1 file changed, 20 insertions(+), 40 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 84ed6a2d5cc..b37b5cda51b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -21,13 +21,9 @@ import { IAgentSessionsService } from '../agentSessionsService.js'; import { AgentSessionStatus, IAgentSession, isSessionInProgressStatus } from '../agentSessionsModel.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction, Separator, SubmenuAction, toAction } from '../../../../../../base/common/actions.js'; -import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; -import { IBrowserWorkbenchEnvironmentService } from '../../../../../services/environment/browser/environmentService.js'; import { IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { Verbosity } from '../../../../../common/editor.js'; -import { Schemas } from '../../../../../../base/common/network.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { openSession } from '../agentSessionsOpener.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -43,6 +39,7 @@ import { IActionViewItemService } from '../../../../../../platform/actions/brows import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { LayoutSettings } from '../../../../../services/layout/browser/layoutService.js'; +import { WindowTitle } from '../../../../../browser/parts/titlebar/windowTitle.js'; import { ChatConfiguration, getAgentControlMode } from '../../../common/constants.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetService } from '../../chat.js'; @@ -84,9 +81,6 @@ const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfilt // Storage key for saving user's filter state before we override it const PREVIOUS_FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.previousUserFilter'; -const NLS_EXTENSION_HOST = localize('devExtensionWindowTitlePrefix', "[Extension Development Host]"); -const TITLE_DIRTY = '\u25cf '; - /** * Agent Status Widget - renders agent status in the command center. * @@ -124,6 +118,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Menu for ChatTitleBarMenu items (same as chat controls dropdown) */ private readonly _chatTitleBarMenu; + /** WindowTitle instance for honoring the user's window.title setting */ + private readonly _windowTitle: WindowTitle; + constructor( action: IAction, options: IBaseActionViewItemOptions | undefined, @@ -133,9 +130,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { @ICommandService private readonly commandService: ICommandService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @ILabelService private readonly labelService: ILabelService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IBrowserWorkbenchEnvironmentService private readonly environmentService: IBrowserWorkbenchEnvironmentService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IEditorService private readonly editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, @@ -154,6 +149,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Create menu for ChatTitleBarMenu to show in sparkle section dropdown this._chatTitleBarMenu = this._register(this.menuService.createMenu(MenuId.ChatTitleBarMenu, this.contextKeyService)); + // Create WindowTitle to honor the user's window.title setting + this._windowTitle = this._register(this.instantiationService.createInstance(WindowTitle, mainWindow)); + // Re-render when control mode or session info changes this._register(this.agentTitleBarStatusService.onDidChangeMode(() => { this._render(); @@ -168,6 +166,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._render(); })); + // Re-render when window title changes (honors user's window.title setting) + this._register(this._windowTitle.onDidChange(() => { + this._render(); + })); + // Re-render when active editor changes (for file name display when tabs are hidden) this._register(this.editorService.onDidActiveEditorChange(() => { this._render(); @@ -1381,19 +1384,18 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** * Compute the label to display, matching the command center behavior. + * Honors the user's window.title setting when customized. * Includes prefix and suffix decorations (remote host, extension dev host, etc.) */ private _getLabel(): string { - const { prefix, suffix } = this._getTitleDecorations(); + const { prefix, suffix } = this._windowTitle.getTitleDecorations(); - // Base label: workspace name or file name (when tabs are hidden) - let label = this.labelService.getWorkspaceLabel(this.workspaceContextService.getWorkspace()); - if (this.editorGroupsService.partOptions.showTabs === 'none') { - const activeEditor = this.editorService.activeEditor; - if (activeEditor) { - const dirty = activeEditor.isDirty() && !activeEditor.isSaving() ? TITLE_DIRTY : ''; - label = `${dirty}${activeEditor.getTitle(Verbosity.SHORT)}`; - } + // Base label: honor custom window.title format, otherwise workspace name or file name + let label = this._windowTitle.workspaceName; + if (this._windowTitle.isCustomTitleFormat()) { + label = this._windowTitle.getWindowTitle(); + } else if (this.editorGroupsService.partOptions.showTabs === 'none') { + label = this._windowTitle.fileName ?? label; } if (!label) { @@ -1411,28 +1413,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { return label.replaceAll(/\r\n|\r|\n/g, '\u23CE'); } - /** - * Get prefix and suffix decorations for the title (matching WindowTitle behavior) - */ - private _getTitleDecorations(): { prefix: string | undefined; suffix: string | undefined } { - let prefix: string | undefined; - const suffix: string | undefined = undefined; - - // Add remote host label if connected to a remote - if (this.environmentService.remoteAuthority) { - prefix = this.labelService.getHostLabel(Schemas.vscodeRemote, this.environmentService.remoteAuthority); - } - - // Add extension development host prefix - if (this.environmentService.isExtensionDevelopment) { - prefix = !prefix - ? NLS_EXTENSION_HOST - : `${NLS_EXTENSION_HOST} - ${prefix}`; - } - - return { prefix, suffix }; - } - // #endregion } From 8f35cf2d906edc9a78d2979ead16bb67507c9e44 Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Wed, 18 Mar 2026 17:25:52 -0700 Subject: [PATCH 024/183] removed agent status from right click menu, fixed false boolean to hidden value --- .../agentSessionProjectionActions.ts | 55 +------------------ .../agentSessionsExperiments.contribution.ts | 15 +---- .../experiments/agentTitleBarStatusWidget.ts | 49 ++++------------- .../contrib/chat/browser/chat.contribution.ts | 12 ---- .../contrib/chat/common/constants.ts | 20 ------- 5 files changed, 15 insertions(+), 136 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index 412729cbd9d..e3855988f0a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize, localize2 } from '../../../../../../nls.js'; -import { Action2, MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { Action2 } from '../../../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; @@ -17,8 +17,7 @@ import { CHAT_CATEGORY } from '../../actions/chatActions.js'; import { ToggleTitleBarConfigAction } from '../../../../../browser/parts/titlebar/titlebarActions.js'; import { IsCompactTitleBarContext } from '../../../../../common/contextkeys.js'; import { inAgentSessionProjection } from './agentSessionProjection.js'; -import { ChatConfiguration, getAgentControlMode } from '../../../common/constants.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../../common/constants.js'; //#region Enter Agent Session Projection @@ -91,56 +90,6 @@ export class ExitAgentSessionProjectionAction extends Action2 { //#endregion -//#region Toggle Agent Status - -export class ToggleAgentStatusAction extends Action2 { - constructor() { - super({ - id: `toggle.${ChatConfiguration.AgentStatusEnabled}`, - title: localize('toggle.agentStatus', 'Agent Status'), - metadata: { description: localize('toggle.agentStatusDescription', "Toggle visibility of the Agent Status in title bar") }, - toggled: ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), - menu: [ - { - id: MenuId.TitleBarContext, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - IsCompactTitleBarContext.negate(), - ChatContextKeys.supported, - ContextKeyExpr.has('config.window.commandCenter') - ), - order: 6, - group: '2_config' - }, - { - id: MenuId.TitleBarTitleContext, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - IsCompactTitleBarContext.negate(), - ChatContextKeys.supported, - ContextKeyExpr.has('config.window.commandCenter') - ), - order: 6, - group: '2_config' - } - ] - }); - } - - run(accessor: ServicesAccessor): void { - const configService = accessor.get(IConfigurationService); - const mode = getAgentControlMode(configService.getValue(ChatConfiguration.AgentStatusEnabled)); - if (mode === 'hidden') { - // When currently hidden, restore to compact mode - configService.updateValue(ChatConfiguration.AgentStatusEnabled, 'compact'); - } else { - configService.updateValue(ChatConfiguration.AgentStatusEnabled, 'hidden'); - } - } -} - -//#endregion - //#region Toggle Agent Quick Input export class ToggleUnifiedAgentsBarAction extends ToggleTitleBarConfigAction { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 38945fbb466..18b69b9bc88 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -6,7 +6,7 @@ import { registerSingleton, InstantiationType } from '../../../../../../platform/instantiation/common/extensions.js'; import { MenuId, MenuRegistry, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IAgentSessionProjectionService, AgentSessionProjectionService, AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS } from './agentSessionProjectionService.js'; -import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleAgentStatusAction, ToggleUnifiedAgentsBarAction } from './agentSessionProjectionActions.js'; +import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleUnifiedAgentsBarAction } from './agentSessionProjectionActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; import { AgentTitleBarStatusRendering } from './agentTitleBarStatusWidget.js'; import { AgentTitleBarStatusService, IAgentTitleBarStatusService } from './agentTitleBarStatusService.js'; @@ -235,7 +235,6 @@ class AgentSessionReadyContribution extends Disposable implements IWorkbenchCont registerAction2(EnterAgentSessionProjectionAction); registerAction2(ExitAgentSessionProjectionAction); -registerAction2(ToggleAgentStatusAction); registerAction2(ToggleUnifiedAgentsBarAction); registerSingleton(IAgentSessionProjectionService, AgentSessionProjectionService, InstantiationType.Delayed); @@ -249,10 +248,7 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { submenu: MenuId.AgentsTitleBarControlMenu, title: localize('agentsControl', "Agents"), icon: Codicon.chatSparkle, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), - ), + when: ChatContextKeys.enabled, order: 10002 // to the right of the chat button }); @@ -268,7 +264,6 @@ MenuRegistry.appendMenuItem(MenuId.TitleBar, { ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabled.negate() ), - ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), ContextKeyExpr.has('config.window.commandCenter').negate(), ), order: 1 @@ -280,11 +275,7 @@ MenuRegistry.appendMenuItem(MenuId.AgentsTitleBarControlMenu, { id: 'workbench.action.chat.toggle', title: localize('openChat', "Open Chat"), }, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), // backward compat: false → hidden - ), + when: ChatContextKeys.enabled, group: 'a_open', order: 1 }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index b37b5cda51b..dfe741d00bd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -40,7 +40,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { mainWindow } from '../../../../../../base/browser/window.js'; import { LayoutSettings } from '../../../../../services/layout/browser/layoutService.js'; import { WindowTitle } from '../../../../../browser/parts/titlebar/windowTitle.js'; -import { ChatConfiguration, getAgentControlMode } from '../../../common/constants.js'; +import { ChatConfiguration } from '../../../common/constants.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetService } from '../../chat.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -196,7 +196,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Re-render when settings change this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled)) { + if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled)) { this._lastRenderState = undefined; // Force re-render this._render(); } @@ -293,9 +293,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); // Check which settings are enabled - const agentControlMode = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); - const unifiedAgentsBarEnabled = agentControlMode === 'compact'; - const agentStatusEnabled = agentControlMode !== 'hidden'; + const unifiedAgentsBarEnabled = true; const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; // Build state key for comparison @@ -311,7 +309,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { isFilteredToInProgress, isFilteredToNeedsInput, unifiedAgentsBarEnabled, - agentStatusEnabled, viewSessionsEnabled, }); @@ -337,11 +334,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } else if (unifiedAgentsBarEnabled) { // Unified Agents Bar - show full pill with label + status badge this._renderChatInputMode(this._dynamicDisposables); - } else if (agentStatusEnabled) { - // Agent Status - show only the status badge (sparkle + unread/active counts) - this._renderBadgeOnlyMode(this._dynamicDisposables); } - // If neither setting is enabled, nothing is rendered (container is already cleared) + // If the setting is not enabled, nothing is rendered (container is already cleared) // Setup roving tabindex for keyboard navigation this._setupRovingTabIndex(this._dynamicDisposables); @@ -621,10 +615,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, exitHandler)); // Status badge (separate rectangle on right) - const agentControlMode = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); - if (agentControlMode !== 'hidden') { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } + this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } /** @@ -673,24 +664,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, enterHandler)); // Status badge (separate rectangle on right) - const agentControlModeForReady = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); - if (agentControlModeForReady !== 'hidden') { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } - } - - /** - * Render badge-only mode - just the status badge without the full pill. - * Used when Agent Status is enabled but Enhanced Agent Status is not. - */ - private _renderBadgeOnlyMode(disposables: DisposableStore): void { - if (!this._container) { - return; - } - - const { activeSessions, unreadSessions, attentionNeededSessions } = this._getSessionStats(); - - // Status badge only - no pill, no command center toolbar this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } @@ -926,8 +899,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // When compact mode is active, show status indicators before the sparkle button: // [needs-input, active, unread, sparkle] (populating inward) // Otherwise, keep original order: [sparkle, unread, active, needs-input] - const agentControlModeForBadge = getAgentControlMode(this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); - const reverseOrder = agentControlModeForBadge === 'compact'; + const reverseOrder = true; if (!reverseOrder) { // Original order: sparkle first @@ -1441,19 +1413,18 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben }, undefined)); // Add/remove CSS classes on workbench based on settings - // Force enable command center and disable chat controls when agent status is enabled + // Force enable command center when unified agents bar is enabled const updateClass = () => { const commandCenterEnabled = configurationService.getValue(LayoutSettings.COMMAND_CENTER) === true; - const mode = getAgentControlMode(configurationService.getValue(ChatConfiguration.AgentStatusEnabled)); - const enabled = mode !== 'hidden' && commandCenterEnabled; - const enhanced = mode === 'compact' && commandCenterEnabled; + const enabled = commandCenterEnabled; + const enhanced = commandCenterEnabled; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); mainWindow.document.body.classList.toggle('unified-agents-bar', enhanced); }; updateClass(); this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER)) { + if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER)) { updateClass(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ee8f528c982..493b662af55 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -237,18 +237,6 @@ configurationRegistry.registerConfiguration({ description: nls.localize('interactiveSession.editor.lineHeight', "Controls the line height in pixels in chat codeblocks. Use 0 to compute the line height from the font size."), default: 0 }, - [ChatConfiguration.AgentStatusEnabled]: { - type: 'string', - enum: ['hidden', 'badge', 'compact'], - enumDescriptions: [ - nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), - nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), - nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), - ], - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), - default: 'compact', - tags: ['experimental'] - }, [ChatConfiguration.UnifiedAgentsBar]: { type: 'boolean', markdownDescription: nls.localize('chat.unifiedAgentsBar.enabled', "Replaces the command center search box with a unified chat and search widget."), diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 909ceec41c9..8e60855816e 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -8,25 +8,6 @@ import { IChatSessionsService } from './chatSessionsService.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -export type AgentControlMode = 'hidden' | 'badge' | 'compact'; - -/** - * Resolves the agent control mode from the configuration, handling backward - * compatibility with the old boolean value. - */ -export function getAgentControlMode(value: unknown): AgentControlMode { - if (value === false || value === 'hidden') { - return 'hidden'; - } - if (value === true || value === 'badge') { - return 'badge'; - } - if (value === 'compact') { - return 'compact'; - } - return 'compact'; -} - export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', PluginsEnabled = 'chat.plugins.enabled', @@ -36,7 +17,6 @@ export enum ChatConfiguration { PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', - AgentStatusEnabled = 'chat.agentsControl.enabled', EditorAssociations = 'chat.editorAssociations', UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', From 8c6dd47a0461185596876f1451e2a462526a3c9d Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Wed, 18 Mar 2026 17:26:05 -0700 Subject: [PATCH 025/183] fix --- .../workbench/browser/parts/titlebar/commandCenterControl.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index c0ac9cacf34..2aa1b2d0464 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -147,8 +147,9 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { container.setAttribute('aria-description', this.getTooltip()); // When agent control mode is 'compact', hide search icon and left-align the label + // Backward compat: the old boolean setting (true) and the new default (undefined) both map to compact const agentControlValue = that._configurationService.getValue('chat.agentsControl.enabled'); - const isCompactMode = agentControlValue === 'compact'; + const isCompactMode = agentControlValue !== false && agentControlValue !== 'hidden'; container.classList.toggle('compact-mode', isCompactMode); const action = this.action; From 93217a0c3ec8a84802f622b6b9d713a84bef7b5b Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Wed, 18 Mar 2026 17:42:13 -0700 Subject: [PATCH 026/183] fixes testResolver issue --- .../experiments/agentTitleBarStatusWidget.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index dfe741d00bd..731c1d9d997 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -1355,19 +1355,17 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // #region Label Helpers /** - * Compute the label to display, matching the command center behavior. - * Honors the user's window.title setting when customized. - * Includes prefix and suffix decorations (remote host, extension dev host, etc.) + * Compute the label to display in the command center. + * Uses the workspace name (folder name) with prefix/suffix decorations. + * Falls back to file name when tabs are hidden, or "Search" when empty. */ private _getLabel(): string { const { prefix, suffix } = this._windowTitle.getTitleDecorations(); - // Base label: honor custom window.title format, otherwise workspace name or file name + // Base label: workspace name or file name when tabs are hidden let label = this._windowTitle.workspaceName; - if (this._windowTitle.isCustomTitleFormat()) { - label = this._windowTitle.getWindowTitle(); - } else if (this.editorGroupsService.partOptions.showTabs === 'none') { - label = this._windowTitle.fileName ?? label; + if (!label && this.editorGroupsService.partOptions.showTabs === 'none') { + label = this._windowTitle.fileName ?? ''; } if (!label) { @@ -1401,7 +1399,8 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, - @IConfigurationService configurationService: IConfigurationService + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService ) { super(); @@ -1412,12 +1411,17 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben return instantiationService.createInstance(AgentTitleBarStatusWidget, action, options); }, undefined)); - // Add/remove CSS classes on workbench based on settings - // Force enable command center when unified agents bar is enabled + // Add/remove CSS classes on workbench based on settings. + // Only hide the default command center search box (via unified-agents-bar) + // when chat is enabled, so the search box remains visible during remote + // connection startup before the agent status widget is ready to render. + const chatEnabledKey = contextKeyService.getContextKeyValue('chatIsEnabled'); + let chatEnabled = !!chatEnabledKey; + const updateClass = () => { const commandCenterEnabled = configurationService.getValue(LayoutSettings.COMMAND_CENTER) === true; - const enabled = commandCenterEnabled; - const enhanced = commandCenterEnabled; + const enabled = commandCenterEnabled && chatEnabled; + const enhanced = commandCenterEnabled && chatEnabled; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); mainWindow.document.body.classList.toggle('unified-agents-bar', enhanced); @@ -1428,5 +1432,11 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben updateClass(); } })); + this._register(contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(new Set(['chatIsEnabled']))) { + chatEnabled = !!contextKeyService.getContextKeyValue('chatIsEnabled'); + updateClass(); + } + })); } } From 2e030d10c938fe1e6c939ba1fb9a843c6a1ebdcc Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Wed, 18 Mar 2026 17:53:31 -0700 Subject: [PATCH 027/183] removed setting oops, fixed but still removed from command center right click --- .../experiments/agentTitleBarStatusWidget.ts | 50 ++++++------------- .../contrib/chat/browser/chat.contribution.ts | 12 +++++ .../contrib/chat/common/constants.ts | 1 + 3 files changed, 27 insertions(+), 36 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 731c1d9d997..be42b0fa660 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -25,7 +25,6 @@ import { IWorkspaceContextService, WorkbenchState } from '../../../../../../plat import { IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; -import { openSession } from '../agentSessionsOpener.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -96,7 +95,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { private readonly _dynamicDisposables = this._register(new DisposableStore()); /** The currently displayed in-progress session (if any) - clicking pill opens this */ - private _displayedSession: IAgentSession | undefined; /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; @@ -491,9 +489,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Label - always shows workspace name in compact mode const label = $('span.agent-status-label'); - const { session: attentionSession, progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); - this._displayedSession = attentionSession; - + const { progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); const defaultLabel = isCompactMode ? this._getLabel() : (progressText ?? this._getLabel()); if (!isCompactMode && progressText) { @@ -544,20 +540,22 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Setup hover tooltip on input area const hoverDelegate = getDefaultHoverDelegate('mouse'); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, inputArea, () => { - if (this._displayedSession) { - return localize('openSessionTooltip', "Open session: {0}", this._displayedSession.label); - } const kbForTooltip = this.keybindingService.lookupKeybinding(UNIFIED_QUICK_ACCESS_ACTION_ID)?.getLabel(); return kbForTooltip ? localize('askTooltip', "Open Quick Access ({0})", kbForTooltip) : localize('askTooltip2', "Open Quick Access"); })); - // Click handler - open displayed session if showing progress, otherwise open unified quick access + // Click handler - always open quick access in compact mode (attention sessions are handled by the badge) disposables.add(addDisposableListener(inputArea, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); - this._handlePillClick(); + this.telemetryService.publicLog2('agentStatusWidget.click', { + source: 'pill', + action: 'quickAccess', + }); + const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); })); // Keyboard handler @@ -565,7 +563,12 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this._handlePillClick(); + this.telemetryService.publicLog2('agentStatusWidget.click', { + source: 'pill', + action: 'quickAccess', + }); + const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); } })); @@ -1294,31 +1297,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // #endregion - // #region Click Handlers - - /** - * Handle pill click - opens the displayed session if showing progress, otherwise opens unified quick access - */ - private _handlePillClick(): void { - if (this._displayedSession) { - this.telemetryService.publicLog2('agentStatusWidget.click', { - source: 'pill', - action: 'openSession', - }); - this.instantiationService.invokeFunction(openSession, this._displayedSession); - } else { - this.telemetryService.publicLog2('agentStatusWidget.click', { - source: 'pill', - action: 'quickAccess', - }); - // Use unified quick access only if that separate setting is enabled, otherwise use normal quick open - const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; - this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); - } - } - - // #endregion - // #region Session Helpers /** diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 493b662af55..ee8f528c982 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -237,6 +237,18 @@ configurationRegistry.registerConfiguration({ description: nls.localize('interactiveSession.editor.lineHeight', "Controls the line height in pixels in chat codeblocks. Use 0 to compute the line height from the font size."), default: 0 }, + [ChatConfiguration.AgentStatusEnabled]: { + type: 'string', + enum: ['hidden', 'badge', 'compact'], + enumDescriptions: [ + nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), + nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), + nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), + ], + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), + default: 'compact', + tags: ['experimental'] + }, [ChatConfiguration.UnifiedAgentsBar]: { type: 'boolean', markdownDescription: nls.localize('chat.unifiedAgentsBar.enabled', "Replaces the command center search box with a unified chat and search widget."), diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8e60855816e..a2303956550 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -17,6 +17,7 @@ export enum ChatConfiguration { PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', + AgentStatusEnabled = 'chat.agentsControl.enabled', EditorAssociations = 'chat.editorAssociations', UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', From b8fe4b89edb8f85a3c8c891c07f503098dbdcb31 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:02:15 -0700 Subject: [PATCH 028/183] Remove `notifySessionOptionsChange` - Use single event for options change - We should not expose notify type methods on services because callers should not control event flows directly like this - Remove use of async event. Afaik this was not actually being used for anything --- .../contrib/chat/browser/newSession.ts | 13 +---- .../api/browser/mainThreadChatSessions.ts | 6 +-- .../browser/mainThreadChatSessions.test.ts | 6 +-- .../chatSessions/chatSessions.contribution.ts | 54 ++++++++++--------- .../browser/widget/input/chatInputPart.ts | 11 ++-- .../chat/common/chatSessionsService.ts | 18 ++----- .../test/common/mockChatSessionsService.ts | 24 +++++---- 7 files changed, 57 insertions(+), 75 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index 06ac91248a2..b6d439ec696 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -6,7 +6,6 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IsolationMode } from './sessionTargetPicker.js'; import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; @@ -102,7 +101,6 @@ export class CopilotCLISession extends Disposable implements INewSession { readonly resource: URI, defaultRepoUri: URI | undefined, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @ILogService private readonly logService: ILogService, ) { super(); if (defaultRepoUri) { @@ -168,10 +166,7 @@ export class CopilotCLISession extends Disposable implements INewSession { } else { this.selectedOptions.set(optionId, value); } - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify session option ${optionId} change:`, err)); + this.chatSessionsService.setSessionOption(this.resource, optionId, value); } } @@ -214,7 +209,6 @@ export class RemoteNewSession extends Disposable implements INewSession { readonly target: AgentSessionProviders, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -272,10 +266,7 @@ export class RemoteNewSession extends Disposable implements INewSession { } this._onDidChange.fire('options'); this._onDidChange.fire('disabled'); - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err)); + this.chatSessionsService.setSessionOption(this.resource, optionId, value); } // --- Option group accessors --- diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index c3c31b321d9..fef8a35d419 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -455,12 +455,12 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._proxy = this._extHostContext.getProxy(ExtHostContext.ExtHostChatSessions); - this._register(this._chatSessionsService.onRequestNotifyExtension(({ sessionResource, updates, waitUntil }) => { + this._register(this._chatSessionsService.onDidChangeSessionOptions(({ sessionResource, updates }) => { warnOnUntitledSessionResource(sessionResource, this._logService); const handle = this._getHandleForSessionType(sessionResource.scheme); this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.length} update(s)`); if (handle !== undefined) { - waitUntil(this.notifyOptionsChange(handle, sessionResource, updates)); + this.notifyOptionsChange(handle, sessionResource, updates); } else { this._logService.warn(`[MainThreadChatSessions] Cannot notify option change for scheme '${sessionResource.scheme}': no provider registered. Registered schemes: [${Array.from(this._sessionTypeToHandle.keys()).join(', ')}]`); } @@ -549,7 +549,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { const sessionResource = URI.revive(sessionResourceComponents); warnOnUntitledSessionResource(sessionResource, this._logService); - this._chatSessionsService.notifySessionOptionsChange(sessionResource, updates); + this._chatSessionsService.updateSessionOptions(sessionResource, updates); } async $onDidCommitChatSessionItem(handle: number, originalComponents: UriComponents, modifiedCompoennts: UriComponents): Promise { diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index ef00b137c84..255313c43b5 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -763,9 +763,7 @@ suite('MainThreadChatSessions', function () { (proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory(); // Simulate an option change - await chatSessionsService.notifySessionOptionsChange(resource, [ - { optionId: 'models', value: 'gpt-4-turbo' } - ]); + chatSessionsService.setSessionOption(resource, 'models', 'gpt-4-turbo'); // Verify the extension was notified assert.ok((proxy.$provideHandleOptionsChange as sinon.SinonStub).calledOnce); @@ -789,7 +787,7 @@ suite('MainThreadChatSessions', function () { // Attempt to notify option change for an unregistered scheme // This should not throw, but also should not call the proxy - await chatSessionsService.notifySessionOptionsChange(resource, [ + chatSessionsService.updateSessionOptions(resource, [ { optionId: 'models', value: 'gpt-4-turbo' } ]); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 91da42a4866..fb12d5a1d04 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -7,7 +7,7 @@ import { sep } from '../../../../../base/common/path.js'; import { AsyncIterableProducer, raceCancellationError } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -31,7 +31,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -295,12 +295,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _onDidChangeContentProviderSchemes = this._register(new Emitter<{ readonly added: string[]; readonly removed: string[] }>()); public get onDidChangeContentProviderSchemes() { return this._onDidChangeContentProviderSchemes.event; } - private readonly _onDidChangeSessionOptions = this._register(new Emitter()); + private readonly _onDidChangeSessionOptions = this._register(new Emitter()); public get onDidChangeSessionOptions() { return this._onDidChangeSessionOptions.event; } private readonly _onDidChangeOptionGroups = this._register(new Emitter()); public get onDidChangeOptionGroups() { return this._onDidChangeOptionGroups.event; } - private readonly _onRequestNotifyExtension = this._register(new AsyncEmitter()); - public get onRequestNotifyExtension() { return this._onRequestNotifyExtension.event; } private readonly inProgressMap: Map = new Map(); private readonly _sessionTypeOptions: Map = new Map(); @@ -1096,7 +1094,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._sessions.set(sessionResource, sessionData); // Make sure any listeners are aware of the new session and its options - this._onDidChangeSessionOptions.fire(sessionResource); + if (session.options) { + const updates = Object.entries(session.options).map(([optionId, value]) => ({ optionId, value })); + this._onDidChangeSessionOptions.fire({ sessionResource, updates }); + } return session; } @@ -1124,8 +1125,28 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean { + return this.updateSessionOptions(sessionResource, [{ optionId, value }]); + } + + public updateSessionOptions(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): boolean { const session = this._sessions.get(this._resolveResource(sessionResource)); - return !!session?.setOption(optionId, value); + if (!session) { + return false; + } + + let didChange = false; + for (const { optionId, value } of updates) { + const existingValue = session.getOption(optionId); + if (existingValue !== value) { + session.setOption(optionId, value); + didChange = true; + } + } + + if (didChange) { + this._onDidChangeSessionOptions.fire({ sessionResource, updates: updates }); + } + return didChange; } /** @@ -1168,25 +1189,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._sessionTypeNewSessionOptions.set(chatSessionType, options); } - /** - * Notify extension about option changes for a session - */ - public async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { - if (!updates.length) { - return; - } - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: starting for ${sessionResource}, ${updates.length} update(s): [${updates.map(u => u.optionId).join(', ')}]`); - // Fire event to notify MainThreadChatSessions (which forwards to extension host) - // Uses fireAsync to properly await async listener work via waitUntil pattern - await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: fireAsync completed for ${sessionResource}`); - for (const u of updates) { - this.setSessionOption(sessionResource, u.optionId, u.value); - } - this._onDidChangeSessionOptions.fire(this._resolveResource(sessionResource)); - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: finished for ${sessionResource}`); - } - /** * Get the capabilities for a specific session type */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index d89e48b88ca..37dbe0bfed8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -563,7 +563,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // React to chat session option changes for the active session this._register(this.chatSessionsService.onDidChangeSessionOptions(e => { const sessionResource = this._widget?.viewModel?.model.sessionResource; - if (sessionResource && isEqual(sessionResource, e)) { + if (sessionResource && isEqual(sessionResource, e.sessionResource)) { // Options changed for our current session - refresh pickers this.refreshChatSessionPickers(); } @@ -704,10 +704,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge : mode.label.read(undefined) !== agentId; // Extensions use Label (name) as identifier for custom agents. } if (needsUpdate) { - this.chatSessionsService.notifySessionOptionsChange( + this.chatSessionsService.updateSessionOptions( ctx.chatSessionResource, [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] - ).catch(err => this.logService.error('Failed to notify extension of agent change:', err)); + ); } } } @@ -882,10 +882,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; const currentCtx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; if (currentCtx) { - this.chatSessionsService.notifySessionOptionsChange( - currentCtx.chatSessionResource, - [{ optionId: optionGroup.id, value: option }] - ).catch(err => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + this.chatSessionsService.setSessionOption(currentCtx.chatSessionResource, optionGroup.id, option); } // Refresh pickers to re-evaluate visibility of other option groups diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 1220250312b..6401409f2b9 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Event, IWaitUntil } from '../../../../base/common/event.js'; +import { Event } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; @@ -236,11 +236,7 @@ export interface IChatSessionItemController { newChatSessionItem?(request: IChatNewSessionRequest, token: CancellationToken): Promise; } -/** - * Event fired when session options need to be sent to the extension. - * Extends IWaitUntil to allow listeners to register async work that will be awaited. - */ -export interface IChatSessionOptionsWillNotifyExtensionEvent extends IWaitUntil { +export interface IChatSessionOptionsChangeEvent { readonly sessionResource: URI; readonly updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; } @@ -306,11 +302,12 @@ export interface IChatSessionsService { getSessionOptions(sessionResource: URI): Map | undefined; getSessionOption(sessionResource: URI, optionId: string): string | IChatSessionProviderOptionItem | undefined; setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean; + updateSessionOptions(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): boolean; /** * Fired when options for a chat session change. */ - readonly onDidChangeSessionOptions: Event; + readonly onDidChangeSessionOptions: Event; /** * Get the capabilities for a specific session type @@ -355,13 +352,6 @@ export interface IChatSessionsService { getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined; setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void; - /** - * Event fired when session options change and need to be sent to the extension. - * MainThreadChatSessions subscribes to this to forward changes to the extension host. - * Uses IWaitUntil pattern to allow listeners to register async work. - */ - readonly onRequestNotifyExtension: Event; - notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise; getInProgressSessionDescription(chatModel: IChatModel): string | undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index dbf321f972e..1d35d055d21 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { AsyncEmitter, Emitter } from '../../../../../base/common/event.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -17,8 +17,9 @@ import { Target } from '../../common/promptSyntax/promptTypes.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; - private readonly _onDidChangeSessionOptions = new Emitter(); + private readonly _onDidChangeSessionOptions = new Emitter(); readonly onDidChangeSessionOptions = this._onDidChangeSessionOptions.event; + private readonly _onDidChangeItemsProviders = new Emitter<{ readonly chatSessionType: string }>(); readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event; @@ -37,8 +38,6 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onDidChangeOptionGroups = new Emitter(); readonly onDidChangeOptionGroups = this._onDidChangeOptionGroups.event; - private readonly _onRequestNotifyExtension = new AsyncEmitter(); - readonly onRequestNotifyExtension = this._onRequestNotifyExtension.event; private sessionItemControllers = new Map }>(); private contentProviders = new Map(); @@ -181,10 +180,6 @@ export class MockChatSessionsService implements IChatSessionsService { // noop } - async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { - await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); - } - getSessionOptions(sessionResource: URI): Map | undefined { const options = this.sessionOptions.get(sessionResource); return options && options.size > 0 ? options : undefined; @@ -195,10 +190,19 @@ export class MockChatSessionsService implements IChatSessionsService { } setSessionOption(sessionResource: URI, optionId: string, value: string): boolean { + return this.updateSessionOptions(sessionResource, [{ optionId, value }]); + } + + updateSessionOptions(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string }>): boolean { if (!this.sessionOptions.has(sessionResource)) { this.sessionOptions.set(sessionResource, new Map()); } - this.sessionOptions.get(sessionResource)!.set(optionId, value); + for (const update of updates) { + this.sessionOptions.get(sessionResource)!.set(update.optionId, update.value); + } + + this._onDidChangeSessionOptions.fire({ sessionResource, updates }); + return true; } From daa0a7042b4d7f758238bc5247be656636cbc288 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 19 Mar 2026 11:43:31 -0400 Subject: [PATCH 029/183] fix light theme contrast issue in questions feature (#303213) fixes #302923 --- .../media/chatQuestionCarousel.css | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 3ae3f7dccea..656af1f8bf1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -247,31 +247,52 @@ background-color: var(--vscode-list-hoverBackground); } - /* Single-select: highlight entire row when selected */ + /* Single-select: highlight entire row when selected (list not focused) */ .chat-question-list-item.selected { - background-color: var(--vscode-list-hoverBackground); - color: var(--vscode-list-activeSelectionForeground); + background-color: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); - .chat-question-label { - color: var(--vscode-list-activeSelectionForeground); + .chat-question-list-label, + .chat-question-list-label-title { + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-label-desc { - color: var(--vscode-list-activeSelectionForeground); - opacity: 0.8; + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-number { - color: var(--vscode-list-activeSelectionForeground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } } + /* When the question list has focus, use active selection styling */ + .chat-question-list:focus .chat-question-list-item.selected { + background-color: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + + .chat-question-label { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-label-desc { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-number { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + } .chat-question-list-item.selected:hover { - background-color: var(--vscode-list-hoverBackground); + background-color: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-hoverBackground)); } /* Checkbox for multi-select */ From 87127523d033660abb93dc4b2de63cdb00cc7efb Mon Sep 17 00:00:00 2001 From: anthonykim1 Date: Thu, 19 Mar 2026 09:13:04 -0700 Subject: [PATCH 030/183] Fix infinite enter for windows terminal 5 when screen reader is enabled --- .../contrib/terminal/common/scripts/shellIntegration.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index afeac192274..72a329b8e39 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -179,11 +179,13 @@ if ($Global:__VSCodeState.IsA11yMode -eq "1") { # Check if the loaded PSReadLine already supports EnableScreenReaderMode $hasScreenReaderParam = (Get-Module -Name PSReadLine) -and (Get-Command Set-PSReadLineOption).Parameters.ContainsKey('EnableScreenReaderMode') - if (-not $hasScreenReaderParam) { + if (-not $hasScreenReaderParam -and $PSVersionTable.PSVersion -ge "7.0") { # The loaded PSReadLine lacks EnableScreenReaderMode (only available in 2.4.4-beta4+). # PowerShell 7.0+ skips autoloading PSReadLine when the OS reports a screen reader active. # When only VS Code's accessibility mode is enabled (no OS screen reader), # it's still loaded and must be removed to load our bundled copy. + # Skip this on Windows PowerShell 5.1 where removing the built-in PSReadLine 2.0.0 + # and replacing it can cause input handling issues (e.g. repeated Enter key presses). if (Get-Module -Name PSReadLine) { Remove-Module PSReadLine -Force } From b7ba4a7e0466d92817fd256f9edd8c843ceba790 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Mar 2026 18:24:26 +0100 Subject: [PATCH 031/183] Add model picker telemetry for admin/upgrade links and Other Models toggles (#303200) * Enhance chat model picker with interaction logging and description link callbacks * Refactor chat model picker to remove interaction callback and implement description link action handler * Refactor action handler naming for clarity in ActionList and ModelPicker * Update owner in ChatModelPickerInteractionClassification to reflect current author * Fix link handler condition to check for disabled items in ModelPickerWidget * Refactor ModelPickerWidget to simplify manageSettingsUrl handling and improve link handler logic * Add IUriIdentityService to ModelPickerWidget for improved URI handling --- .../actionWidget/browser/actionList.ts | 26 ++++++++++++-- .../browser/widget/input/chatModelPicker.ts | 36 ++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index d6065d2de3f..0e8d0502762 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -193,6 +193,7 @@ class ActionItemRenderer implements IListRenderer, IAction private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, private readonly _onSubmenuIndicatorHover: ((element: IActionListItem, indicator: HTMLElement, disposables: DisposableStore) => void) | undefined, private _hasAnySubmenuActions: boolean, + private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -284,7 +285,12 @@ class ActionItemRenderer implements IListRenderer, IAction } else { const rendered = renderMarkdown(element.description, { actionHandler: (content: string) => { - this._openerService.open(URI.parse(content), { allowCommands: true }); + const uri = URI.parse(content); + if (this._linkHandler) { + this._linkHandler(uri, element); + } else { + void this._openerService.open(uri, { allowCommands: true }); + } } }); data.elementDisposables.add(rendered); @@ -404,6 +410,17 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * Optional handler for markdown links activated in item descriptions or hovers. + * When unset, links open via the opener service with command links allowed. + */ + readonly linkHandler?: (uri: URI, item: IActionListItem) => void; + + /** + * Optional callback fired when a section's collapsed state changes. + */ + readonly onDidToggleSection?: (section: string, collapsed: boolean) => void; + /** * When true, descriptions are rendered as subtext below the title * instead of inline to the right. @@ -518,7 +535,7 @@ export class ActionListWidget extends Disposable { const hasAnySubmenuActions = items.some(item => !!item.submenuActions?.length); this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer(preview, (item) => this._removeItem(item), (element, indicator, disposables) => this._wireSubmenuIndicator(element, indicator, disposables), hasAnySubmenuActions, this._keybindingService, this._openerService), + new ActionItemRenderer(preview, (item) => this._removeItem(item), (element, indicator, disposables) => this._wireSubmenuIndicator(element, indicator, disposables), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -638,6 +655,7 @@ export class ActionListWidget extends Disposable { } else { this._collapsedSections.add(section); } + this._options?.onDidToggleSection?.(section, this._collapsedSections.has(section)); this._applyFilter(); } @@ -1162,10 +1180,14 @@ export class ActionListWidget extends Disposable { } const markdown = typeof element.hover!.content === 'string' ? new MarkdownString(element.hover!.content) : element.hover!.content; + const linkHandler = this._options?.linkHandler; this._hover.value = this._hoverService.showDelayedHover({ content: markdown ?? '', target: rowElement, additionalClasses: ['action-widget-hover'], + linkHandler: linkHandler ? (url: string) => { + linkHandler(URI.parse(url), element); + } : undefined, position: { hoverPosition: HoverPosition.LEFT, forcePosition: false, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index b4f952eb197..dd43c63d365 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -14,12 +14,14 @@ import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IHoverPositionOptions } from '../../../../../../base/browser/ui/hover/hover.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; @@ -28,6 +30,7 @@ import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageM import { ChatEntitlement, IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; import * as semver from '../../../../../../base/common/semver/semver.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; +import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IUpdateService, StateType } from '../../../../../../platform/update/common/update.js'; function isVersionAtLeast(current: string, required: string): boolean { @@ -74,6 +77,18 @@ type ChatModelChangeEvent = { toModel: string | TelemetryTrustedValue; }; +type ChatModelPickerInteraction = 'disabledModelContactAdminClicked' | 'premiumModelUpgradePlanClicked' | 'otherModelsExpanded' | 'otherModelsCollapsed'; + +type ChatModelPickerInteractionClassification = { + owner: 'sandy081'; + comment: 'Reporting interactions in the chat model picker'; + interaction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model picker interaction that occurred' }; +}; + +type ChatModelPickerInteractionEvent = { + interaction: ChatModelPickerInteraction; +}; + function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, @@ -537,11 +552,13 @@ export class ModelPickerWidget extends Disposable { private readonly _hoverPosition: IHoverPositionOptions | undefined, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @ICommandService private readonly _commandService: ICommandService, + @IOpenerService private readonly _openerService: IOpenerService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IProductService private readonly _productService: IProductService, @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, @IUpdateService private readonly _updateService: IUpdateService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); @@ -632,6 +649,10 @@ export class ModelPickerWidget extends Disposable { const controlModelsForTier = isPro ? manifest.paid : manifest.free; const canShowManageModelsAction = this._delegate.showManageModelsAction() && shouldShowManageModelsAction(this._entitlementService); const manageModelsAction = canShowManageModelsAction ? createManageModelsAction(this._commandService) : undefined; + const logModelPickerInteraction = (interaction: ChatModelPickerInteraction) => { + this._telemetryService.publicLog2('chat.modelPickerInteraction', { interaction }); + }; + const manageSettingsUrl = this._productService.defaultChatAgent?.manageSettingsUrl; const items = buildModelPickerItems( models, this._selectedModel?.identifier, @@ -640,7 +661,7 @@ export class ModelPickerWidget extends Disposable { this._productService.version, this._updateService.state.type, onSelect, - this._productService.defaultChatAgent?.manageSettingsUrl, + manageSettingsUrl, this._delegate.useGroupedModelPicker(), !showFilter ? manageModelsAction : undefined, this._entitlementService, @@ -656,6 +677,19 @@ export class ModelPickerWidget extends Disposable { filterActions: showFilter && manageModelsAction ? [manageModelsAction] : undefined, focusFilterOnOpen: true, collapsedByDefault: new Set([ModelPickerSection.Other]), + onDidToggleSection: (section: string, collapsed: boolean) => { + if (section === ModelPickerSection.Other) { + logModelPickerInteraction(collapsed ? 'otherModelsCollapsed' : 'otherModelsExpanded'); + } + }, + linkHandler: (uri: URI) => { + if (uri.scheme === 'command' && uri.path === 'workbench.action.chat.upgradePlan') { + logModelPickerInteraction('premiumModelUpgradePlanClicked'); + } else if (manageSettingsUrl && this._uriIdentityService.extUri.isEqual(uri, URI.parse(manageSettingsUrl))) { + logModelPickerInteraction('disabledModelContactAdminClicked'); + } + void this._openerService.open(uri, { allowCommands: true }); + }, minWidth: 200, }; const previouslyFocusedElement = dom.getActiveElement(); From 970e6fb2ba4c4e8fb82fa24b0bb20b03ba4a3dd6 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 18:27:16 +0100 Subject: [PATCH 032/183] sessions - allow to collapse section headers (#303228) --- .../agentSessions.contribution.ts | 3 ++- .../browser/agentSessions/agentSessions.ts | 2 ++ .../agentSessions/agentSessionsActions.ts | 19 +++++++++++++++++++ .../agentSessions/agentSessionsControl.ts | 15 ++++++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 4000cefb73b..61ef7f44865 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -16,7 +16,7 @@ import { IAgentSessionsService, AgentSessionsService } from './agentSessionsServ import { LocalAgentsSessionsController } from './localAgentSessionsController.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction } from './agentSessionsActions.js'; +import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction, CollapseAllAgentSessionSectionsAction } from './agentSessionsActions.js'; import { AgentSessionsQuickAccessProvider, AGENT_SESSIONS_QUICK_ACCESS_PREFIX } from './agentSessionsQuickAccess.js'; //#region Actions and Menus @@ -28,6 +28,7 @@ registerAction2(MarkAllAgentSessionsReadAction); registerAction2(ArchiveAgentSessionSectionAction); registerAction2(UnarchiveAgentSessionSectionAction); registerAction2(MarkAgentSessionSectionReadAction); +registerAction2(CollapseAllAgentSessionSectionsAction); registerAction2(ArchiveAgentSessionAction); registerAction2(UnarchiveAgentSessionAction); registerAction2(PinAgentSessionAction); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index f01ebe74835..b8b3f9f8373 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -171,6 +171,8 @@ export interface IAgentSessionsControl { clearFocus(): void; hasFocusOrSelection(): boolean; + + collapseAllSections(): void; } export const agentSessionReadIndicatorForeground = registerColor( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index c7d0360d000..b501f0a66ba 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -365,6 +365,25 @@ export class MarkAgentSessionSectionReadAction extends Action2 { } } +export class CollapseAllAgentSessionSectionsAction extends Action2 { + + constructor() { + super({ + id: 'agentSessionSection.collapseAll', + title: localize2('collapseAll', "Collapse All"), + menu: [{ + id: MenuId.AgentSessionSectionContext, + group: '2_collapse', + order: 1, + }] + }); + } + + async run(accessor: ServicesAccessor, _section: unknown, control?: IAgentSessionsControl): Promise { + control?.collapseAllSections(); + } +} + //#endregion //#region Session Actions diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1130ad7925e..388a65c72a0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -421,7 +421,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.contextMenuService.showContextMenu({ getActions: () => Separator.join(...menu.getActions({ arg: section, shouldForwardArgs: true }).map(([, actions]) => actions)), getAnchor: () => anchor, - getActionsContext: () => section, + getActionsContext: () => this, }); menu.dispose(); @@ -509,6 +509,19 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return this.agentSessionsService.model.resolve(undefined); } + collapseAllSections(): void { + if (!this.sessionsList) { + return; + } + + const model = this.agentSessionsService.model; + for (const child of this.sessionsList.getNode(model).children) { + if (isAgentSessionSection(child.element) && !child.collapsed) { + this.sessionsList.collapse(child.element); + } + } + } + async update(): Promise { return this.updateSessionsListThrottler.queue(async () => { await this.sessionsList?.updateChildren(); From 000a053bc6e0f96b7a61a5484783c64344b1965e Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:29:11 +0000 Subject: [PATCH 033/183] Sessions - refactor changes view (#303256) * Initial cleanup * Move more things to the view model --- .../contrib/changes/browser/changesView.ts | 458 +++++++----------- 1 file changed, 162 insertions(+), 296 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index e4f6e029bcf..5cfb9a5e66f 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -12,8 +12,8 @@ import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/ 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, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -23,7 +23,7 @@ import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/but import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId, Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -35,7 +35,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; @@ -48,21 +48,21 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.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 { 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 { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionWorkspace.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { CIStatusWidget } from './ciStatusWidget.js'; +import { arrayEqualsC } from '../../../../base/common/equals.js'; const $ = dom.$; @@ -202,6 +202,100 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement; + readonly activeSessionResourceObs: IObservable; + readonly activeSessionRepositoryObs: IObservableWithChange; + readonly activeSessionChangesObs: IObservable; + + readonly versionModeObs: ISettableObservable; + setVersionMode(mode: ChangesVersionMode): void { + if (this.versionModeObs.get() === mode) { + return; + } + this.versionModeObs.set(mode, undefined); + } + + readonly viewModeObs: ISettableObservable; + setViewMode(mode: ChangesViewMode): void { + if (this.viewModeObs.get() === mode) { + return; + } + this.viewModeObs.set(mode, undefined); + } + + constructor( + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IGitService private readonly gitService: IGitService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + // Active session changes + this.sessionsChangedSignal = observableSignalFromEvent(this, + this.agentSessionsService.model.onDidChangeSessions); + + // Active session resource + this.activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.resource; + }); + + // Active session changes + this.activeSessionChangesObs = derivedOpts({ + equalsFn: arrayEqualsC() + }, reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return Iterable.empty(); + } + + this.sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + return model?.changes instanceof Array ? model.changes : Iterable.empty(); + }); + + // Active session repository + const activeSessionRepositoryPromiseObs = derived(reader => { + const activeSessionResource = this.activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return constObservable(undefined); + } + + const activeSession = this.sessionManagementService.getActiveSession(); + if (!activeSession?.worktree) { + return constObservable(undefined); + } + + return new ObservablePromise(this.gitService.openRepository(activeSession.worktree)).resolvedValue; + }); + + this.activeSessionRepositoryObs = derived(reader => { + const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); + if (activeSessionRepositoryPromise === undefined) { + return undefined; + } + + return activeSessionRepositoryPromise.read(reader); + }); + + // Version mode + this.versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); + + this._register(runOnChange(this.activeSessionResourceObs, () => { + this.setVersionMode(ChangesVersionMode.AllChanges); + })); + + // View mode + const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); + const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; + this.viewModeObs = observableValue(this, initialMode); + } +} + // --- View Pane export class ChangesViewPane extends ViewPane { @@ -224,44 +318,7 @@ export class ChangesViewPane extends ViewPane { private currentBodyHeight = 0; private currentBodyWidth = 0; - // View mode (list vs tree) - private readonly viewModeObs: ReturnType>; - private readonly viewModeContextKey: IContextKey; - - get viewMode(): ChangesViewMode { return this.viewModeObs.get(); } - set viewMode(mode: ChangesViewMode) { - if (this.viewModeObs.get() === mode) { - return; - } - this.viewModeObs.set(mode, undefined); - this.viewModeContextKey.set(mode); - this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); - } - - // Version mode (all changes, last turn, uncommitted) - private readonly versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); - private readonly versionModeContextKey: IContextKey; - - setVersionMode(mode: ChangesVersionMode): void { - if (this.versionModeObs.get() === mode) { - return; - } - this.versionModeObs.set(mode, undefined); - this.versionModeContextKey.set(mode); - } - - // Track the active session used by this view - private readonly activeSession: IObservableWithChange; - private readonly activeSessionFileCountObs: IObservableWithChange; - private readonly activeSessionHasChangesObs: IObservableWithChange; - private readonly activeSessionRepositoryObs: IObservableWithChange; - - get activeSessionHasChanges(): IObservable { - return this.activeSessionHasChangesObs; - } - - // Badge for file count - private readonly badgeDisposable = this._register(new MutableDisposable()); + readonly viewModel: ChangesViewModel; constructor( options: IViewPaneOptions, @@ -274,123 +331,51 @@ export class ChangesViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, @IEditorService private readonly editorService: IEditorService, @IActivityService private readonly activityService: IActivityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, - @IStorageService private readonly storageService: IStorageService, @ICodeReviewService private readonly codeReviewService: ICodeReviewService, - @IGitService private readonly gitService: IGitService, @IGitHubService private readonly gitHubService: IGitHubService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - // View mode - const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); - const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; - this.viewModeObs = observableValue(this, initialMode); - this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); - this.viewModeContextKey.set(initialMode); + this.viewModel = this.instantiationService.createInstance(ChangesViewModel); // Version mode - this.versionModeContextKey = changesVersionModeContextKey.bindTo(contextKeyService); - this.versionModeContextKey.set(ChangesVersionMode.AllChanges); - - // Track active session from sessions management service - this.activeSession = derivedOpts({ - equalsFn: (a, b) => isEqual(a?.resource, b?.resource), - }, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - if (!activeSession?.resource) { - return undefined; - } - - return activeSession; - }).recomputeInitiallyAndOnChange(this._store); - - // Track active session repository changes - const activeSessionRepositoryPromiseObs = derived(reader => { - const activeSessionWorktree = this.activeSession.read(reader)?.worktree; - if (!activeSessionWorktree) { - return constObservable(undefined); - } - - return new ObservablePromise(this.gitService.openRepository(activeSessionWorktree)).resolvedValue; - }); - - this.activeSessionRepositoryObs = derived(reader => { - const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); - if (activeSessionRepositoryPromise === undefined) { - return undefined; - } - - return activeSessionRepositoryPromise.read(reader); - }); - - this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); - this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); - - // 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. - const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this._register(autorun(reader => { - const activeSession = this.activeSession.read(reader); - viewSessionTypeKey.set(activeSession?.providerType ?? ''); + this._register(bindContextKey(changesVersionModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.versionModeObs.read(reader); })); - } - private createActiveSessionFileCountObservable(): IObservableWithChange { - const activeSessionResource = this.activeSession.map(a => a?.resource); + // View mode + this._register(bindContextKey(changesViewModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.viewModeObs.read(reader); + })); - const sessionsChangedSignal = observableFromEvent( - this, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); + // Set chatSessionType on the view's context key service so ViewTitlev menu items + // can use it in their `when` clauses. Update reactively when the active session + // changes. + this._register(bindContextKey(ChatContextKeys.agentSessionType, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.providerType ?? ''; + })); - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); + // Badge + const badgeDisposable = this._register(new MutableDisposable()); + + this._register(autorun(reader => { + const changes = this.viewModel.activeSessionChangesObs.read(reader); + if (changes.length === 0) { + badgeDisposable.clear(); + return; } - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); - - return derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return 0; - } - - let editingSessionCount = 0; - if (activeSession.providerType !== AgentSessionProviders.Background) { - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - editingSessionCount = session ? session.entries.read(reader).length : 0; - } - - const sessionFiles = [...sessionFileChangesObs.read(reader)]; - const sessionFilesCount = sessionFiles.length; - - return editingSessionCount + sessionFilesCount; - }).recomputeInitiallyAndOnChange(this._store); - } - - private updateBadge(fileCount: number): void { - if (fileCount > 0) { - const message = fileCount === 1 + const message = changes.length === 1 ? localize('changesView.oneFileChanged', '1 file changed') - : localize('changesView.filesChanged', '{0} files changed', fileCount); - this.badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(fileCount, () => message) }); - } else { - this.badgeDisposable.clear(); - } + : localize('changesView.filesChanged', '{0} files changed', changes.length); + badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(changes.length, () => message) }); + })); } protected override renderBody(container: HTMLElement): void { @@ -446,80 +431,10 @@ export class ChangesViewPane extends ViewPane { private onVisible(): void { this.renderDisposables.clear(); - const activeSessionResource = this.activeSession.map(a => a?.resource); - - // Create observable for the active editing session - // Note: We must read editingSessionsObs to establish a reactive dependency, - // so that the view updates when a new editing session is added (e.g., cloud sessions) - const activeEditingSessionObs = derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return undefined; - } - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - return sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - }); - - // Create observable for edit session entries from the ACTIVE session only (local editing sessions) - const editSessionEntriesObs = derived(reader => { - const activeSession = this.activeSession.read(reader); - - // Background chat sessions render the working set based on the session files, not the editing session - if (activeSession?.providerType === AgentSessionProviders.Background) { - return []; - } - - const session = activeEditingSessionObs.read(reader); - if (!session) { - return []; - } - - const entries = session.entries.read(reader); - const items: IChangesFileItem[] = []; - - for (const entry of entries) { - const isDeletion = entry.isDeletion ?? false; - const linesAdded = entry.linesAdded?.read(reader) ?? 0; - const linesRemoved = entry.linesRemoved?.read(reader) ?? 0; - - items.push({ - type: 'file', - uri: entry.modifiedURI, - originalUri: entry.originalURI, - state: entry.state.read(reader), - isDeletion, - changeType: isDeletion ? 'deleted' : 'modified', - linesAdded, - linesRemoved, - reviewCommentCount: 0, - }); - } - - return items; - }); - - // Signal observable that triggers when sessions data changes - const sessionsChangedSignal = observableFromEvent( - this.renderDisposables, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); - - // Observable for session file changes from agentSessionsService (cloud/background sessions) - // Reactive to both activeSession changes AND session data changes - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); - } - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); const reviewCommentCountByFileObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - const sessionChanges = [...sessionFileChangesObs.read(reader)]; + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; if (!sessionResource) { return new Map(); @@ -534,11 +449,11 @@ export class ChangesViewPane extends ViewPane { } } - if (sessionChanges.length === 0) { + if (changes.length === 0) { return result; } - const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewFiles = getCodeReviewFilesFromSessionChanges(changes as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); const reviewVersion = getCodeReviewVersion(reviewFiles); const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); @@ -557,8 +472,9 @@ export class ChangesViewPane extends ViewPane { // Convert session file changes to list items (cloud/background sessions) const sessionFilesObs = derived(reader => { const reviewCommentCountByFile = reviewCommentCountByFileObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; - return [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + return changes.map((entry): IChangesFileItem => { const isDeletion = entry.modifiedUri === undefined; const isAddition = entry.originalUri === undefined; const uri = isIChatSessionFileChange2(entry) @@ -578,21 +494,18 @@ export class ChangesViewPane extends ViewPane { }); }); - // Create observable for last turn changes using diffBetweenWithStats - // Reactively computes the diff between HEAD^ and HEAD. Memoize the diff observable so - // that we only recompute it when the HEAD commit id actually changes. const headCommitObs = derived(reader => { - const repository = this.activeSessionRepositoryObs.read(reader); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); return repository?.state.read(reader)?.HEAD?.commit; }); const lastCheckpointRefObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); if (!sessionResource) { return undefined; } - sessionsChangedSignal.read(reader); + this.viewModel.sessionsChangedSignal.read(reader); const model = this.agentSessionsService.getSession(sessionResource); return model?.metadata?.lastCheckpointRef as string | undefined; @@ -615,7 +528,7 @@ export class ChangesViewPane extends ViewPane { }); const lastTurnChangesObs = derived(reader => { - const repository = this.activeSessionRepositoryObs.read(reader); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); const headCommit = headCommitObs.read(reader); if (!repository || !headCommit) { @@ -633,10 +546,9 @@ export class ChangesViewPane extends ViewPane { // Combine both entry sources for display const combinedEntriesObs = derived(reader => { const headCommit = headCommitObs.read(reader); - const versionMode = this.versionModeObs.read(reader); - const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); const lastTurnDiffChanges = lastTurnChangesObs.read(reader).read(reader); + const versionMode = this.viewModel.versionModeObs.read(reader); let sourceEntries: IChangesFileItem[]; if (versionMode === ChangesVersionMode.LastTurn) { @@ -679,7 +591,7 @@ export class ChangesViewPane extends ViewPane { } satisfies IChangesFileItem; }); } else { - sourceEntries = [...editEntries, ...sessionFiles]; + sourceEntries = [...sessionFiles]; } const resources = new Set(); @@ -695,8 +607,6 @@ export class ChangesViewPane extends ViewPane { // Calculate stats from combined entries const topLevelStats = derived(reader => { - const editEntries = editSessionEntriesObs.read(reader); - const sessionFiles = sessionFilesObs.read(reader); const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -706,10 +616,7 @@ export class ChangesViewPane extends ViewPane { removed += entry.linesRemoved; } - const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; - - return { files, added, removed, isSessionMenu }; + return { files: entries.length, added, removed }; }); // Setup context keys and actions toolbar @@ -718,66 +625,30 @@ export class ChangesViewPane extends ViewPane { const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); - // Set the chat session type context key reactively so that menu items with - // `chatSessionType == copilotcli` (e.g. Create Pull Request) are shown - const chatSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this.renderDisposables.add(autorun(reader => { - const activeSession = this.activeSession.read(reader); - chatSessionTypeKey.set(activeSession?.providerType ?? ''); - })); - - // Bind required context keys for the menu buttons - this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, this.scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); - })); - - this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, this.scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.length > 0; - })); - - const hasAgentSessionChangesObs = derived(reader => { + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { const { files } = topLevelStats.read(reader); return files > 0; - }); + })); - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, r => hasAgentSessionChangesObs.read(r))); - - const isMergeBaseBranchProtectedObs = derived(reader => { - const activeSession = this.activeSession.read(reader); + this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); return activeSession?.worktreeBaseBranchProtected === true; - }); + })); - this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, r => isMergeBaseBranchProtectedObs.read(r))); - - const hasOpenPullRequestObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); + this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, reader => { + this.viewModel.sessionsChangedSignal.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); if (!sessionResource) { return false; } - sessionsChangedSignal.read(reader); - const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; - return !!metadata?.pullRequestUrl; - }); - - this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, r => hasOpenPullRequestObs.read(r))); + return metadata?.pullRequestUrl !== undefined; + })); this.renderDisposables.add(autorun(reader => { - const { isSessionMenu, added, removed } = topLevelStats.read(reader); - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); // Re-evaluate when session metadata changes (e.g. pullRequestUrl) - const menuId = isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar; + const { added, removed } = topLevelStats.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); // Read code review state to update the button label dynamically let reviewCommentCount: number | undefined; @@ -807,11 +678,11 @@ export class ChangesViewPane extends ViewPane { reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, - menuId, + MenuId.ChatEditingSessionChangesToolbar, { telemetrySource: 'changesView', - disableWhileRunning: isSessionMenu, - menuOptions: isSessionMenu && sessionResource + disableWhileRunning: true, + menuOptions: sessionResource ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, buttonConfigProvider: (action) => { @@ -860,11 +731,6 @@ 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); @@ -925,7 +791,7 @@ export class ChangesViewPane extends ViewPane { }, compressionEnabled: true, twistieAdditionalCssClass: (e: unknown) => { - return this.viewMode === ChangesViewMode.List + return this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 'force-no-twistie' : undefined; }, @@ -1020,7 +886,7 @@ export class ChangesViewPane extends ViewPane { // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const entries = combinedEntriesObs.read(reader); - const viewMode = this.viewModeObs.read(reader); + const viewMode = this.viewModel.viewModeObs.read(reader); if (!this.tree) { return; @@ -1367,7 +1233,7 @@ class SetChangesListViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.List; + view.viewModel.setViewMode(ChangesViewMode.List); } } @@ -1390,7 +1256,7 @@ class SetChangesTreeViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.Tree; + view.viewModel.setViewMode(ChangesViewMode.Tree); } } @@ -1426,7 +1292,7 @@ class AllChangesAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); - view?.setVersionMode(ChangesVersionMode.AllChanges); + view?.viewModel.setVersionMode(ChangesVersionMode.AllChanges); } } registerAction2(AllChangesAction); @@ -1449,7 +1315,7 @@ class LastTurnChangesAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); - view?.setVersionMode(ChangesVersionMode.LastTurn); + view?.viewModel.setVersionMode(ChangesVersionMode.LastTurn); } } registerAction2(LastTurnChangesAction); From 08316e41103937de79c3f22dbcf4dbf098149dbb Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 18:29:39 +0100 Subject: [PATCH 034/183] sessions - fix title bar issue on macOS (#303224) * sessions - fix title bar issue on macOS * ccr --- .../electron-browser/parts/titlebarPart.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts index b72a879cb7d..5c2692372ff 100644 --- a/src/vs/sessions/electron-browser/parts/titlebarPart.ts +++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts @@ -10,6 +10,7 @@ import { IContextKeyService } from '../../../platform/contextkey/common/contextk import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { useWindowControlsOverlay } from '../../../platform/window/common/window.js'; @@ -20,6 +21,7 @@ import { IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titleba import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; import { TitlebarPart, TitleService } from '../../browser/parts/titlebarPart.js'; +import { isMacintosh } from '../../../base/common/platform.js'; export class NativeTitlebarPart extends TitlebarPart { @@ -37,6 +39,7 @@ export class NativeTitlebarPart extends TitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService private readonly productService: IProductService, @INativeHostService private readonly nativeHostService: INativeHostService, ) { super(id, targetWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); @@ -44,6 +47,24 @@ export class NativeTitlebarPart extends TitlebarPart { this.handleWindowsAlwaysOnTop(targetWindow.vscodeWindowId, contextKeyService); } + protected override createContentArea(parent: HTMLElement): HTMLElement { + + // Workaround for macOS/Electron bug where the window does not + // appear in the "Windows" menu if the first `document.title` + // matches the BrowserWindow's initial title. + // See: https://github.com/microsoft/vscode/issues/191288 + if (isMacintosh) { + const window = getWindow(this.element); + const nativeTitle = this.productService.nameLong; + if (!window.document.title || window.document.title === nativeTitle) { + window.document.title = `${nativeTitle} \u200b`; + } + window.document.title = nativeTitle; + } + + return super.createContentArea(parent); + } + private async handleWindowsAlwaysOnTop(targetWindowId: number, contextKeyService: IContextKeyService): Promise { const isWindowAlwaysOnTopContext = IsWindowAlwaysOnTopContext.bindTo(contextKeyService); @@ -107,9 +128,10 @@ class MainNativeTitlebarPart extends NativeTitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { - super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } } @@ -130,10 +152,11 @@ class AuxiliaryNativeTitlebarPart extends NativeTitlebarPart implements IAuxilia @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { const id = AuxiliaryNativeTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } override get preventZoom(): boolean { From 58757c0a2c359aa9f7816573bd61c03ccffb77aa Mon Sep 17 00:00:00 2001 From: Jamie Cansdale Date: Thu, 19 Mar 2026 17:29:45 +0000 Subject: [PATCH 035/183] Use bracketed paste for multiline executed terminal text (#302526) --- .../terminal/browser/terminalInstance.ts | 4 +- .../test/browser/terminalInstance.test.ts | 66 +++++++++++++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index f8fc98554eb..d303a0233ba 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -1343,9 +1343,11 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } async sendText(text: string, shouldExecute: boolean, bracketedPasteMode?: boolean): Promise { + const useBracketedPasteMode = (bracketedPasteMode || /[\r\n]/.test(text)) && this.xterm?.raw.modes.bracketedPasteMode; + // Apply bracketed paste sequences if the terminal has the mode enabled, this will prevent // the text from triggering keybindings and ensure new lines are handled properly - if (bracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { + if (useBracketedPasteMode) { text = `\x1b[200~${text}\x1b[201~`; } diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 8c3c2d9982a..dffd6ab5e73 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { deepStrictEqual, strictEqual } from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -123,7 +124,8 @@ suite('Workbench - TerminalInstance', () => { suite('TerminalInstance', () => { let terminalInstance: ITerminalInstance; - test('should create an instance of TerminalInstance with env from default profile', async () => { + + async function createTerminalInstance(): Promise { const instantiationService = workbenchInstantiationService({ configurationService: () => new TestConfigurationService({ files: {}, @@ -146,9 +148,25 @@ suite('Workbench - TerminalInstance', () => { instantiationService.stub(IEnvironmentVariableService, store.add(instantiationService.createInstance(EnvironmentVariableService))); instantiationService.stub(ITerminalInstanceService, store.add(new TestTerminalInstanceService())); instantiationService.stub(ITerminalService, { setNextCommandId: async () => { } } as Partial); - terminalInstance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, {})); - // //Wait for the teminalInstance._xtermReadyPromise to resolve - await new Promise(resolve => setTimeout(resolve, 100)); + const instance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, {})); + await instance.xtermReadyPromise; + return instance; + } + + async function waitForShellLaunchConfigEnv(instance: ITerminalInstance): Promise { + for (let i = 0; i < 50; i++) { + if (instance.shellLaunchConfig.env) { + return; + } + await timeout(0); + } + + throw new Error('Timed out waiting for shell launch config env'); + } + + test('should create an instance of TerminalInstance with env from default profile', async () => { + terminalInstance = await createTerminalInstance(); + await waitForShellLaunchConfigEnv(terminalInstance); deepStrictEqual(terminalInstance.shellLaunchConfig.env, { TEST: 'TEST' }); }); @@ -192,6 +210,46 @@ suite('Workbench - TerminalInstance', () => { // Verify that the task name is preserved strictEqual(taskTerminal.title, 'Test Task Name', 'Task terminal should preserve API-set title'); }); + + test('should use bracketed paste mode for multiline executed text when available', async () => { + const instance = await createTerminalInstance(); + const writes: string[] = []; + const processManager = (instance as unknown as { _processManager: { write(data: string): Promise } })._processManager; + const originalWrite = processManager.write; + const originalXterm = instance.xterm!; + const testRaw = Object.create(originalXterm.raw) as typeof originalXterm.raw; + Object.defineProperty(testRaw, 'modes', { + value: { + ...originalXterm.raw.modes, + bracketedPasteMode: true + }, + configurable: true + }); + const testXterm = Object.create(originalXterm) as typeof originalXterm; + Object.defineProperty(testXterm, 'raw', { + value: testRaw, + configurable: true + }); + Object.defineProperty(testXterm, 'scrollToBottom', { + value: () => { }, + configurable: true + }); + + processManager.write = async (data: string) => { + writes.push(data); + }; + instance.xterm = testXterm; + + try { + await instance.sendText('echo hello\nworld', true); + } finally { + processManager.write = originalWrite; + instance.xterm = originalXterm; + } + + strictEqual(writes.length, 1); + strictEqual(writes[0].replace(/\x1b/g, '\\x1b').replace(/\r/g, '\\r'), '\\x1b[200~echo hello\\rworld\\x1b[201~\\r'); + }); }); suite('parseExitResult', () => { test('should return no message for exit code = undefined', () => { From 3b60cf4f6789b8d3f0df30ae53ed443f0bec9762 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Thu, 19 Mar 2026 18:31:12 +0100 Subject: [PATCH 036/183] chore - Add telemetry logging for chat editing session store and restore events (#303225) * chore - Add telemetry logging for chat editing session store and restore events * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * CCR mess cleanup --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../browser/chatEditing/chatEditingSession.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 8439b5518b7..918b0a9c767 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -30,6 +30,7 @@ import { EditorActivation } from '../../../../../platform/editor/common/editor.j import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -47,7 +48,7 @@ import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; -import { FileOperation, FileOperationType } from './chatEditingOperations.js'; +import { FileOperation, FileOperationType, getKeyForChatSessionResource } from './chatEditingOperations.js'; import { IChatEditingExplanationModelManager, IExplanationDiffInfo, IExplanationGenerationHandle } from './chatEditingExplanationModelManager.js'; import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js'; import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; @@ -59,6 +60,42 @@ const enum NotExistBehavior { Abort, } +type ChatEditingSessionStoreEvent = { + sessionId: string; + entryCount: number; + modifiedCount: number; + acceptedCount: number; + rejectedCount: number; +}; + +type ChatEditingSessionStoreClassification = { + owner: 'jrieken'; + comment: 'Tracks the number and state of chat editing entries when a session is stored.'; + sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; + entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries stored with the session.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when storing.' }; + acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when storing.' }; + rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when storing.' }; +}; + +type ChatEditingSessionRestoreEvent = { + sessionId: string; + entryCount: number; + modifiedCount: number; + acceptedCount: number; + rejectedCount: number; +}; + +type ChatEditingSessionRestoreClassification = { + owner: 'jrieken'; + comment: 'Tracks the number and state of chat editing entries when a session is restored.'; + sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; + entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries restored with the session.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when restoring.' }; + acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when restoring.' }; + rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when restoring.' }; +}; + class ThrottledSequencer extends Sequencer { private _size = 0; @@ -199,6 +236,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IConfigurationService private readonly configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, @IChatEditingExplanationModelManager private readonly _explanationModelManager: IChatEditingExplanationModelManager, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._timeline = this._instantiationService.createInstance( @@ -308,7 +346,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource); - return storage.storeState(this._getStoredState()); + const storedState = this._getStoredState(); + this._telemetryService.publicLog2('chatEditing/sessionStore', { + sessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(this._entriesObs.get()), + }); + return storage.storeState(storedState); } private _getStoredState(sessionResource = this.chatSessionResource): StoredSessionState { @@ -945,6 +988,10 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._entriesObs.set(entriesArr, undefined); + this._telemetryService.publicLog2('chatEditing/sessionRestore', { + sessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(entriesArr), + }); } private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { @@ -981,6 +1028,28 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }; } + private _countEntryStates(entries: readonly AbstractChatEditingModifiedFileEntry[]): { entryCount: number; modifiedCount: number; acceptedCount: number; rejectedCount: number } { + let entryCount = 0; + let modifiedCount = 0; + let acceptedCount = 0; + let rejectedCount = 0; + for (const entry of entries) { + entryCount += 1; + switch (entry.state.get()) { + case ModifiedFileEntryState.Modified: + modifiedCount += 1; + break; + case ModifiedFileEntryState.Accepted: + acceptedCount += 1; + break; + case ModifiedFileEntryState.Rejected: + rejectedCount += 1; + break; + } + } + return { entryCount, modifiedCount, acceptedCount, rejectedCount }; + } + private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise { const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString()); if (!hasOtherTasks) { From 8b06f20f30df170fdf1c9b6bd54e4b4ced4675af Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Mar 2026 18:46:39 +0100 Subject: [PATCH 037/183] fix populating copilot featured models (#303262) --- .../contrib/chat/common/languageModels.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 0e4616251d6..a0dd5106900 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -1740,7 +1740,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelExistsInCache(entry.id) }; + free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -1750,7 +1750,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelExistsInCache(entry.id) }; + paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -1758,17 +1758,7 @@ export class LanguageModelsService implements ILanguageModelsService { this._onDidChangeModelsControlManifest.fire(this._modelsControlManifest); } - private _modelExistsInCache(metadataId: string): boolean { - for (const model of this._modelCache.values()) { - if (model.id === metadataId) { - return true; - } - } - return false; - } - //#region Chat control data - private _initChatControlData(): void { this._chatControlUrl = this._productService.chatParticipantRegistry; if (!this._chatControlUrl) { From b369f046ae7b8c4de5b90d7819fa980a5efb9779 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:55:16 -0700 Subject: [PATCH 038/183] Enhance URL glob matching to enforce subdomain wildcard matching on dot boundaries (#303263) --- src/vs/platform/url/common/urlGlob.ts | 5 +++- .../platform/url/test/common/urlGlob.test.ts | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/url/common/urlGlob.ts b/src/vs/platform/url/common/urlGlob.ts index 9cfd6f530d2..9202ee672cd 100644 --- a/src/vs/platform/url/common/urlGlob.ts +++ b/src/vs/platform/url/common/urlGlob.ts @@ -131,7 +131,10 @@ function doUrlPartMatch( if (!['/', ':'].includes(urlPart[urlOffset])) { options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset + 1, globUrlOffset)); } - options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + // Only skip *. if we're at the start (bare domain) or at a dot boundary + if (urlOffset === 0 || urlPart[urlOffset - 1] === '.') { + options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + } } if (globUrlPart[globUrlOffset] === '*') { diff --git a/src/vs/platform/url/test/common/urlGlob.test.ts b/src/vs/platform/url/test/common/urlGlob.test.ts index 83534f62ad6..90fee896069 100644 --- a/src/vs/platform/url/test/common/urlGlob.test.ts +++ b/src/vs/platform/url/test/common/urlGlob.test.ts @@ -56,8 +56,29 @@ suite('urlGlob', () => { assert.strictEqual(testUrlMatchesGlob('https://sub.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://sub.domain.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://example.com', 'https://*.example.com'), true); - // *. matches any number of characters before the domain, including other domains - assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), true); + }); + + test('subdomain wildcard must match on dot boundary', () => { + // Should NOT match: no dot boundary before the domain + assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evilmicrosoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-example.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://myexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://notexample.com/path', 'https://*.example.com/path'), false); + + // Should match: proper subdomain with dot boundary + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://a.b.c.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://sub.example.com/path', 'https://*.example.com/path'), true); + }); + + test('subdomain wildcard without scheme must match on dot boundary', () => { + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('http://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', '*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('http://sub.microsoft.com', '*.microsoft.com'), true); }); test('port matching', () => { From 5e3ed39231ba2400302ca3d594d9b982b4367052 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 18:58:01 +0100 Subject: [PATCH 039/183] chat - prevent race conditions in `loadSession` (#303244) * chat - prevent race conditions in `loadSession` * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * ccr * polish --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.ts | 60 +++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index db74594fd35..2adfac1bc56 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -8,7 +8,7 @@ import { $, addDisposableListener, append, EventHelper, EventType, getWindow, se import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { Orientation, Sash } from '../../../../../../base/browser/ui/sash/sash.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; @@ -97,6 +97,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private welcomeController: ChatViewWelcomeController | undefined; private restoringSession: Promise | undefined; + private readonly loadSessionCts = this._register(new MutableDisposable()); private readonly modelRef = this._register(new MutableDisposable()); private readonly activityBadge = this._register(new MutableDisposable()); @@ -262,7 +263,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { try { this._widget.setVisible(false); - await this.showModel(modelRef); + await this.showModel(CancellationToken.None, modelRef); } finally { this._widget.setVisible(wasVisible); } @@ -687,29 +688,39 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private async _applyModel(): Promise { const sessionResource = this.getTransferredOrPersistedSessionInfo(); const modelRef = sessionResource ? await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None) : undefined; - await this.showModel(modelRef); + await this.showModel(CancellationToken.None, modelRef); } - private async showModel(modelRef?: IChatModelReference | undefined, startNewSession = true): Promise { + private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true): Promise { const oldModelResource = this.modelRef.value?.object.sessionResource; this.modelRef.value = undefined; let ref: IChatModelReference | undefined; if (startNewSession) { ref = modelRef ?? (this.chatService.transferredSessionResource - ? await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, CancellationToken.None) + ? await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, token) : this.chatService.startNewLocalSession(ChatAgentLocation.Chat)); if (!ref) { throw new Error('Could not start chat session'); } } + if (token.isCancellationRequested) { + ref?.dispose(); + return undefined; + } + this.modelRef.value = ref; const model = ref?.object; if (model) { await this.updateWidgetLockState(getChatSessionType(model.sessionResource)); // Update widget lock state based on session type + if (token.isCancellationRequested) { + this.modelRef.value = undefined; + return undefined; + } + // remember as model to restore in view state this.viewState.sessionResource = model.sessionResource; } @@ -760,46 +771,75 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } private async clear(): Promise { + // Cancel any in-flight loadSession call to prevent it from + // overwriting the fresh session we are about to create. + this.loadSessionCts.value?.cancel(); // Grab the widget's latest view state because it will be loaded back into the widget this.updateViewState(); - await this.showModel(undefined); + await this.showModel(CancellationToken.None); // Update the toolbar context with new sessionId this.updateActions(); } async loadSession(sessionResource: URI): Promise { + // Cancel any in-flight loadSession call so the last one always wins + this.loadSessionCts.value?.cancel(); + const cts = this.loadSessionCts.value = new CancellationTokenSource(); + const token = cts.token; + // Wait for any in-progress session restore (e.g. from onDidChangeAgents) // to finish first, so our showModel call is guaranteed to be the last one. if (this.restoringSession) { await this.restoringSession; } + if (token.isCancellationRequested) { + return undefined; + } + return this.progressService.withProgress({ location: ChatViewId, delay: 200 }, async () => { let queue: Promise = Promise.resolve(); // A delay here to avoid blinking because only Cloud sessions are slow, most others are fast const clearWidget = disposableTimeout(() => { + // Only clear the current model if this loadSession call is still the active one + // and has not been cancelled. This preserves the "last call wins" behavior. + if (token.isCancellationRequested || this.loadSessionCts.value !== cts) { + return; + } // clear current model without starting a new one - queue = this.showModel(undefined, false).then(() => { }); + queue = this.showModel(token, undefined, false).then(() => { }); }, 100); + const clearWidgetCancellationListener = token.onCancellationRequested(() => clearWidget.dispose()); try { - const newModelRef = await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + const newModelRef = await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, token); clearWidget.dispose(); await queue; - return this.showModel(newModelRef); + if (token.isCancellationRequested) { + newModelRef?.dispose(); + return undefined; + } + + return this.showModel(token, newModelRef); } catch (err) { clearWidget.dispose(); await queue; + if (token.isCancellationRequested) { + return undefined; + } + // Recover by starting a fresh empty session so the widget // is not left in a broken state without title or back button. this.logService.error(`Failed to load chat session '${sessionResource.toString()}'`, err); this.notificationService.error(localize('chat.loadSessionFailed', "Failed to open chat session: {0}", toErrorMessage(err))); - return this.showModel(undefined); + return this.showModel(token, undefined); + } finally { + clearWidgetCancellationListener.dispose(); } }); } From 44e1de53c2e0a2bf11d1b13f0683332cc5e6a968 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 19 Mar 2026 19:03:28 +0100 Subject: [PATCH 040/183] component explorer fixture for chat customization tabs (#303243) * component explorer fixture for chat customization tabs * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../aiCustomizationListWidget.fixture.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts new file mode 100644 index 00000000000..98b8a3ddf45 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { ResourceSet } from '../../../../base/common/map.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../contrib/chat/common/customizationHarnessService.js'; +import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath, IExtensionPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; +import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; + +// Ensure color registrations are loaded +import '../../../../platform/theme/common/colors/inputColors.js'; +import '../../../../platform/theme/common/colors/listColors.js'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const defaultFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension], +}; + +interface IFixtureInstructionFile { + readonly promptPath: IPromptPath; + /** If set, this instruction file has an applyTo pattern (on-demand). */ + readonly applyTo?: string; +} + +function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): IPromptsService { + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeSkills = Event.None; + override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } + override async listPromptFiles(type: PromptsType) { + if (type === PromptsType.instructions) { + return instructionFiles.map(f => f.promptPath); + } + return []; + } + override async listAgentInstructions() { return agentInstructionFiles; } + override async getCustomAgents() { return []; } + override async parseNew(uri: URI, _token: CancellationToken): Promise { + return new ParsedPromptFile(uri); + } + }(); +} + +function createMockWorkspaceService(): IAICustomizationWorkspaceService { + const activeProjectRoot = observableValue('mockActiveProjectRoot', URI.file('/workspace')); + return new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = activeProjectRoot; + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter() { return defaultFilter; } + }(); +} + +function createMockHarnessService(): ICustomizationHarnessService { + const descriptor = createVSCodeHarnessDescriptor([PromptsStorage.extension]); + return new class extends mock() { + override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly availableHarnesses = observableValue('harnesses', [descriptor]); + override getStorageSourceFilter() { return defaultFilter; } + override getActiveDescriptor() { return descriptor; } + }(); +} + +function createMockWorkspaceContextService(): IWorkspaceContextService { + return new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { + return { id: 'test', folders: [] }; + } + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): Promise { + const width = 500; + const height = 400; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const contextMenuService = new class extends mock() { + override onDidShowContextMenu = Event.None; + override onDidHideContextMenu = Event.None; + override showContextMenu(): void { } + }; + + const contextViewService = new class extends mock() { + override anchorAlignment = 0; + override showContextView() { return { close: () => { } }; } + override hideContextView(): void { } + override getContextViewElement(): HTMLElement { return ctx.container; } + override layout(): void { } + }; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + reg.defineInstance(IContextMenuService, contextMenuService); + reg.defineInstance(IContextViewService, contextViewService); + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IPromptsService, createMockPromptsService(instructionFiles, agentInstructionFiles)); + reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); + reg.defineInstance(ICustomizationHarnessService, createMockHarnessService()); + reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + reg.defineInstance(IPathService, new class extends mock() { + override readonly defaultUriScheme = 'file'; + override userHome(): URI; + override userHome(): Promise; + override userHome(): URI | Promise { return URI.file('/home/dev'); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationListWidget) + ); + ctx.container.appendChild(widget.element); + await widget.setSection(AICustomizationManagementSection.Instructions); + widget.layout(height, width); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'chat/' }, { + + InstructionsTabWithItems: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderInstructionsTab(ctx, [ + // Always-active instructions (no applyTo) + { promptPath: { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' } }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style preferences' } }, + // Always-included instruction (applyTo: **) + { promptPath: { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'General Guidelines', description: 'General development guidelines' }, applyTo: '**' }, + // On-demand instructions (with applyTo pattern) + { promptPath: { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing Guidelines', description: 'Testing best practices' }, applyTo: '**/*.test.ts' }, + { promptPath: { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security Review', description: 'Security review checklist' }, applyTo: 'src/auth/**' }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, name: 'TypeScript Rules', description: 'TypeScript conventions', extension: undefined!, source: undefined! } satisfies IExtensionPromptPath, applyTo: '**/*.ts' }, + ], [ + // Agent instruction files (AGENTS.md, copilot-instructions.md) + { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, + { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd }, + ]), + }), + + InstructionsTabEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderInstructionsTab(ctx, []), + }), +}); From 7fbc59c1000abceacd1e416431b251556ab2853f Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 19:21:50 +0100 Subject: [PATCH 041/183] Sessions: Consider making `Group by Repository` the default state (fix #303014) (#303267) * Sessions: Consider making `Group by Repository` the default state (fix #303014) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../sessions/contrib/sessions/browser/sessionsViewPane.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 86c6a2cc0ba..d9fe0643bb9 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -41,7 +41,7 @@ import { IHostService } from '../../../../workbench/services/host/browser/host.j const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); -const IsGroupedByRepositoryContext = new RawContextKey('sessionsView.isGroupedByRepository', false); +const IsGroupedByRepositoryContext = new RawContextKey('sessionsView.isGroupedByRepository', true); const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; export class AgenticSessionsViewPane extends ViewPane { @@ -49,7 +49,7 @@ export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; - private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Date; + private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Repository; private isGroupedByRepoKey: ReturnType | undefined; constructor( @@ -75,6 +75,10 @@ export class AgenticSessionsViewPane extends ViewPane { if (stored && Object.values(AgentSessionsGrouping).includes(stored as AgentSessionsGrouping)) { this.currentGrouping = stored as AgentSessionsGrouping; } + + // Ensure the view-title context reflects the restored grouping immediately + this.isGroupedByRepoKey = IsGroupedByRepositoryContext.bindTo(contextKeyService); + this.isGroupedByRepoKey.set(this.currentGrouping === AgentSessionsGrouping.Repository); } protected override renderBody(parent: HTMLElement): void { From 5c7192e8ec24427e48b1e45cf1bff35d3581f48d Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 19:44:32 +0100 Subject: [PATCH 042/183] sessions - tweaks to grouping by repo (fix #302453) (#303250) --- .../sessions/browser/sessionsViewPane.ts | 34 +++------------- .../agentSessions/agentSessionsControl.ts | 4 +- .../agentSessions/agentSessionsViewer.ts | 40 +++++++++---------- .../agentSessionsDataSource.test.ts | 31 ++++++++++++++ 4 files changed, 56 insertions(+), 53 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index d9fe0643bb9..cc14bd12263 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -285,7 +285,7 @@ MenuRegistry.appendMenuItem(MenuId.ViewTitle, { title: localize2('filterAgentSessions', "Filter Sessions"), group: 'navigation', order: 3, - icon: Codicon.filter, + icon: Codicon.settings, when: ContextKeyExpr.equals('view', SessionsViewId) } satisfies ISubmenuItem); @@ -294,36 +294,12 @@ registerAction2(class GroupByRepositoryAction extends Action2 { super({ id: 'sessionsView.groupByRepository', title: localize2('groupByRepository', "Group by Repository"), - icon: Codicon.repo, category: SessionsCategories.Sessions, + toggled: IsGroupedByRepositoryContext, menu: [{ - id: MenuId.ViewTitle, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext.negate()), - }] - }); - } - - override run(accessor: ServicesAccessor) { - const viewsService = accessor.get(IViewsService); - const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleGroupByRepository(); - } -}); - -registerAction2(class GroupByDateAction extends Action2 { - constructor() { - super({ - id: 'sessionsView.groupByDate', - title: localize2('groupByDate', "Group by Date"), - icon: Codicon.history, - category: SessionsCategories.Sessions, - menu: [{ - id: MenuId.ViewTitle, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext), + id: SessionsViewFilterSubMenu, + group: 'grouping', + order: 0, }] }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 388a65c72a0..9fd3e62c596 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -42,7 +42,6 @@ import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { IChatWidget } from '../chat.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; export interface IAgentSessionsControlOptions { readonly overrideStyles: IStyleOverride; @@ -109,7 +108,6 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, @IStorageService private readonly storageService: IStorageService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -251,7 +249,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo ...this.options, isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, }, approvalModel, activeSessionResource)); - const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter, this.logService)); + const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', container, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 6eed12abc64..a71767c8770 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -49,8 +49,6 @@ import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; - export type AgentSessionListItem = IAgentSession | IAgentSessionSection; @@ -760,7 +758,6 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, - private readonly logService?: ILogService, ) { super(); } @@ -898,8 +895,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const repoMap = new Map(); const pinnedSessions: IAgentSession[] = []; const archivedSessions: IAgentSession[] = []; - const unknownKey = '\x00unknown'; - const unknownLabel = localize('agentSessions.noRepository', "Other"); + const otherSessions: IAgentSession[] = []; for (const session of sortedSessions) { if (session.isArchived()) { @@ -912,19 +908,17 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou continue; } - const repoName = this.getRepositoryName(session); - if (!repoName) { - this.logService?.warn('[AgentSessions] Could not determine repository name for session, categorizing as "Other"', JSON.stringify(session)); + const repoName = getRepositoryName(session); + if (repoName) { + let group = repoMap.get(repoName); + if (!group) { + group = { label: repoName, sessions: [] }; + repoMap.set(repoName, group); + } + group.sessions.push(session); + } else { + otherSessions.push(session); } - const repoId = repoName || unknownKey; - const repoLabel = repoName || unknownLabel; - - let group = repoMap.get(repoId); - if (!group) { - group = { label: repoLabel, sessions: [] }; - repoMap.set(repoId, group); - } - group.sessions.push(session); } const result: AgentSessionListItem[] = []; @@ -945,6 +939,14 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou }); } + if (otherSessions.length > 0) { + result.push({ + section: AgentSessionSection.Repository, + label: localize('agentSessions.noRepository', "Other"), + sessions: otherSessions, + }); + } + if (archivedSessions.length > 0) { result.push({ section: AgentSessionSection.Archived, @@ -955,10 +957,6 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou return result; } - - private getRepositoryName(session: IAgentSession): string | undefined { - return getRepositoryName(session); - } } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index f5403958562..434ff42e758 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -951,6 +951,37 @@ suite('AgentSessionsDataSource', () => { assert.deepStrictEqual(result.map(s => s.label), ['vscode']); }); + + test('Other group appears after named repos and before Archived', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: 'no-repo', startTime: now }), + createMockSession({ id: 'repo-a', startTime: now - 1, metadata: { repositoryPath: '/path/alpha' } }), + createMockSession({ id: 'archived', startTime: now - 2, isArchived: true }), + createMockSession({ id: 'repo-b', startTime: now - 3, metadata: { repositoryPath: '/path/beta' } }), + createMockSession({ id: 'no-repo-2', startTime: now - 4 }), + ]; + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter())); + const result = getSectionsFromResult(dataSource.getChildren(createMockModel(sessions))); + + const labels = result.map(s => s.label); + const otherIndex = labels.indexOf('Other'); + const archivedIndex = labels.indexOf('Archived'); + + // Other must exist and contain the 2 sessions without repo info + assert.ok(otherIndex !== -1, 'Other section should be present'); + assert.strictEqual(result[otherIndex].sessions.length, 2); + + // Other must come after all named repo groups + for (let i = 0; i < otherIndex; i++) { + assert.strictEqual(result[i].section, AgentSessionSection.Repository, `section at index ${i} should be a named repository group`); + } + + // Archived must come after Other + assert.ok(archivedIndex > otherIndex, 'Archived section should come after Other'); + }); }); suite('getRepositoryName', () => { From 9ddc847ccfa2f30221ab4b46b4e8f307e3bd6669 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 12:49:13 -0600 Subject: [PATCH 043/183] Add recent repository label tracking to agent sessions control --- .../agentSessions/agentSessionsControl.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1130ad7925e..f8d7b8f2448 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -13,7 +13,7 @@ import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; -import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; import { AgentSessionsGrouping } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; @@ -80,8 +80,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private emptyFilterMessage: HTMLElement | undefined; private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; + private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; + private sessionsListFindIsOpen = false; private _isProgrammaticCollapseChange = false; + private readonly _recentRepositoryLabels = new Set(); private readonly updateSessionsListThrottler = this._register(new Throttler()); @@ -238,6 +241,9 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo if (element.section === AgentSessionSection.Yesterday && this.hasTodaySessions()) { return true; // Also collapse Yesterday when there are sessions from Today } + if (element.section === AgentSessionSection.Repository && !this._recentRepositoryLabels.has(element.label)) { + return true; // Collapse repository sections that don't contain recent sessions + } } } @@ -305,6 +311,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); + this.computeRecentRepositoryLabels(); list.setInput(model); this._register(list.onDidOpen(e => this.openAgentSession(e))); @@ -376,6 +383,22 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo ); } + private computeRecentRepositoryLabels(): void { + this._recentRepositoryLabels.clear(); + + const sessions = this.agentSessionsService.model.sessions + .filter(s => !s.isArchived() && !s.isPinned()) + .sort((a, b) => b.timing.created - a.timing.created) + .slice(0, AgentSessionsControl.RECENT_SESSIONS_FOR_EXPAND); + + for (const session of sessions) { + const name = getRepositoryName(session); + if (name) { + this._recentRepositoryLabels.add(name); + } + } + } + private async openAgentSession(e: IOpenEvent): Promise { const element = e.element; if (!element || isAgentSessionSection(element)) { @@ -511,6 +534,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo async update(): Promise { return this.updateSessionsListThrottler.queue(async () => { + this.computeRecentRepositoryLabels(); await this.sessionsList?.updateChildren(); this._onDidUpdate.fire(); From bf5f2dc920ab417eb27e3b938e487cbc1575dfbf Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 20:06:52 +0100 Subject: [PATCH 044/183] docs - update copilot instructions for event usage (#303252) --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d56465c45a..54502973051 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -145,6 +145,7 @@ function f(x: number, y: string): void { } - You MUST NOT use storage keys of another component only to make changes to that component. You MUST come up with proper API to change another component. - Use `IEditorService` to open editors instead of `IEditorGroupsService.activeGroup.openEditor` to ensure that the editor opening logic is properly followed and to avoid bypassing important features such as `revealIfOpened` or `preserveFocus`. - Avoid using `bind()`, `call()` and `apply()` solely to control `this` or partially apply arguments; prefer arrow functions or closures to capture the necessary context, and use these methods only when required by an API or interoperability. +- Avoid using events to drive control flow between components. Instead, prefer direct method calls or service interactions to ensure clearer dependencies and easier traceability of logic. Events should be reserved for broadcasting state changes or notifications rather than orchestrating behavior across components. ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. From bb5aae30e3fe6e4daeb9cf3b5d6927991b1a6710 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 19 Mar 2026 12:08:15 -0700 Subject: [PATCH 045/183] plugins: fix a bunch of issues in customizations (#303270) - Adds a "Show Plugin" option on every plugin-contributed item in the customizations view (closes #302514) - Fixes MCP servers from plugins showing under "built-in" - Fixes "Show Configuration" on plugin MCP servers opening a not found file - Fixes hooks from plugins not showing in the customizations list. A bit of a special case here because hooks get parsed from plugin manifests and have special interpolation logic. --- .../aiCustomizationListWidget.ts | 72 ++++++- .../aiCustomizationManagement.contribution.ts | 109 +++++++--- .../aiCustomizationManagement.ts | 5 + .../aiCustomizationManagementEditor.ts | 37 +++- .../browser/aiCustomization/mcpListWidget.ts | 201 ++++++++++++------ .../chat/common/plugins/agentPluginService.ts | 3 + .../common/plugins/agentPluginServiceImpl.ts | 46 ++-- .../service/promptsServiceImpl.ts | 14 +- .../service/promptsService.test.ts | 4 + .../common/discovery/pluginMcpDiscovery.ts | 12 +- 10 files changed, 380 insertions(+), 123 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 67186ad4470..46a7200b5cb 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -10,7 +10,7 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl import { Emitter, Event } from '../../../../../base/common/event.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ResourceSet } from '../../../../../base/common/map.js'; @@ -22,7 +22,8 @@ import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../. 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, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY } from './aiCustomizationManagement.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.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'; @@ -92,6 +93,8 @@ export interface IAICustomizationListItem { readonly disabled: boolean; /** When set, overrides `storage` for display grouping purposes. */ readonly groupKey?: string; + /** URI of the parent plugin, when this item comes from an installed plugin. */ + readonly pluginUri?: URI; nameMatches?: IMatch[]; descriptionMatches?: IMatch[]; } @@ -260,6 +263,7 @@ class AICustomizationItemRenderer implements IListRenderer { const uriLabel = this.labelService.getUriLabel(element.uri, { relative: false }); + let content = `${element.name}\n${uriLabel}`; + const plugin = element.pluginUri && this.agentPluginService.plugins.get().find(p => isEqual(p.uri, element.pluginUri)); + if (plugin) { + content += `\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`; + } return { - content: `${element.name}\n${uriLabel}`, + content, appearance: { compact: true, skipFadeInAnimation: true, @@ -357,15 +366,20 @@ class AICustomizationItemRenderer implements IListRenderer { + const agentPluginService = accessor.get(IAgentPluginService); + const editorService = accessor.get(IEditorService); + + const pluginUri = extractPluginUri(context); + if (!pluginUri) { + return; + } + const plugin = agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUri.toString()); + if (!plugin) { + return; + } + + const item = { + kind: AgentPluginItemKind.Installed as const, + name: plugin.label, + description: plugin.fromMarketplace?.description ?? '', + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + + // Try to show within the active AI Customization editor (with back navigation) + const input = AICustomizationManagementEditorInput.getOrCreate(); + const pane = await editorService.openEditor(input, { pinned: true }); + if (pane instanceof AICustomizationManagementEditor) { + await pane.showPluginDetail(item); + } + } +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: SHOW_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('showPlugin', "Show Plugin") }, + group: '1_open', + order: 2, + when: WHEN_ITEM_IS_PLUGIN, +}); + // Disable item action const DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID = 'aiCustomizationManagement.disableItem'; registerAction2(class extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 008c653a8b0..d997ccb513b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -87,6 +87,11 @@ export const AI_CUSTOMIZATION_ITEM_STORAGE_KEY = 'aiCustomizationManagementItemS */ export const AI_CUSTOMIZATION_ITEM_URI_KEY = 'aiCustomizationManagementItemUri'; +/** + * Context key for the parent plugin URI, set when the item is provided by a plugin. + */ +export const AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY = 'aiCustomizationManagementItemPluginUri'; + /** * Context key indicating whether the item is disabled. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 55b98a33a88..4b3abe9ab75 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -287,6 +287,8 @@ export class AICustomizationManagementEditor extends EditorPane { private pluginDetailContainer: HTMLElement | undefined; private embeddedPluginEditor: AgentPluginEditor | undefined; private readonly pluginDetailDisposables = this._register(new DisposableStore()); + /** Section to restore when navigating back from plugin detail (when opened from a non-plugin section). */ + private pluginDetailReturnSection: AICustomizationManagementSection | undefined; private dimension: DOM.Dimension | undefined; private readonly sections: ISectionItem[] = []; @@ -775,6 +777,10 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorDisposables.add(this.mcpListWidget.onDidSelectServer(server => { this.showEmbeddedMcpDetail(server); })); + + this.editorDisposables.add(this.mcpListWidget.onDidRequestShowPlugin(item => { + this.showPluginDetail(item); + })); } // Container for Plugins content @@ -788,6 +794,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.createEmbeddedPluginDetail(); this.editorDisposables.add(this.pluginListWidget.onDidSelectPlugin(item => { + this.pluginDetailReturnSection = undefined; this.showEmbeddedPluginDetail(item); })); } @@ -1794,16 +1801,40 @@ export class AICustomizationManagementEditor extends EditorPane { } } + /** + * Public method to show a plugin detail from any section (e.g. from "Show Plugin" context menu). + * Saves the current section so the back button returns the user to it. + */ + public async showPluginDetail(item: IAgentPluginItem): Promise { + if (this.selectedSection !== AICustomizationManagementSection.Plugins) { + this.pluginDetailReturnSection = this.selectedSection; + } + await this.showEmbeddedPluginDetail(item); + } + private goBackFromPluginDetail(): void { this.pluginDetailDisposables.clear(); this.embeddedPluginEditor?.clearInput(); - this.viewMode = 'list'; - this.updateContentVisibility(); + + const returnSection = this.pluginDetailReturnSection; + this.pluginDetailReturnSection = undefined; + + if (returnSection) { + // Return to the section the user was on before opening the plugin detail. + // selectSection may early-return when the section hasn't changed, so always + // ensure viewMode and content visibility are updated. + this.viewMode = 'list'; + this.updateContentVisibility(); + this.selectSection(returnSection); + } else { + this.viewMode = 'list'; + this.updateContentVisibility(); + this.pluginListWidget?.focusSearch(); + } if (this.dimension) { this.layout(this.dimension); } - this.pluginListWidget?.focusSearch(); } //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 4565a981675..2dddce3c0f8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -16,7 +16,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService, IMcpServer } from '../../../../contrib/mcp/common/mcpTypes.js'; import { isContributionDisabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; import { autorun } from '../../../../../base/common/observable.js'; @@ -31,23 +31,32 @@ import { getContextMenuActions } from '../../../../contrib/mcp/browser/mcpServer import { LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { workspaceIcon, userIcon, mcpServerIcon, builtinIcon, extensionIcon } from './aiCustomizationIcons.js'; +import { workspaceIcon, userIcon, mcpServerIcon, builtinIcon, pluginIcon, extensionIcon } from './aiCustomizationIcons.js'; import { formatDisplayName, truncateToFirstSentence } from './aiCustomizationListWidget.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, CustomizationHarness } from '../../common/customizationHarnessService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; +import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; const $ = DOM.$; const MCP_ITEM_HEIGHT = 36; +const PLUGIN_COLLECTION_PREFIX = 'plugin.'; + +function getPluginUriFromCollectionId(collectionId: string | undefined): string | undefined { + return collectionId?.startsWith(PLUGIN_COLLECTION_PREFIX) ? collectionId.slice(PLUGIN_COLLECTION_PREFIX.length) : undefined; +} + /** * Represents a collapsible group header in the MCP server list. */ interface IMcpGroupHeaderEntry extends ICustomizationGroupHeaderEntry { - readonly scope: LocalMcpServerScope | 'builtin' | 'extension'; + readonly scope: LocalMcpServerScope | 'builtin' | 'plugin' | 'extension'; } /** @@ -116,6 +125,7 @@ class McpServerItemRenderer implements IListRenderer { + const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); + if (plugin) { + return { + content: `${element.label}\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`, + appearance: { compact: true, skipFadeInAnimation: true }, + }; + } + return { content: element.label, appearance: { compact: true, skipFadeInAnimation: true } }; + })); + } return; } @@ -328,6 +353,9 @@ export class McpListWidget extends Disposable { private readonly _onDidChangeItemCount = this._register(new Emitter()); readonly onDidChangeItemCount = this._onDidChangeItemCount.event; + private readonly _onDidRequestShowPlugin = this._register(new Emitter()); + readonly onDidRequestShowPlugin = this._onDidRequestShowPlugin.event; + private sectionHeader!: HTMLElement; private sectionDescription!: HTMLElement; private sectionLink!: HTMLAnchorElement; @@ -357,6 +385,7 @@ export class McpListWidget extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, @IMcpService private readonly mcpService: IMcpService, + @IMcpRegistry private readonly mcpRegistry: IMcpRegistry, @ICommandService private readonly commandService: ICommandService, @IOpenerService private readonly openerService: IOpenerService, @IContextViewService private readonly contextViewService: IContextViewService, @@ -708,64 +737,100 @@ export class McpListWidget extends Disposable { isFirst = false; } - // Add extension-provided and built-in servers - if (builtinServers.length > 0) { - const extensionServers = builtinServers.filter(s => s.collection.id.startsWith('ext.')); - const otherBuiltinServers = builtinServers.filter(s => !s.collection.id.startsWith('ext.')); - - if (extensionServers.length > 0) { - const collapsed = this.collapsedGroups.has('extension'); - entries.push({ - type: 'group-header', - id: 'mcp-group-extension', - scope: 'extension', - label: localize('extensionGroup', "Extensions"), - icon: extensionIcon, - count: extensionServers.length, - isFirst, - description: localize('extensionGroupDescription', "MCP servers contributed by installed VS Code extensions."), - collapsed, - }); - if (!collapsed) { - for (const server of extensionServers) { - entries.push({ - type: 'builtin-item', - id: `builtin-${server.definition.id}`, - label: server.definition.label, - description: '', - collectionId: server.collection.id, - }); - } - } - isFirst = false; + // Add plugin-provided, extension-provided, and built-in servers + const collectionSources = new Map(this.mcpRegistry.collections.get().map(c => [c.id, c.source])); + const pluginServers: IMcpServer[] = []; + const extensionServers: IMcpServer[] = []; + const otherBuiltinServers: IMcpServer[] = []; + for (const server of builtinServers) { + if (server.collection.id.startsWith(PLUGIN_COLLECTION_PREFIX)) { + pluginServers.push(server); + } else if (collectionSources.get(server.collection.id) instanceof ExtensionIdentifier) { + extensionServers.push(server); + } else { + otherBuiltinServers.push(server); } + } - if (otherBuiltinServers.length > 0) { - const collapsed = this.collapsedGroups.has('builtin'); - entries.push({ - type: 'group-header', - id: 'mcp-group-builtin', - scope: 'builtin', - label: localize('builtInGroup', "Built-in"), - icon: builtinIcon, - count: otherBuiltinServers.length, - isFirst, - description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), - collapsed, - }); - if (!collapsed) { - for (const server of otherBuiltinServers) { - entries.push({ - type: 'builtin-item', - id: `builtin-${server.definition.id}`, - label: server.definition.label, - description: '', - collectionId: server.collection.id, - }); - } + if (pluginServers.length > 0) { + const collapsed = this.collapsedGroups.has('plugin'); + entries.push({ + type: 'group-header', + id: 'mcp-group-plugin', + scope: 'plugin', + label: localize('pluginGroup', "Plugins"), + icon: pluginIcon, + count: pluginServers.length, + isFirst, + description: localize('pluginGroupDescription', "MCP servers provided by installed plugins."), + collapsed, + }); + if (!collapsed) { + for (const server of pluginServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); } - isFirst = false; } + isFirst = false; + } + + if (extensionServers.length > 0) { + const collapsed = this.collapsedGroups.has('extension'); + entries.push({ + type: 'group-header', + id: 'mcp-group-extension', + scope: 'extension', + label: localize('extensionGroup', "Extensions"), + icon: extensionIcon, + count: extensionServers.length, + isFirst, + description: localize('extensionGroupDescription', "MCP servers contributed by installed VS Code extensions."), + collapsed, + }); + if (!collapsed) { + for (const server of extensionServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); + } + } + isFirst = false; + } + + if (otherBuiltinServers.length > 0) { + const collapsed = this.collapsedGroups.has('builtin'); + entries.push({ + type: 'group-header', + id: 'mcp-group-builtin', + scope: 'builtin', + label: localize('builtInGroup', "Built-in"), + icon: builtinIcon, + count: otherBuiltinServers.length, + isFirst, + description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), + collapsed, + }); + if (!collapsed) { + for (const server of otherBuiltinServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); + } + } + isFirst = false; } this.displayEntries = entries; @@ -861,16 +926,32 @@ export class McpListWidget extends Disposable { // Plugin-provided builtin items get an "Uninstall Plugin" context menu if (e.element.type === 'builtin-item') { const collectionId = e.element.collectionId; - if (!collectionId?.startsWith('plugin.')) { + const pluginUriStr = getPluginUriFromCollectionId(collectionId); + if (!pluginUriStr) { return; } - const pluginUriStr = collectionId.slice('plugin.'.length); const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); if (!plugin) { return; } const disposables = new DisposableStore(); + const showPluginAction = disposables.add(new Action( + 'mcpServer.showPlugin', + localize('showPlugin', "Show Plugin"), + undefined, + true, + async () => { + const item = { + kind: AgentPluginItemKind.Installed as const, + name: plugin.label, + description: plugin.fromMarketplace?.description ?? '', + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + this._onDidRequestShowPlugin.fire(item); + } + )); const uninstallAction = disposables.add(new Action( 'mcpServer.uninstallPlugin', localize('uninstallPlugin', "Uninstall Plugin"), @@ -891,7 +972,7 @@ export class McpListWidget extends Disposable { this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, - getActions: () => [uninstallAction], + getActions: () => [showPluginAction, uninstallAction], onHide: () => disposables.dispose(), }); return; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 392d86f6139..3f531617166 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -20,6 +20,8 @@ export const IAgentPluginService = createDecorator('agentPl export interface IAgentPluginHook { readonly type: HookType; readonly hooks: readonly IHookCommand[]; + /** URI where this hook is defined -- not unique, multiple hooks may be in a manifest */ + readonly uri: URI; readonly originalId: string; } @@ -46,6 +48,7 @@ export interface IAgentPluginInstruction { export interface IAgentPluginMcpServerDefinition { readonly name: string; readonly configuration: IMcpServerConfiguration; + readonly uri: URI; } export interface IAgentPlugin { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index c044c2d0cd5..e06a82098a0 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -57,11 +57,11 @@ interface IAgentPluginFormatAdapter { readonly hookConfigPath: string; readonly pluginRootToken: string | undefined; readonly pluginRootEnvVar: string | undefined; - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[]; + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[]; } -function mapParsedHooks(parsed: Map): IAgentPluginHook[] { - return [...parsed.entries()].map(([type, { hooks, originalId }]) => ({ type, hooks, originalId })); +function mapParsedHooks(uri: URI, parsed: Map): IAgentPluginHook[] { + return [...parsed.entries()].map(([type, { hooks, originalId }]) => ({ type, uri, hooks, originalId })); } /** @@ -85,9 +85,9 @@ class CopilotPluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService); - return mapParsedHooks(parseCopilotHooks(json, workspaceRoot, userHome)); + return mapParsedHooks(hookURI, parseCopilotHooks(json, workspaceRoot, userHome)); } } @@ -201,7 +201,7 @@ function interpolateMcpPluginRoot( interpolated = remote; } - return { name: def.name, configuration: interpolated }; + return { name: def.name, configuration: interpolated, uri: def.uri }; } /** @@ -211,6 +211,7 @@ function interpolateMcpPluginRoot( * delegates to {@link parseClaudeHooks} for the actual hook resolution. */ function parsePluginRootHooks( + hookURI: URI, json: unknown, pluginUri: URI, userHome: string, @@ -265,7 +266,7 @@ function parsePluginRootHooks( return []; } - return mapParsedHooks(hooks); + return mapParsedHooks(hookURI, hooks); } class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { @@ -279,8 +280,8 @@ class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { - return parsePluginRootHooks(json, pluginUri, userHome, this._workspaceContextService, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + return parsePluginRootHooks(hookURI, json, pluginUri, userHome, this._workspaceContextService, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); } } @@ -295,8 +296,8 @@ class OpenPluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { - return parsePluginRootHooks(json, pluginUri, userHome, this._workspaceContextService, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + return parsePluginRootHooks(hookURI, json, pluginUri, userHome, this._workspaceContextService, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); } } @@ -584,6 +585,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements return result.recomputeInitiallyAndOnChange(store); }; + const manifestUri = joinPath(uri, adapter.manifestPath); const commands = observeComponent('commands', d => this._readMarkdownComponents(d)); const skills = observeComponent('skills', d => this._readSkills(uri, d)); const agents = observeComponent('agents', d => this._readMarkdownComponents(d)); @@ -593,7 +595,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements paths => this._readHooksFromPaths(uri, paths, adapter), async section => { const userHome = (await this._pathService.userHome()).fsPath; - return adapter.parseHooks(section, uri, userHome); + return adapter.parseHooks(manifestUri, section, uri, userHome); }, adapter.hookConfigPath, ); @@ -601,7 +603,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const mcpServerDefinitions = observeComponent( 'mcpServers', paths => this._readMcpDefinitionsFromPaths(paths, uri.fsPath, adapter), - async section => this._parseMcpServerDefinitionMap({ mcpServers: section }, uri.fsPath, adapter), + async section => this._parseMcpServerDefinitionMap(manifestUri, { mcpServers: section }, uri.fsPath, adapter), '.mcp.json', ); @@ -611,7 +613,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements }; const manifestWatcher = this._fileService.createWatcher( - joinPath(uri, adapter.manifestPath), + manifestUri, { recursive: false, excludes: [] }, ); store.add(manifestWatcher); @@ -657,7 +659,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const json = await this._readJsonFile(hookPath); if (json) { try { - return adapter.parseHooks(json, pluginUri, userHome); + return adapter.parseHooks(hookPath, json, pluginUri, userHome); } catch (e) { this._logService.info(`[AgentPluginDiscovery] Failed to parse hooks from ${hookPath.toString()}:`, e); } @@ -672,21 +674,19 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements * server name wins. */ private async _readMcpDefinitionsFromPaths(paths: readonly URI[], pluginFsPath: string, adapter: IAgentPluginFormatAdapter): Promise { - const merged = new Map(); + const merged = new Map(); for (const mcpPath of paths) { const json = await this._readJsonFile(mcpPath); - for (const def of this._parseMcpServerDefinitionMap(json, pluginFsPath, adapter)) { + for (const def of this._parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, adapter)) { if (!merged.has(def.name)) { - merged.set(def.name, def.configuration); + merged.set(def.name, def); } } } - return [...merged.entries()] - .map(([name, configuration]) => ({ name, configuration } satisfies IAgentPluginMcpServerDefinition)) - .sort((a, b) => a.name.localeCompare(b.name)); + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); } - private _parseMcpServerDefinitionMap(raw: unknown, pluginFsPath: string, adapter: IAgentPluginFormatAdapter): IAgentPluginMcpServerDefinition[] { + private _parseMcpServerDefinitionMap(definitionURI: URI, raw: unknown, pluginFsPath: string, adapter: IAgentPluginFormatAdapter): IAgentPluginMcpServerDefinition[] { const mcpServers = resolveMcpServersMap(raw); if (!mcpServers) { return []; @@ -699,7 +699,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements continue; } - let def: IAgentPluginMcpServerDefinition = { name, configuration }; + let def: IAgentPluginMcpServerDefinition = { name, configuration, uri: definitionURI }; if (adapter.pluginRootToken && adapter.pluginRootEnvVar) { def = interpolateMcpPluginRoot(def, pluginFsPath, adapter.pluginRootToken, adapter.pluginRootEnvVar); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index c419bb45c52..521578682ed 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -267,11 +267,23 @@ export class PromptsService extends Disposable implements IPromptsService { this._register(autorun(reader => { const plugins = this.agentPluginService.plugins.read(reader); + const hookFiles: IPluginPromptPath[] = []; for (const plugin of plugins) { if (isContributionEnabled(plugin.enablement.read(reader))) { - plugin.hooks.read(reader); + for (const hook of plugin.hooks.read(reader)) { + hookFiles.push({ + uri: hook.uri, + storage: PromptsStorage.plugin, + type: PromptsType.hook, + name: getCanonicalPluginCommandId(plugin, hook.originalId), + pluginUri: plugin.uri, + }); + } } } + + this._pluginPromptFilesByType.set(PromptsType.hook, hookFiles); + this.cachedFileLocations[PromptsType.hook] = undefined; this._onDidPluginHooksChange.fire(); })); } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 495e54702d1..5924b079206 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -3763,6 +3763,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo from-plugin' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); @@ -3784,6 +3785,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo before' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); @@ -3796,6 +3798,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo after' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }], undefined); const after = await service.getHooks(CancellationToken.None); @@ -3848,6 +3851,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo from-plugin' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 7cb6a6efebd..d175c3a067a 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -43,11 +43,17 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { if (!isContributionEnabled(plugin.enablement.read(reader))) { continue; } + const servers = plugin.mcpServerDefinitions.read(reader); + if (servers.length === 0) { + continue; + } + seen.add(plugin.uri); let collectionState = this._collections.get(plugin.uri); if (!collectionState) { - collectionState = this.createCollectionState(plugin); + // note: all plugin servers are currently defined in the same file + collectionState = this.createCollectionState(plugin, servers[0].uri); this._collections.set(plugin.uri, collectionState); } } @@ -60,7 +66,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { })); } - private createCollectionState(plugin: IAgentPlugin) { + private createCollectionState(plugin: IAgentPlugin, manifestURI: URI) { const collectionId = `plugin.${plugin.uri}`; return this._mcpRegistry.registerCollection({ id: collectionId, @@ -72,7 +78,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { serverDefinitions: plugin.mcpServerDefinitions.map(defs => defs.map(d => this._toServerDefinition(collectionId, d)).filter(isDefined)), presentation: { - origin: plugin.uri, + origin: manifestURI, order: McpCollectionSortOrder.Plugin, }, }); From 21f384ae59c4ae27d51cde65282be04a24bcd11c Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 20:13:21 +0100 Subject: [PATCH 046/183] sessions - allow to create new chat per repository from the section header (#303266) * sessions - allow to create new chat per repository from the section header * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * ci --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatViewPane.ts | 9 +++++ .../sessions/browser/sessions.contribution.ts | 40 +++++++++++++++++++ .../browser/sessionsManagementService.ts | 10 +++++ 3 files changed, 59 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 6845c427983..edb09c2187c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -1095,6 +1095,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } } + setProject(projectUri: URI): void { + const project = new SessionWorkspace(projectUri); + this._workspacePicker.setSelectedProject(project, true); + } + sendQuery(text: string): void { const model = this._editor?.getModel(); if (model) { @@ -1162,6 +1167,10 @@ export class NewChatViewPane extends ViewPane { this._widget?.sendQuery(text); } + setProject(projectUri: URI): void { + this._widget?.setProject(projectUri); + } + override setVisible(visible: boolean): void { super.setVisible(visible); if (visible) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 52bd4417115..14a681d901a 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -15,6 +15,12 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AgentSessionSection, IAgentSessionSection, isAgentSessionSection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { NewChatViewPane, SessionsViewId as NewChatViewId } from '../../chat/browser/newChatViewPane.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -48,3 +54,37 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); + +registerAction2(class NewSessionForRepositoryAction extends Action2 { + + constructor() { + super({ + id: 'agentSessionSection.newSession', + title: localize2('newSessionForRepo', "New Session"), + icon: Codicon.newSession, + menu: [{ + id: MenuId.AgentSessionSectionToolbar, + group: 'navigation', + order: 0, + when: ChatContextKeys.agentSessionSection.isEqualTo(AgentSessionSection.Repository), + }] + }); + } + + async run(accessor: ServicesAccessor, context?: IAgentSessionSection): Promise { + if (!context || !isAgentSessionSection(context) || context.sessions.length === 0) { + return; + } + + const sessionsManagementService = accessor.get(ISessionsManagementService); + const viewsService = accessor.get(IViewsService); + + const repositoryUri = sessionsManagementService.getSessionRepositoryUri(context.sessions[0]); + sessionsManagementService.openNewSessionView(); + + const view = await viewsService.openView(NewChatViewId, true); + if (view instanceof NewChatViewPane && repositoryUri) { + view.setProject(repositoryUri); + } + } +}); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index f243fead347..bbd1cd22998 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -83,6 +83,11 @@ export interface ISessionsManagementService { */ openNewSessionView(): void; + /** + * Returns the repository URI for the given session, if available. + */ + getSessionRepositoryUri(session: IAgentSession): URI | undefined; + /** * Create a pending session object for the given target type. * Local sessions collect options locally; remote sessions notify the extension. @@ -460,6 +465,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.isNewChatSessionContext.set(true); } + getSessionRepositoryUri(session: IAgentSession): URI | undefined { + const [repositoryUri] = this.getRepositoryFromMetadata(session); + return repositoryUri; + } + private setActiveSession(session: IAgentSession | INewSession | undefined): void { let activeSessionItem: IActiveSessionItem | undefined; if (session) { From 1d22de6cf8636a7297fad300be63c584917f43ce Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:21:59 -0700 Subject: [PATCH 047/183] Bug fix: Fix skill load regression (#303277) * skill load regression fix * change to opt-in --- .../contrib/chat/browser/widget/chatWidget.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 21f8316a2f8..ee6806961e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2776,11 +2776,18 @@ export class ChatWidget extends Disposable implements IChatWidget { */ private async _autoAttachInstructions({ attachedContext }: IChatRequestInputOptions): Promise { const contribution = this._lockedAgent ? this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id) : undefined; - if (!contribution?.autoAttachReferences) { + + // For contributed session types, default to false for autoAttachReferences. + const isContributedSession = !!contribution; + const autoAttachEnabled = isContributedSession ? + contribution.autoAttachReferences === true : true; + + if (!autoAttachEnabled) { this.logService.debug(`ChatWidget#_autoAttachInstructions: skipped, autoAttachReferences is disabled`); return; } - this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are always enabled`); + + this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are enabled`); const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined; const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined; const sessionResource = this._viewModel?.model.sessionResource; From f533b7f36552886684b6b4b401408f7009f1ccd9 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 13:24:30 -0600 Subject: [PATCH 048/183] Add command to sessionStart hook for environment setup --- .github/hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json index 4457634963e..3d50f6f21ec 100644 --- a/.github/hooks/hooks.json +++ b/.github/hooks/hooks.json @@ -4,7 +4,7 @@ "sessionStart": [ { "type": "command", - "bash": "" + "bash": "nvm use && npm i" } ], "sessionEnd": [ From 6e247436e9eeeb3fdf51bf5839ab9a693ca8b915 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 13:32:59 -0600 Subject: [PATCH 049/183] Add resetSectionCollapseState method to AgentSessionsControl and invoke it in AgenticSessionsViewPane --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 1 + .../contrib/chat/browser/agentSessions/agentSessions.ts | 2 ++ .../chat/browser/agentSessions/agentSessionsControl.ts | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 86c6a2cc0ba..5400ea7055c 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -238,6 +238,7 @@ export class AgenticSessionsViewPane extends ViewPane { this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); this.isGroupedByRepoKey?.set(this.currentGrouping === AgentSessionsGrouping.Repository); + this.sessionsControl?.resetSectionCollapseState(); this.sessionsControl?.update(); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index f01ebe74835..d5df09ff377 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -171,6 +171,8 @@ export interface IAgentSessionsControl { clearFocus(): void; hasFocusOrSelection(): boolean; + + resetSectionCollapseState(): void; } export const agentSessionReadIndicatorForeground = registerColor( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index f8d7b8f2448..5865e383517 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -218,6 +218,10 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.storageService.store(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); } + resetSectionCollapseState(): void { + this.storageService.remove(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + } + private createList(container: HTMLElement): void { const collapseByDefault = (element: unknown) => { if (isAgentSessionSection(element)) { From 6a8fe54a3d535770255a7208d8c404999564f484 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 13:37:42 -0600 Subject: [PATCH 050/183] Call resetSectionCollapseState on sessionsControl after updating grouping state --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 5400ea7055c..e171f09b5ed 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -238,6 +238,7 @@ export class AgenticSessionsViewPane extends ViewPane { this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); this.isGroupedByRepoKey?.set(this.currentGrouping === AgentSessionsGrouping.Repository); + // TODO: Unsure if this is going to be annoying or helpful so that you can quickly see the active sessions this.sessionsControl?.resetSectionCollapseState(); this.sessionsControl?.update(); } From 9b8ad503fd97c53051866e0b93e198c1eb752e6f Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 13:40:41 -0600 Subject: [PATCH 051/183] Clean up --- .github/hooks/hooks.json | 2 +- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json index 3d50f6f21ec..4457634963e 100644 --- a/.github/hooks/hooks.json +++ b/.github/hooks/hooks.json @@ -4,7 +4,7 @@ "sessionStart": [ { "type": "command", - "bash": "nvm use && npm i" + "bash": "" } ], "sessionEnd": [ diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index e171f09b5ed..d506bb6062c 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -238,7 +238,7 @@ export class AgenticSessionsViewPane extends ViewPane { this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); this.isGroupedByRepoKey?.set(this.currentGrouping === AgentSessionsGrouping.Repository); - // TODO: Unsure if this is going to be annoying or helpful so that you can quickly see the active sessions + // TODO @osortega: Unsure if this is going to be annoying or helpful so that you can quickly see the active sessions this.sessionsControl?.resetSectionCollapseState(); this.sessionsControl?.update(); } From d78a1032e046addf9a795860ec6dcf5b429585ab Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 20:54:08 +0100 Subject: [PATCH 052/183] sessions - action renames (#303291) --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 2 +- .../contrib/chat/browser/agentSessions/agentSessionsFilter.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index cc14bd12263..7d731922dc9 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -293,7 +293,7 @@ registerAction2(class GroupByRepositoryAction extends Action2 { constructor() { super({ id: 'sessionsView.groupByRepository', - title: localize2('groupByRepository', "Group by Repository"), + title: localize2('groupByRepository', "Group by Project"), category: SessionsCategories.Sessions, toggled: IsGroupedByRepositoryContext, menu: [{ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 5cb399a6c87..ca1fb92a7af 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -279,7 +279,7 @@ export class AgentSessionsFilter extends Disposable implements Required Date: Thu, 19 Mar 2026 14:01:38 -0600 Subject: [PATCH 053/183] Include "Other" repo label in recent repository tracking Sessions without a detected repository are grouped under "Other" in repository grouping. Previously, computeRecentRepositoryLabels skipped these sessions, so the "Other" section would always default to collapsed even if it contained recent sessions. Now we add the unknown repository label to the set when getRepositoryName returns undefined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/agentSessions/agentSessionsControl.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 5865e383517..ede2cc48171 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -81,6 +81,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; + private static readonly UNKNOWN_REPOSITORY_LABEL = localize('agentSessions.noRepository', "Other"); private sessionsListFindIsOpen = false; private _isProgrammaticCollapseChange = false; @@ -397,9 +398,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo for (const session of sessions) { const name = getRepositoryName(session); - if (name) { - this._recentRepositoryLabels.add(name); - } + this._recentRepositoryLabels.add(name ?? AgentSessionsControl.UNKNOWN_REPOSITORY_LABEL); } } From b633a098d378299dc8438d00a64cfe9adec2626b Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Thu, 19 Mar 2026 13:04:24 -0700 Subject: [PATCH 054/183] fix: strip outputs from notebook backup when exceeding size limit (#301202) Instead of throwing 'Notebook too large to backup' when notebook outputs exceed the backup size limit, strip outputs from the snapshot to produce a valid (degraded) backup without outputs. This fixes unhandled errors in both the hot-exit backup tracker and the chat editing snapshot system, which both use SnapshotContext.Backup. Previously, the error either propagated unhandled (chat editing) or was silently swallowed resulting in no backup at all (hot-exit). Now callers get a backup that preserves cell source code and metadata, just without the large outputs. --- .../common/model/notebookTextModel.ts | 32 +++++++++++-------- .../test/browser/notebookEditorModel.test.ts | 29 +++++++++-------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts index ec476411052..c4b92d62807 100644 --- a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts +++ b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts @@ -479,7 +479,24 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel cells: [], }; - let outputSize = 0; + // When backing up, if total output size exceeds the limit, strip outputs + // instead of throwing so that callers still get a valid (degraded) snapshot. + let includeOutputs = !transientOptions.transientOutputs; + if (includeOutputs && options.context === SnapshotContext.Backup && options.outputSizeLimit > 0) { + let totalOutputSize = 0; + for (const cell of this.cells) { + for (const output of cell.outputs) { + for (const item of output.outputs) { + totalOutputSize += item.data.byteLength; + } + } + if (totalOutputSize > options.outputSizeLimit) { + includeOutputs = false; + break; + } + } + } + for (const cell of this.cells) { const cellData: ICellDto2 = { cellKind: cell.cellKind, @@ -490,18 +507,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel internalMetadata: cell.internalMetadata }; - if (options.context === SnapshotContext.Backup && options.outputSizeLimit > 0) { - cell.outputs.forEach(output => { - output.outputs.forEach(item => { - outputSize += item.data.byteLength; - }); - }); - if (outputSize > options.outputSizeLimit) { - throw new Error('Notebook too large to backup'); - } - } - - cellData.outputs = !transientOptions.transientOutputs ? cell.outputs : []; + cellData.outputs = includeOutputs ? cell.outputs : []; cellData.metadata = filter(cell.metadata, key => !transientOptions.transientCellMetadata[key]); data.cells.push(cellData); diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts index 9274063cef1..f0c62290570 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts @@ -221,7 +221,7 @@ suite('NotebookFileWorkingCopyModel', function () { } }); - test('Notebooks with outputs beyond the size threshold will throw for backup snapshots', async function () { + test('Notebooks with outputs beyond the size threshold will strip outputs for backup snapshots', async function () { const outputLimit = 100; await configurationService.setUserConfiguration(NotebookSetting.outputBackupSizeLimit, outputLimit * 1.0 / 1024); const largeOutput: IOutputDto = { outputId: '123', outputs: [{ mime: Mimes.text, data: VSBuffer.fromString('a'.repeat(outputLimit + 1)) }] }; @@ -234,16 +234,23 @@ suite('NotebookFileWorkingCopyModel', function () { ); disposables.add(notebook); - let callCount = 0; + let backupCallCount = 0; + let saveCallCount = 0; const model = disposables.add(new NotebookFileWorkingCopyModel( notebook, mockNotebookService(notebook, new class extends mock() { - override options: TransientOptions = { transientOutputs: true, transientDocumentMetadata: {}, transientCellMetadata: { bar: true }, cellContentMetadata: {} }; + override options: TransientOptions = { transientOutputs: false, transientDocumentMetadata: {}, transientCellMetadata: { bar: true }, cellContentMetadata: {} }; override async notebookToData(notebook: NotebookData) { - callCount += 1; - assert.strictEqual(notebook.cells[0].metadata!.foo, 123); - assert.strictEqual(notebook.cells[0].metadata!.bar, undefined); + if (backupCallCount === 0) { + backupCallCount += 1; + // Backup should strip outputs when they exceed the limit + assert.deepStrictEqual(notebook.cells[0].outputs, []); + } else { + saveCallCount += 1; + // Save should still include outputs + assert.strictEqual(notebook.cells[0].outputs.length, 1); + } return VSBuffer.fromString(''); } }, @@ -254,15 +261,11 @@ suite('NotebookFileWorkingCopyModel', function () { logservice )); - try { - await model.snapshot(SnapshotContext.Backup, CancellationToken.None); - assert.fail('Expected snapshot to throw an error for large output'); - } catch (e) { - assert.notEqual(e.code, 'ERR_ASSERTION', e.message); - } + await model.snapshot(SnapshotContext.Backup, CancellationToken.None); + assert.strictEqual(backupCallCount, 1); await model.snapshot(SnapshotContext.Save, CancellationToken.None); - assert.strictEqual(callCount, 1); + assert.strictEqual(saveCallCount, 1); }); From 58c1dd95001a4760a26969e24d8b3c627a2394b0 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 19 Mar 2026 14:06:14 -0600 Subject: [PATCH 055/183] Centralize "Other" label and ensure ordering in repo grouping Move the localized "Other" label into AgentSessionSectionLabels so both groupSessionsByRepository and computeRecentRepositoryLabels share the same string. Ensure the "Other" group always appears after all named repository sections and just above Archived. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentSessionsControl.ts | 5 ++--- .../agentSessions/agentSessionsViewer.ts | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index ede2cc48171..7b68c36611a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -13,7 +13,7 @@ import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; -import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; import { AgentSessionsGrouping } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; @@ -81,7 +81,6 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; - private static readonly UNKNOWN_REPOSITORY_LABEL = localize('agentSessions.noRepository', "Other"); private sessionsListFindIsOpen = false; private _isProgrammaticCollapseChange = false; @@ -398,7 +397,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo for (const session of sessions) { const name = getRepositoryName(session); - this._recentRepositoryLabels.add(name ?? AgentSessionsControl.UNKNOWN_REPOSITORY_LABEL); + this._recentRepositoryLabels.add(name ?? AgentSessionSectionLabels[AgentSessionSection.Repository]); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 6eed12abc64..503ef3668e6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -899,7 +899,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const pinnedSessions: IAgentSession[] = []; const archivedSessions: IAgentSession[] = []; const unknownKey = '\x00unknown'; - const unknownLabel = localize('agentSessions.noRepository', "Other"); + const unknownLabel = AgentSessionSectionLabels[AgentSessionSection.Repository]; for (const session of sortedSessions) { if (session.isArchived()) { @@ -937,7 +937,10 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou }); } - for (const [, { label, sessions }] of repoMap) { + for (const [repoId, { label, sessions }] of repoMap) { + if (repoId === unknownKey) { + continue; // "Other" group is added after all named repos + } result.push({ section: AgentSessionSection.Repository, label, @@ -945,6 +948,15 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou }); } + const unknownGroup = repoMap.get(unknownKey); + if (unknownGroup) { + result.push({ + section: AgentSessionSection.Repository, + label: unknownGroup.label, + sessions: unknownGroup.sessions, + }); + } + if (archivedSessions.length > 0) { result.push({ section: AgentSessionSection.Archived, @@ -1112,6 +1124,7 @@ export const AgentSessionSectionLabels = { [AgentSessionSection.Older]: localize('agentSessions.olderSection', "Older"), [AgentSessionSection.Archived]: localize('agentSessions.archivedSection', "Archived"), [AgentSessionSection.More]: localize('agentSessions.moreSection', "More"), + [AgentSessionSection.Repository]: localize('agentSessions.noRepository', "Other"), }; const DAY_THRESHOLD = 24 * 60 * 60 * 1000; From 750a39f9eae4a3cd11c623e744a0a2c5805d2ae7 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Mar 2026 21:13:21 +0100 Subject: [PATCH 056/183] sessions - viewer tweaks (#303298) --- .../contrib/sessions/browser/media/sessionsViewPane.css | 5 ----- .../chat/browser/agentSessions/media/agentsessionsviewer.css | 1 + 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 1666be70127..4c96289ccb3 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -93,10 +93,5 @@ .agent-sessions-control-container { flex: 1; overflow: hidden; - - /* Override section header padding to align with dot indicators */ - .agent-session-section { - padding-left: 12px; - } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index d72760c92a1..7b7361103e7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -310,6 +310,7 @@ font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground); + text-transform: uppercase; /* align with session item padding */ padding: 0 6px; From 38742b5a615a1bc0a2a258953235893b0a50231b Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:20:55 -0700 Subject: [PATCH 057/183] Browser Quick Open / Tab Management (#303058) * Browser Quick Open * feedback, toolbar * Feedback, close all --- .../browserView/common/browserView.ts | 20 +- .../common/browserViewTelemetry.ts | 4 + .../electron-browser/browserEditor.ts | 20 +- .../browserView.contribution.ts | 72 +-- .../electron-browser/browserViewActions.ts | 91 +--- .../features/browserTabManagementFeatures.ts | 487 ++++++++++++++++++ 6 files changed, 520 insertions(+), 174 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index cb5a7fb8017..1cf026d1c75 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -10,21 +10,37 @@ import { localize } from '../../../nls.js'; const commandPrefix = 'workbench.action.browser'; export enum BrowserViewCommandId { + // Tab management Open = `${commandPrefix}.open`, NewTab = `${commandPrefix}.newTab`, + QuickOpen = `${commandPrefix}.quickOpen`, + CloseAll = `${commandPrefix}.closeAll`, + CloseAllInGroup = `${commandPrefix}.closeAllInGroup`, + + // Navigation GoBack = `${commandPrefix}.goBack`, GoForward = `${commandPrefix}.goForward`, Reload = `${commandPrefix}.reload`, HardReload = `${commandPrefix}.hardReload`, + + // Editor actions FocusUrlInput = `${commandPrefix}.focusUrlInput`, + OpenExternal = `${commandPrefix}.openExternal`, + OpenSettings = `${commandPrefix}.openSettings`, + + // Chat actions AddElementToChat = `${commandPrefix}.addElementToChat`, AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, + + // Dev Tools ToggleDevTools = `${commandPrefix}.toggleDevTools`, - OpenExternal = `${commandPrefix}.openExternal`, + + // Storage ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, - OpenSettings = `${commandPrefix}.openSettings`, + + // Find in page ShowFind = `${commandPrefix}.showFind`, HideFind = `${commandPrefix}.hideFind`, FindNext = `${commandPrefix}.findNext`, diff --git a/src/vs/platform/browserView/common/browserViewTelemetry.ts b/src/vs/platform/browserView/common/browserViewTelemetry.ts index 66853e50999..0f5037b877b 100644 --- a/src/vs/platform/browserView/common/browserViewTelemetry.ts +++ b/src/vs/platform/browserView/common/browserViewTelemetry.ts @@ -17,6 +17,10 @@ export type IntegratedBrowserOpenSource = /** Opened via the "Open Integrated Browser" command with a URL argument. * This typically means another extension or component invoked the command programmatically. */ | 'commandWithUrl' + /** Opened via the quick open feature with no initial URL. */ + | 'quickOpenWithoutUrl' + /** Opened via the quick open feature with an initial URL. */ + | 'quickOpenWithUrl' /** Opened via the "New Tab" command from an existing tab. */ | 'newTabCommand' /** Opened via the localhost link opener when the `workbench.browser.openLocalhostLinks` setting diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 26ce86842e3..004f4829eb9 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -507,6 +507,14 @@ export class BrowserEditor extends EditorPane { }))); } + override focus(): void { + if (this._model?.url && !this._model.error) { + void this._model.focus(); + } else { + this.focusUrlInput(); + } + } + override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); if (token.isCancellationRequested) { @@ -541,18 +549,6 @@ export class BrowserEditor extends EditorPane { }); this.setBackgroundImage(this._model.screenshot); - if (!options?.preserveFocus) { - setTimeout(() => { - if (this._model === model) { - if (this._model.url) { - this._browserContainer.focus(); - } else { - this.focusUrlInput(); - } - } - }, 0); - } - // Start / stop screenshots when the model visibility changes this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 6bf1b7da480..ff65b1dd6a8 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -11,7 +11,6 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { BrowserEditor } from './browserEditor.js'; import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; @@ -23,20 +22,12 @@ import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../common/ import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; import { BrowserViewCDPService } from './browserViewCDPService.js'; import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; -import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; // Register actions and browser features import './browserViewActions.js'; import './features/browserEditorChatFeatures.js'; import './features/browserEditorZoomFeature.js'; +import './features/browserTabManagementFeatures.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( @@ -103,73 +94,12 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { registerWorkbenchContribution2(BrowserEditorResolverContribution.ID, BrowserEditorResolverContribution, WorkbenchPhase.BlockStartup); -/** - * Opens localhost URLs in the Integrated Browser when the setting is enabled. - */ -class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IExternalOpener { - static readonly ID = 'workbench.contrib.localhostLinkOpener'; - - constructor( - @IOpenerService openerService: IOpenerService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IEditorService private readonly editorService: IEditorService, - @ITelemetryService private readonly telemetryService: ITelemetryService - ) { - super(); - - this._register(openerService.registerExternalOpener(this)); - } - - async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { - if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { - return false; - } - - try { - const parsed = new URL(href); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return false; - } - if (!isLocalhostAuthority(parsed.host)) { - return false; - } - } catch { - return false; - } - - logBrowserOpen(this.telemetryService, 'localhostLinkOpener'); - - const browserUri = BrowserViewUri.forId(generateUuid()); - await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href } } }); - return true; - } -} - -registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); - registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); registerSingleton(IBrowserViewCDPService, BrowserViewCDPService, InstantiationType.Delayed); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ ...workbenchConfigurationNodeBase, properties: { - 'workbench.browser.showInTitleBar': { - type: 'boolean', - default: false, - experiment: { mode: 'startup' }, - description: localize( - { comment: ['This is the description for a setting.'], key: 'browser.showInTitleBar' }, - 'Controls whether the Integrated Browser button is shown in the title bar.' - ) - }, - 'workbench.browser.openLocalhostLinks': { - type: 'boolean', - default: false, - markdownDescription: localize( - { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, - 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' - ) - }, 'workbench.browser.dataStorage': { type: 'string', enum: [ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 46011e85290..04a101b58b3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -3,25 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from '../../../../nls.js'; +import { localize2 } from '../../../../nls.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { Action2, registerAction2, MenuId } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; -import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; -import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { ToggleTitleBarConfigAction } from '../../../browser/parts/titlebar/titlebarActions.js'; // Context key expression to check if browser editor is active export const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); @@ -34,80 +29,6 @@ export enum BrowserActionGroup { Settings = '4_settings' } -interface IOpenBrowserOptions { - url?: string; - openToSide?: boolean; -} - -class OpenIntegratedBrowserAction extends Action2 { - constructor() { - super({ - id: BrowserViewCommandId.Open, - title: localize2('browser.openAction', "Open Integrated Browser"), - category: BrowserActionCategory, - icon: Codicon.globe, - f1: true, - menu: { - id: MenuId.TitleBar, - group: 'navigation', - order: 10, - when: ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', true) - } - }); - } - - async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { - const editorService = accessor.get(IEditorService); - const telemetryService = accessor.get(ITelemetryService); - - // Parse arguments - const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); - const resource = BrowserViewUri.forId(generateUuid()); - const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP; - - logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); - - const editorPane = await editorService.openEditor({ resource, options: { viewState: { url: options.url } } }, group); - - // Lock the group when opening to the side - if (options.openToSide && editorPane?.group) { - editorPane.group.lock(true); - } - } -} - -class NewTabAction extends Action2 { - constructor() { - super({ - id: BrowserViewCommandId.NewTab, - title: localize2('browser.newTabAction', "New Tab"), - category: BrowserActionCategory, - f1: true, - precondition: BROWSER_EDITOR_ACTIVE, - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Tabs, - order: 1, - }, - // When already in a browser, Ctrl/Cmd + T opens a new tab - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions - primary: KeyMod.CtrlCmd | KeyCode.KeyT, - } - }); - } - - async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - const editorService = accessor.get(IEditorService); - const telemetryService = accessor.get(ITelemetryService); - const resource = BrowserViewUri.forId(generateUuid()); - - logBrowserOpen(telemetryService, 'newTabCommand'); - - await editorService.openEditor({ resource }); - } -} - class GoBackAction extends Action2 { static readonly ID = BrowserViewCommandId.GoBack; @@ -549,8 +470,6 @@ class BrowserFindPreviousAction extends Action2 { } // Register actions -registerAction2(OpenIntegratedBrowserAction); -registerAction2(NewTabAction); registerAction2(GoBackAction); registerAction2(GoForwardAction); registerAction2(ReloadAction); @@ -566,9 +485,3 @@ registerAction2(ShowBrowserFindAction); registerAction2(HideBrowserFindAction); registerAction2(BrowserFindNextAction); registerAction2(BrowserFindPreviousAction); - -registerAction2(class ToggleBrowserTitleBarButton extends ToggleTitleBarConfigAction { - constructor() { - super('workbench.browser.showInTitleBar', localize('toggle.browser', 'Integrated Browser'), localize('toggle.browserDescription', "Toggle visibility of the Integrated Browser button in title bar"), 8); - } -}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts new file mode 100644 index 00000000000..3f2e4e6365e --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -0,0 +1,487 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; +import { IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js'; +import { EditorsOrder, GroupIdentifier } from '../../../../common/editor.js'; +import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPickSeparator, QuickInputButtonLocation, IQuickPick } from '../../../../../platform/quickinput/common/quickInput.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import { logBrowserOpen } from '../../../../../platform/browserView/common/browserViewTelemetry.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { isLocalhostAuthority } from '../../../../../platform/url/common/trustedDomains.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; + +export const CONTEXT_BROWSER_EDITOR_OPEN = new RawContextKey('browserEditorOpen', false, localize('browser.editorOpen', "Whether any browser editor is currently open")); + +interface IBrowserQuickPickItem extends IQuickPickItem { + groupId: GroupIdentifier; + editor: BrowserEditorInput; +} + +const closeButtonItem: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.close), + tooltip: localize('browser.closeTab', "Close") +}; + +const closeAllButtonItem: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.closeAll), + tooltip: localize('browser.closeAllTabs', "Close All"), + location: QuickInputButtonLocation.Inline +}; + + +/** + * Manages a quick pick that lists all open browser tabs grouped by editor group, + * with close buttons, live updates, and an always-visible "New Integrated Browser Tab" entry. + */ +class BrowserTabQuickPick extends Disposable { + + private readonly _quickPick: IQuickPick; + private readonly _itemListeners = this._register(new DisposableStore()); + + private readonly _openNewTabPick: IBrowserQuickPickItem = { + groupId: -1, + editor: undefined!, + label: localize('browser.openNewTab', "New Integrated Browser Tab"), + iconClass: ThemeIcon.asClassName(Codicon.add), + alwaysShow: true, + }; + + constructor( + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IQuickInputService quickInputService: IQuickInputService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(); + + this._quickPick = this._register(quickInputService.createQuickPick({ useSeparators: true })); + this._quickPick.placeholder = localize('browser.quickOpenPlaceholder', "Select a browser tab or enter a URL"); + this._quickPick.matchOnDescription = true; + this._quickPick.sortByLabel = false; + this._quickPick.buttons = [closeAllButtonItem]; + + this._register(this._quickPick.onDidTriggerItemButton(async ({ item }) => { + if (!item.editor) { + return; + } + const group = this._editorGroupsService.getGroup(item.groupId); + if (group) { + await group.closeEditor(item.editor, { + preserveFocus: true // Don't shift focus so the quickpick doesn't close + }); + } + })); + + this._register(this._quickPick.onDidTriggerButton(async () => { + for (const group of this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + const browserEditors = group.editors.filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors, { + preserveFocus: true // Don't shift focus so the quickpick doesn't close + }); + } + } + })); + + this._register(this._quickPick.onDidAccept(async () => { + const [selected] = this._quickPick.selectedItems; + if (!selected) { + return; + } + if (selected === this._openNewTabPick) { + logBrowserOpen(telemetryService, 'quickOpenWithoutUrl'); + await this._editorService.openEditor({ + resource: BrowserViewUri.forId(generateUuid()), + }); + } else { + await this._editorService.openEditor(selected.editor, selected.groupId); + } + })); + + this._register(this._quickPick.onDidHide(() => this.dispose())); + } + + show(): void { + this._buildItems(); + + // Pre-select the currently active browser editor + const activeEditor = this._editorService.activeEditor; + if (activeEditor instanceof BrowserEditorInput) { + const activePick = (this._quickPick.items as readonly (IBrowserQuickPickItem | IQuickPickSeparator)[]) + .find((item): item is IBrowserQuickPickItem => item.type !== 'separator' && item.editor === activeEditor); + if (activePick) { + this._quickPick.activeItems = [activePick]; + } + } + + this._quickPick.show(); + } + + private _buildItems(): void { + this._itemListeners.clear(); + + // Remember which editor was active so we can restore selection + const activeEditor = this._quickPick.activeItems[0]?.editor; + + const picks: (IBrowserQuickPickItem | IQuickPickSeparator)[] = []; + const groups = this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE); + + const groupsWithBrowserEditors = groups + .map(group => ({ group, browserEditors: group.editors.filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput) })) + .filter(({ browserEditors }) => browserEditors.length > 0); + const multipleGroups = groupsWithBrowserEditors.length > 1; + + // Build a map of group ID to aria label for screen readers + const mapGroupIdToGroupAriaLabel = new Map(); + for (const { group } of groupsWithBrowserEditors) { + mapGroupIdToGroupAriaLabel.set(group.id, group.ariaLabel); + } + + let newActivePick: IBrowserQuickPickItem | undefined; + + for (const { group, browserEditors } of groupsWithBrowserEditors) { + if (multipleGroups) { + picks.push({ type: 'separator', label: group.label }); + } + for (const editor of browserEditors) { + const icon = editor.getIcon(); + const description = editor.getDescription(); + const nameAndDescription = description ? `${editor.getName()} ${description}` : editor.getName(); + const pick: IBrowserQuickPickItem = { + groupId: group.id, + editor, + label: editor.getName(), + ariaLabel: multipleGroups + ? localize('browserEntryAriaLabelWithGroup', "{0}, {1}", nameAndDescription, mapGroupIdToGroupAriaLabel.get(group.id)) + : nameAndDescription, + description, + buttons: [closeButtonItem], + italic: !group.isPinned(editor), + }; + if (icon instanceof URI) { + pick.iconPath = { dark: icon }; + } else if (icon) { + pick.iconClass = ThemeIcon.asClassName(icon); + } + picks.push(pick); + + if (editor === activeEditor) { + newActivePick = pick; + } + + this._itemListeners.add(editor.onDidChangeLabel(() => this._buildItems())); + } + this._itemListeners.add(group.onDidModelChange(() => this._buildItems())); + } + + picks.push({ type: 'separator' }); + picks.push(this._openNewTabPick); + + this._quickPick.keepScrollPosition = true; + this._quickPick.items = picks; + if (newActivePick) { + this._quickPick.activeItems = [newActivePick]; + } + } +} + +class QuickOpenBrowserAction extends Action2 { + constructor() { + const neverShowInTitleBar = ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', false); + super({ + id: BrowserViewCommandId.QuickOpen, + title: localize2('browser.quickOpenAction', "Quick Open Browser Tab..."), + icon: Codicon.globe, + category: BrowserActionCategory, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + // Note: on Linux this conflicts with the "toggle block comment" keybinding. + // it's not as problem at the moment becase oh the `when`, but worth noting for the future. + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + when: BROWSER_EDITOR_ACTIVE + }, + menu: { + id: MenuId.TitleBar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and(CONTEXT_BROWSER_EDITOR_OPEN, neverShowInTitleBar.negate()), + } + }); + } + + run(accessor: ServicesAccessor): void { + const picker = accessor.get(IInstantiationService).createInstance(BrowserTabQuickPick); + picker.show(); + } +} + +interface IOpenBrowserOptions { + url?: string; + openToSide?: boolean; +} + +class OpenIntegratedBrowserAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.Open, + title: localize2('browser.openAction', "Open Integrated Browser"), + category: BrowserActionCategory, + icon: Codicon.globe, + f1: true, + menu: { + id: MenuId.TitleBar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and( + // This is a hack to work around `true` just testing for truthiness of the key. It works since `1 == true` in JS. + ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', 1), + CONTEXT_BROWSER_EDITOR_OPEN.negate() + ) + } + }); + } + + async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { + const editorService = accessor.get(IEditorService); + const telemetryService = accessor.get(ITelemetryService); + + // Parse arguments + const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); + const resource = BrowserViewUri.forId(generateUuid()); + const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP; + + logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); + + const editorPane = await editorService.openEditor({ resource, options: { viewState: { url: options.url } } }, group); + + // Lock the group when opening to the side + if (options.openToSide && editorPane?.group) { + editorPane.group.lock(true); + } + } +} + +class NewTabAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.NewTab, + title: localize2('browser.newTabAction', "New Tab"), + category: BrowserActionCategory, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Tabs, + order: 1, + }, + // When already in a browser, Ctrl/Cmd + T opens a new tab + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions + primary: KeyMod.CtrlCmd | KeyCode.KeyT, + } + }); + } + + async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + const editorService = accessor.get(IEditorService); + const telemetryService = accessor.get(ITelemetryService); + const resource = BrowserViewUri.forId(generateUuid()); + + logBrowserOpen(telemetryService, 'newTabCommand'); + + await editorService.openEditor({ resource }); + } +} + +class CloseAllBrowserTabsAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.CloseAll, + title: localize2('browser.closeAll', "Close All Browser Tabs"), + category: BrowserActionCategory, + f1: true, + precondition: CONTEXT_BROWSER_EDITOR_OPEN, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + for (const group of editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors); + } + } + } +} + +class CloseAllBrowserTabsInGroupAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.CloseAllInGroup, + title: localize2('browser.closeAllInGroup', "Close All Browser Tabs in Group"), + category: BrowserActionCategory, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); + const group = editorGroupsService.getGroup(editorService.activeEditorPane?.group?.id ?? editorGroupsService.activeGroup.id); + if (!group) { + return; + } + const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors); + } + } +} + +// Register as "Close All Browser Tabs" action in editor title menu to align with the regular "Close All" action +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: BrowserViewCommandId.CloseAllInGroup, title: localize('browser.closeAllInGroupShort', "Close All Browser Tabs") }, group: '1_close', order: 55, when: BROWSER_EDITOR_ACTIVE }); + +registerAction2(QuickOpenBrowserAction); +registerAction2(OpenIntegratedBrowserAction); +registerAction2(NewTabAction); +registerAction2(CloseAllBrowserTabsAction); +registerAction2(CloseAllBrowserTabsInGroupAction); + +registerAction2(class ToggleBrowserTitleBarButton extends ToggleTitleBarConfigAction { + constructor() { + super('workbench.browser.showInTitleBar', localize('toggle.browser', 'Integrated Browser'), localize('toggle.browserDescription', "Toggle visibility of the Integrated Browser button in title bar"), 8); + } +}); + +/** + * Tracks whether any browser editor is open across all editor groups and + * keeps the `browserEditorOpen` context key in sync. + */ +class BrowserEditorOpenContextKeyContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.browserEditorOpenContextKey'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IEditorService editorService: IEditorService, + ) { + super(); + + const contextKey = CONTEXT_BROWSER_EDITOR_OPEN.bindTo(contextKeyService); + const update = () => contextKey.set(editorService.editors.some(e => e instanceof BrowserEditorInput)); + + update(); + + this._register(editorService.onWillOpenEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + contextKey.set(true); + } + })); + this._register(editorService.onDidCloseEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + update(); + } + })); + } +} + +registerWorkbenchContribution2(BrowserEditorOpenContextKeyContribution.ID, BrowserEditorOpenContextKeyContribution, WorkbenchPhase.AfterRestored); + +/** + * Opens localhost URLs in the Integrated Browser when the setting is enabled. + */ +class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IExternalOpener { + static readonly ID = 'workbench.contrib.localhostLinkOpener'; + + constructor( + @IOpenerService openerService: IOpenerService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorService private readonly editorService: IEditorService, + @ITelemetryService private readonly telemetryService: ITelemetryService + ) { + super(); + + this._register(openerService.registerExternalOpener(this)); + } + + async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { + if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { + return false; + } + + try { + const parsed = new URL(href); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + if (!isLocalhostAuthority(parsed.host)) { + return false; + } + } catch { + return false; + } + + logBrowserOpen(this.telemetryService, 'localhostLinkOpener'); + + const browserUri = BrowserViewUri.forId(generateUuid()); + await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href } } }); + return true; + } +} + +registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.showInTitleBar': { + type: ['boolean', 'string'], + enum: [true, false, 'whenOpen'], + enumDescriptions: [ + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.true' }, 'The button is always shown in the title bar.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.false' }, 'The button is never shown in the title bar.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.whenOpen' }, 'The button is shown in the title bar when a browser editor is open.') + ], + default: 'whenOpen', + experiment: { mode: 'startup' }, + description: localize( + { comment: ['This is the description for a setting.'], key: 'browser.showInTitleBar' }, + 'Controls whether the Integrated Browser button is shown in the title bar.' + ) + }, + 'workbench.browser.openLocalhostLinks': { + type: 'boolean', + default: false, + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, + 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' + ) + } + } +}); From 1f1587637f42bf31f09b52da6f95b0e1d0fae6a0 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Thu, 19 Mar 2026 13:44:03 -0700 Subject: [PATCH 058/183] fix: handle notebook snapshot failure in chat editing callers (#301202) When NotebookTextModel.createSnapshot() throws 'Notebook too large to backup' (outputs exceed notebook.backup.sizeLimit), catch the error at the caller side instead of letting it propagate unhandled. - chatEditingModifiedNotebookSnapshot.createSnapshot(): Catch error and retry with transientOutputs: true to produce a degraded snapshot without outputs. Logs a warning. - chatEditingModifiedNotebookEntry.create(): Catch error from createNotebookTextDocumentSnapshot with Backup context and fall back to Save context for the one-time original model initialization. Logs a warning. --- .../chatEditingModifiedNotebookEntry.ts | 5 +++++ .../chatEditingModifiedNotebookSnapshot.ts | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 48073f03acf..8fc039c7cd2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -113,6 +113,11 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie const [options, buffer] = await Promise.all([ notebookService.withNotebookDataProvider(resourceRef.object.notebook.notebookType), notebookService.createNotebookTextDocumentSnapshot(notebook.uri, SnapshotContext.Backup, CancellationToken.None).then(s => streamToBuffer(s)) + .catch(e => { + // When backup snapshot fails (e.g. outputs exceed size limit), fall back to Save context. + console.warn('Failed to create notebook snapshot for chat editing, retrying without backup size limit', e); + return notebookService.createNotebookTextDocumentSnapshot(notebook.uri, SnapshotContext.Save, CancellationToken.None).then(s => streamToBuffer(s)); + }) ]); const disposables = new DisposableStore(); // Register so that we can load this from file system. diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts index ad163493543..9bd648d6783 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts @@ -32,7 +32,20 @@ export function parseNotebookSnapshotFileURI(resource: URI): ChatEditingSnapshot export function createSnapshot(notebook: INotebookTextModel, transientOptions: TransientOptions | undefined, outputSizeConfig: IConfigurationService | number): string { const outputSizeLimit = (typeof outputSizeConfig === 'number' ? outputSizeConfig : outputSizeConfig.getValue(NotebookSetting.outputBackupSizeLimit)) * 1024; - return serializeSnapshot(notebook.createSnapshot({ context: SnapshotContext.Backup, outputSizeLimit, transientOptions }), transientOptions); + try { + return serializeSnapshot(notebook.createSnapshot({ context: SnapshotContext.Backup, outputSizeLimit, transientOptions }), transientOptions); + } catch (e) { + // When backup snapshot fails (e.g. outputs exceed size limit), retry without outputs. + // A degraded snapshot is better than crashing the chat editing session. + console.warn('Failed to create notebook snapshot, retrying without outputs', e); + const withoutOutputs: TransientOptions = { + transientOutputs: true, + transientCellMetadata: transientOptions?.transientCellMetadata ?? {}, + transientDocumentMetadata: transientOptions?.transientDocumentMetadata ?? {}, + cellContentMetadata: transientOptions?.cellContentMetadata ?? {}, + }; + return serializeSnapshot(notebook.createSnapshot({ context: SnapshotContext.Backup, outputSizeLimit, transientOptions: withoutOutputs }), withoutOutputs); + } } export function restoreSnapshot(notebook: INotebookTextModel, snapshot: string): void { From 3f4f33b7dac0bb1d8753716d1302836a1d936ac7 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:47:36 -0700 Subject: [PATCH 059/183] Fix close tracking in browser API (#303304) --- .../src/singlefolder-tests/browser.test.ts | 12 +++++++++++- src/vs/workbench/api/browser/mainThreadBrowsers.ts | 9 +++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index fc0bcbb66bf..0791391e6af 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { window, ViewColumn } from 'vscode'; +import { window, commands, ViewColumn } from 'vscode'; import { assertNoRpc, closeAllEditors } from '../utils'; (vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('vscode API - browser', () => { @@ -73,6 +73,16 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.strictEqual(window.browserTabs.length, countBefore - 1); }); + test('Can move a browser tab to a new group and close it successfully', async () => { + const tab = await window.openBrowserTab('about:blank'); + assert.ok(window.browserTabs.includes(tab)); + + await commands.executeCommand('workbench.action.moveEditorToNextGroup'); + + await tab.close(); + assert.ok(!window.browserTabs.includes(tab)); + }); + // #endregion // #region onDidOpenBrowserTab diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts index aad0abf2054..257e0130ab2 100644 --- a/src/vs/workbench/api/browser/mainThreadBrowsers.ts +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -41,11 +41,6 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers this._track(e.editor); } })); - this._register(this.editorService.onDidCloseEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - this._knownBrowsers.deleteAndDispose(e.editor.id); - } - })); this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); // Initial sync @@ -102,9 +97,11 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers this._proxy.$onDidChangeBrowserTabState(input.id, this._toDto(input)); })); disposables.add(input.onWillDispose(() => { - this._proxy.$onDidCloseBrowserTab(input.id); this._knownBrowsers.deleteAndDispose(input.id); })); + disposables.add(toDisposable(() => { + this._proxy.$onDidCloseBrowserTab(input.id); + })); this._knownBrowsers.set(input.id, { input, dispose: () => disposables.dispose() }); this._proxy.$onDidOpenBrowserTab(this._toDto(input)); From 55a8db1b31b80baa9a8840189e2a42c57ff2ebca Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:53:13 +0000 Subject: [PATCH 060/183] Sessions - more changes view cleanup (#303303) * Sessions - more changes view cleanup * Pull request feedback * More changes --- .../contrib/changes/browser/changesView.contribution.ts | 4 ++-- src/vs/sessions/contrib/changes/browser/changesView.ts | 9 ++++++--- .../{toggleChangesView.ts => changesViewController.ts} | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) rename src/vs/sessions/contrib/changes/browser/{toggleChangesView.ts => changesViewController.ts} (97%) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 9da044d818d..386fd6603fc 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts @@ -12,7 +12,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../work import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import './changesViewActions.js'; -import { ToggleChangesViewContribution } from './toggleChangesView.js'; +import { ChangesViewController } from './changesViewController.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -42,4 +42,4 @@ viewsRegistry.registerViews([{ windowVisibility: WindowVisibility.Sessions }], changesViewContainer); -registerWorkbenchContribution2(ToggleChangesViewContribution.ID, ToggleChangesViewContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChangesViewController.ID, ChangesViewController, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 5cfb9a5e66f..946be8667a6 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -342,6 +342,7 @@ export class ChangesViewPane extends ViewPane { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); this.viewModel = this.instantiationService.createInstance(ChangesViewModel); + this._register(this.viewModel); // Version mode this._register(bindContextKey(changesVersionModeContextKey, this.scopedContextKeyService, reader => { @@ -353,7 +354,7 @@ export class ChangesViewPane extends ViewPane { return this.viewModel.viewModeObs.read(reader); })); - // Set chatSessionType on the view's context key service so ViewTitlev menu items + // 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. this._register(bindContextKey(ChatContextKeys.agentSessionType, this.scopedContextKeyService, reader => { @@ -623,8 +624,6 @@ export class ChangesViewPane extends ViewPane { if (this.actionsContainer) { dom.clearNode(this.actionsContainer); - const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { const { files } = topLevelStats.read(reader); return files > 0; @@ -646,6 +645,10 @@ export class ChangesViewPane extends ViewPane { return metadata?.pullRequestUrl !== undefined; })); + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); + const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); + this.renderDisposables.add(scopedInstantiationService); + this.renderDisposables.add(autorun(reader => { const { added, removed } = topLevelStats.read(reader); const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); diff --git a/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts b/src/vs/sessions/contrib/changes/browser/changesViewController.ts similarity index 97% rename from src/vs/sessions/contrib/changes/browser/toggleChangesView.ts rename to src/vs/sessions/contrib/changes/browser/changesViewController.ts index ff3ee68fdcd..95a090d25cc 100644 --- a/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewController.ts @@ -20,9 +20,9 @@ interface IPendingTurnState { readonly submittedAt: number; } -export class ToggleChangesViewContribution extends Disposable { +export class ChangesViewController extends Disposable { - static readonly ID = 'workbench.contrib.toggleChangesView'; + static readonly ID = 'workbench.contrib.changesViewController'; private readonly pendingTurnStateByResource = new ResourceMap(); From caef4ed7e48a2bbd23de8f2345e003cee7499be1 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 19 Mar 2026 22:50:51 +0100 Subject: [PATCH 061/183] fix aiCustomizationListWidget.fixture.ts (#303311) --- .../componentFixtures/aiCustomizationListWidget.fixture.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts index 98b8a3ddf45..1fdac89c3f6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -15,6 +15,7 @@ import { IListService, ListService } from '../../../../platform/list/browser/lis import { IWorkspace, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../contrib/chat/common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath, IExtensionPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; @@ -126,6 +127,9 @@ async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFi reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); reg.defineInstance(ICustomizationHarnessService, createMockHarnessService()); reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = observableValue('plugins', []); + }()); reg.defineInstance(IFileService, new class extends mock() { override readonly onDidFilesChange = Event.None; }()); From b4018e320b8598737d30d21e7ff122ae30fa9fcf Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:56:56 -0700 Subject: [PATCH 062/183] Never use simple browser on desktop (#303312) * Never use simple browser on desktop * Update extensions/simple-browser/package.nls.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/simple-browser/package.json | 14 ++++++++------ extensions/simple-browser/package.nls.json | 3 +-- extensions/simple-browser/src/extension.ts | 7 ------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 59a0c8677fe..bf4dfcb250a 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -42,6 +42,14 @@ "category": "Simple Browser" } ], + "menus": { + "commandPalette": [ + { + "command": "simpleBrowser.show", + "when": "isWeb" + } + ] + }, "configuration": [ { "title": "Simple Browser", @@ -51,12 +59,6 @@ "default": true, "title": "Focus Lock Indicator Enabled", "description": "%configuration.focusLockIndicator.enabled.description%" - }, - "simpleBrowser.useIntegratedBrowser": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.useIntegratedBrowser.description%", - "scope": "application" } } } diff --git a/extensions/simple-browser/package.nls.json b/extensions/simple-browser/package.nls.json index 0b88b068fbc..496dc28dfdd 100644 --- a/extensions/simple-browser/package.nls.json +++ b/extensions/simple-browser/package.nls.json @@ -1,6 +1,5 @@ { "displayName": "Simple Browser", "description": "A very basic built-in webview for displaying web content.", - "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser.", - "configuration.useIntegratedBrowser.description": "When enabled, the `simpleBrowser.show` command will open URLs in the integrated browser instead of the Simple Browser webview. **Note:** This setting is only available on desktop." + "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser." } diff --git a/extensions/simple-browser/src/extension.ts b/extensions/simple-browser/src/extension.ts index 75ee87d4da7..ddcdc52b42d 100644 --- a/extensions/simple-browser/src/extension.ts +++ b/extensions/simple-browser/src/extension.ts @@ -15,7 +15,6 @@ declare class URL { const openApiCommand = 'simpleBrowser.api.open'; const showCommand = 'simpleBrowser.show'; const integratedBrowserCommand = 'workbench.action.browser.open'; -const useIntegratedBrowserSetting = 'simpleBrowser.useIntegratedBrowser'; const enabledHosts = new Set([ 'localhost', @@ -37,12 +36,6 @@ const openerId = 'simpleBrowser.open'; * Checks if the integrated browser should be used instead of the simple browser */ async function shouldUseIntegratedBrowser(): Promise { - const config = vscode.workspace.getConfiguration(); - if (!config.get(useIntegratedBrowserSetting, true)) { - return false; - } - - // Verify that the integrated browser command is available const commands = await vscode.commands.getCommands(true); return commands.includes(integratedBrowserCommand); } From 6a0fe955f7ccb3a2a8ae0cf2ff74a9d63087eeb5 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Thu, 19 Mar 2026 14:59:50 -0700 Subject: [PATCH 063/183] fix: catch notebook snapshot errors in chat editing with telemetry (#301202) When NotebookTextModel.createSnapshot() throws 'Notebook too large to backup' (outputs exceed notebook.backup.sizeLimit), catch the error at the caller side and log a telemetry event instead of letting it propagate unhandled. - chatEditingModifiedNotebookEntry.create(): Catch error from createNotebookTextDocumentSnapshot with Backup context and fall back to Save context for the one-time original model initialization. - chatEditingModifiedNotebookEntry.createSnapshot(): Catch errors from the snapshot utility and fall back to initialContent so that session persistence doesn't crash. - Both paths log 'chatEditing/notebookSnapshotError' telemetry events. --- .../chatEditingModifiedNotebookEntry.ts | 35 +++++++++++++++++-- .../chatEditingModifiedNotebookSnapshot.ts | 15 +------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 8fc039c7cd2..9ab70f300d8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -26,6 +26,7 @@ import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../../../../platform/undoRedo/common/undoRedo.js'; import { IEditorPane, SaveReason } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; @@ -107,6 +108,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie const notebookService = accessor.get(INotebookService); const resolver = accessor.get(INotebookEditorModelResolverService); const configurationServie = accessor.get(IConfigurationService); + const telemetryService = accessor.get(ITelemetryService); const resourceRef: IReference = await resolver.resolve(uri); const notebook = resourceRef.object.notebook; const originalUri = getNotebookSnapshotFileURI(telemetryInfo.sessionResource, telemetryInfo.requestId, generateUuid(), notebook.uri.scheme === Schemas.untitled ? `/${notebook.uri.path}` : notebook.uri.path, notebook.viewType); @@ -115,7 +117,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie notebookService.createNotebookTextDocumentSnapshot(notebook.uri, SnapshotContext.Backup, CancellationToken.None).then(s => streamToBuffer(s)) .catch(e => { // When backup snapshot fails (e.g. outputs exceed size limit), fall back to Save context. - console.warn('Failed to create notebook snapshot for chat editing, retrying without backup size limit', e); + telemetryService.publicLogError2('chatEditing/notebookSnapshotError', { operation: 'create', errorMessage: String(e?.message ?? e) }); return notebookService.createNotebookTextDocumentSnapshot(notebook.uri, SnapshotContext.Save, CancellationToken.None).then(s => streamToBuffer(s)); }) ]); @@ -195,6 +197,7 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie @INotebookLoggingService private readonly loggingService: INotebookLoggingService, @INotebookEditorModelResolverService private readonly notebookResolver: INotebookEditorModelResolverService, @IAiEditTelemetryService aiEditTelemetryService: IAiEditTelemetryService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(modifiedResourceRef.object.notebook.uri, telemetryInfo, kind, configurationService, fileConfigService, chatService, fileService, undoRedoService, instantiationService, aiEditTelemetryService); this.initialContentComparer = new SnapshotComparer(initialContent); @@ -921,12 +924,26 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } override createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry { + let original: string; + let current: string; + try { + original = createSnapshot(this.originalModel, this.transientOptions, this.configurationService); + } catch (e) { + this.telemetryService.publicLogError2('chatEditing/notebookSnapshotError', { operation: 'snapshotOriginal', errorMessage: String(e?.message ?? e) }); + original = this.initialContent; + } + try { + current = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); + } catch (e) { + this.telemetryService.publicLogError2('chatEditing/notebookSnapshotError', { operation: 'snapshotCurrent', errorMessage: String(e?.message ?? e) }); + current = this.initialContent; + } return { resource: this.modifiedURI, languageId: SnapshotLanguageId, snapshotUri: getNotebookSnapshotFileURI(chatSessionResource, requestId, undoStop, this.modifiedURI.path, this.modifiedModel.viewType), - original: createSnapshot(this.originalModel, this.transientOptions, this.configurationService), - current: createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService), + original, + current, state: this.state.get(), telemetryInfo: this.telemetryInfo, }; @@ -1113,3 +1130,15 @@ function generateCellHash(cellUri: URI) { hash.update(cellUri.toString()); return hash.digest().substring(0, 8); } + +type NotebookSnapshotErrorEvent = { + operation: string; + errorMessage: string; +}; + +type NotebookSnapshotErrorClassification = { + operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The snapshot operation that failed (create, snapshotOriginal, snapshotCurrent).' }; + errorMessage: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The error message from the failed snapshot.' }; + owner: 'AamundM'; + comment: 'Tracks notebook snapshot failures in chat editing, e.g. when outputs exceed the backup size limit.'; +}; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts index 9bd648d6783..ad163493543 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/notebook/chatEditingModifiedNotebookSnapshot.ts @@ -32,20 +32,7 @@ export function parseNotebookSnapshotFileURI(resource: URI): ChatEditingSnapshot export function createSnapshot(notebook: INotebookTextModel, transientOptions: TransientOptions | undefined, outputSizeConfig: IConfigurationService | number): string { const outputSizeLimit = (typeof outputSizeConfig === 'number' ? outputSizeConfig : outputSizeConfig.getValue(NotebookSetting.outputBackupSizeLimit)) * 1024; - try { - return serializeSnapshot(notebook.createSnapshot({ context: SnapshotContext.Backup, outputSizeLimit, transientOptions }), transientOptions); - } catch (e) { - // When backup snapshot fails (e.g. outputs exceed size limit), retry without outputs. - // A degraded snapshot is better than crashing the chat editing session. - console.warn('Failed to create notebook snapshot, retrying without outputs', e); - const withoutOutputs: TransientOptions = { - transientOutputs: true, - transientCellMetadata: transientOptions?.transientCellMetadata ?? {}, - transientDocumentMetadata: transientOptions?.transientDocumentMetadata ?? {}, - cellContentMetadata: transientOptions?.cellContentMetadata ?? {}, - }; - return serializeSnapshot(notebook.createSnapshot({ context: SnapshotContext.Backup, outputSizeLimit, transientOptions: withoutOutputs }), withoutOutputs); - } + return serializeSnapshot(notebook.createSnapshot({ context: SnapshotContext.Backup, outputSizeLimit, transientOptions }), transientOptions); } export function restoreSnapshot(notebook: INotebookTextModel, snapshot: string): void { From ea64ce82683ca8366d73e499332464e095985bca Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:15:39 -0700 Subject: [PATCH 064/183] Try storing all chat session options in a map --- .../browser/sessionsManagementService.ts | 5 +- .../api/browser/mainThreadChatAgents2.ts | 7 +-- .../api/browser/mainThreadChatSessions.ts | 32 ++++++----- .../workbench/api/common/extHost.protocol.ts | 23 ++++---- .../api/common/extHostChatSessions.ts | 22 ++++---- .../browser/mainThreadChatSessions.test.ts | 15 +++--- .../chatSessions/chatSessions.contribution.ts | 44 +++++++-------- .../browser/widget/input/chatInputPart.ts | 2 +- .../chat/common/chatService/chatService.ts | 3 +- .../common/chatService/chatServiceImpl.ts | 5 +- .../chat/common/chatSessionsService.ts | 53 +++++++++++++++---- .../common/chatService/chatService.test.ts | 4 +- .../test/common/mockChatSessionsService.ts | 21 ++++---- 13 files changed, 134 insertions(+), 102 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index bbd1cd22998..1a5a8810b33 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -401,12 +401,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (selectedOptions && selectedOptions.size > 0) { const contributedSession = model.contributedChatSession; if (contributedSession) { - const initialSessionOptions = [...selectedOptions.entries()].map( - ([optionId, value]) => ({ optionId, value }) - ); model.setContributedChatSession({ ...contributedSession, - initialSessionOptions, + initialSessionOptions: selectedOptions, }); } } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index f40d952f332..8ac37889384 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -34,7 +34,7 @@ import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/ch import { ChatRequestParser } from '../../contrib/chat/common/requestParser/chatRequestParser.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../contrib/chat/browser/attachments/chatVariables.js'; import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatService, IChatTask, IChatTaskSerialized, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../contrib/chat/common/constants.js'; import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; @@ -253,10 +253,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA chatSessionContext = { chatSessionResource, isUntitled, - initialSessionOptions: contributedSession.initialSessionOptions?.map(o => ({ - optionId: o.optionId, - value: typeof o.value === 'string' ? o.value : o.value.id, - })), + initialSessionOptions: ChatSessionOptionsMap.toStrValueArray(contributedSession.initialSessionOptions), }; } return await this._proxy.$invokeAgent(handle, request, { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index fef8a35d419..8bf7c55d481 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -25,7 +25,7 @@ import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/c import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { awaitStatsForSession } from '../../contrib/chat/common/chat.js'; import { IChatContentInlineReference, IChatProgress, IChatService, ResponseModelState } from '../../contrib/chat/common/chatService/chatService.js'; -import { ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService, ReadonlyChatSessionOptionsMap } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; @@ -44,9 +44,9 @@ export class ObservableChatSession extends Disposable implements IChatSession { readonly providerHandle: number; readonly history: Array; title?: string; - private _options?: Record; - public get options(): Record | undefined { - return this._options; + private _options?: ChatSessionOptionsMap; + public get options(): ReadonlyChatSessionOptionsMap | undefined { + return this._options ? new Map(this._options) : undefined; } private readonly _progressObservable = observableValue(this, []); private readonly _isCompleteObservable = observableValue(this, false); @@ -115,7 +115,7 @@ export class ObservableChatSession extends Disposable implements IChatSession { token ); - this._options = sessionContent.options; + this._options = sessionContent.options ? ChatSessionOptionsMap.fromRecord(sessionContent.options) : undefined; this.title = sessionContent.title; this.history.length = 0; this.history.push(...sessionContent.history.map((turn: IChatSessionHistoryItemDto) => { @@ -383,7 +383,11 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes } async newChatSessionItem(request: IChatNewSessionRequest, token: CancellationToken): Promise { - const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, request, token), token); + const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, { + prompt: request.prompt, + command: request.command, + initialSessionOptions: request.initialSessionOptions ? ChatSessionOptionsMap.toStrValueArray(request.initialSessionOptions) : undefined, + }, token), token); if (!dto) { return undefined; } @@ -458,7 +462,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._register(this._chatSessionsService.onDidChangeSessionOptions(({ sessionResource, updates }) => { warnOnUntitledSessionResource(sessionResource, this._logService); const handle = this._getHandleForSessionType(sessionResource.scheme); - this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.length} update(s)`); + this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.size} update(s)`); if (handle !== undefined) { this.notifyOptionsChange(handle, sessionResource, updates); } else { @@ -546,10 +550,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat controller.addOrUpdateItem(resolvedItem); } - $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { + $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: Record): void { const sessionResource = URI.revive(sessionResourceComponents); warnOnUntitledSessionResource(sessionResource, this._logService); - this._chatSessionsService.updateSessionOptions(sessionResource, updates); + this._chatSessionsService.updateSessionOptions(sessionResource, ChatSessionOptionsMap.fromRecord(updates)); } async $onDidCommitChatSessionItem(handle: number, originalComponents: UriComponents, modifiedCompoennts: UriComponents): Promise { @@ -696,12 +700,12 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat try { const initialSessionOptions = this._chatSessionsService.getSessionOptions(sessionResource); await session.initialize(token, { - initialSessionOptions: initialSessionOptions ? [...initialSessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined, + initialSessionOptions: initialSessionOptions ? [...initialSessionOptions].map(([optionId, value]) => ({ optionId, value: typeof value === 'string' ? value : value?.id })) : undefined, }); if (session.options) { for (const [_, handle] of this._sessionTypeToHandle) { if (handle === providerHandle) { - for (const [optionId, value] of Object.entries(session.options)) { + for (const [optionId, value] of session.options) { this._chatSessionsService.setSessionOption(sessionResource, optionId, value); } break; @@ -810,7 +814,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); } if (options?.newSessionOptions) { - this._chatSessionsService.setNewSessionOptionsForSessionType(chatSessionScheme, options.newSessionOptions); + this._chatSessionsService.setNewSessionOptionsForSessionType(chatSessionScheme, ChatSessionOptionsMap.fromRecord(options.newSessionOptions)); } }).catch(err => this._logService.error('Error fetching chat session options', err)); } @@ -850,10 +854,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat /** * Notify the extension about option changes for a session */ - async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>): Promise { + async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyMap): Promise { this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: starting proxy call for handle ${handle}, sessionResource ${sessionResource}`); try { - await this._proxy.$provideHandleOptionsChange(handle, sessionResource, updates, CancellationToken.None); + await this._proxy.$provideHandleOptionsChange(handle, sessionResource, Object.fromEntries(updates), CancellationToken.None); this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: proxy call completed for handle ${handle}, sessionResource ${sessionResource}`); } catch (error) { this._logService.error(`[MainThreadChatSessions] notifyOptionsChange: error for handle ${handle}, sessionResource ${sessionResource}:`, error); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7972ca1fb01..5c2be45087c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -60,7 +60,7 @@ import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common import { IChatContextItem } from '../../contrib/chat/common/contextContrib/chatContext.js'; import { IChatProgressHistoryResponseContent, IChatRequestVariableData } from '../../contrib/chat/common/model/chatModel.js'; import { ChatResponseClearToPreviousToolInvocationReason, IChatContentInlineReference, IChatExternalEditsDto, IChatFollowup, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; -import { IChatNewSessionRequest, IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments/chatVariables.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatRequestOptions, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; @@ -3592,20 +3592,19 @@ export type IChatSessionHistoryItemDto = { export type IChatSessionRequestHistoryItemDto = Extract; -export interface ChatSessionOptionUpdateDto { - readonly optionId: string; - readonly value: string | IChatSessionProviderOptionItem | undefined; -} -export interface ChatSessionOptionUpdateDto2 { - readonly optionId: string; - readonly value: string | IChatSessionProviderOptionItem; -} export interface ChatSessionContentContextDto { readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; } +export interface IChatNewSessionRequestDto { + readonly prompt: string; + readonly command?: string; + + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; +} + export interface ChatSessionDto { id: string; resource: UriComponents; @@ -3636,7 +3635,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $onDidCommitChatSessionItem(controllerHandle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; $unregisterChatSessionContentProvider(handle: number): void; - $onDidChangeChatSessionOptions(handle: number, sessionResource: UriComponents, updates: ReadonlyArray): void; + $onDidChangeChatSessionOptions(handle: number, sessionResource: UriComponents, updates: Record): void; $onDidChangeChatSessionProviderOptions(handle: number): void; $handleProgressChunk(handle: number, sessionResource: UriComponents, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise; @@ -3647,7 +3646,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { export interface ExtHostChatSessionsShape { $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; - $newChatSessionItem(controllerHandle: number, request: IChatNewSessionRequest, token: CancellationToken): Promise | undefined>; + $newChatSessionItem(controllerHandle: number, request: IChatNewSessionRequestDto, token: CancellationToken): Promise | undefined>; $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise; $interruptChatSessionActiveResponse(providerHandle: number, sessionResource: UriComponents, requestId: string): Promise; @@ -3655,7 +3654,7 @@ export interface ExtHostChatSessionsShape { $invokeChatSessionRequestHandler(providerHandle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise; $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; - $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; + $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: Record, token: CancellationToken): Promise; $forkChatSession(providerHandle: number, sessionResource: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise>; } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 9ecd3a3a6e4..81d57e5ee1d 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -18,11 +18,11 @@ import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; -import { IChatNewSessionRequest, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; +import { ChatSessionContentContextDto, ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape, IChatNewSessionRequestDto } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; @@ -485,7 +485,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio if (provider.onDidChangeChatSessionOptions) { disposables.add(provider.onDidChangeChatSessionOptions(evt => { - this._proxy.$onDidChangeChatSessionOptions(handle, evt.resource, evt.updates); + const updates: Record = Object.create(null); + for (const update of evt.updates) { + updates[update.optionId] = update.value; + } + this._proxy.$onDidChangeChatSessionOptions(handle, evt.resource, updates); })); } @@ -568,7 +572,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>, token: CancellationToken): Promise { + async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: Record, token: CancellationToken): Promise { const sessionResource = URI.revive(sessionResourceComponents); const provider = this._chatSessionContentProviders.get(handle); if (!provider) { @@ -582,11 +586,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } try { - const updatesToSend = updates.map(update => ({ - optionId: update.optionId, - value: update.value === undefined ? undefined : (typeof update.value === 'string' ? update.value : update.value.id) + const updatesToSend = Object.entries(updates).map(([optionId, value]) => ({ + optionId, + value: value === undefined ? undefined : (typeof value === 'string' ? value : value.id) })); - await provider.provider.provideHandleOptionsChange(sessionResource, updatesToSend, token); + provider.provider.provideHandleOptionsChange(sessionResource, updatesToSend, token); } catch (error) { this._logService.error(`Error calling provideHandleOptionsChange for handle ${handle}, sessionResource ${sessionResource}:`, error); } @@ -831,7 +835,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio await controllerData.controller.refreshHandler(token); } - async $newChatSessionItem(handle: number, request: IChatNewSessionRequest, token: CancellationToken): Promise | undefined> { + async $newChatSessionItem(handle: number, request: IChatNewSessionRequestDto, token: CancellationToken): Promise | undefined> { const controllerData = this._chatSessionItemControllers.get(handle); if (!controllerData) { this._logService.warn(`No controller found for handle ${handle}`); diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 255313c43b5..ffbce98d40f 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -34,7 +34,7 @@ import { ExtHostChatSessions } from '../../common/extHostChatSessions.js'; import { ExtHostCommands } from '../../common/extHostCommands.js'; import { ExtHostLanguageModels } from '../../common/extHostLanguageModels.js'; import * as extHostTypes from '../../common/extHostTypes.js'; -import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js'; +import { ChatSessionDto, ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js'; import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; import { IExtHostTelemetry } from '../../common/extHostTelemetry.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; @@ -744,11 +744,14 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(handle, sessionScheme); - const sessionContent = { + const sessionContent: ChatSessionDto = { id: 'test-session', + resource: URI.parse(`${sessionScheme}:/test-session`), history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4' } @@ -770,7 +773,7 @@ suite('MainThreadChatSessions', function () { const call = (proxy.$provideHandleOptionsChange as sinon.SinonStub).firstCall; assert.strictEqual(call.args[0], handle); assert.deepStrictEqual(call.args[1], resource); - assert.deepStrictEqual(call.args[2], [{ optionId: 'models', value: 'gpt-4-turbo' }]); + assert.deepStrictEqual(call.args[2], { models: 'gpt-4-turbo' }); mainThread.$unregisterChatSessionContentProvider(handle); }); @@ -787,9 +790,9 @@ suite('MainThreadChatSessions', function () { // Attempt to notify option change for an unregistered scheme // This should not throw, but also should not call the proxy - chatSessionsService.updateSessionOptions(resource, [ - { optionId: 'models', value: 'gpt-4-turbo' } - ]); + chatSessionsService.updateSessionOptions(resource, new Map([ + ['models', 'gpt-4-turbo'] + ])); // Verify the extension was NOT notified (no provider registered) assert.strictEqual((proxy.$provideHandleOptionsChange as sinon.SinonStub).callCount, 0); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index fb12d5a1d04..f98c6d170b6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -31,7 +31,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ReadonlyChatSessionOptionsMap, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -235,7 +235,7 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint; + private readonly _optionsCache: ChatSessionOptionsMap; public getOption(optionId: string): string | IChatSessionProviderOptionItem | undefined { return this._optionsCache.get(optionId); } @@ -250,17 +250,12 @@ class ContributedChatSessionData extends Disposable { readonly session: IChatSession, readonly chatSessionType: string, readonly resource: URI, - readonly options: Record | undefined, + readonly options: ReadonlyChatSessionOptionsMap | undefined, private readonly onWillDispose: (resource: URI) => void ) { super(); - this._optionsCache = new Map(); - if (options) { - for (const [key, value] of Object.entries(options)) { - this._optionsCache.set(key, value); - } - } + this._optionsCache = new Map(options); this._register(this.session.onWillDispose(() => { this.onWillDispose(this.resource); @@ -301,8 +296,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ public get onDidChangeOptionGroups() { return this._onDidChangeOptionGroups.event; } private readonly inProgressMap: Map = new Map(); - private readonly _sessionTypeOptions: Map = new Map(); - private readonly _sessionTypeNewSessionOptions: Map> = new Map(); + private readonly _sessionTypeOptions = new Map(); + private readonly _sessionTypeNewSessionOptions = new Map(); private readonly _sessions = new ResourceMap(); private readonly _resourceAliases = new ResourceMap(); // real resource -> untitled resource @@ -1073,8 +1068,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); } - for (const [optionId, value] of Object.entries(session.options ?? {})) { - this.setSessionOption(sessionResource, optionId, value); + if (session.options) { + for (const [optionId, value] of session.options) { + this.setSessionOption(sessionResource, optionId, value); + } } // Make sure another session wasn't created while we were awaiting the provider @@ -1095,8 +1092,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ // Make sure any listeners are aware of the new session and its options if (session.options) { - const updates = Object.entries(session.options).map(([optionId, value]) => ({ optionId, value })); - this._onDidChangeSessionOptions.fire({ sessionResource, updates }); + this._onDidChangeSessionOptions.fire({ sessionResource, updates: session.options }); } return session; @@ -1104,7 +1100,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ public hasAnySessionOptions(sessionResource: URI): boolean { const session = this._sessions.get(this._resolveResource(sessionResource)); - return !!session && !!session.options && Object.keys(session.options).length > 0; + return !!session && !!session.options && session.options.size > 0; } public getSessionOptions(sessionResource: URI): Map | undefined { @@ -1125,17 +1121,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean { - return this.updateSessionOptions(sessionResource, [{ optionId, value }]); + return this.updateSessionOptions(sessionResource, new Map([[optionId, value]])); } - public updateSessionOptions(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): boolean { + public updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean { const session = this._sessions.get(this._resolveResource(sessionResource)); if (!session) { return false; } let didChange = false; - for (const { optionId, value } of updates) { + for (const [optionId, value] of updates) { const existingValue = session.getOption(optionId); if (existingValue !== value) { session.setOption(optionId, value); @@ -1181,12 +1177,12 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._sessionTypeOptions.get(chatSessionType); } - public getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined { - return this._sessionTypeNewSessionOptions.get(chatSessionType); + public getNewSessionOptionsForSessionType(chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined { + return new Map(this._sessionTypeNewSessionOptions.get(chatSessionType)); } - public setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void { - this._sessionTypeNewSessionOptions.set(chatSessionType, options); + public setNewSessionOptionsForSessionType(chatSessionType: string, options: ReadonlyChatSessionOptionsMap): void { + this._sessionTypeNewSessionOptions.set(chatSessionType, new Map(options)); } /** @@ -1293,7 +1289,7 @@ export enum ChatSessionPosition { type NewChatSessionSendOptions = { readonly prompt: string; readonly attachedContext?: IChatRequestVariableEntry[]; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; }; export type NewChatSessionOpenOptions = { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 37dbe0bfed8..55a40c0a6a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -706,7 +706,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (needsUpdate) { this.chatSessionsService.updateSessionOptions( ctx.chatSessionResource, - [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] + new Map([[agentOptionId, mode.isBuiltin ? '' : modeName]]) ); } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index f63c853a48b..8e7dc8dc246 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -31,6 +31,7 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { IChatRequestVariableValue } from '../attachments/chatVariables.js'; import { ChatAgentLocation } from '../constants.js'; import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult, IToolResultInputOutputDetails, ToolDataSource } from '../tools/languageModelToolsService.js'; +import { ReadonlyChatSessionOptionsMap } from '../chatSessionsService.js'; export interface IChatRequest { message: string; @@ -1541,7 +1542,7 @@ export interface IChatService { export interface IChatSessionContext { readonly chatSessionResource: URI; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; } export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index a1d839b05b0..6aea62fe4eb 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -830,8 +830,7 @@ export class ChatService extends Disposable implements IChatService { // Capture session options before loading the remote session, // since the alias registration below may change the lookup. - const sessionOptions = this.chatSessionService.getSessionOptions(sessionResource); - const initialSessionOptions = sessionOptions ? [...sessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined; + const initialSessionOptions = this.chatSessionService.getSessionOptions(sessionResource); const newItem = await this.chatSessionService.createNewChatSessionItem(getChatSessionType(sessionResource), { prompt: requestText, command: commandPart?.text, initialSessionOptions }, CancellationToken.None); if (newItem) { @@ -847,7 +846,7 @@ export class ChatService extends Disposable implements IChatService { // so that the agent receives them when invoked. model.setContributedChatSession({ chatSessionResource: newItem.resource, - initialSessionOptions: sessionOptions ? [...sessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined, + initialSessionOptions: initialSessionOptions, }); sessionResource = newItem.resource; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 6401409f2b9..f69e3ffb662 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -174,11 +174,8 @@ export interface IChatSession extends IDisposable { readonly history: readonly IChatSessionHistoryItem[]; - /** - * Session options as key-value pairs. Keys correspond to option group IDs (e.g., 'models', 'subagents') - * and values are either the selected option item IDs (string) or full option items (for locked state). - */ - readonly options?: Record; + + readonly options?: ReadonlyChatSessionOptionsMap; readonly progressObs?: IObservable; readonly isCompleteObs?: IObservable; @@ -217,7 +214,7 @@ export interface IChatNewSessionRequest { readonly prompt: string; readonly command?: string; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; } export interface IChatSessionItemsDelta { @@ -238,13 +235,47 @@ export interface IChatSessionItemController { export interface IChatSessionOptionsChangeEvent { readonly sessionResource: URI; - readonly updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; + readonly updates: ReadonlyMap; } export type ResolvedChatSessionsExtensionPoint = Omit & { readonly icon: ThemeIcon | URI | undefined; }; +/** + * Session options as key-value pairs. + * + * Keys correspond to option group IDs (e.g., 'models', 'subagents') and values are either the selected option item IDs (string) or full option items (for locked state). + */ +export type ChatSessionOptionsMap = Map; + +export namespace ChatSessionOptionsMap { + export function fromRecord(obj: { [key: string]: string | IChatSessionProviderOptionItem }): ChatSessionOptionsMap { + return new Map(Object.entries(obj)); + } + + export function toRecord(map: ReadonlyChatSessionOptionsMap): Record { + const record: Record = Object.create(null); + for (const [key, value] of map) { + record[key] = value; + } + return record; + } + + export function toStrValueArray(map: ReadonlyChatSessionOptionsMap | undefined): Array<{ optionId: string; value: string }> | undefined { + if (!map) { + return undefined; + } + return Array.from(map, ([optionId, value]) => ({ optionId, value: typeof value === 'string' ? value : value.id })); + } +} + +/** + * Readonly version of {@link ChatSessionOptionsMap} + */ +export type ReadonlyChatSessionOptionsMap = ReadonlyMap; + + export const IChatSessionsService = createDecorator('chatSessionsService'); export interface IChatSessionsService { @@ -299,10 +330,10 @@ export interface IChatSessionsService { getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise; hasAnySessionOptions(sessionResource: URI): boolean; - getSessionOptions(sessionResource: URI): Map | undefined; + getSessionOptions(sessionResource: URI): ReadonlyChatSessionOptionsMap | undefined; getSessionOption(sessionResource: URI, optionId: string): string | IChatSessionProviderOptionItem | undefined; setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean; - updateSessionOptions(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): boolean; + updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean; /** * Fired when options for a chat session change. @@ -350,8 +381,8 @@ export interface IChatSessionsService { getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; - getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined; - setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void; + getNewSessionOptionsForSessionType(chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined; + setNewSessionOptionsForSessionType(chatSessionType: string, options: ReadonlyChatSessionOptionsMap): void; getInProgressSessionDescription(chatModel: IChatModel): string | undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 6727b376b48..c4dacb71de5 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -55,7 +55,7 @@ import { MockChatVariablesService } from '../mockChatVariables.js'; import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; import { MockLanguageModelToolsService } from '../tools/mockLanguageModelToolsService.js'; import { MockChatService } from './mockChatService.js'; -import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { MockChatSessionsService } from '../mockChatSessionsService.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; @@ -937,7 +937,7 @@ suite('ChatService', () => { assert.ok(newModel, 'New model should exist at the real resource'); assert.ok(newModel.contributedChatSession, 'New model should have contributedChatSession'); assert.deepStrictEqual( - newModel.contributedChatSession?.initialSessionOptions?.map(o => ({ optionId: o.optionId, value: o.value })), + ChatSessionOptionsMap.toStrValueArray(newModel.contributedChatSession?.initialSessionOptions), [ { optionId: 'model', value: 'claude-3.5-sonnet' }, { optionId: 'repo', value: 'my-repo' }, diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 1d35d055d21..c2eb2fef88a 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -9,7 +9,7 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { ReadonlyChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint, ChatSessionOptionsMap } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -43,7 +43,7 @@ export class MockChatSessionsService implements IChatSessionsService { private contentProviders = new Map(); private contributions: IChatSessionsExtensionPoint[] = []; private optionGroups = new Map(); - private sessionOptions = new ResourceMap>(); + private sessionOptions = new ResourceMap(); private inProgress = new Map(); // For testing: allow triggering events @@ -172,33 +172,34 @@ export class MockChatSessionsService implements IChatSessionsService { } } - getNewSessionOptionsForSessionType(_chatSessionType: string): Record | undefined { + getNewSessionOptionsForSessionType(_chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined { return undefined; } - setNewSessionOptionsForSessionType(_chatSessionType: string, _options: Record): void { + setNewSessionOptionsForSessionType(_chatSessionType: string, _options: ReadonlyChatSessionOptionsMap): void { // noop } - getSessionOptions(sessionResource: URI): Map | undefined { + getSessionOptions(sessionResource: URI): ReadonlyChatSessionOptionsMap | undefined { const options = this.sessionOptions.get(sessionResource); return options && options.size > 0 ? options : undefined; } getSessionOption(sessionResource: URI, optionId: string): string | undefined { - return this.sessionOptions.get(sessionResource)?.get(optionId); + const value = this.sessionOptions.get(sessionResource)?.get(optionId); + return typeof value === 'string' ? value : value?.id; } setSessionOption(sessionResource: URI, optionId: string, value: string): boolean { - return this.updateSessionOptions(sessionResource, [{ optionId, value }]); + return this.updateSessionOptions(sessionResource, new Map([[optionId, value]])); } - updateSessionOptions(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string }>): boolean { + updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean { if (!this.sessionOptions.has(sessionResource)) { this.sessionOptions.set(sessionResource, new Map()); } - for (const update of updates) { - this.sessionOptions.get(sessionResource)!.set(update.optionId, update.value); + for (const [optionId, value] of updates) { + this.sessionOptions.get(sessionResource)!.set(optionId, value); } this._onDidChangeSessionOptions.fire({ sessionResource, updates }); From 0c355cbbf27a67ff3d0faa03f2ac703c587eb2a1 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:42:48 -0700 Subject: [PATCH 065/183] Browser: feature contributions for storage / devtools (#303320) * Browser: feature contributions for storage / devtools * Update src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../electron-browser/browserEditor.ts | 15 -- .../browserView.contribution.ts | 32 +--- .../electron-browser/browserViewActions.ts | 119 +------------- .../features/browserDataStorageFeatures.ts | 151 ++++++++++++++++++ .../features/browserDevToolsFeature.ts | 78 +++++++++ .../features/browserEditorChatFeatures.ts | 2 +- .../features/browserEditorZoomFeature.ts | 4 +- .../features/browserTabManagementFeatures.ts | 2 +- 8 files changed, 239 insertions(+), 164 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 004f4829eb9..9f4bfff07a6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -49,10 +49,8 @@ import { ILayoutService } from '../../../../platform/layout/browser/layoutServic 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")); export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); -export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error")); -export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); // Re-export find widget context keys for use in actions export { CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE }; @@ -360,10 +358,8 @@ export class BrowserEditor extends EditorPane { private _findWidget!: Lazy; private _canGoBackContext!: IContextKey; private _canGoForwardContext!: IContextKey; - private _storageScopeContext!: IContextKey; private _hasUrlContext!: IContextKey; private _hasErrorContext!: IContextKey; - private _devToolsOpenContext!: IContextKey; private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; @@ -395,10 +391,8 @@ export class BrowserEditor extends EditorPane { // Bind navigation capability context keys this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService); this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); - this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); - this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); @@ -533,9 +527,6 @@ export class BrowserEditor extends EditorPane { this._model = model; this._onDidChangeModel.fire(model); - this._storageScopeContext.set(this._model.storageScope); - this._devToolsOpenContext.set(this._model.isDevToolsOpen); - // Update find widget with new model this._findWidget.rawValue?.setModel(this._model); @@ -578,10 +569,6 @@ export class BrowserEditor extends EditorPane { } })); - this._inputDisposables.add(this._model.onDidChangeDevToolsState(e => { - this._devToolsOpenContext.set(e.isDevToolsOpen); - })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, url, location, position }) => { logBrowserOpen(this.telemetryService, (() => { switch (location) { @@ -1104,8 +1091,6 @@ export class BrowserEditor extends EditorPane { this._canGoForwardContext.reset(); this._hasUrlContext.reset(); this._hasErrorContext.reset(); - this._storageScopeContext.reset(); - this._devToolsOpenContext.reset(); this._navigationBar.clear(); this.setBackgroundImage(undefined); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index ff65b1dd6a8..f0fd9ca1d74 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -13,18 +13,17 @@ import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEd import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { Schemas } from '../../../../base/common/network.js'; import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; import { BrowserViewCDPService } from './browserViewCDPService.js'; -import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; // Register actions and browser features import './browserViewActions.js'; +import './features/browserDataStorageFeatures.js'; +import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; import './features/browserEditorZoomFeature.js'; import './features/browserTabManagementFeatures.js'; @@ -96,30 +95,3 @@ registerWorkbenchContribution2(BrowserEditorResolverContribution.ID, BrowserEdit registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); registerSingleton(IBrowserViewCDPService, BrowserViewCDPService, InstantiationType.Delayed); - -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - ...workbenchConfigurationNodeBase, - properties: { - 'workbench.browser.dataStorage': { - type: 'string', - enum: [ - BrowserViewStorageScope.Global, - BrowserViewStorageScope.Workspace, - BrowserViewStorageScope.Ephemeral - ], - markdownEnumDescriptions: [ - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') - ], - restricted: true, - default: BrowserViewStorageScope.Global, - markdownDescription: localize( - { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, - 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' - ), - scope: ConfigurationScope.WINDOW, - order: 100 - } - } -}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 04a101b58b3..9a9f4b2d754 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,9 +11,8 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; -import { IBrowserViewWorkbenchService } from '../common/browserView.js'; -import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; +import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; @@ -183,37 +182,6 @@ class FocusUrlInputAction extends Action2 { } } -class ToggleDevToolsAction extends Action2 { - static readonly ID = BrowserViewCommandId.ToggleDevTools; - - constructor() { - super({ - id: ToggleDevToolsAction.ID, - title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), - category: BrowserActionCategory, - icon: Codicon.terminal, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), - menu: { - id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 3, - }, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.F12 - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.toggleDevTools(); - } - } -} - class OpenInExternalBrowserAction extends Action2 { static readonly ID = BrowserViewCommandId.OpenExternal; @@ -250,83 +218,6 @@ class OpenInExternalBrowserAction extends Action2 { } } -class ClearGlobalBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearGlobalStorage; - - constructor() { - super({ - id: ClearGlobalBrowserStorageAction.ID, - title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), - category: BrowserActionCategory, - icon: Codicon.clearAll, - f1: true, - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Settings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - await browserViewWorkbenchService.clearGlobalStorage(); - } -} - -class ClearWorkspaceBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearWorkspaceStorage; - - constructor() { - super({ - id: ClearWorkspaceBrowserStorageAction.ID, - title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), - category: BrowserActionCategory, - icon: Codicon.clearAll, - f1: true, - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Settings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - await browserViewWorkbenchService.clearWorkspaceStorage(); - } -} - -class ClearEphemeralBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearEphemeralStorage; - - constructor() { - super({ - id: ClearEphemeralBrowserStorageAction.ID, - title: localize2('browser.clearEphemeralStorageAction', 'Clear Storage (Ephemeral)'), - category: BrowserActionCategory, - icon: Codicon.clearAll, - f1: true, - precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Settings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.clearStorage(); - } - } -} - class OpenBrowserSettingsAction extends Action2 { static readonly ID = BrowserViewCommandId.OpenSettings; @@ -474,13 +365,11 @@ registerAction2(GoBackAction); registerAction2(GoForwardAction); registerAction2(ReloadAction); registerAction2(HardReloadAction); + registerAction2(FocusUrlInputAction); -registerAction2(ToggleDevToolsAction); registerAction2(OpenInExternalBrowserAction); -registerAction2(ClearGlobalBrowserStorageAction); -registerAction2(ClearWorkspaceBrowserStorageAction); -registerAction2(ClearEphemeralBrowserStorageAction); registerAction2(OpenBrowserSettingsAction); + registerAction2(ShowBrowserFindAction); registerAction2(HideBrowserFindAction); registerAction2(BrowserFindNextAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts new file mode 100644 index 00000000000..d3ecac788fc --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { BrowserEditor, BrowserEditorContribution } from '../browserEditor.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../../platform/browserView/common/browserView.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import type { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; + +const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); + +class BrowserEditorStorageScopeContribution extends BrowserEditorContribution { + private readonly _storageScopeContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editor); + this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); + } + + protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + this._storageScopeContext.set(model.storageScope); + } + + override clear(): void { + this._storageScopeContext.reset(); + } +} + +BrowserEditor.registerContribution(BrowserEditorStorageScopeContribution); + +class ClearGlobalBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearGlobalStorage; + + constructor() { + super({ + id: ClearGlobalBrowserStorageAction.ID, + title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearGlobalStorage(); + } +} + +class ClearWorkspaceBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearWorkspaceStorage; + + constructor() { + super({ + id: ClearWorkspaceBrowserStorageAction.ID, + title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearWorkspaceStorage(); + } +} + +class ClearEphemeralBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearEphemeralStorage; + + constructor() { + super({ + id: ClearEphemeralBrowserStorageAction.ID, + title: localize2('browser.clearEphemeralStorageAction', 'Clear Storage (Ephemeral)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.clearStorage(); + } + } +} + +registerAction2(ClearGlobalBrowserStorageAction); +registerAction2(ClearWorkspaceBrowserStorageAction); +registerAction2(ClearEphemeralBrowserStorageAction); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.dataStorage': { + type: 'string', + enum: [ + BrowserViewStorageScope.Global, + BrowserViewStorageScope.Workspace, + BrowserViewStorageScope.Ephemeral + ], + markdownEnumDescriptions: [ + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') + ], + restricted: true, + default: BrowserViewStorageScope.Global, + markdownDescription: localize( + { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, + 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' + ), + scope: ConfigurationScope.WINDOW, + order: 100 + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts new file mode 100644 index 00000000000..c22809d56ee --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory } from '../browserViewActions.js'; + +const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); + +class BrowserEditorDevToolsContribution extends BrowserEditorContribution { + private readonly _devToolsOpenContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editor); + this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + this._devToolsOpenContext.set(model.isDevToolsOpen); + store.add(model.onDidChangeDevToolsState(e => { + this._devToolsOpenContext.set(e.isDevToolsOpen); + })); + } + + override clear(): void { + this._devToolsOpenContext.reset(); + } +} + +BrowserEditor.registerContribution(BrowserEditorDevToolsContribution); + +class ToggleDevToolsAction extends Action2 { + static readonly ID = BrowserViewCommandId.ToggleDevTools; + + constructor() { + super({ + id: ToggleDevToolsAction.ID, + title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), + category: BrowserActionCategory, + icon: Codicon.terminal, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 3, + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.F12 + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.toggleDevTools(); + } + } +} + +registerAction2(ToggleDevToolsAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 793dcc0a2e8..f962e57395f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -45,7 +45,7 @@ import { BrowserActionCategory } from '../browserViewActions.js'; const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); const BrowserCategory = localize2('browserCategory', "Browser"); -export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); +const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); const canShareBrowserWithAgentContext = ContextKeyExpr.and( ChatContextKeys.enabled, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts index 881467ec8e7..a527d8ab6c4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts @@ -30,8 +30,8 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/in import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; -export const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); -export const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); +const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); +const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); /** * Transient zoom-level indicator that briefly appears inside the URL bar on zoom changes. diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 3f2e4e6365e..dc391f59251 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -34,7 +34,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; -export const CONTEXT_BROWSER_EDITOR_OPEN = new RawContextKey('browserEditorOpen', false, localize('browser.editorOpen', "Whether any browser editor is currently open")); +const CONTEXT_BROWSER_EDITOR_OPEN = new RawContextKey('browserEditorOpen', false, localize('browser.editorOpen', "Whether any browser editor is currently open")); interface IBrowserQuickPickItem extends IQuickPickItem { groupId: GroupIdentifier; From ba18c003e9dbf40ec80877aabc4647f7af8278bd Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Thu, 19 Mar 2026 15:45:51 -0700 Subject: [PATCH 066/183] fix: catch notebook snapshot errors in chat editing with log fallback (#301202) When NotebookTextModel.createSnapshot() throws 'Notebook too large to backup' (outputs exceed notebook.backup.sizeLimit), catch the error at the caller side and log via loggingService instead of letting it propagate unhandled. - chatEditingModifiedNotebookEntry.getCurrentSnapshot(): Catch error, log, and fall back to initialContent. - chatEditingModifiedNotebookEntry.createSnapshot(): Catch errors from the snapshot utility for both original and modified models, log, and fall back to initialContent so that session persistence doesn't crash. - chatEditingModifiedNotebookEntry.create(): Let the error propagate to the session's existing try/catch in _createModifiedFileEntry(). - Removed ITelemetryService injection and telemetry type definitions. --- .../chatEditingModifiedNotebookEntry.ts | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 9ab70f300d8..aae9dd0970c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -26,7 +26,6 @@ import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../../../../platform/undoRedo/common/undoRedo.js'; import { IEditorPane, SaveReason } from '../../../../common/editor.js'; import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js'; @@ -108,18 +107,12 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie const notebookService = accessor.get(INotebookService); const resolver = accessor.get(INotebookEditorModelResolverService); const configurationServie = accessor.get(IConfigurationService); - const telemetryService = accessor.get(ITelemetryService); const resourceRef: IReference = await resolver.resolve(uri); const notebook = resourceRef.object.notebook; const originalUri = getNotebookSnapshotFileURI(telemetryInfo.sessionResource, telemetryInfo.requestId, generateUuid(), notebook.uri.scheme === Schemas.untitled ? `/${notebook.uri.path}` : notebook.uri.path, notebook.viewType); const [options, buffer] = await Promise.all([ notebookService.withNotebookDataProvider(resourceRef.object.notebook.notebookType), notebookService.createNotebookTextDocumentSnapshot(notebook.uri, SnapshotContext.Backup, CancellationToken.None).then(s => streamToBuffer(s)) - .catch(e => { - // When backup snapshot fails (e.g. outputs exceed size limit), fall back to Save context. - telemetryService.publicLogError2('chatEditing/notebookSnapshotError', { operation: 'create', errorMessage: String(e?.message ?? e) }); - return notebookService.createNotebookTextDocumentSnapshot(notebook.uri, SnapshotContext.Save, CancellationToken.None).then(s => streamToBuffer(s)); - }) ]); const disposables = new DisposableStore(); // Register so that we can load this from file system. @@ -197,7 +190,6 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie @INotebookLoggingService private readonly loggingService: INotebookLoggingService, @INotebookEditorModelResolverService private readonly notebookResolver: INotebookEditorModelResolverService, @IAiEditTelemetryService aiEditTelemetryService: IAiEditTelemetryService, - @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(modifiedResourceRef.object.notebook.uri, telemetryInfo, kind, configurationService, fileConfigService, chatService, fileService, undoRedoService, instantiationService, aiEditTelemetryService); this.initialContentComparer = new SnapshotComparer(initialContent); @@ -920,7 +912,12 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } public getCurrentSnapshot() { - return createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); + try { + return createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); + } catch (e) { + this.loggingService.error('Notebook Chat', `Error creating snapshot for ${this.modifiedModel.uri}: ${e}`); + return this.initialContent; + } } override createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry { @@ -929,13 +926,13 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie try { original = createSnapshot(this.originalModel, this.transientOptions, this.configurationService); } catch (e) { - this.telemetryService.publicLogError2('chatEditing/notebookSnapshotError', { operation: 'snapshotOriginal', errorMessage: String(e?.message ?? e) }); + this.loggingService.error('Notebook Chat', `Error creating snapshot for original ${this.originalModel.uri}: ${e}`); original = this.initialContent; } try { current = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); } catch (e) { - this.telemetryService.publicLogError2('chatEditing/notebookSnapshotError', { operation: 'snapshotCurrent', errorMessage: String(e?.message ?? e) }); + this.loggingService.error('Notebook Chat', `Error creating snapshot for modified ${this.modifiedModel.uri}: ${e}`); current = this.initialContent; } return { @@ -1130,15 +1127,3 @@ function generateCellHash(cellUri: URI) { hash.update(cellUri.toString()); return hash.digest().substring(0, 8); } - -type NotebookSnapshotErrorEvent = { - operation: string; - errorMessage: string; -}; - -type NotebookSnapshotErrorClassification = { - operation: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The snapshot operation that failed (create, snapshotOriginal, snapshotCurrent).' }; - errorMessage: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The error message from the failed snapshot.' }; - owner: 'AamundM'; - comment: 'Tracks notebook snapshot failures in chat editing, e.g. when outputs exceed the backup size limit.'; -}; From 36c908a37bea71dd44661ba2d1f3f08d0649f206 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 20 Mar 2026 00:04:10 +0100 Subject: [PATCH 067/183] Revert "chore - Add telemetry logging for chat editing session store and restore events (#303225)" (#303319) This reverts commit 3b60cf4f6789b8d3f0df30ae53ed443f0bec9762. --- .../browser/chatEditing/chatEditingSession.ts | 73 +------------------ 1 file changed, 2 insertions(+), 71 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 918b0a9c767..8439b5518b7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -30,7 +30,6 @@ import { EditorActivation } from '../../../../../platform/editor/common/editor.j import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -48,7 +47,7 @@ import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; -import { FileOperation, FileOperationType, getKeyForChatSessionResource } from './chatEditingOperations.js'; +import { FileOperation, FileOperationType } from './chatEditingOperations.js'; import { IChatEditingExplanationModelManager, IExplanationDiffInfo, IExplanationGenerationHandle } from './chatEditingExplanationModelManager.js'; import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js'; import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; @@ -60,42 +59,6 @@ const enum NotExistBehavior { Abort, } -type ChatEditingSessionStoreEvent = { - sessionId: string; - entryCount: number; - modifiedCount: number; - acceptedCount: number; - rejectedCount: number; -}; - -type ChatEditingSessionStoreClassification = { - owner: 'jrieken'; - comment: 'Tracks the number and state of chat editing entries when a session is stored.'; - sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; - entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries stored with the session.' }; - modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when storing.' }; - acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when storing.' }; - rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when storing.' }; -}; - -type ChatEditingSessionRestoreEvent = { - sessionId: string; - entryCount: number; - modifiedCount: number; - acceptedCount: number; - rejectedCount: number; -}; - -type ChatEditingSessionRestoreClassification = { - owner: 'jrieken'; - comment: 'Tracks the number and state of chat editing entries when a session is restored.'; - sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; - entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries restored with the session.' }; - modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when restoring.' }; - acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when restoring.' }; - rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when restoring.' }; -}; - class ThrottledSequencer extends Sequencer { private _size = 0; @@ -236,7 +199,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IConfigurationService private readonly configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, @IChatEditingExplanationModelManager private readonly _explanationModelManager: IChatEditingExplanationModelManager, - @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._timeline = this._instantiationService.createInstance( @@ -346,12 +308,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource); - const storedState = this._getStoredState(); - this._telemetryService.publicLog2('chatEditing/sessionStore', { - sessionId: getKeyForChatSessionResource(this.chatSessionResource), - ...this._countEntryStates(this._entriesObs.get()), - }); - return storage.storeState(storedState); + return storage.storeState(this._getStoredState()); } private _getStoredState(sessionResource = this.chatSessionResource): StoredSessionState { @@ -988,10 +945,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._entriesObs.set(entriesArr, undefined); - this._telemetryService.publicLog2('chatEditing/sessionRestore', { - sessionId: getKeyForChatSessionResource(this.chatSessionResource), - ...this._countEntryStates(entriesArr), - }); } private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { @@ -1028,28 +981,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }; } - private _countEntryStates(entries: readonly AbstractChatEditingModifiedFileEntry[]): { entryCount: number; modifiedCount: number; acceptedCount: number; rejectedCount: number } { - let entryCount = 0; - let modifiedCount = 0; - let acceptedCount = 0; - let rejectedCount = 0; - for (const entry of entries) { - entryCount += 1; - switch (entry.state.get()) { - case ModifiedFileEntryState.Modified: - modifiedCount += 1; - break; - case ModifiedFileEntryState.Accepted: - acceptedCount += 1; - break; - case ModifiedFileEntryState.Rejected: - rejectedCount += 1; - break; - } - } - return { entryCount, modifiedCount, acceptedCount, rejectedCount }; - } - private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise { const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString()); if (!hasOtherTasks) { From e29b65b1bc7e296fc096e0b5c2fe3ef2f23981ee Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:07:37 -0700 Subject: [PATCH 068/183] don't show shimmer for things that shouldn't shimmer (#303327) --- .../chatToolPartUtilities.ts | 2 +- .../chat/browser/widget/chatListRenderer.ts | 5 ++++ .../chatToolProgressPart.test.ts | 26 ++++++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) 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 03c147cf351..9ba9846de1a 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 @@ -17,7 +17,7 @@ export function isMcpToolInvocation(toolInvocation: IChatToolInvocation | IChatT */ export function shouldShimmerForTool(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { if (isMcpToolInvocation(toolInvocation)) { - return true; + return !IChatToolInvocation.isComplete(toolInvocation); } if (toolInvocation.toolId === 'copilot_askQuestions' || toolInvocation.toolId === 'vscode_askQuestions') { return false; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 573b0b05d41..ea350214eac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1025,6 +1025,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part) && isMcpToolInvocation(part))) { + return false; + } + // Show if no content, only "used references", ends with a complete tool call, or ends with complete text edits and there is no incomplete tool call (edits are still being applied some time after they are all generated) const lastPart = findLast(partsToRender, part => part.kind !== 'markdownContent' || part.content.value.trim().length > 0); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts index 3125d372e86..535b44dcd18 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts @@ -182,7 +182,7 @@ suite('ChatToolProgressSubPart', () => { }); test('adds shimmer styling for active MCP tool progress', () => { - const mcpTool = createSerializedToolInvocation({ + const mcpTool = createToolInvocation({ source: { type: 'mcp', label: 'Weather MCP', @@ -221,4 +221,28 @@ suite('ChatToolProgressSubPart', () => { assert.strictEqual(part.domNode.querySelector('.shimmer-progress'), null); }); + + test('does not add shimmer styling for completed MCP tool progress', () => { + const mcpTool = createSerializedToolInvocation({ + source: { + type: 'mcp', + label: 'Weather MCP', + serverLabel: 'Weather', + instructions: undefined, + collectionId: 'collection', + definitionId: 'definition' + }, + toolId: 'weather_lookup' + }); + + const part = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + mcpTool, + createRenderContext(false), + mockMarkdownRenderer, + new Set() + )); + + assert.strictEqual(part.domNode.querySelector('.shimmer-progress'), null); + }); }); From ca12dcbdd2453560e41f59c7a0dd7009110cc5e5 Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Thu, 19 Mar 2026 16:19:10 -0700 Subject: [PATCH 069/183] fixed setting and window title --- .../browser/actions/quickAccessActions.ts | 31 ++++--- .../parts/titlebar/commandCenterControl.ts | 7 +- .../agentSessionsExperiments.contribution.ts | 6 +- .../experiments/agentTitleBarStatusWidget.ts | 80 ++++++++++++++++--- 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/browser/actions/quickAccessActions.ts b/src/vs/workbench/browser/actions/quickAccessActions.ts index 35cdd20ccc3..099ce915198 100644 --- a/src/vs/workbench/browser/actions/quickAccessActions.ts +++ b/src/vs/workbench/browser/actions/quickAccessActions.ts @@ -9,7 +9,8 @@ import { KeyMod, KeyCode } from '../../../base/common/keyCodes.js'; import { KeybindingsRegistry, KeybindingWeight, IKeybindingRule } from '../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, ItemActivation, QuickInputHideReason } from '../../../platform/quickinput/common/quickInput.js'; import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { inQuickPickContext, defaultQuickAccessContext, getQuickNavigateHandler } from '../quickaccess.js'; import { ILocalizedString } from '../../../platform/action/common/action.js'; @@ -162,15 +163,27 @@ registerAction2(class QuickAccessAction extends Action2 { } run(accessor: ServicesAccessor): void { - const quickInputService = accessor.get(IQuickInputService); - const providerOptions: AnythingQuickAccessProviderRunOptions = { - includeHelp: true, - from: 'commandCenter', + const openClassicQuickAccess = (): void => { + const quickInputService = accessor.get(IQuickInputService); + const providerOptions: AnythingQuickAccessProviderRunOptions = { + includeHelp: true, + from: 'commandCenter', + }; + quickInputService.quickAccess.show(undefined, { + preserveValue: true, + providerOptions + }); }; - quickInputService.quickAccess.show(undefined, { - preserveValue: true, - providerOptions - }); + + const configurationService = accessor.get(IConfigurationService); + const commandService = accessor.get(ICommandService); + const useUnifiedQuickAccess = configurationService.getValue('chat.unifiedAgentsBar.enabled') === true; + if (useUnifiedQuickAccess) { + void commandService.executeCommand('workbench.action.unifiedQuickAccess').then(undefined, () => openClassicQuickAccess()); + return; + } + + openClassicQuickAccess(); } }); diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index 2aa1b2d0464..99d575cf640 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -148,8 +148,13 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { // When agent control mode is 'compact', hide search icon and left-align the label // Backward compat: the old boolean setting (true) and the new default (undefined) both map to compact + const aiFeaturesDisabled = that._configurationService.getValue('chat.disableAIFeatures') === true; + const aiCustomizationsDisabled = that._configurationService.getValue('disableAICustomizations') === true + || that._configurationService.getValue('workbench.disableAICustomizations') === true + || that._configurationService.getValue('chat.customizationsMenu.enabled') === false; + const forcedHidden = aiFeaturesDisabled && aiCustomizationsDisabled; const agentControlValue = that._configurationService.getValue('chat.agentsControl.enabled'); - const isCompactMode = agentControlValue !== false && agentControlValue !== 'hidden'; + const isCompactMode = !forcedHidden && (agentControlValue === true || agentControlValue === undefined || agentControlValue === 'compact'); container.classList.toggle('compact-mode', isCompactMode); const action = this.action; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 18b69b9bc88..4e1d91d4927 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -248,7 +248,11 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { submenu: MenuId.AgentsTitleBarControlMenu, title: localize('agentsControl', "Agents"), icon: Codicon.chatSparkle, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, false) + ), order: 10002 // to the right of the chat button }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index be42b0fa660..0ec54a9d165 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -80,6 +80,40 @@ const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfilt // Storage key for saving user's filter state before we override it const PREVIOUS_FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.previousUserFilter'; +type AgentStatusSettingMode = 'hidden' | 'badge' | 'compact'; + +function shouldForceHiddenAgentStatus(configurationService: IConfigurationService): boolean { + const aiFeaturesDisabled = configurationService.getValue(ChatConfiguration.AIDisabled) === true; + const aiCustomizationsDisabled = configurationService.getValue('disableAICustomizations') === true + || configurationService.getValue('workbench.disableAICustomizations') === true + || configurationService.getValue(ChatConfiguration.ChatCustomizationMenuEnabled) === false; + + return aiFeaturesDisabled && aiCustomizationsDisabled; +} + +function getAgentStatusSettingMode(configurationService: IConfigurationService): AgentStatusSettingMode { + if (shouldForceHiddenAgentStatus(configurationService)) { + return 'hidden'; + } + + const value = configurationService.getValue(ChatConfiguration.AgentStatusEnabled); + + if (value === false || value === 'hidden') { + return 'hidden'; + } + + if (value === 'badge') { + return 'badge'; + } + + // Backward compatibility: previous experiments stored this as a boolean. + if (value === true || value === undefined || value === 'compact') { + return 'compact'; + } + + return 'compact'; +} + /** * Agent Status Widget - renders agent status in the command center. * @@ -194,7 +228,15 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Re-render when settings change this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled)) { + if ( + e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) + || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) + || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled) + || e.affectsConfiguration(ChatConfiguration.AIDisabled) + || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) + || e.affectsConfiguration('disableAICustomizations') + || e.affectsConfiguration('workbench.disableAICustomizations') + ) { this._lastRenderState = undefined; // Force re-render this._render(); } @@ -290,8 +332,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Get current filter state for state key const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); - // Check which settings are enabled - const unifiedAgentsBarEnabled = true; + const statusMode = getAgentStatusSettingMode(this.configurationService); + const unifiedAgentsBarEnabled = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; // Build state key for comparison @@ -306,6 +348,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput, + statusMode, unifiedAgentsBarEnabled, viewSessionsEnabled, }); @@ -329,11 +372,14 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } else if (this.agentTitleBarStatusService.mode === AgentStatusMode.SessionReady) { // Session ready mode - show session title + enter projection button this._renderSessionReadyMode(this._dynamicDisposables); - } else if (unifiedAgentsBarEnabled) { - // Unified Agents Bar - show full pill with label + status badge + } else if (statusMode === 'compact') { + // Compact mode - replace command center search with integrated control this._renderChatInputMode(this._dynamicDisposables); + } else if (statusMode === 'badge') { + // Badge mode - render status badge next to command center search + this._renderStatusBadge(this._dynamicDisposables, activeSessions, unreadSessions, attentionNeededSessions); } - // If the setting is not enabled, nothing is rendered (container is already cleared) + // Hidden mode intentionally renders nothing. // Setup roving tabindex for keyboard navigation this._setupRovingTabIndex(this._dynamicDisposables); @@ -902,7 +948,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // When compact mode is active, show status indicators before the sparkle button: // [needs-input, active, unread, sparkle] (populating inward) // Otherwise, keep original order: [sparkle, unread, active, needs-input] - const reverseOrder = true; + const reverseOrder = !!inlineContainer; if (!reverseOrder) { // Original order: sparkle first @@ -1340,9 +1386,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { private _getLabel(): string { const { prefix, suffix } = this._windowTitle.getTitleDecorations(); - // Base label: workspace name or file name when tabs are hidden + // Base label: custom title, workspace name, or file name when tabs are hidden let label = this._windowTitle.workspaceName; - if (!label && this.editorGroupsService.partOptions.showTabs === 'none') { + if (this._windowTitle.isCustomTitleFormat()) { + label = this._windowTitle.getWindowTitle(); + } else if (!label && this.editorGroupsService.partOptions.showTabs === 'none') { label = this._windowTitle.fileName ?? ''; } @@ -1398,15 +1446,23 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben const updateClass = () => { const commandCenterEnabled = configurationService.getValue(LayoutSettings.COMMAND_CENTER) === true; - const enabled = commandCenterEnabled && chatEnabled; - const enhanced = commandCenterEnabled && chatEnabled; + const statusMode = getAgentStatusSettingMode(configurationService); + const enabled = commandCenterEnabled && chatEnabled && statusMode !== 'hidden'; + const enhanced = enabled && statusMode === 'compact'; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); mainWindow.document.body.classList.toggle('unified-agents-bar', enhanced); }; updateClass(); this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER)) { + if ( + e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) + || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER) + || e.affectsConfiguration(ChatConfiguration.AIDisabled) + || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) + || e.affectsConfiguration('disableAICustomizations') + || e.affectsConfiguration('workbench.disableAICustomizations') + ) { updateClass(); } })); From 4be80a8b42f7e8366e87b7462d0e96a39cde3975 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Thu, 19 Mar 2026 22:52:50 +0100 Subject: [PATCH 070/183] Update to @vscode/proxy-agent 0.40.0 (#298236) --- package-lock.json | 8 ++++---- package.json | 4 ++-- remote/package-lock.json | 8 ++++---- remote/package.json | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 777c60561a8..bdd32a8097d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -4779,9 +4779,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.39.1.tgz", - "integrity": "sha512-Au6ra1oVBNlxgroyr58VuaO2mVH02xidPfN6o0znYr/NQ8OvXNm4CVM44iwyCP1sOzhLKjMoPEeWIbKDpLgtFg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.40.0.tgz", + "integrity": "sha512-G2OUy5b2vxYXoRWo38BwxBKW1GCjwno9tivcshJNBWkeHjwcidLkL6KFaVRgIDDxJjojPkoxy9AivTDU/ksJ6g==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index fb4e1fdd1eb..d0eba8ad9ce 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -249,4 +249,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} diff --git a/remote/package-lock.json b/remote/package-lock.json index ee126bb8a8e..c6cfdf09904 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -17,7 +17,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", @@ -608,9 +608,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.39.1.tgz", - "integrity": "sha512-Au6ra1oVBNlxgroyr58VuaO2mVH02xidPfN6o0znYr/NQ8OvXNm4CVM44iwyCP1sOzhLKjMoPEeWIbKDpLgtFg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.40.0.tgz", + "integrity": "sha512-G2OUy5b2vxYXoRWo38BwxBKW1GCjwno9tivcshJNBWkeHjwcidLkL6KFaVRgIDDxJjojPkoxy9AivTDU/ksJ6g==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index 0142cf0cb92..526d813cc36 100644 --- a/remote/package.json +++ b/remote/package.json @@ -12,7 +12,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", From b6e8c5a1732a55abc0a8de6b6deee55b3c9cdb9e Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Thu, 19 Mar 2026 16:35:54 -0700 Subject: [PATCH 071/183] refactor, addressed comments --- .../workbench/browser/actions/quickAccessActions.ts | 12 +++++++++--- .../browser/parts/titlebar/commandCenterControl.ts | 10 +++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/browser/actions/quickAccessActions.ts b/src/vs/workbench/browser/actions/quickAccessActions.ts index 099ce915198..48aca00b6f3 100644 --- a/src/vs/workbench/browser/actions/quickAccessActions.ts +++ b/src/vs/workbench/browser/actions/quickAccessActions.ts @@ -17,6 +17,8 @@ import { ILocalizedString } from '../../../platform/action/common/action.js'; import { AnythingQuickAccessProviderRunOptions } from '../../../platform/quickinput/common/quickAccess.js'; import { Codicon } from '../../../base/common/codicons.js'; +const UNIFIED_AGENTS_BAR_SETTING = 'chat.unifiedAgentsBar.enabled'; + //#region Quick access management commands and keys const globalQuickAccessKeybinding = { @@ -162,7 +164,7 @@ registerAction2(class QuickAccessAction extends Action2 { }); } - run(accessor: ServicesAccessor): void { + async run(accessor: ServicesAccessor): Promise { const openClassicQuickAccess = (): void => { const quickInputService = accessor.get(IQuickInputService); const providerOptions: AnythingQuickAccessProviderRunOptions = { @@ -177,9 +179,13 @@ registerAction2(class QuickAccessAction extends Action2 { const configurationService = accessor.get(IConfigurationService); const commandService = accessor.get(ICommandService); - const useUnifiedQuickAccess = configurationService.getValue('chat.unifiedAgentsBar.enabled') === true; + const useUnifiedQuickAccess = configurationService.getValue(UNIFIED_AGENTS_BAR_SETTING) === true; if (useUnifiedQuickAccess) { - void commandService.executeCommand('workbench.action.unifiedQuickAccess').then(undefined, () => openClassicQuickAccess()); + try { + await commandService.executeCommand('workbench.action.unifiedQuickAccess'); + } catch { + openClassicQuickAccess(); + } return; } diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index 99d575cf640..cdf065ca432 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -24,6 +24,10 @@ import { IEditorGroupsService } from '../../../services/editor/common/editorGrou import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +const AI_DISABLED_SETTING = 'chat.disableAIFeatures'; +const AI_CUSTOMIZATION_MENU_ENABLED_SETTING = 'chat.customizationsMenu.enabled'; +const AGENT_STATUS_ENABLED_SETTING = 'chat.agentsControl.enabled'; + export class CommandCenterControl { private readonly _disposables = new DisposableStore(); @@ -148,12 +152,12 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { // When agent control mode is 'compact', hide search icon and left-align the label // Backward compat: the old boolean setting (true) and the new default (undefined) both map to compact - const aiFeaturesDisabled = that._configurationService.getValue('chat.disableAIFeatures') === true; + const aiFeaturesDisabled = that._configurationService.getValue(AI_DISABLED_SETTING) === true; const aiCustomizationsDisabled = that._configurationService.getValue('disableAICustomizations') === true || that._configurationService.getValue('workbench.disableAICustomizations') === true - || that._configurationService.getValue('chat.customizationsMenu.enabled') === false; + || that._configurationService.getValue(AI_CUSTOMIZATION_MENU_ENABLED_SETTING) === false; const forcedHidden = aiFeaturesDisabled && aiCustomizationsDisabled; - const agentControlValue = that._configurationService.getValue('chat.agentsControl.enabled'); + const agentControlValue = that._configurationService.getValue(AGENT_STATUS_ENABLED_SETTING); const isCompactMode = !forcedHidden && (agentControlValue === true || agentControlValue === undefined || agentControlValue === 'compact'); container.classList.toggle('compact-mode', isCompactMode); From c52ead66d9d4011cab30250d2cfd655ea3e43888 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:38:36 -0700 Subject: [PATCH 072/183] Browser API optimizations (#303321) * Browser API optimizations * Update src/vs/workbench/api/common/extHostBrowsers.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/browser/mainThreadBrowsers.ts | 13 ++++++--- .../workbench/api/common/extHost.protocol.ts | 4 +-- .../workbench/api/common/extHostBrowsers.ts | 11 +++----- .../api/test/browser/extHostBrowsers.test.ts | 28 ++++++++----------- 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts index 257e0130ab2..2c0e0b7875b 100644 --- a/src/vs/workbench/api/browser/mainThreadBrowsers.ts +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -77,12 +77,17 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers // #region Browser tab tracking + private _lastActiveBrowserId: string | undefined = undefined; private async _syncActiveBrowserTab(): Promise { const active = this.editorService.activeEditorPane?.input; + let activeId: string | undefined; if (active instanceof BrowserEditorInput) { - this._proxy.$onDidChangeActiveBrowserTab(this._toDto(active)); - } else { - this._proxy.$onDidChangeActiveBrowserTab(undefined); + this._track(active); + activeId = active.id; + } + if (this._lastActiveBrowserId !== activeId) { + this._lastActiveBrowserId = activeId; + this._proxy.$onDidChangeActiveBrowserTab(activeId); } } @@ -94,7 +99,7 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers // Track property changes. Currently all the tracked properties are covered under the `onDidChangeLabel` event. disposables.add(input.onDidChangeLabel(() => { - this._proxy.$onDidChangeBrowserTabState(input.id, this._toDto(input)); + this._proxy.$onDidChangeBrowserTabState(this._toDto(input)); })); disposables.add(input.onWillDispose(() => { this._knownBrowsers.deleteAndDispose(input.id); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7972ca1fb01..bcc636d1927 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1371,8 +1371,8 @@ export interface MainThreadBrowsersShape extends IDisposable { export interface ExtHostBrowsersShape { $onDidOpenBrowserTab(browser: BrowserTabDto): void; $onDidCloseBrowserTab(browserId: string): void; - $onDidChangeActiveBrowserTab(browser: BrowserTabDto | undefined): void; - $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void; + $onDidChangeActiveBrowserTab(browserId: string | undefined): void; + $onDidChangeBrowserTabState(browser: BrowserTabDto): void; $onCDPSessionMessage(sessionId: string, message: CDPResponse | CDPEvent): void; $onCDPSessionClosed(sessionId: string): void; } diff --git a/src/vs/workbench/api/common/extHostBrowsers.ts b/src/vs/workbench/api/common/extHostBrowsers.ts index a66e3113409..bbb360c2705 100644 --- a/src/vs/workbench/api/common/extHostBrowsers.ts +++ b/src/vs/workbench/api/common/extHostBrowsers.ts @@ -237,16 +237,13 @@ export class ExtHostBrowsers extends Disposable implements ExtHostBrowsersShape } } - $onDidChangeActiveBrowserTab(dto: BrowserTabDto | undefined): void { - this._activeBrowserTabId = dto?.id; - if (dto) { - this._getOrCreateTab(dto); - } + $onDidChangeActiveBrowserTab(browserId: string | undefined): void { + this._activeBrowserTabId = browserId; this._onDidChangeActiveBrowserTab.fire(this.activeBrowserTab); } - $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void { - const tab = this._browserTabs.get(browserId); + $onDidChangeBrowserTabState(data: BrowserTabDto): void { + const tab = this._browserTabs.get(data.id); if (tab && tab.update(data)) { this._onDidChangeBrowserTabState.fire(tab.value); } diff --git a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts index e05e040cf70..c27dab06c97 100644 --- a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts +++ b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts @@ -74,7 +74,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1', url: 'https://active.com' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.strictEqual(extHost.activeBrowserTab?.url, 'https://active.com'); }); @@ -83,23 +83,19 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.ok(extHost.activeBrowserTab); extHost.$onDidChangeActiveBrowserTab(undefined); assert.strictEqual(extHost.activeBrowserTab, undefined); }); - test('$onDidChangeActiveBrowserTab with unknown tab creates it and fires open event', () => { + test('$onDidChangeActiveBrowserTab with unknown tab returns undefined', () => { const extHost = createExtHostBrowsers(); - const opened: vscode.BrowserTab[] = []; - store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); - extHost.$onDidChangeActiveBrowserTab(createDto({ id: 'new-tab', url: 'https://new.com' })); + extHost.$onDidChangeActiveBrowserTab('non-existent'); - assert.strictEqual(extHost.activeBrowserTab?.url, 'https://new.com'); - assert.strictEqual(extHost.browserTabs.length, 1); - assert.strictEqual(opened.length, 1, 'onDidOpenBrowserTab should fire for the new tab'); + assert.strictEqual(extHost.activeBrowserTab, undefined); }); // #endregion @@ -186,7 +182,7 @@ suite('ExtHostBrowsers', () => { store.add(extHost.onDidChangeBrowserTabState(tab => changes.push(tab))); extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com' })); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com' })); assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].url, 'https://new.com'); @@ -196,7 +192,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Old Title' })); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); assert.strictEqual(extHost.browserTabs[0].url, 'https://example.com'); assert.strictEqual(extHost.browserTabs[0].title, 'New Title'); @@ -213,7 +209,7 @@ suite('ExtHostBrowsers', () => { const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); extHost.$onDidChangeActiveBrowserTab(undefined); assert.deepStrictEqual(activeChanges, ['https://example.com', undefined]); @@ -242,7 +238,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined })); assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/new.ico'); }); @@ -251,7 +247,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/icon.ico' })); assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/icon.ico'); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: undefined })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: undefined })); assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); }); @@ -379,7 +375,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com', title: 'Old' })); const tabBefore = extHost.browserTabs[0]; - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); const tabAfter = extHost.browserTabs[0]; assert.strictEqual(tabBefore, tabAfter); @@ -417,7 +413,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.ok(extHost.activeBrowserTab); extHost.$onDidCloseBrowserTab('b1'); From 7081563fd4e6d486ac58acd234fbd0dff1de8af6 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Thu, 19 Mar 2026 16:48:23 -0700 Subject: [PATCH 073/183] fix: catch notebook snapshot errors in chat editing (#301202) When NotebookTextModel.createSnapshot() throws 'Notebook too large to backup' (outputs exceed notebook.backup.sizeLimit), catch the error at the caller side instead of letting it propagate unhandled. - getCurrentSnapshot(): Catch error, log via loggingService, fall back to initialContent. - createSnapshot(): Reuse getCurrentSnapshot() for the modified model snapshot. Catch error for the original model snapshot and fall back to initialContent. - create(): Let error propagate to the session's existing try/catch. - Revert model-level changes (NotebookTextModel still throws). - Log only error messages, not URIs, to avoid privacy concerns. --- .../chatEditingModifiedNotebookEntry.ts | 12 ++----- .../common/model/notebookTextModel.ts | 32 ++++++++----------- .../test/browser/notebookEditorModel.test.ts | 29 ++++++++--------- 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index aae9dd0970c..456e9a98724 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -915,26 +915,20 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie try { return createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); } catch (e) { - this.loggingService.error('Notebook Chat', `Error creating snapshot for ${this.modifiedModel.uri}: ${e}`); + this.loggingService.error('Notebook Chat', `Error creating current snapshot: ${e instanceof Error ? e.message : e}`); return this.initialContent; } } override createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry { let original: string; - let current: string; try { original = createSnapshot(this.originalModel, this.transientOptions, this.configurationService); } catch (e) { - this.loggingService.error('Notebook Chat', `Error creating snapshot for original ${this.originalModel.uri}: ${e}`); + this.loggingService.error('Notebook Chat', `Error creating original snapshot: ${e instanceof Error ? e.message : e}`); original = this.initialContent; } - try { - current = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); - } catch (e) { - this.loggingService.error('Notebook Chat', `Error creating snapshot for modified ${this.modifiedModel.uri}: ${e}`); - current = this.initialContent; - } + const current = this.getCurrentSnapshot(); return { resource: this.modifiedURI, languageId: SnapshotLanguageId, diff --git a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts index c4b92d62807..ec476411052 100644 --- a/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts +++ b/src/vs/workbench/contrib/notebook/common/model/notebookTextModel.ts @@ -479,24 +479,7 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel cells: [], }; - // When backing up, if total output size exceeds the limit, strip outputs - // instead of throwing so that callers still get a valid (degraded) snapshot. - let includeOutputs = !transientOptions.transientOutputs; - if (includeOutputs && options.context === SnapshotContext.Backup && options.outputSizeLimit > 0) { - let totalOutputSize = 0; - for (const cell of this.cells) { - for (const output of cell.outputs) { - for (const item of output.outputs) { - totalOutputSize += item.data.byteLength; - } - } - if (totalOutputSize > options.outputSizeLimit) { - includeOutputs = false; - break; - } - } - } - + let outputSize = 0; for (const cell of this.cells) { const cellData: ICellDto2 = { cellKind: cell.cellKind, @@ -507,7 +490,18 @@ export class NotebookTextModel extends Disposable implements INotebookTextModel internalMetadata: cell.internalMetadata }; - cellData.outputs = includeOutputs ? cell.outputs : []; + if (options.context === SnapshotContext.Backup && options.outputSizeLimit > 0) { + cell.outputs.forEach(output => { + output.outputs.forEach(item => { + outputSize += item.data.byteLength; + }); + }); + if (outputSize > options.outputSizeLimit) { + throw new Error('Notebook too large to backup'); + } + } + + cellData.outputs = !transientOptions.transientOutputs ? cell.outputs : []; cellData.metadata = filter(cell.metadata, key => !transientOptions.transientCellMetadata[key]); data.cells.push(cellData); diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts index f0c62290570..9274063cef1 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookEditorModel.test.ts @@ -221,7 +221,7 @@ suite('NotebookFileWorkingCopyModel', function () { } }); - test('Notebooks with outputs beyond the size threshold will strip outputs for backup snapshots', async function () { + test('Notebooks with outputs beyond the size threshold will throw for backup snapshots', async function () { const outputLimit = 100; await configurationService.setUserConfiguration(NotebookSetting.outputBackupSizeLimit, outputLimit * 1.0 / 1024); const largeOutput: IOutputDto = { outputId: '123', outputs: [{ mime: Mimes.text, data: VSBuffer.fromString('a'.repeat(outputLimit + 1)) }] }; @@ -234,23 +234,16 @@ suite('NotebookFileWorkingCopyModel', function () { ); disposables.add(notebook); - let backupCallCount = 0; - let saveCallCount = 0; + let callCount = 0; const model = disposables.add(new NotebookFileWorkingCopyModel( notebook, mockNotebookService(notebook, new class extends mock() { - override options: TransientOptions = { transientOutputs: false, transientDocumentMetadata: {}, transientCellMetadata: { bar: true }, cellContentMetadata: {} }; + override options: TransientOptions = { transientOutputs: true, transientDocumentMetadata: {}, transientCellMetadata: { bar: true }, cellContentMetadata: {} }; override async notebookToData(notebook: NotebookData) { - if (backupCallCount === 0) { - backupCallCount += 1; - // Backup should strip outputs when they exceed the limit - assert.deepStrictEqual(notebook.cells[0].outputs, []); - } else { - saveCallCount += 1; - // Save should still include outputs - assert.strictEqual(notebook.cells[0].outputs.length, 1); - } + callCount += 1; + assert.strictEqual(notebook.cells[0].metadata!.foo, 123); + assert.strictEqual(notebook.cells[0].metadata!.bar, undefined); return VSBuffer.fromString(''); } }, @@ -261,11 +254,15 @@ suite('NotebookFileWorkingCopyModel', function () { logservice )); - await model.snapshot(SnapshotContext.Backup, CancellationToken.None); - assert.strictEqual(backupCallCount, 1); + try { + await model.snapshot(SnapshotContext.Backup, CancellationToken.None); + assert.fail('Expected snapshot to throw an error for large output'); + } catch (e) { + assert.notEqual(e.code, 'ERR_ASSERTION', e.message); + } await model.snapshot(SnapshotContext.Save, CancellationToken.None); - assert.strictEqual(saveCallCount, 1); + assert.strictEqual(callCount, 1); }); From 9bdfc5c14b0dc0cdeb98f7ea3d20e9ebd4ffb119 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:00:59 -0700 Subject: [PATCH 074/183] Agent debug log export: include session Id in filename and show success notification (#303334) * agent debug export notification * feedbackl updates --- .../actions/chatOpenAgentDebugPanelAction.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 263c8839baf..0f48de3cae2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -14,6 +14,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -25,6 +26,7 @@ import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; /** * Registers the Open Agent Debug Logs and Show Agent Debug Logs actions. @@ -127,6 +129,7 @@ export function registerChatOpenAgentDebugPanelAction() { const fileDialogService = accessor.get(IFileDialogService); const fileService = accessor.get(IFileService); const notificationService = accessor.get(INotificationService); + const openerService = accessor.get(IOpenerService); const telemetryService = accessor.get(ITelemetryService); const sessionResource = chatDebugService.activeSessionResource; @@ -135,7 +138,11 @@ export function registerChatOpenAgentDebugPanelAction() { return; } - const defaultUri = joinPath(await fileDialogService.defaultFilePath(), defaultDebugLogFileName); + const localSessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); + const rawIdentifier = localSessionId ?? (sessionResource.path.replace(/^\//, '') || sessionResource.authority); + const sessionIdentifier = rawIdentifier?.replace(/[/\\:*?"<>|.]+/g, '_').replace(/^_+|_+$/g, ''); + const exportFileName = sessionIdentifier ? `agent-debug-log-${sessionIdentifier}.json` : defaultDebugLogFileName; + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), exportFileName); const outputPath = await fileDialogService.showSaveDialog({ defaultUri, filters: debugLogFilters }); if (!outputPath) { return; @@ -152,6 +159,15 @@ export function registerChatOpenAgentDebugPanelAction() { telemetryService.publicLog2('chatDebugLogExported', { fileSizeBytes: data.byteLength, }); + + notificationService.prompt( + Severity.Info, + localize('chatDebugLog.exportSuccess', "Agent debug log exported successfully."), + [{ + label: localize('chatDebugLog.openExportedFile', "Open File"), + run: () => openerService.open(outputPath) + }] + ); } }); From f43d8aa7666ec8c1f342450791aba63f1032e667 Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Thu, 19 Mar 2026 17:17:42 -0700 Subject: [PATCH 075/183] refactor: extract _safeCreateSnapshot helper to deduplicate catch --- .../chatEditingModifiedNotebookEntry.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 456e9a98724..6dda2da6585 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -911,23 +911,21 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } } - public getCurrentSnapshot() { + private _safeCreateSnapshot(model: NotebookTextModel): string { try { - return createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); + return createSnapshot(model, this.transientOptions, this.configurationService); } catch (e) { - this.loggingService.error('Notebook Chat', `Error creating current snapshot: ${e instanceof Error ? e.message : e}`); + this.loggingService.error('Notebook Chat', `Error creating snapshot: ${e instanceof Error ? e.message : e}`); return this.initialContent; } } + public getCurrentSnapshot() { + return this._safeCreateSnapshot(this.modifiedModel); + } + override createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry { - let original: string; - try { - original = createSnapshot(this.originalModel, this.transientOptions, this.configurationService); - } catch (e) { - this.loggingService.error('Notebook Chat', `Error creating original snapshot: ${e instanceof Error ? e.message : e}`); - original = this.initialContent; - } + const original = this._safeCreateSnapshot(this.originalModel); const current = this.getCurrentSnapshot(); return { resource: this.modifiedURI, From 92e096f8ebe9d7a3175f85d2dd6297956bbca16c Mon Sep 17 00:00:00 2001 From: Bryan Chen Date: Thu, 19 Mar 2026 17:20:19 -0700 Subject: [PATCH 076/183] fix: enhance draft PR guidelines for error handling and validation steps --- .github/prompts/fix-error.prompt.md | 42 ++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md index 9dbef3ce12f..3781f160e76 100644 --- a/.github/prompts/fix-error.prompt.md +++ b/.github/prompts/fix-error.prompt.md @@ -23,16 +23,32 @@ After the fix is validated (compilation clean, tests pass): 1. **Create a branch**: `git checkout -b /` (e.g., `bryanchen-d/fix-notebook-index-error`). 2. **Commit**: Stage changed files and commit with a message like `fix: (#)`. 3. **Push**: `git push -u origin `. -4. **Create a draft PR** with a description that includes: - - A summary of the change. - - `Fixes #` so GitHub auto-closes the issue when the PR merges. - - What scenarios may trigger the error. - - The code flow explaining why the error gets thrown and goes unhandled. - - Steps a user can follow to manually validate the fix. - - How the fix addresses the issue, with a brief note per changed file. -5. **Monitor the PR** for Copilot review comments. Wait 1-2 minutes after each push for Copilot to leave its review, then check for new comments. Evaluate each comment: - - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). - - If not applicable, leave a reply explaining why. - - After addressing comments, update the PR description if the changes affect the summary, code flow explanation, or per-file notes. -6. **Repeat monitoring** after each push: wait 1-2 minutes, check for new Copilot comments, and address them. Continue this loop until no new comments appear. -7. **Re-run tests** after addressing review comments to confirm nothing regressed. +4. **Create a draft PR** with a description that includes these sections: + - **Summary**: A concise description of what was changed and why. + - **Issue link**: `Fixes #` so GitHub auto-closes the issue when the PR merges. + - **Trigger scenarios**: What user actions or system conditions cause this error to surface. + - **Code flow diagram**: A Mermaid swimlane/sequence diagram showing the call chain from trigger to error. Use participant labels for the key components (e.g., classes, modules, processes). Example: + ```` + ```mermaid + sequenceDiagram + participant A as CallerComponent + participant B as MiddleLayer + participant C as LowLevelUtil + A->>B: someOperation(data) + B->>C: validate(data) + C-->>C: data is invalid + C->>B: throws "error message" + B->>A: unhandled error propagates + ``` + ```` + - **Manual validation steps**: Concrete, step-by-step instructions a reviewer can follow to reproduce the original error and verify the fix. Include specific setup requirements (e.g., file types to open, settings to change, actions to perform). If the error cannot be easily reproduced manually, explain why and describe what alternative validation was performed (e.g., unit tests, code inspection). + - **How the fix works**: A brief explanation of the fix approach, with a note per changed file. +5. **Monitor the PR — BLOCKING**: You MUST NOT complete the task until the monitoring loop below is done. + - Wait 2 minutes after each push, then check for Copilot review comments using `gh pr view --json reviews,comments` and `gh api repos/{owner}/{repo}/pulls/{number}/comments`. + - If there are review comments, evaluate each one: + - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). + - If not applicable, leave a reply explaining why. + - After addressing comments, update the PR description if the changes affect the summary, diagram, or per-file notes. + - **Re-run tests** after addressing review comments to confirm nothing regressed. + - After each push, repeat the wait-and-check cycle. Continue until **two consecutive checks return zero new comments**. +6. **Verify CI**: After the monitoring loop is done, check that CI checks are passing using `gh pr checks `. If any required checks fail, investigate and fix. Do NOT complete the task with failing CI. From 9541d493351e6c89b30899b6034f1394c98e03f0 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:37:36 -0700 Subject: [PATCH 077/183] chat customizations: component fixtures, developer skill, spec updates (#303309) * component explorer fixture for chat customization tabs * chat customizations: full editor fixture + developer skill * Refine AI customization management editor fixtures * fix: update DOM element creation to use shorthand syntax --------- Co-authored-by: Martin Aeschlimann --- .../chat-customizations-editor/SKILL.md | 42 +++ src/vs/sessions/AI_CUSTOMIZATIONS.md | 74 +++- .../aiCustomizationManagementEditor.ts | 42 +-- .../aiCustomizationListWidget.fixture.ts | 50 ++- ...aiCustomizationManagementEditor.fixture.ts | 353 ++++++++++++++++++ 5 files changed, 506 insertions(+), 55 deletions(-) create mode 100644 .github/skills/chat-customizations-editor/SKILL.md create mode 100644 src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md new file mode 100644 index 00000000000..b90fb5b46cf --- /dev/null +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -0,0 +1,42 @@ +--- +name: chat-customizations-editor +description: Use when working on the Chat Customizations editor — the management UI for agents, skills, instructions, hooks, prompts, MCP servers, and plugins. +--- + +# Chat Customizations Editor + +Split-view management pane for AI customization items across workspace, user, extension, and plugin storage. Supports harness-based filtering (Local, Copilot CLI, Claude). + +## Spec + +**`src/vs/sessions/AI_CUSTOMIZATIONS.md`** — always read before making changes, always update after. + +## Key Folders + +| Folder | What | +|--------|------| +| `src/vs/workbench/contrib/chat/common/` | `ICustomizationHarnessService`, `ISectionOverride`, `IStorageSourceFilter` — shared interfaces and filter helpers | +| `src/vs/workbench/contrib/chat/browser/aiCustomization/` | Management editor, list widgets (prompts, MCP, plugins), harness service registration | +| `src/vs/sessions/contrib/chat/browser/` | Sessions-window overrides (harness service, workspace service) | +| `src/vs/sessions/contrib/sessions/browser/` | Sessions tree view counts and toolbar | + +When changing harness descriptor interfaces or factory functions, verify both core and sessions registrations compile. + +## Key Interfaces + +- **`IHarnessDescriptor`** — drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference. +- **`ISectionOverride`** — per-section button customization (command invocation, root file creation, type labels, file extensions). +- **`IStorageSourceFilter`** — controls which storage sources and user roots are visible per harness/type. + +Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code. + +## Testing + +Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. + +```bash +./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts" +npm run compile-check-ts-native && npm run valid-layers-check +``` + +See the `sessions` skill for sessions-window specific guidance. diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 98fbccd9873..bf46c8c1f66 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -15,18 +15,21 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle +├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) ├── aiCustomizationDebugPanel.ts # Debug diagnostics panel ├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl -├── customizationHarnessService.ts # Core harness service impl (VS Code harness) +├── customizationHarnessService.ts # Core harness service impl (agent-gated) ├── customizationCreatorService.ts # AI-guided creation flow -├── mcpListWidget.ts # MCP servers section +├── customizationGroupHeaderRenderer.ts # Collapsible group header renderer +├── mcpListWidget.ts # MCP servers section (Extensions + Built-in groups) +├── pluginListWidget.ts # Agent plugins section ├── aiCustomizationIcons.ts # Icons └── media/ └── aiCustomizationManagement.css src/vs/workbench/contrib/chat/common/ ├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter -└── customizationHarnessService.ts # ICustomizationHarnessService + CustomizationHarness enum +└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers ``` The tree view and overview live in `vs/sessions` (sessions window only): @@ -46,9 +49,10 @@ Sessions-specific overrides: ``` src/vs/sessions/contrib/chat/browser/ ├── aiCustomizationWorkspaceService.ts # Sessions workspace service override -├── customizationHarnessService.ts # Sessions harness service (CLI + Claude harnesses) +├── customizationHarnessService.ts # Sessions harness service (CLI harness only) └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ +├── aiCustomizationShortcutsWidget.ts # Shortcuts widget ├── customizationCounts.ts # Source count utilities (type-aware) └── customizationsToolbar.contribution.ts # Sidebar customization links ``` @@ -59,7 +63,7 @@ The `IAICustomizationWorkspaceService` interface controls per-window behavior: | Property / Method | Core VS Code | Sessions Window | |----------|-------------|----------| -| `managementSections` | All sections except Models | Same minus MCP | +| `managementSections` | All sections except Models | All sections except Models | | `getStorageSourceFilter(type)` | Delegates to `ICustomizationHarnessService` | Delegates to `ICustomizationHarnessService` | | `isSessionsWindow` | `false` | `true` | | `activeProjectRoot` | First workspace folder | Active session worktree | @@ -71,19 +75,34 @@ Storage answers "where did this come from?"; harness answers "who consumes it?". The service is defined in `common/customizationHarnessService.ts` which also provides: - **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor` — parameterized by an `extras` array (the additional storage sources beyond `local`, `user`, `plugin`). Core passes `[PromptsStorage.extension]`; sessions passes `[BUILTIN_STORAGE]`. -- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge so it isn't duplicated. +- **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). +- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. +- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. +- **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. Available harnesses: | Harness | Label | Description | |---------|-------|-------------| -| `vscode` | VS Code | Shows all storage sources (default in core) | +| `vscode` | Local | Shows all storage sources (default in core) | | `cli` | Copilot CLI | Restricts user roots to `~/.copilot`, `~/.claude`, `~/.agents` | -| `claude` | Claude | Restricts user roots to `~/.claude` | +| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | -In core VS Code, all three harnesses are registered; VS Code is the default. -In sessions, `cli` and `claude` harnesses are registered with a toggle bar above the list. +In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. +In sessions, only CLI is registered (single harness, toggle bar hidden). + +### IHarnessDescriptor + +Key properties on the harness descriptor: + +| Property | Purpose | +|----------|--------| +| `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | +| `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | +| `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | +| `sectionOverrides` | Per-section `ISectionOverride` map for button behavior | +| `requiredAgentId` | Agent ID that must be registered for harness to appear | +| `instructionFileFilter` | Filename/path patterns to filter instruction items | ### IStorageSourceFilter @@ -99,9 +118,7 @@ interface IStorageSourceFilter { The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. -**Sessions filter behavior by harness and type:** - -CLI harness: +**Sessions filter behavior (CLI harness):** | Type | sources | includedUserFileRoots | |------|---------|----------------------| @@ -109,15 +126,31 @@ CLI harness: | Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | | Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.copilot, ~/.claude, ~/.agents]` | -Claude harness: +**Core VS Code filter behavior:** + +Local harness: all types use `[local, user, extension, plugin]` with no user root filter. + +CLI harness (core): | Type | sources | includedUserFileRoots | |------|---------|----------------------| | Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.claude]` | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.copilot, ~/.claude, ~/.agents]` | -**Core VS Code:** All types use `[local, user, extension, plugin]` with no user root filter. +Claude harness (core): + +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local, plugin]` | N/A | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.claude]` | + +Claude additionally applies: +- `hiddenSections: [Prompts, Plugins]` +- `instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md']` +- `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) +- `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension ### AgenticPromptsService (Sessions) @@ -149,7 +182,7 @@ Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. T | Skills | `findAgentSkills()` | Parsed skills with frontmatter | | Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | | Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | -| Hooks | `listPromptFiles()` | Raw hook files | +| Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` | ### Debug Panel @@ -175,8 +208,9 @@ All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizatio ## Settings -Settings use the `chat.customizationsMenu.` namespace: +Settings use the `chat.customizationsMenu.` and `chat.customizations.` namespaces: | Setting | Default | Description | |---------|---------|-------------| | `chat.customizationsMenu.enabled` | `true` | Show the Chat Customizations editor in the Command Palette | +| `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar | diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 4b3abe9ab75..2896cbbef08 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -74,6 +74,8 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { Action } from '../../../../../base/common/actions.js'; import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -331,6 +333,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IHoverService private readonly hoverService: IHoverService, @IModelService private readonly modelService: IModelService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, @IFileService private readonly fileService: IFileService, @INotificationService private readonly notificationService: INotificationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @@ -606,7 +609,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateHarnessDropdown(); this.editorDisposables.add(DOM.addDisposableListener(this.harnessDropdownButton, 'click', () => { - this.showHarnessPicker(); + this.showHarnessMenu(); })); } @@ -627,31 +630,26 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private showHarnessPicker(): void { + private showHarnessMenu(): void { + if (!this.harnessDropdownButton) { + return; + } const harnesses = this.harnessService.availableHarnesses.get(); const activeId = this.harnessService.activeHarness.get(); - const items = harnesses.map(h => ({ - label: h.label, - iconClass: ThemeIcon.asClassName(h.icon), - id: h.id, - picked: h.id === activeId, - })); - - const picker = this.quickInputService.createQuickPick(); - picker.items = items; - picker.placeholder = localize('selectTarget', "Select customization target"); - picker.canSelectMany = false; - picker.activeItems = items.filter(i => i.picked); - picker.onDidAccept(() => { - const selected = picker.activeItems[0] as typeof items[0] | undefined; - if (selected) { - this.harnessService.setActiveHarness(selected.id); - } - picker.dispose(); + const actions = harnesses.map(h => { + const action = new Action(h.id, h.label, ThemeIcon.asClassName(h.icon), true, () => { + this.harnessService.setActiveHarness(h.id); + }); + action.checked = h.id === activeId; + return action; + }); + + this.contextMenuService.showContextMenu({ + getAnchor: () => this.harnessDropdownButton!, + getActions: () => actions, + getCheckedActionsRepresentation: () => 'radio', }); - picker.onDidHide(() => picker.dispose()); - picker.show(); } private createFolderPicker(sidebarContent: HTMLElement): void { diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts index 1fdac89c3f6..b588f0de2e1 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; -import { ResourceSet } from '../../../../base/common/map.js'; +import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; @@ -17,7 +17,7 @@ import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../.. import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../contrib/chat/common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath, IExtensionPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; import { IPathService } from '../../../services/path/common/pathService.js'; @@ -33,16 +33,28 @@ import '../../../../platform/theme/common/colors/listColors.js'; // ============================================================================ const defaultFilter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension], + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin], }; interface IFixtureInstructionFile { - readonly promptPath: IPromptPath; + readonly uri: URI; + readonly storage: PromptsStorage; + readonly type: PromptsType; + readonly name?: string; + readonly description?: string; /** If set, this instruction file has an applyTo pattern (on-demand). */ readonly applyTo?: string; } function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): IPromptsService { + // Build a map from URI to applyTo for parseNew + const applyToMap = new ResourceMap(); + const descriptionMap = new ResourceMap(); + for (const file of instructionFiles) { + applyToMap.set(file.uri, file.applyTo); + descriptionMap.set(file.uri, file.description); + } + return new class extends mock() { override readonly onDidChangeCustomAgents = Event.None; override readonly onDidChangeSlashCommands = Event.None; @@ -50,14 +62,26 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } override async listPromptFiles(type: PromptsType) { if (type === PromptsType.instructions) { - return instructionFiles.map(f => f.promptPath); + return instructionFiles.map(f => ({ + uri: f.uri, + storage: f.storage as PromptsStorage.local, + type: f.type, + name: f.name, + description: f.description, + })); } return []; } override async listAgentInstructions() { return agentInstructionFiles; } override async getCustomAgents() { return []; } override async parseNew(uri: URI, _token: CancellationToken): Promise { - return new ParsedPromptFile(uri); + const applyTo = applyToMap.get(uri); + const description = descriptionMap.get(uri); + const header = { + get applyTo() { return applyTo; }, + get description() { return description; }, + }; + return new ParsedPromptFile(uri, header as never); } }(); } @@ -154,20 +178,20 @@ async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFi // Fixtures // ============================================================================ -export default defineThemedFixtureGroup({ path: 'chat/' }, { +export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { InstructionsTabWithItems: defineComponentFixture({ labels: { kind: 'screenshot' }, render: ctx => renderInstructionsTab(ctx, [ // Always-active instructions (no applyTo) - { promptPath: { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' } }, - { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style preferences' } }, + { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, + { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style preferences' }, // Always-included instruction (applyTo: **) - { promptPath: { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'General Guidelines', description: 'General development guidelines' }, applyTo: '**' }, + { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'General Guidelines', description: 'General development guidelines', applyTo: '**' }, // On-demand instructions (with applyTo pattern) - { promptPath: { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing Guidelines', description: 'Testing best practices' }, applyTo: '**/*.test.ts' }, - { promptPath: { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security Review', description: 'Security review checklist' }, applyTo: 'src/auth/**' }, - { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, name: 'TypeScript Rules', description: 'TypeScript conventions', extension: undefined!, source: undefined! } satisfies IExtensionPromptPath, applyTo: '**/*.ts' }, + { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing Guidelines', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security Review', description: 'Security review checklist', applyTo: 'src/auth/**' }, + { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Typescript Rules', description: 'TypeScript conventions', applyTo: '**/*.ts' }, ], [ // Agent instruction files (AGENTS.md, copilot-instructions.md) { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts new file mode 100644 index 00000000000..f57c42abd49 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.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 * as DOM from '../../../../base/browser/dom.js'; +import { Dimension } from '../../../../base/browser/dom.js'; +import { IRenderedMarkdown } from '../../../../base/browser/markdownRenderer.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; +import { constObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IRequestService } from '../../../../platform/request/common/request.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; +import { IWebviewService } from '../../../contrib/webview/browser/webview.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createClaudeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots, getClaudeUserRoots } from '../../../contrib/chat/common/customizationHarnessService.js'; +import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; +import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { IPluginMarketplaceService } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { IPluginInstallService } from '../../../contrib/chat/common/plugins/pluginInstallService.js'; +import { AICustomizationManagementEditor } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpRegistry } from '../../../contrib/mcp/common/mcpRegistryTypes.js'; +import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; + +// Ensure theme colors & widget CSS are loaded +import '../../../../platform/theme/common/colors/inputColors.js'; +import '../../../../platform/theme/common/colors/listColors.js'; +import '../../../contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const userHome = URI.file('/home/dev'); +const BUILTIN_STORAGE = 'builtin'; + +interface IFixtureFile { + readonly uri: URI; + readonly storage: PromptsStorage; + readonly type: PromptsType; + readonly name?: string; + readonly description?: string; + readonly applyTo?: string; +} + +function createMockEditorGroup(): IEditorGroup { + return new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + }(); +} + +function createMockPromptsService(files: IFixtureFile[], agentInstructions: IResolvedAgentFile[]): IPromptsService { + const applyToMap = new ResourceMap(); + const descriptionMap = new ResourceMap(); + for (const f of files) { applyToMap.set(f.uri, f.applyTo); descriptionMap.set(f.uri, f.description); } + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeSkills = Event.None; + override readonly onDidChangeInstructions = Event.None; + override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } + override async listPromptFiles(type: PromptsType) { + return files.filter(f => f.type === type).map(f => ({ + uri: f.uri, storage: f.storage as PromptsStorage.local, type: f.type, name: f.name, description: f.description, + })); + } + override async listAgentInstructions() { return agentInstructions; } + override async getCustomAgents() { + return files.filter(f => f.type === PromptsType.agent).map(a => ({ + uri: a.uri, name: a.name ?? 'agent', description: a.description, storage: a.storage, + source: { storage: a.storage }, + })) as never[]; + } + override async parseNew(uri: URI, _token: CancellationToken): Promise { + const header = { + get applyTo() { return applyToMap.get(uri); }, + get description() { return descriptionMap.get(uri); }, + }; + return new ParsedPromptFile(uri, header as never); + } + override async getSourceFolders() { return [] as never[]; } + override async findAgentSkills() { return [] as never[]; } + override async getPromptSlashCommands() { return [] as never[]; } + }(); +} + +function createMockHarnessService(activeHarness: CustomizationHarness, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { + const active = observableValue('activeHarness', activeHarness); + return new class extends mock() { + override readonly activeHarness = active; + override readonly availableHarnesses = constObservable(descriptors); + override getStorageSourceFilter(type: PromptsType) { + const d = descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + return d.getStorageSourceFilter(type); + } + override getActiveDescriptor() { + return descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + } + override setActiveHarness(id: CustomizationHarness) { active.set(id, undefined); } + }(); +} + +function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScope, description?: string): IWorkbenchMcpServer { + return new class extends mock() { + override readonly id = id; + override readonly name = id; + override readonly label = label; + override readonly description = description ?? ''; + override readonly installState = McpServerInstallState.Installed; + override readonly local = new class extends mock() { + override readonly id = id; + override readonly scope = scope; + }(); + }(); +} + +// ============================================================================ +// Realistic test data — a project that has Copilot + Claude customizations +// ============================================================================ + +const allFiles: IFixtureFile[] = [ + // Copilot instructions + { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, + { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' }, + // Claude rules + { uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' }, + { uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' }, + { uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' }, + // Agents + { uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' }, + { uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' }, + { uri: URI.file('/workspace/.claude/agents/planner.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, + // Skills + { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, + { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, + // Prompts + { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, + { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, +]; + +const agentInstructions: IResolvedAgentFile[] = [ + { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, + { uri: URI.file('/workspace/CLAUDE.md'), realPath: undefined, type: AgentFileType.claudeMd }, + { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd }, +]; + +const mcpWorkspaceServers = [ + makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), + makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), +]; +const mcpUserServers = [ + makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'), +]; +const mcpRuntimeServers = [ + { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, +]; + +interface IRenderEditorOptions { + readonly harness: CustomizationHarness; + readonly isSessionsWindow?: boolean; + readonly managementSections?: readonly AICustomizationManagementSection[]; + readonly availableHarnesses?: readonly IHarnessDescriptor[]; +} + +// ============================================================================ +// Render helper — creates the full management editor +// ============================================================================ + +async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditorOptions): Promise { + const width = 900; + const height = 600; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const isSessionsWindow = options.isSessionsWindow ?? false; + const managementSections = options.managementSections ?? [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, + ]; + const availableHarnesses = options.availableHarnesses ?? [ + createVSCodeHarnessDescriptor([PromptsStorage.extension]), + createCliHarnessDescriptor(getCliUserRoots(userHome), []), + createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), []), + ]; + + const allMcpServers = [...mcpWorkspaceServers, ...mcpUserServers]; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + const harnessService = createMockHarnessService(options.harness, availableHarnesses); + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IPromptsService, createMockPromptsService(allFiles, agentInstructions)); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = isSessionsWindow; + override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter(type: PromptsType) { return harnessService.getStorageSourceFilter(type); } + override clearOverrideProjectRoot() { } + override setOverrideProjectRoot() { } + override readonly managementSections = managementSections; + override async generateCustomization() { } + }()); + reg.defineInstance(ICustomizationHarnessService, harnessService); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } + override getWorkbenchState(): WorkbenchState { return WorkbenchState.WORKSPACE; } + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + reg.defineInstance(IPathService, new class extends mock() { + override readonly defaultUriScheme = 'file'; + override userHome(): URI; + override userHome(): Promise; + override userHome(): URI | Promise { return userHome; } + }()); + reg.defineInstance(ITextModelService, new class extends mock() { }()); + reg.defineInstance(IWorkingCopyService, new class extends mock() { + override readonly onDidChangeDirty = Event.None; + }()); + reg.defineInstance(IFileDialogService, new class extends mock() { }()); + reg.defineInstance(IExtensionService, new class extends mock() { }()); + reg.defineInstance(IQuickInputService, new class extends mock() { }()); + reg.defineInstance(IRequestService, new class extends mock() { }()); + reg.defineInstance(IMarkdownRendererService, new class extends mock() { + override render() { + const rendered: IRenderedMarkdown = { + element: DOM.$('span'), + dispose() { }, + }; + return rendered; + } + }()); + reg.defineInstance(IWebviewService, new class extends mock() { }()); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local = allMcpServers; + override async queryLocal() { return allMcpServers; } + override canInstall() { return true as const; } + }()); + reg.defineInstance(IMcpService, new class extends mock() { + override readonly servers = constObservable(mcpRuntimeServers as never[]); + }()); + reg.defineInstance(IMcpRegistry, new class extends mock() { + override readonly collections = constObservable([]); + override readonly delegates = constObservable([]); + override readonly onDidChangeInputs = Event.None; + }()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([]); + override readonly enablementModel = undefined as never; + }()); + reg.defineInstance(IPluginMarketplaceService, new class extends mock() { + override readonly installedPlugins = constObservable([]); + override readonly onDidChangeMarketplaces = Event.None; + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + }, + }); + + const editor = ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationManagementEditor, createMockEditorGroup()) + ); + editor.create(ctx.container); + editor.layout(new Dimension(width, height)); + + // setInput may fail on unmocked service calls — catch to still show the editor shell + try { + await editor.setInput(AICustomizationManagementEditorInput.getOrCreate(), undefined, {}, CancellationToken.None); + } catch { + // Expected in fixture — some services are partially mocked + } +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { + + // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, + // Generate buttons, AGENTS.md shortcut, all storage groups + LocalHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), + }), + + // Full editor with Copilot CLI harness — no prompts section, CLI-specific + // root files and instruction filtering under .github/.copilot paths. + CliHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.CLI }), + }), + + // Full editor with Claude harness — Prompts+Plugins hidden, Agents visible, + // "Add CLAUDE.md" button, "New Rule" dropdown, instruction filtering, bridged MCP badge + ClaudeHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.Claude }), + }), + + // Sessions-window variant of the full editor with workspace override UX + // and sessions section ordering. + Sessions: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.CLI, + isSessionsWindow: true, + availableHarnesses: [ + createCliHarnessDescriptor(getCliUserRoots(userHome), [BUILTIN_STORAGE]), + ], + managementSections: [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, + ], + }), + }), +}); From 820aec47a7d42ef6b81ab6d251051a353552030e Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:59:49 -0700 Subject: [PATCH 078/183] Agent Debug panel: Fix Copilot CLI sessions not appearing until the second message was sent (#303356) * Fix Copilot CLI sessions not appearing in Chat Agent Debug panel on first message * feedback update --- .../api/browser/mainThreadChatSessions.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index fef8a35d419..ee57641a997 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -30,6 +30,7 @@ import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { IChatAgentRequest } from '../../contrib/chat/common/participants/chatAgents.js'; +import { IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; import { IChatArtifactsService } from '../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatTodoListService } from '../../contrib/chat/common/tools/chatTodoListService.js'; import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; @@ -445,6 +446,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IChatTodoListService private readonly _chatTodoListService: IChatTodoListService, @IChatArtifactsService private readonly _chatArtifactsService: IChatArtifactsService, + @IChatDebugService private readonly _chatDebugService: IChatDebugService, @IDialogService private readonly _dialogService: IDialogService, @IEditorService private readonly _editorService: IEditorService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @@ -575,6 +577,18 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // Migrate artifacts from old session to new session this._chatArtifactsService.migrateArtifacts(originalResource, modifiedResource); + // Eagerly invoke debug providers for Copilot CLI sessions so the real + // session appears in the debug panel immediately after the untitled → + // real swap. Without this, the untitled session is filtered out (it + // only has a "Load Hooks" event) and the real session has no events + // until someone navigates to it — which can't happen because it's + // not listed. + if (chatSessionType === 'copilotcli') { + // Fire-and-forget: don't block the editor swap. Errors are + // handled internally by invokeProviders via onUnexpectedError. + this._chatDebugService.invokeProviders(modifiedResource).catch(() => { /* handled internally */ }); + } + // Find the group containing the original editor const originalGroup = this.editorGroupService.groups.find(group => group.editors.some(editor => isEqual(editor.resource, originalResource))) From 1de403d8e03ee2035179defd2b44c3f292391b68 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Thu, 19 Mar 2026 20:16:49 -0500 Subject: [PATCH 079/183] Remove sign in dialog experiment --- .../welcome/browser/welcome.contribution.ts | 1 - .../chatSetup/chatSetupContributions.ts | 4 ++-- .../chat/browser/chatSetup/chatSetupRunner.ts | 23 +++++++------------ 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index f22ddd122c5..f6b57dbbd24 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -90,7 +90,6 @@ class SessionsWelcomeOverlay extends Disposable { dialogTitle: this.chatEntitlementService.anonymous ? localize('sessions.startUsingSessions', "Start using Sessions") : localize('sessions.signinRequired', "Sign in to use Sessions"), - dialogHideSkip: true }); if (success) { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 4a7157945cc..349ffc80a83 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -232,7 +232,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr }); } - override async run(accessor: ServicesAccessor, mode?: ChatModeKind | string, options?: { forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; inputValue?: string; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + override async run(accessor: ServicesAccessor, mode?: ChatModeKind | string, options?: { forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; inputValue?: string; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const widgetService = accessor.get(IChatWidgetService); const instantiationService = accessor.get(IInstantiationService); const dialogService = accessor.get(IDialogService); @@ -281,7 +281,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr }); } - override async run(accessor: ServicesAccessor, options?: { dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + override async run(accessor: ServicesAccessor, options?: { dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const commandService = accessor.get(ICommandService); const telemetryService = accessor.get(ITelemetryService); const chatEntitlementService = accessor.get(IChatEntitlementService); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 7e77a64f55e..dbf3a6895b7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -32,7 +32,6 @@ import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; -import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -51,7 +50,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService), accessor.get(IWorkbenchAssignmentService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService)); }); } @@ -75,14 +74,13 @@ export class ChatSetup { @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IHostService private readonly hostService: IHostService, - @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, ) { } skipDialog(): void { this.skipDialogOnce = true; } - async run(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + async run(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { if (this.pendingRun) { return this.pendingRun; } @@ -96,7 +94,7 @@ export class ChatSetup { } } - private async doRun(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + private async doRun(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { this.context.update({ later: false }); const dialogSkipped = this.skipDialogOnce; @@ -162,11 +160,10 @@ export class ChatSetup { return { success, dialogSkipped }; } - private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const disposables = new DisposableStore(); - const useCloseButton = options?.dialogHideSkip || await this.experimentService.getTreatment('chatSetupDialogCloseButton'); - const buttons = this.getButtons(options, useCloseButton); + const buttons = this.getButtons(options); const dialog = disposables.add(new Dialog( this.layoutService.activeContainer, @@ -178,8 +175,8 @@ export class ChatSetup { detail: ' ', // workaround allowing us to render the message in large icon: options?.dialogIcon ?? Codicon.copilotLarge, alignment: DialogContentsAlignment.Vertical, - cancelId: useCloseButton ? buttons.length : buttons.length - 1, - disableCloseButton: !useCloseButton, + cancelId: buttons.length, + disableCloseButton: false, renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)), buttonOptions: buttons.map(button => button[2]) }, this.keybindingService, this.layoutService, this.hostService) @@ -191,7 +188,7 @@ export class ChatSetup { return buttons[button]?.[1] ?? ChatSetupStrategy.Canceled; } - private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }, useCloseButton?: boolean): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { + private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { type ContinueWithButton = [string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]; const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) }); @@ -225,10 +222,6 @@ export class ChatSetup { buttons = [[localize('setupAIButton', "Use AI Features"), ChatSetupStrategy.DefaultSetup, undefined]]; } - if (!useCloseButton) { - buttons.push([localize('skipForNow', "Skip for now"), ChatSetupStrategy.Canceled, styleButton('link-button', 'skip-button')]); - } - return buttons; } From 2e7711d796a31586d65258468ab90e8461082546 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 20 Mar 2026 02:14:32 +0000 Subject: [PATCH 080/183] Sessions: Implement collapsed panel widgets for sidebar and auxiliary bar (#303253) * Implement collapsed panel widgets for sidebar and auxiliary bar Co-authored-by: Copilot * Update new session command to open chat in collapsed sidebar widget Co-authored-by: Copilot * Refactor collapsed panel widgets and update sidebar visibility conditions --------- Co-authored-by: mrleemurray Co-authored-by: Copilot --- .../sessions/browser/collapsedPartWidgets.ts | 277 ++++++++++++++++++ src/vs/sessions/browser/layoutActions.ts | 32 +- .../browser/media/collapsedPanelWidget.css | 107 +++++++ src/vs/sessions/browser/parts/titlebarPart.ts | 3 + src/vs/sessions/browser/workbench.ts | 65 +++- 5 files changed, 458 insertions(+), 26 deletions(-) create mode 100644 src/vs/sessions/browser/collapsedPartWidgets.ts create mode 100644 src/vs/sessions/browser/media/collapsedPanelWidget.css diff --git a/src/vs/sessions/browser/collapsedPartWidgets.ts b/src/vs/sessions/browser/collapsedPartWidgets.ts new file mode 100644 index 00000000000..10de969c260 --- /dev/null +++ b/src/vs/sessions/browser/collapsedPartWidgets.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/collapsedPanelWidget.css'; +import { $, addDisposableListener, append, EventType } from '../../base/browser/dom.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../base/common/lifecycle.js'; +import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; +import { IHoverService } from '../../platform/hover/browser/hover.js'; +import { createInstantHoverDelegate } from '../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { localize } from '../../nls.js'; +import { ThemeIcon } from '../../base/common/themables.js'; +import { Codicon } from '../../base/common/codicons.js'; +import { IAgentSessionsService } from '../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionStatus, getAgentChangesSummary, IAgentSession } from '../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ICommandService } from '../../platform/commands/common/commands.js'; +import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; +import { ViewContainerLocation } from '../../workbench/common/views.js'; +import { URI } from '../../base/common/uri.js'; +import { Event } from '../../base/common/event.js'; + +// Duplicated from vs/sessions/contrib/changes/browser/changesView.ts to avoid a layering import. +const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; + +/** + * Collapsed widget shown in the bottom-left corner when the sidebar is hidden. + * Shows session status counts (active, errors, completed) and a new session button. + */ +export class CollapsedSidebarWidget extends Disposable { + + private readonly element: HTMLElement; + private readonly indicatorContainer: HTMLElement; + private readonly indicatorDisposables = this._register(new DisposableStore()); + private readonly hoverDelegate = this._register(createInstantHoverDelegate()); + + constructor( + parent: HTMLElement, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHoverService private readonly hoverService: IHoverService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + this.element = append(parent, $('.collapsed-panel-widget.collapsed-sidebar-widget')); + this.indicatorContainer = append(this.element, $('.collapsed-panel-buttons')); + + // New session button + this._register(this.createNewSessionButton()); + + // Listen for session changes + this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); + + // Initial build + this.rebuildIndicators(); + + this.hide(); + } + + private createNewSessionButton(): DisposableStore { + const store = new DisposableStore(); + const btn = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-new-session')); + append(btn, $(ThemeIcon.asCSSSelector(Codicon.newSession))); + + store.add(this.hoverService.setupManagedHover(this.hoverDelegate, btn, localize('newSession', "New Session"))); + + store.add(addDisposableListener(btn, EventType.CLICK, () => { + this.commandService.executeCommand('workbench.action.sessions.newChat'); + })); + + return store; + } + + private rebuildIndicators(): void { + this.indicatorDisposables.clear(); + this.indicatorContainer.textContent = ''; + + const sessions = this.agentSessionsService.model.sessions; + const counts = this.countSessionsByStatus(sessions); + + // In-progress indicator + if (counts.inProgress > 0) { + this.createIndicator( + Codicon.loading, + `${counts.inProgress}`, + localize('sessionsInProgress', "{0} session(s) in progress", counts.inProgress), + 'collapsed-sidebar-indicator-active' + ); + } + + // Needs input indicator + if (counts.needsInput > 0) { + this.createIndicator( + Codicon.bell, + `${counts.needsInput}`, + localize('sessionsNeedInput', "{0} session(s) need input", counts.needsInput), + 'collapsed-sidebar-indicator-input' + ); + } + + // Error indicator + if (counts.failed > 0) { + this.createIndicator( + Codicon.error, + `${counts.failed}`, + localize('sessionsFailed', "{0} session(s) with errors", counts.failed), + 'collapsed-sidebar-indicator-error' + ); + } + + // Completed indicator + if (counts.completed > 0) { + this.createIndicator( + Codicon.check, + `${counts.completed}`, + localize('sessionsCompleted', "{0} session(s) completed", counts.completed), + 'collapsed-sidebar-indicator-done' + ); + } + + // If no sessions at all, show a total count + if (sessions.length === 0) { + this.createIndicator( + Codicon.commentDiscussion, + '0', + localize('noSessions', "No sessions"), + 'collapsed-sidebar-indicator-empty' + ); + } + } + + private createIndicator(icon: ThemeIcon, count: string, tooltip: string, className: string): void { + const indicator = append(this.indicatorContainer, $(`.collapsed-panel-button.${className}`)); + append(indicator, $(ThemeIcon.asCSSSelector(icon))); + const label = append(indicator, $('span.collapsed-sidebar-count')); + label.textContent = count; + + this.indicatorDisposables.add(this.hoverService.setupManagedHover(this.hoverDelegate, indicator, tooltip)); + + this.indicatorDisposables.add(addDisposableListener(indicator, EventType.CLICK, () => { + this.layoutService.setPartHidden(false, Parts.SIDEBAR_PART); + })); + } + + private countSessionsByStatus(sessions: IAgentSession[]): { inProgress: number; needsInput: number; failed: number; completed: number } { + let inProgress = 0; + let needsInput = 0; + let failed = 0; + let completed = 0; + + for (const session of sessions) { + if (session.isArchived()) { + continue; + } + switch (session.status) { + case AgentSessionStatus.InProgress: + inProgress++; + break; + case AgentSessionStatus.NeedsInput: + needsInput++; + break; + case AgentSessionStatus.Failed: + failed++; + break; + case AgentSessionStatus.Completed: + completed++; + break; + } + } + + return { inProgress, needsInput, failed, completed }; + } + + show(): void { + this.element.classList.remove('collapsed-panel-hidden'); + } + + hide(): void { + this.element.classList.add('collapsed-panel-hidden'); + } +} + +/** + * Collapsed widget shown in the bottom-right corner when the auxiliary bar is hidden. + * Shows file change counts (files, insertions, deletions) from the active session. + */ +export class CollapsedAuxiliaryBarWidget extends Disposable { + + private readonly element: HTMLElement; + private readonly indicatorContainer: HTMLElement; + private readonly indicatorDisposables = this._register(new DisposableStore()); + private readonly hoverDelegate = this._register(createInstantHoverDelegate()); + private activeSessionResource: (() => URI | undefined) | undefined; + private readonly activeSessionDisposable = this._register(new MutableDisposable()); + + constructor( + parent: HTMLElement, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHoverService private readonly hoverService: IHoverService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IPaneCompositePartService private readonly paneCompositeService: IPaneCompositePartService, + ) { + super(); + + this.element = append(parent, $('.collapsed-panel-widget.collapsed-auxbar-widget')); + this.indicatorContainer = append(this.element, $('.collapsed-panel-buttons')); + + // Listen for session changes to update indicators + this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); + + // Initial build + this.rebuildIndicators(); + + this.hide(); + } + + /** + * Bind an active-session provider so indicators reflect the currently + * selected session rather than aggregating all sessions. + */ + setActiveSessionProvider(getResource: () => URI | undefined, onDidChange: Event): void { + this.activeSessionResource = getResource; + this.activeSessionDisposable.value = onDidChange(() => this.rebuildIndicators()); + this.rebuildIndicators(); + } + + private rebuildIndicators(): void { + this.indicatorDisposables.clear(); + this.indicatorContainer.textContent = ''; + + // Get change summary from the active session + const resource = this.activeSessionResource?.(); + const session = resource ? this.agentSessionsService.getSession(resource) : undefined; + const summary = session ? getAgentChangesSummary(session.changes) : undefined; + + // Combined changes button: [diff icon] +insertions -deletions fileCount + const changesBtn = append(this.indicatorContainer, $('.collapsed-panel-button.collapsed-auxbar-indicator')); + + append(changesBtn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); + + if (summary && summary.insertions > 0) { + const insLabel = append(changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-insertions')); + insLabel.textContent = `+${summary.insertions}`; + } + + if (summary && summary.deletions > 0) { + const delLabel = append(changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-deletions')); + delLabel.textContent = `-${summary.deletions}`; + } + + if (summary) { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, changesBtn, + localize('changesSummary', "{0} file(s) changed, {1} insertion(s), {2} deletion(s)", summary.files, summary.insertions, summary.deletions) + )); + } else { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, changesBtn, + localize('showChanges', "Show Changes") + )); + } + + this.indicatorDisposables.add(addDisposableListener(changesBtn, EventType.CLICK, () => { + this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + this.paneCompositeService.openPaneComposite(CHANGES_VIEW_CONTAINER_ID, ViewContainerLocation.AuxiliaryBar); + })); + } + + show(): void { + this.element.classList.remove('collapsed-panel-hidden'); + } + + hide(): void { + this.element.classList.add('collapsed-panel-hidden'); + } +} diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index c9bf983b7c9..8f6701aecb4 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -19,10 +19,6 @@ import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/ import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; // Register Icons -const panelLeftIcon = registerIcon('agent-panel-left', Codicon.layoutSidebarLeft, localize('panelLeft', "Represents a side bar in the left position")); -const panelLeftOffIcon = registerIcon('agent-panel-left-off', Codicon.layoutSidebarLeftOff, localize('panelLeftOff', "Represents a side bar in the left position that is hidden")); -const panelRightIcon = registerIcon('agent-panel-right', Codicon.layoutSidebarRight, localize('panelRight', "Represents a secondary side bar in the right position")); -const panelRightOffIcon = registerIcon('agent-panel-right-off', Codicon.layoutSidebarRightOff, localize('panelRightOff', "Represents a secondary side bar in the right position that is hidden")); const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); class ToggleSidebarVisibilityAction extends Action2 { @@ -34,13 +30,7 @@ class ToggleSidebarVisibilityAction extends Action2 { super({ id: ToggleSidebarVisibilityAction.ID, title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: panelLeftOffIcon, - toggled: { - condition: SideBarVisibleContext, - icon: panelLeftIcon, - title: localize('primary sidebar', "Primary Side Bar"), - mnemonicTitle: localize({ key: 'primary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Primary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), }, @@ -52,10 +42,10 @@ class ToggleSidebarVisibilityAction extends Action2 { }, menu: [ { - id: Menus.TitleBarLeftLayout, + id: Menus.SidebarTitle, group: 'navigation', - order: 0, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) + order: 100, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SideBarVisibleContext, SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, @@ -90,13 +80,7 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { super({ id: ToggleSecondarySidebarVisibilityAction.ID, title: localize2('toggleSecondarySidebar', 'Toggle Secondary Side Bar Visibility'), - icon: panelRightOffIcon, - toggled: { - condition: AuxiliaryBarVisibleContext, - icon: panelRightIcon, - title: localize('secondary sidebar', "Secondary Side Bar"), - mnemonicTitle: localize({ key: 'secondary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Secondary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSecondarySidebar', 'Open/Show and Close/Hide Secondary Side Bar'), }, @@ -104,10 +88,10 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { f1: true, menu: [ { - id: Menus.TitleBarRightLayout, + id: Menus.AuxiliaryBarTitle, group: 'navigation', - order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) + order: 100, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), AuxiliaryBarVisibleContext, SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, diff --git a/src/vs/sessions/browser/media/collapsedPanelWidget.css b/src/vs/sessions/browser/media/collapsedPanelWidget.css new file mode 100644 index 00000000000..ee946bdb7d8 --- /dev/null +++ b/src/vs/sessions/browser/media/collapsedPanelWidget.css @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Collapsed Part Widgets (shared, inline in titlebar) ---- */ + +.agent-sessions-workbench .collapsed-panel-widget { + display: flex; + flex-direction: row; + align-items: center; + gap: 2px; + padding: 0 4px; + height: 100%; + position: relative; + z-index: 2500; /* Above titlebar toolbar actions so widgets remain clickable */ + -webkit-app-region: no-drag; +} + +.agent-sessions-workbench .collapsed-panel-widget.collapsed-panel-hidden { + display: none; +} + +/* ---- Sidebar widget (in titlebar-left) ---- */ + +.agent-sessions-workbench .collapsed-sidebar-widget { + order: 10; + padding-left: 8px; +} + +/* ---- Auxiliary Bar widget (in titlebar-right) ---- */ + +.agent-sessions-workbench .collapsed-auxbar-widget { + order: 0; +} + +/* ---- Buttons (match titlebar action-item sizing) ---- */ + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: 0; + height: 100%; +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button { + display: flex; + align-items: center; + justify-content: center; + height: 22px; + padding: 0 4px; + border-radius: var(--vscode-cornerRadius-medium); + cursor: pointer; + color: inherit; + gap: 3px; +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button:active { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button .codicon { + font-size: 16px; + color: inherit; +} + +/* ---- Sidebar indicators ---- */ + +.agent-sessions-workbench .collapsed-sidebar-count, +.agent-sessions-workbench .collapsed-auxbar-count { + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 16px; + color: inherit; +} + +.agent-sessions-workbench .collapsed-sidebar-indicator-active .codicon { + animation: codicon-spin 1.5s infinite linear; +} + +.agent-sessions-workbench .collapsed-sidebar-indicator-error { + color: var(--vscode-errorForeground); +} + +.agent-sessions-workbench .collapsed-sidebar-indicator-input { + color: var(--vscode-notificationsInfoIcon-foreground); +} + +/* ---- Auxiliary bar indicators ---- */ + +.agent-sessions-workbench .collapsed-auxbar-insertions { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.agent-sessions-workbench .collapsed-auxbar-deletions { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.agent-sessions-workbench span.collapsed-auxbar-count.collapsed-auxbar-insertions, +.agent-sessions-workbench span.collapsed-auxbar-count.collapsed-auxbar-deletions { + font-weight: 600; +} diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 18c2d2867f0..f481cb17807 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -81,6 +81,9 @@ export class TitlebarPart extends Part implements ITitlebarPart { private centerContent!: HTMLElement; private rightContent!: HTMLElement; + get leftContainer(): HTMLElement { return this.leftContent; } + get rightContainer(): HTMLElement { return this.rightContent; } + private readonly titleBarStyle: TitlebarStyle; private isInactive: boolean = false; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index ca045ac8260..8c4f963dd47 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -5,6 +5,7 @@ import '../../workbench/browser/style.js'; import './media/style.css'; +import { CollapsedSidebarWidget, CollapsedAuxiliaryBarWidget } from './collapsedPartWidgets.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; @@ -22,7 +23,7 @@ import { IEditorService } from '../../workbench/services/editor/common/editorSer import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../workbench/common/views.js'; import { ILogService } from '../../platform/log/common/log.js'; -import { IInstantiationService, ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor, createDecorator } from '../../platform/instantiation/common/instantiation.js'; import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; import { mainWindow, CodeWindow } from '../../base/browser/window.js'; import { coalesce } from '../../base/common/arrays.js'; @@ -60,7 +61,19 @@ import { NotificationsToasts } from '../../workbench/browser/parts/notifications import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; -import { TitleService } from './parts/titlebarPart.js'; +import { TitleService, TitlebarPart } from './parts/titlebarPart.js'; +import { URI } from '../../base/common/uri.js'; +import { IObservable } from '../../base/common/observable.js'; + +/** + * Minimal typing for ISessionsManagementService resolved dynamically to avoid + * a layering import from vs/sessions/contrib/. + */ +interface IMinimalSessionsManagementService { + getActiveSession(): { resource: URI } | undefined; + readonly activeSession: IObservable; +} +const _ISessionsManagementService = createDecorator('sessionsManagementService'); //#region Workbench Options @@ -231,6 +244,9 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { private chatBarPartView!: ISerializableView; + private collapsedSidebarWidget: CollapsedSidebarWidget | undefined; + private collapsedAuxiliaryBarWidget: CollapsedAuxiliaryBarWidget | undefined; + private readonly partVisibility: IPartVisibilityState = { sidebar: true, auxiliaryBar: false, @@ -369,6 +385,37 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Layout this.layout(); + // Collapsed Sidebar Widget (shown when sidebar is hidden) + const titlebarPart = this.getPart(Parts.TITLEBAR_PART) as TitlebarPart; + this.collapsedSidebarWidget = this._register(instantiationService.createInstance(CollapsedSidebarWidget, titlebarPart.leftContainer)); + if (!this.partVisibility.sidebar) { + this.collapsedSidebarWidget.show(); + } + + // Collapsed Auxiliary Bar Widget (shown when auxiliary bar is hidden) + this.collapsedAuxiliaryBarWidget = this._register(instantiationService.createInstance(CollapsedAuxiliaryBarWidget, titlebarPart.rightContainer)); + if (!this.partVisibility.auxiliaryBar) { + this.collapsedAuxiliaryBarWidget.show(); + } + + // Wire active session provider after restore, when ISessionsManagementService is available. + // Resolved via createDecorator to avoid a layering import from vs/sessions/contrib/. + // Note: whenRestored is a deferred promise that resolves inside restore() below. + const auxWidget = this.collapsedAuxiliaryBarWidget; + this.whenRestored.then(() => { + instantiationService.invokeFunction(accessor => { + try { + const svc = accessor.get(_ISessionsManagementService); + auxWidget.setActiveSessionProvider( + () => svc.getActiveSession()?.resource, + Event.fromObservableLight(svc.activeSession) + ); + } catch { + // Service not registered — indicators will remain empty + } + }); + }); + // Restore this.restore(lifecycleService); }); @@ -1060,6 +1107,13 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !hidden, ); + // Toggle collapsed sidebar widget + if (hidden) { + this.collapsedSidebarWidget?.show(); + } else { + this.collapsedSidebarWidget?.hide(); + } + // If sidebar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); @@ -1089,6 +1143,13 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !hidden, ); + // Toggle collapsed auxiliary bar widget + if (hidden) { + this.collapsedAuxiliaryBarWidget?.show(); + } else { + this.collapsedAuxiliaryBarWidget?.hide(); + } + // If auxiliary bar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); From cbc648ad896620e76b1bc1668df365d9316a9d73 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Thu, 19 Mar 2026 19:15:02 -0700 Subject: [PATCH 081/183] Support rendering image pills when thinking parts are collapsed. (#303363) * Support rendering image pills when thinking parts are collapsed. * :lipstick: --- .../chatResourceGroupWidget.ts | 5 ++ .../chatThinkingContentPart.ts | 41 +++++++++ .../chatThinkingExternalResourcesWidget.ts | 88 +++++++++++++++++++ .../media/chatThinkingContent.css | 5 ++ .../chatThinkingContentPart.test.ts | 63 +++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts index dcd85778a0e..cfa95551f1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts @@ -7,6 +7,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { disposableTimeout } from '../../../../../../base/common/async.js'; import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { basename, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -47,6 +48,8 @@ const IMAGE_DECODE_DELAY_MS = 100; */ export class ChatResourceGroupWidget extends Disposable { public readonly domNode: HTMLElement; + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; constructor( parts: IChatCollapsibleIODataPart[], @@ -124,6 +127,7 @@ export class ChatResourceGroupWidget extends Disposable { }; itemsContainer.appendChild(attachments.domNode!); + this._onDidChangeHeight.fire(); const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, MenuId.ChatToolOutputResourceToolbar, { menuOptions: { @@ -146,6 +150,7 @@ export class ChatResourceGroupWidget extends Disposable { // Update attachments in place attachments.updateVariables(entries); + this._onDidChangeHeight.fire(); }, IMAGE_DECODE_DELAY_MS)); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 67e0bbf0ce1..00b9f678dab 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -34,6 +34,9 @@ import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; import './media/chatThinkingContent.css'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; +import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; function extractTextFromPart(content: IChatThinkingPart): string { @@ -233,6 +236,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private lastKnownScrollTop: number = 0; private titleShimmerSpan: HTMLElement | undefined; private titleDetailContainer: HTMLElement | undefined; + private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget; private readonly _titleDetailRendered = this._register(new MutableDisposable()); private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { @@ -313,6 +317,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const node = this.domNode; node.classList.add('chat-thinking-box'); + this._externalResourceWidget = this._register(this.instantiationService.createInstance(ChatThinkingExternalResourceWidget)); + this._register(this._externalResourceWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + node.appendChild(this._externalResourceWidget.domNode); + if (!this.streamingCompleted && !this.element.isComplete) { if (!this.fixedScrollingMode) { node.classList.add('chat-thinking-active'); @@ -374,6 +382,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } + this._externalResourceWidget.setCollapsed(!isExpanded); + // Fire when expanded/collapsed this._onDidChangeHeight.fire(); })); @@ -1232,6 +1242,8 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } this.toolLabelsByCallId.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + this.updateDropdownClickability(); this._onDidChangeHeight.fire(); } @@ -1263,6 +1275,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): // Use the tracked displayed label (which may differ from invocationMessage // for streaming edit tools that show "Editing files") const toolCallId = removedItem.toolInvocationOrMarkdown.toolCallId; + this._externalResourceWidget.removeToolInvocation(toolCallId); const label = this.toolLabelsByCallId.get(toolCallId); if (label) { const titleIndex = this.extractedTitles.indexOf(label); @@ -1356,6 +1369,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.extractedTitles.splice(titleIndex, 1); } this.toolLabelsByCallId.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); this.updateDropdownClickability(); this._onDidChangeHeight.fire(); } @@ -1402,6 +1416,11 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): const toolCallId = toolInvocationOrMarkdown.toolCallId; this.toolLabelsByCallId.set(toolCallId, toolCallLabel); + // Render external image pills for serialized (already-completed) tool invocations + if (toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + } + // track state for live/still streaming tools, excluding serialized tools if (toolInvocationOrMarkdown.kind === 'toolInvocation') { let currentToolLabel = toolCallLabel; @@ -1462,6 +1481,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); this.schedulePendingRemovalsFlush(); } + + // Render image pills outside the collapsible area for completed tools + if (currentState.type === IChatToolInvocation.StateKind.Completed) { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + } + isComplete = true; return; } @@ -1526,6 +1551,22 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } } + private updateExternalResourceParts(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + const extractedImages = extractImagesFromToolInvocationOutputDetails(toolInvocation, this.element.sessionResource); + if (extractedImages.length === 0) { + return; + } + + const parts: IChatCollapsibleIODataPart[] = extractedImages.map(image => ({ + kind: 'data', + value: image.data.buffer, + mimeType: image.mimeType, + uri: image.uri, + })); + + this._externalResourceWidget.setToolInvocationParts(toolInvocation.toolCallId, parts); + } + private appendItemToDOM( content: HTMLElement, toolInvocationId?: string, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts new file mode 100644 index 00000000000..732ff270de5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, clearNode, hide, show } from '../../../../../../base/browser/dom.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ChatResourceGroupWidget } from './chatResourceGroupWidget.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; + +export class ChatThinkingExternalResourceWidget extends Disposable { + + public readonly domNode: HTMLElement; + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private readonly resourcePartsByToolCallId = new Map(); + private readonly resourceGroupWidget = this._register(new MutableDisposable()); + private readonly resourceGroupWidgetHeightListener = this._register(new MutableDisposable()); + private isCollapsed = true; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.domNode = $('.chat-thinking-external-resources'); + hide(this.domNode); + } + + public setToolInvocationParts(toolCallId: string, parts: IChatCollapsibleIODataPart[]): void { + if (parts.length === 0) { + return; + } + + this.resourcePartsByToolCallId.set(toolCallId, parts); + + this.rebuild(); + } + + public removeToolInvocation(toolCallId: string): void { + if (!this.resourcePartsByToolCallId.delete(toolCallId)) { + return; + } + + this.rebuild(); + } + + public setCollapsed(collapsed: boolean): void { + this.isCollapsed = collapsed; + + if (!this.resourceGroupWidget.value) { + hide(this.domNode); + return; + } + + if (this.isCollapsed) { + show(this.domNode); + } else { + hide(this.domNode); + } + } + + private rebuild(): void { + const allParts: IChatCollapsibleIODataPart[] = []; + for (const parts of this.resourcePartsByToolCallId.values()) { + allParts.push(...parts); + } + + this.resourceGroupWidgetHeightListener.clear(); + this.resourceGroupWidget.clear(); + clearNode(this.domNode); + + if (allParts.length === 0) { + hide(this.domNode); + this._onDidChangeHeight.fire(); + return; + } + + const widget = this.instantiationService.createInstance(ChatResourceGroupWidget, allParts); + this.resourceGroupWidgetHeightListener.value = widget.onDidChangeHeight(() => this._onDidChangeHeight.fire()); + this.resourceGroupWidget.value = widget; + this.domNode.appendChild(widget.domNode); + this.setCollapsed(this.isCollapsed); + this._onDidChangeHeight.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 28990fb01f8..19684ae349f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -13,6 +13,11 @@ position: relative; color: var(--vscode-descriptionForeground); + .chat-thinking-external-resources { + margin-top: 4px; + margin-left: 5px; + } + .chat-used-context { margin: 0px; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 1a76f109771..4d58c6c4339 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -1239,6 +1239,30 @@ suite('ChatThinkingContentPart', () => { } as IChatToolInvocation; } + function createMockSerializedImageToolInvocation(toolId: string, invocationMessage: string, toolCallId: string): IChatToolInvocationSerialized { + return { + kind: 'toolInvocationSerialized', + toolId, + toolCallId, + invocationMessage, + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + resultDetails: { + output: { + type: 'data', + mimeType: 'image/png', + base64Data: 'AQID' + } + }, + isConfirmed: { type: 0 }, + isComplete: true, + source: ToolDataSource.Internal, + generatedTitle: undefined, + isAttachedToThinking: false, + }; + } + test('should show "Editing files" for streaming edit tools instead of generic display name', () => { const content = createThinkingPart('**Working**'); const context = createMockRenderContext(false); @@ -1364,5 +1388,44 @@ suite('ChatThinkingContentPart', () => { const labelText = button.querySelector('.icon-label')?.textContent ?? button.textContent ?? ''; assert.ok(labelText.includes('Creating newFile.ts'), `Title should contain "Creating newFile.ts" but got "${labelText}"`); }); + + test('should show external resources for serialized image tools when initially collapsed and hide them when expanded', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + const serializedImageTool = createMockSerializedImageToolInvocation( + 'chat_screenshot', 'Captured screenshot', 'image-call-1' + ); + + part.appendItem(() => { + const div = $('div.test-item'); + div.textContent = 'Image tool'; + return { domNode: div }; + }, serializedImageTool.toolId, serializedImageTool); + + const externalResources = part.domNode.querySelector('.chat-thinking-external-resources') as HTMLElement; + assert.ok(externalResources, 'Should render external resources container'); + assert.notStrictEqual(externalResources.style.display, 'none', 'Should show external resources while initially collapsed'); + + const button = part.domNode.querySelector('.monaco-button') as HTMLElement; + assert.ok(button, 'Should have expand button'); + button.click(); + + assert.strictEqual(externalResources.style.display, 'none', 'Should hide external resources when expanded'); + + button.click(); + assert.notStrictEqual(externalResources.style.display, 'none', 'Should show external resources again after collapsing'); + }); }); }); From 14d5176f5fdebbd638c901845cf591d6856f5ce2 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:18:27 -0700 Subject: [PATCH 082/183] =?UTF-8?q?Fix=20MCP=20servers=20from=20Copilot=20?= =?UTF-8?q?extension=20showing=20under=20Extensions=20inste=E2=80=A6=20(#3?= =?UTF-8?q?03359)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix MCP servers from Copilot extension showing under Extensions instead of Built-in Servers from github.copilot and github.copilot-chat are now treated as built-in. Only servers from other extensions show under the Extensions group. --- .../browser/aiCustomization/mcpListWidget.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 2dddce3c0f8..84243027596 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -17,6 +17,8 @@ import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService, IMcpServer } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { isContributionDisabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; import { autorun } from '../../../../../base/common/observable.js'; @@ -39,8 +41,6 @@ import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWo import { ICustomizationHarnessService, CustomizationHarness } from '../../common/customizationHarnessService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; -import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; const $ = DOM.$; @@ -48,6 +48,12 @@ const MCP_ITEM_HEIGHT = 36; const PLUGIN_COLLECTION_PREFIX = 'plugin.'; +const COPILOT_EXTENSION_IDS = ['github.copilot', 'github.copilot-chat']; + +function isCopilotExtension(id: ExtensionIdentifier): boolean { + return COPILOT_EXTENSION_IDS.some(copilotId => ExtensionIdentifier.equals(id, copilotId)); +} + function getPluginUriFromCollectionId(collectionId: string | undefined): string | undefined { return collectionId?.startsWith(PLUGIN_COLLECTION_PREFIX) ? collectionId.slice(PLUGIN_COLLECTION_PREFIX.length) : undefined; } @@ -737,15 +743,18 @@ export class McpListWidget extends Disposable { isFirst = false; } - // Add plugin-provided, extension-provided, and built-in servers + // Add plugin-provided, extension-provided, and built-in servers. + // Servers from the Copilot extension (github.copilot / github.copilot-chat) + // are treated as built-in; servers from other extensions go under "Extensions". const collectionSources = new Map(this.mcpRegistry.collections.get().map(c => [c.id, c.source])); const pluginServers: IMcpServer[] = []; const extensionServers: IMcpServer[] = []; const otherBuiltinServers: IMcpServer[] = []; for (const server of builtinServers) { + const source = collectionSources.get(server.collection.id); if (server.collection.id.startsWith(PLUGIN_COLLECTION_PREFIX)) { pluginServers.push(server); - } else if (collectionSources.get(server.collection.id) instanceof ExtensionIdentifier) { + } else if (source instanceof ExtensionIdentifier && !isCopilotExtension(source)) { extensionServers.push(server); } else { otherBuiltinServers.push(server); From 4315cca9583c0fbf60e1a406ea6c7dfb48698c33 Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 20 Mar 2026 11:21:37 +0900 Subject: [PATCH 083/183] Revert "chore: bump electron@39.8.3" (#303369) * Revert "chore: bump electron@39.8.3 (#302875)" This reverts commit d4f7ac5014bb1e54a52bc620f26611e8cb8c2f9d. * chore: bump distro --- .npmrc | 4 +- build/checksums/electron.txt | 150 +++++++++++++++++------------------ cgmanifest.json | 6 +- package-lock.json | 8 +- package.json | 4 +- remote/.npmrc | 2 +- 6 files changed, 87 insertions(+), 87 deletions(-) diff --git a/.npmrc b/.npmrc index 6df04ca0e7e..2e08f5efcdd 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.8.3" -ms_build_id="13586709" +target="39.8.2" +ms_build_id="13563792" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 3a0e930f3f4..4364e9bfc3e 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -0ab48e3e8888b5c33950be0c36da939aa989df7609d3c32140c5e5371ea53abb *chromedriver-v39.8.3-darwin-arm64.zip -b7103565ffb4068dc705c50ce3039ed3178cac350301abf82545de54ac3bc849 *chromedriver-v39.8.3-darwin-x64.zip -e7e43ee7a3d14482ce488d0b0bc338a026a00ee544e5a3d55aed220af6b5da0e *chromedriver-v39.8.3-linux-arm64.zip -060223baebe6d8f9e8c7367bf561dd558fca03509edcc3bce660c42f96ad73ea *chromedriver-v39.8.3-linux-armv7l.zip -854a6f921684e59866aed9db0e9f61d28f756f70b7898f947359b4d04dba76db *chromedriver-v39.8.3-linux-x64.zip -f70ea58bc5e4e51eec51f65e153cfd36eea568ecd571c2815a4c05a457b6923d *chromedriver-v39.8.3-mas-arm64.zip -8e3e1450bc544bff712ffab0ba365d1ed2c9b79116b4ec4750a46c8607242ed4 *chromedriver-v39.8.3-mas-x64.zip -c07e35a2a5a673c8902452571f3436ca8b774fa4628ad9e42f179d3c935f4ed7 *chromedriver-v39.8.3-win32-arm64.zip -d0361344208d8bdf58500d08ae1bb723b9ccdc66fc736c2fc6c9f011bcc6e47d *chromedriver-v39.8.3-win32-ia32.zip -e2e91fd7d97e3e9806d22c4990253cbd5e466cdfa1a8e4c86c72431f7d3a8d0f *chromedriver-v39.8.3-win32-x64.zip -f004c879e159edf3eb403bd43bc76c3069b0b375c6dfae5b249b96d543e51e26 *electron-api.json -21a5324aaed782fead97b2e50f833373148392d4c13ec818f80f142e800c6158 *electron-v39.8.3-darwin-arm64-dsym-snapshot.zip -bb9c14900f48aabb7d272149ba4b60813b366f1e67f95b510da73355e15ba78c *electron-v39.8.3-darwin-arm64-dsym.zip -8a42b50a0841e7bfefc49e704f5cfdb3cbb7b9a507ac74b9982004a9350a202d *electron-v39.8.3-darwin-arm64-symbols.zip -e1b9e03a56fc27ad567c8d2bb32a21e0e2afe6a095f71c26df5b8b8ed8dd8d4c *electron-v39.8.3-darwin-arm64.zip -5b474116e398286a80def6509fa255481ab88fbb52b1770dfd5d39ddff124c6b *electron-v39.8.3-darwin-x64-dsym-snapshot.zip -14648a98eef5a28c1158f0580a813617d9ce6d77a8b7881c389acfff34d328cd *electron-v39.8.3-darwin-x64-dsym.zip -231e13b26c39cceecec359e74c00e4d6a13de3ae9fb6459f18846f91f214074f *electron-v39.8.3-darwin-x64-symbols.zip -22cf6f6147d5d632e2a8ad5207504a18db94a8c96e3f4f65f792822eaed7bf1c *electron-v39.8.3-darwin-x64.zip -fdf25df8857e1ef2cdb0a5be71b78dfb9923a6061cf11336577c6a4368ecfdcd *electron-v39.8.3-linux-arm64-debug.zip -731bf3f908a1efe871e862852087b67027c791427284f057d42376634d4d53d3 *electron-v39.8.3-linux-arm64-symbols.zip -e1a0e6939fe2d10c1f807888f74dbbb9f28a2cfc25e28bb8168f5513513fc124 *electron-v39.8.3-linux-arm64.zip -65893fd03097eadb0c89eb95ded97e97a9910bbc53634f12170cfb40b9165832 *electron-v39.8.3-linux-armv7l-debug.zip -997acce3540d16f9e0551cde811021999a4276c970bfe42ed77c3fd769ba6d05 *electron-v39.8.3-linux-armv7l-symbols.zip -5d5825966a3b2678c50121c81ed3fb8c39d35c3798dd0413a19afaac04109ef9 *electron-v39.8.3-linux-armv7l.zip -52b44ef60f73ef7b7c8461f520a1048da3601d9cc869262ec63f507cd6591e78 *electron-v39.8.3-linux-x64-debug.zip -c4e1fa21d21724ab7f5bcdb6c1bfc03dca837ebcca00d6af56944041499d35a9 *electron-v39.8.3-linux-x64-symbols.zip -5866d6c6f8fcf15967279107d2387edfa4589c5a00ad52d4b770d7504106a734 *electron-v39.8.3-linux-x64.zip -3ff4c9fb99f40dda465486fa6fa23eaedd89b87dcd9cce402a171accfcacc9bd *electron-v39.8.3-mas-arm64-dsym-snapshot.zip -9401101eabaf5e55063b9fad94bf3ac2fe9d743ff88ec638a3c6c665b2266564 *electron-v39.8.3-mas-arm64-dsym.zip -0905e57da501b64436dff51b1378bae311cb1276372dc39dedb7aef44f1b947a *electron-v39.8.3-mas-arm64-symbols.zip -1af2cdee3405c0b8e1c8145a65891b249ac737dd35d959cebd6833970ad5eb08 *electron-v39.8.3-mas-arm64.zip -9e8c6b7b880ac726cda52aaf2b02bc9f0750559be85ef1583789d52b617914b9 *electron-v39.8.3-mas-x64-dsym-snapshot.zip -483ce280606a61c7ed4394e99008ad6fc7e2ce9c35149c5ad745bce9ee78a7a2 *electron-v39.8.3-mas-x64-dsym.zip -1e16529ab1ee8404b1623df611077abcbbbabc1f825a57e93e2ef2b1f7ba788a *electron-v39.8.3-mas-x64-symbols.zip -19df399b352db2c3b3f26f830700b17adf697b70e4d361b1e0f20790e6e703b6 *electron-v39.8.3-mas-x64.zip -bdfd01e7c55ea4beb90afd4285ab3639e3a66808ec993389e9eaf62c3edcb5e9 *electron-v39.8.3-win32-arm64-pdb.zip -7329264c9d308a78081509cb4173f0bd931522655d2f434ad858555e735e5721 *electron-v39.8.3-win32-arm64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-arm64-toolchain-profile.zip -699933ff8c4d7216fc0318f239a5f593f06487c0dc9c3722b8744f6a44fca94e *electron-v39.8.3-win32-arm64.zip -1ffab5a8419a1a93476e2edc09754a52bbe9f3d39915e097f2a1ee50ffdbbd13 *electron-v39.8.3-win32-ia32-pdb.zip -6445047728d64a09db80205c24135f140ba60c25433d833f581c57092638b875 *electron-v39.8.3-win32-ia32-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-ia32-toolchain-profile.zip -b80bb96a4eda2c2b6bd223d2d8b6abfc39abdebac0b36cf74cd70661d43258a5 *electron-v39.8.3-win32-ia32.zip -c8e3cab205bdfe42f916a4428fe0a5e88b6f90e8482e297dadfe1234420abb8f *electron-v39.8.3-win32-x64-pdb.zip -eda4a2f01e388eccfc2ecc7587b0987d123ae01e5b832b73a0a76bb62680bd7c *electron-v39.8.3-win32-x64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-x64-toolchain-profile.zip -12eabd7c5f08823525034c1ff3ab286f271af802928e0f224b458235e2689c5a *electron-v39.8.3-win32-x64.zip -d5345fc0cb336a425f7a25885f67969452746cbf30cc1e95449f7a68221aab07 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.3-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.3-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.3-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-mas-x64.zip -06d402e51bf66fd1a0eddc7e8329b31eb8a1bc6c829e5bc13694708a010feb07 *ffmpeg-v39.8.3-win32-arm64.zip -4b1c2ddedebbf32b7792fb788ddce577f2dc6f8ecccd913e72e842068b2f81e5 *ffmpeg-v39.8.3-win32-ia32.zip -0cf0f521a452d6fdfe1313b81284d646e991954e91c356d805f5c066f2ecf278 *ffmpeg-v39.8.3-win32-x64.zip -bb2d6ec64c43e5a41da5bc55a3daa2b7ef8a0dee722dc73f4fa31bcae5487cb2 *hunspell_dictionaries.zip -7fd663a5eeaf4d0a93785ea4ea715d21464c70c1341b7d8629c96a7bfe24044a *libcxx-objects-v39.8.3-linux-arm64.zip -bdd7e9f732b97b6be2c8293d9391bce3a5cd60feae1c762a0dda0790493da7a2 *libcxx-objects-v39.8.3-linux-armv7l.zip -0de009f84fed7c1bba087ff161674177ca91951fca2f4c60850be0bffb42dfdf *libcxx-objects-v39.8.3-linux-x64.zip -8ae7fd5c3bdc332f9f49830a9316e470d43f17e6ad6adbd05ac629d03d1718c2 *libcxx_headers.zip -9b988e2bb379c6d3094872f600944ad3284510cf225f86368c4f43270b89673c *libcxxabi_headers.zip -d504296ed183e3f460028a73b4a5e2bcd99bc4a3c74b8dc73ba987719c005458 *mksnapshot-v39.8.3-darwin-arm64.zip -b18b8a0e902cf86961d53486826fb07feb3ac98e018b2849cf2bb13150077b13 *mksnapshot-v39.8.3-darwin-x64.zip -7d9dc2ceb3f88d8d532af5b90387479ade571a0370489429571871d386c12322 *mksnapshot-v39.8.3-linux-arm64-x64.zip -84114ba67259f52ae462210544e815b602d70b71162f7f70982a7c36db54b4fa *mksnapshot-v39.8.3-linux-armv7l-x64.zip -be08392f0964d2166bb84212d225c5380fde9b12e622599cb040f45524ff7882 *mksnapshot-v39.8.3-linux-x64.zip -ffebb01a6fe568ec51391f9585e8abf1a93a566a7c991b2abacc33cc2e94d705 *mksnapshot-v39.8.3-mas-arm64.zip -858a8ee80b6f826b1d24a9458b8acb0fcc9805ee3c309652d60ed5c07b578113 *mksnapshot-v39.8.3-mas-x64.zip -dbc82c573f1ba098b6d321ad79c6580f27066d94e13fd93c0b7650a54150eb5c *mksnapshot-v39.8.3-win32-arm64-x64.zip -c8f7c43741b20da99558db596d32c86ebff3483aaf6b3e2539cd85a471b92043 *mksnapshot-v39.8.3-win32-ia32.zip -000941eeb8e1169d581120df7d42aa0b76e33de99a06528a9c4767bcffb74cbf *mksnapshot-v39.8.3-win32-x64.zip +0f8398b79fb1d6a0036be18c24caef2d48dab9e8980ff6a7f0f658e11df86ca0 *chromedriver-v39.8.2-darwin-arm64.zip +f9995e244e0c703b0c1e06bcad2b1b9feca79d4437901e3b9dfa1f635b03884b *chromedriver-v39.8.2-darwin-x64.zip +45083a530bd03781dd759720519c805c046f392d88e2404268392446f896e265 *chromedriver-v39.8.2-linux-arm64.zip +09a6548e5abc4e1589870031bf35edb00b506da10102bb5d1b52fc069b7c1b34 *chromedriver-v39.8.2-linux-armv7l.zip +713570bbe7877fa950cbb533197cfb12aa7ff85d4db7e1fc9ad6ac57ca5733c9 *chromedriver-v39.8.2-linux-x64.zip +66a0109f235f0dec7d05d95f67f3ab07edebfd3e919d093ce71115484d2cfea2 *chromedriver-v39.8.2-mas-arm64.zip +d124f6440f2ff6de9c26f8764ad461cb8daec8e150699006d2ece850f1ff7125 *chromedriver-v39.8.2-mas-x64.zip +89c57558bf892492f5945415c20dc34cf7836661ed82f0f5816081a9e85b6859 *chromedriver-v39.8.2-win32-arm64.zip +2f5452b92dd26d0262329be08ad185bee3e9ce73536337df961e2a36273e99a9 *chromedriver-v39.8.2-win32-ia32.zip +d18fcd1ee0e2905ea8775470e956cd8ccd357f5e790169820bac26b5d5e5f540 *chromedriver-v39.8.2-win32-x64.zip +b8a2b1464313aa4e3d3e70ba84604879a1e2f21b654ef1feedc244eff294e46f *electron-api.json +e2a63aff66cfae22037682db1b3bdbeb616c9070eb56eac8f0cca58ff67168dd *electron-v39.8.2-darwin-arm64-dsym-snapshot.zip +8eca78b4b567cf258a4cfe6f9277060fbc1533dffe494936cc407453d68afe1a *electron-v39.8.2-darwin-arm64-dsym.zip +540715f221cf9c286c2ba30013bae3900595950e3e32fd7650b670a70f82d472 *electron-v39.8.2-darwin-arm64-symbols.zip +1910b2b857e0ee6d2ebd57ead75c3ace7d367a6bb9ccd6a48f8d2b23d93ffe67 *electron-v39.8.2-darwin-arm64.zip +491c9092487835661006c7d9665f3293ba547af1379ea779458cc7c3a79665a0 *electron-v39.8.2-darwin-x64-dsym-snapshot.zip +4e6a5a65947b7cec21571e8cac7afcd4d548c7c98c42c1107a47451fb35b1057 *electron-v39.8.2-darwin-x64-dsym.zip +942688360848bcf4b371553096e0ad77627acbb92eeea2426ba7885c7e4949b6 *electron-v39.8.2-darwin-x64-symbols.zip +9d80221dd2621a9526047be09379e32bbfc9dd57331e41bc0826aadbb69f632a *electron-v39.8.2-darwin-x64.zip +6ca8338548b63198143e25d9be34fa729763b82b68401b4112a787cf1d08ef60 *electron-v39.8.2-linux-arm64-debug.zip +12e1cae738ee45020249c7f15a3c2fb379425f0c8b6226ce7d3f53db356f3a82 *electron-v39.8.2-linux-arm64-symbols.zip +856848216c549a783b39f8d84dd93668d71da0d804e3bba709265804e5b4ba94 *electron-v39.8.2-linux-arm64.zip +a4b19bc2da1d531c6e689c2ac82af1453d45883197021ac8fd4f25029a9cf995 *electron-v39.8.2-linux-armv7l-debug.zip +e3be10fea936d22abaf70371c093d732a330ed639931ceeb04865edbce4c48bc *electron-v39.8.2-linux-armv7l-symbols.zip +56602fe1579eec07d810389ccf3d10c3d50e994f0319048f4f3057f8b24aa97b *electron-v39.8.2-linux-armv7l.zip +89d9a1c4dd9e632ebb1d7f816e003d152d58722f4b1849ff962df2330aa55edc *electron-v39.8.2-linux-x64-debug.zip +c5ad596d3017e4b2e5c8dd8fe7b7fbfaeca97505462c157f10ceedb1782c8cc2 *electron-v39.8.2-linux-x64-symbols.zip +3977017548b5dfdf78e1342cbe251c7ee7a127e52514903e181fa92143b0fa3a *electron-v39.8.2-linux-x64.zip +559f513006663e18e65dc936c6f50add34cda8fc0d639fd861daf354f017d293 *electron-v39.8.2-mas-arm64-dsym-snapshot.zip +d98d80c47d06169790a68bc72f652c5300235d93612787a97580a3ec6201ff03 *electron-v39.8.2-mas-arm64-dsym.zip +7862973f21e05dc5619110e789ddfc8b3973ae482a3dbcd6f33dfe42c939dc3d *electron-v39.8.2-mas-arm64-symbols.zip +a7dacb1566e909407510437d030904825ad88829186dc31e364d5b7a747b4fc6 *electron-v39.8.2-mas-arm64.zip +ecb9de6b8d7c564e5b0e62f5153d9c61272ef49e7f186fb986c58e225172c2b2 *electron-v39.8.2-mas-x64-dsym-snapshot.zip +3044fa159f9ff6b9c53311a4b4561b726f544240ae9452e1aa8505ccdb08a457 *electron-v39.8.2-mas-x64-dsym.zip +1b5e30679a43faee9a671b67b7c04b9ac464469aa493ce3c14cb8f52debec0f2 *electron-v39.8.2-mas-x64-symbols.zip +dcb094d185447f8bef67c3bf5537b47c61a80e077c2482d4a1a1289121c7cad0 *electron-v39.8.2-mas-x64.zip +d8475aef9e0e5f8f77fb9e3e9547656ec4c7688a432957686761ea8a19fa1a92 *electron-v39.8.2-win32-arm64-pdb.zip +1113ba8fc6dbebbad1a6eb0c0ba3f14698e0b99c16aa9b8cf6d637408f01646a *electron-v39.8.2-win32-arm64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-arm64-toolchain-profile.zip +d3d478f30002a70da0bf02775436b5f865345b9a25d0e0b75e1b089560bbf7fd *electron-v39.8.2-win32-arm64.zip +2d67e61dce2d50d291305df43e7bd312c2c665b71257d71e5c8cfab6c0ec931e *electron-v39.8.2-win32-ia32-pdb.zip +b5dd932b5ca51089dec5b9830554dfda5abdcee6a3bbd69f8531ae76a27524e6 *electron-v39.8.2-win32-ia32-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-ia32-toolchain-profile.zip +fd8270cb5ba43193d32a371263fa0cf73d112534ab852867fb86d10c6a82db39 *electron-v39.8.2-win32-ia32.zip +31f069c1ebdf46d3dc6704157e3ed60d707aec414f391b9993c6918c0b8ae0fd *electron-v39.8.2-win32-x64-pdb.zip +c732d314d4a7f44c20bcaeb6bc12a74947e7f28e16426fdbe041c9a35759e76f *electron-v39.8.2-win32-x64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-x64-toolchain-profile.zip +e5b2c8bda64b65e6587c2f3c97f48857fd02ab894bb7a6e4c73bd4a5bcc10416 *electron-v39.8.2-win32-x64.zip +179d2bf1b64e27cda05128656ff6bbbbd80eaf8b2ff04de3ae0999b850362785 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.2-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.2-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.2-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-mas-x64.zip +a0b525af0aa198214ba3c29a0b41297b15618fdad8c4f5aa3c42cf6a6ab80bfa *ffmpeg-v39.8.2-win32-arm64.zip +5418269cf6fe82f3d9fc5cbbc6d6f9241462b40046a87178515a36cb45549be1 *ffmpeg-v39.8.2-win32-ia32.zip +10bbd25b3e9af36f26147410b31e6e1d928bf4c25ac28571fa1bbe4eb7fe9af9 *ffmpeg-v39.8.2-win32-x64.zip +122ba5515c3a94b272886d156f0bb174ca120a18be44e21bc8b5f586dd679b6e *hunspell_dictionaries.zip +e8aebe7d361983ce1329598f5541c4dde26d18e72228d0ab1ac526c0e1a40dfc *libcxx-objects-v39.8.2-linux-arm64.zip +0d9e646e77ed3fad4560d10f7964cf316cbdcd9a50114c9163427be2222eb35b *libcxx-objects-v39.8.2-linux-armv7l.zip +928c6ff0761f496deda96203960d1933cae1ff488483ea31283a0e8ffe36426a *libcxx-objects-v39.8.2-linux-x64.zip +c65cf035770b74a8a6be4692be704c286427e63eb577e9d10c226b600f6121cc *libcxx_headers.zip +006ccb83761a3bf5791d165bc9795e4de3308c65df646b4cbcaa61025fe7a6c5 *libcxxabi_headers.zip +b189f37011a77ce5d3b6478474172b4594fee626daa75b63da8feb9d376ad983 *mksnapshot-v39.8.2-darwin-arm64.zip +436daa4ae7ca171c51d265976ddc5a5e8ede5b7c1c9cb5467547f14cef87b0c9 *mksnapshot-v39.8.2-darwin-x64.zip +b25ee4873f0bdb9ad663446f9443aa23faeb9e4e2f2734afe47c383e66b6939b *mksnapshot-v39.8.2-linux-arm64-x64.zip +26ebf5acbec96fd08d58d3d9351c26b8cb1ded51a948e0b0513a636deaf17648 *mksnapshot-v39.8.2-linux-armv7l-x64.zip +619b5349abd00d4b7c91894114b2c2aae94d2467d912f476ebcd8c718031493b *mksnapshot-v39.8.2-linux-x64.zip +ec14eeccd924c97a2716b2de5c8279dc5ecb0588c3a333be1dfe4122d192bebc *mksnapshot-v39.8.2-mas-arm64.zip +503f0b1263ebd86c094140307c02c7da474c219b839079a59fcdb1dc1451986a *mksnapshot-v39.8.2-mas-x64.zip +5d7082a1811e11807f78ce9888b00085db92c3dd721d67f54954fd0192570826 *mksnapshot-v39.8.2-win32-arm64-x64.zip +531ea5cd112438eb9276c6327487f8b1f0845b11c080697c438406968d51a859 *mksnapshot-v39.8.2-win32-ia32.zip +5f3ab3f4c4bb7783cd59235a2fffd22ceb86afdafcecdc9492b595302e03ed3e *mksnapshot-v39.8.2-win32-x64.zip diff --git a/cgmanifest.json b/cgmanifest.json index 281bfb40dc8..2502a1b1ed7 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "e6928c13198c854aa014c319d72eea599e2e0ee7", - "tag": "39.8.3" + "commitHash": "8e0f534873e9fdba5b365879bbdf6b47a0a64e1d", + "tag": "39.8.2" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.8.3" + "version": "39.8.2" }, { "component": { diff --git a/package-lock.json b/package-lock.json index bdd32a8097d..03b6d34bf96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,7 +106,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.3", + "electron": "39.8.2", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -8640,9 +8640,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.8.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.3.tgz", - "integrity": "sha512-ZhetvWz2qbI2WbBHdK/utR8I5bi1pYWJdit9tP0sGzs42CpsAFyu/FirXE88NWSh+3U8X6Wuf9jjDEYvAyrxNw==", + "version": "39.8.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.2.tgz", + "integrity": "sha512-uwNJHeqm8pzQEZf/KX4XM1fJctZpHcA0Za/MlP9mOg0FAWHbKo6yRC33QbdfLX7PeNjYZC3I3nnVhE5L2CLqxw==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index d0eba8ad9ce..a8107a7cc72 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.113.0", - "distro": "f7f14fdd95367f272a8a1fc24811b3b55bdd0fe3", + "distro": "a469262cd3af261072efec49f751e7f9587d41a4", "author": { "name": "Microsoft Corporation" }, @@ -176,7 +176,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.3", + "electron": "39.8.2", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/remote/.npmrc b/remote/.npmrc index 7c6849a8708..8310ec94634 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -1,6 +1,6 @@ disturl="https://nodejs.org/dist" target="22.22.1" -ms_build_id="420922" +ms_build_id="420065" runtime="node" build_from_source="true" legacy-peer-deps="true" From 42986aaffd4430dee333b663e58a5f56a5309125 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:35:59 -0700 Subject: [PATCH 084/183] Restore sandbox lock message for background commands (#303381) Restore sandbox lock message for background commands. --- .../browser/tools/runInTerminalTool.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index addf7bf2123..b9635789cef 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -771,12 +771,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { : rawDisplayCommand; const escapedDisplayCommand = escapeMarkdownSyntaxTokens(displayCommand); const invocationMessage = toolSpecificData.commandLine.isSandboxWrapped - ? new MarkdownString(args.isBackground - ? localize('runInTerminal.invocation.sandbox.background', "$(lock) Running `{0}` in sandbox in background", escapedDisplayCommand) - : localize('runInTerminal.invocation.sandbox', "$(lock) Running `{0}` in sandbox", escapedDisplayCommand), { supportThemeIcons: true }) - : new MarkdownString(args.isBackground - ? localize('runInTerminal.invocation.background', "Running `{0}` in background", escapedDisplayCommand) - : localize('runInTerminal.invocation', "Running `{0}`", escapedDisplayCommand)); + ? args.isBackground + ? new MarkdownString(localize('runInTerminal.invocation.sandbox.background', "$(lock) Running `{0}` in sandbox in background", escapedDisplayCommand), { supportThemeIcons: true }) + : new MarkdownString(localize('runInTerminal.invocation.sandbox', "$(lock) Running `{0}` in sandbox", escapedDisplayCommand), { supportThemeIcons: true }) + : args.isBackground + ? new MarkdownString(localize('runInTerminal.invocation.background', "Running `{0}` in background", escapedDisplayCommand)) + : new MarkdownString(localize('runInTerminal.invocation', "Running `{0}`", escapedDisplayCommand)); return { invocationMessage, From 0b90e94168157d4d7b58a7ebe7c0b1d0d4b0ad0e Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 20 Mar 2026 15:56:03 +1100 Subject: [PATCH 085/183] Disable terminal sandboxing for confirmation prompts (#301940) * Disable terminal sandboxing for confirmation prompts * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Simpler * Revert * Rename with comments --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../browser/tools/runInTerminalConfirmationTool.ts | 4 ++++ .../browser/tools/runInTerminalTool.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts index 65a0a565068..1093ebfcabd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts @@ -58,6 +58,10 @@ export const ConfirmTerminalCommandToolData: IToolData = { }; export class ConfirmTerminalCommandTool extends RunInTerminalTool { + override get _enableCommandLineSandboxRewriting() { + return false; + } + override async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const preparedInvocation = await super.prepareToolInvocation(context, token); if (preparedInvocation) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index b9635789cef..a7f21d62662 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -399,7 +399,14 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { RunInTerminalTool._activeExecutions.delete(id); return true; } - + /** + * Controls whether this tool wires up sandbox-specific command rewriting. + * This is separate from ITerminalSandboxService.isEnabled(), which reports + * whether terminal sandboxing is currently enabled for the running window. + */ + protected get _enableCommandLineSandboxRewriting() { + return true; + } constructor( @IChatService protected readonly _chatService: IChatService, @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -432,8 +439,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._register(this._instantiationService.createInstance(CommandLineCdPrefixRewriter)), this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)), this._register(this._instantiationService.createInstance(CommandLinePreventHistoryRewriter)), - this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter)), ]; + if (this._enableCommandLineSandboxRewriting) { + this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter))); + } this._commandLineAnalyzers = [ this._register(this._instantiationService.createInstance(CommandLineFileWriteAnalyzer, this._treeSitterCommandParser, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineFileWriteAnalyzer: ${message}`, args))), this._register(this._instantiationService.createInstance(CommandLineAutoApproveAnalyzer, this._treeSitterCommandParser, this._telemetry, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineAutoApproveAnalyzer: ${message}`, args))), From aa152e6756fcc535031b529d4019a62443338bfd Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 20 Mar 2026 09:08:32 +0100 Subject: [PATCH 086/183] =?UTF-8?q?Revert=20"Revert=20"chore=20-=20Add=20t?= =?UTF-8?q?elemetry=20logging=20for=20chat=20editing=20session=20store=20?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 36c908a37bea71dd44661ba2d1f3f08d0649f206. --- .../browser/chatEditing/chatEditingSession.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 8439b5518b7..918b0a9c767 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -30,6 +30,7 @@ import { EditorActivation } from '../../../../../platform/editor/common/editor.j import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -47,7 +48,7 @@ import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; -import { FileOperation, FileOperationType } from './chatEditingOperations.js'; +import { FileOperation, FileOperationType, getKeyForChatSessionResource } from './chatEditingOperations.js'; import { IChatEditingExplanationModelManager, IExplanationDiffInfo, IExplanationGenerationHandle } from './chatEditingExplanationModelManager.js'; import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js'; import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; @@ -59,6 +60,42 @@ const enum NotExistBehavior { Abort, } +type ChatEditingSessionStoreEvent = { + sessionId: string; + entryCount: number; + modifiedCount: number; + acceptedCount: number; + rejectedCount: number; +}; + +type ChatEditingSessionStoreClassification = { + owner: 'jrieken'; + comment: 'Tracks the number and state of chat editing entries when a session is stored.'; + sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; + entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries stored with the session.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when storing.' }; + acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when storing.' }; + rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when storing.' }; +}; + +type ChatEditingSessionRestoreEvent = { + sessionId: string; + entryCount: number; + modifiedCount: number; + acceptedCount: number; + rejectedCount: number; +}; + +type ChatEditingSessionRestoreClassification = { + owner: 'jrieken'; + comment: 'Tracks the number and state of chat editing entries when a session is restored.'; + sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; + entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries restored with the session.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when restoring.' }; + acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when restoring.' }; + rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when restoring.' }; +}; + class ThrottledSequencer extends Sequencer { private _size = 0; @@ -199,6 +236,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IConfigurationService private readonly configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, @IChatEditingExplanationModelManager private readonly _explanationModelManager: IChatEditingExplanationModelManager, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._timeline = this._instantiationService.createInstance( @@ -308,7 +346,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource); - return storage.storeState(this._getStoredState()); + const storedState = this._getStoredState(); + this._telemetryService.publicLog2('chatEditing/sessionStore', { + sessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(this._entriesObs.get()), + }); + return storage.storeState(storedState); } private _getStoredState(sessionResource = this.chatSessionResource): StoredSessionState { @@ -945,6 +988,10 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._entriesObs.set(entriesArr, undefined); + this._telemetryService.publicLog2('chatEditing/sessionRestore', { + sessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(entriesArr), + }); } private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { @@ -981,6 +1028,28 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }; } + private _countEntryStates(entries: readonly AbstractChatEditingModifiedFileEntry[]): { entryCount: number; modifiedCount: number; acceptedCount: number; rejectedCount: number } { + let entryCount = 0; + let modifiedCount = 0; + let acceptedCount = 0; + let rejectedCount = 0; + for (const entry of entries) { + entryCount += 1; + switch (entry.state.get()) { + case ModifiedFileEntryState.Modified: + modifiedCount += 1; + break; + case ModifiedFileEntryState.Accepted: + acceptedCount += 1; + break; + case ModifiedFileEntryState.Rejected: + rejectedCount += 1; + break; + } + } + return { entryCount, modifiedCount, acceptedCount, rejectedCount }; + } + private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise { const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString()); if (!hasOtherTasks) { From 2fc003844a2be0c2a0b3e80c6cabb340d64865a8 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 20 Mar 2026 09:15:35 +0100 Subject: [PATCH 087/183] fix `editSessionId` --- .../browser/chatEditing/chatEditingSession.ts | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 918b0a9c767..04343f023ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -60,41 +60,24 @@ const enum NotExistBehavior { Abort, } -type ChatEditingSessionStoreEvent = { - sessionId: string; +type ChatEditingSessionInfoEvent = { + editSessionId: string; entryCount: number; modifiedCount: number; acceptedCount: number; rejectedCount: number; }; -type ChatEditingSessionStoreClassification = { +type ChatEditingSessionInfoClassification = { owner: 'jrieken'; comment: 'Tracks the number and state of chat editing entries when a session is stored.'; - sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; + editSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries stored with the session.' }; modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when storing.' }; acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when storing.' }; rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when storing.' }; }; -type ChatEditingSessionRestoreEvent = { - sessionId: string; - entryCount: number; - modifiedCount: number; - acceptedCount: number; - rejectedCount: number; -}; - -type ChatEditingSessionRestoreClassification = { - owner: 'jrieken'; - comment: 'Tracks the number and state of chat editing entries when a session is restored.'; - sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; - entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries restored with the session.' }; - modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when restoring.' }; - acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when restoring.' }; - rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when restoring.' }; -}; class ThrottledSequencer extends Sequencer { @@ -347,8 +330,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource); const storedState = this._getStoredState(); - this._telemetryService.publicLog2('chatEditing/sessionStore', { - sessionId: getKeyForChatSessionResource(this.chatSessionResource), + this._telemetryService.publicLog2('chatEditing/sessionStore', { + editSessionId: getKeyForChatSessionResource(this.chatSessionResource), ...this._countEntryStates(this._entriesObs.get()), }); return storage.storeState(storedState); @@ -988,8 +971,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._entriesObs.set(entriesArr, undefined); - this._telemetryService.publicLog2('chatEditing/sessionRestore', { - sessionId: getKeyForChatSessionResource(this.chatSessionResource), + this._telemetryService.publicLog2('chatEditing/sessionRestore', { + editSessionId: getKeyForChatSessionResource(this.chatSessionResource), ...this._countEntryStates(entriesArr), }); } From 31ed44d62d70faf7b38241abd7cf4fcd34415447 Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 20 Mar 2026 17:33:22 +0900 Subject: [PATCH 088/183] fix: support protocol handling for sessions app on windows (#303398) * fix: support protocol handling for sessions app on windows * chore: always use HKCU for protocol registration --- build/gulpfile.vscode.win32.ts | 1 + build/win32/code.iss | 9 +++++++++ src/vs/platform/url/electron-main/electronUrlListener.ts | 7 ++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 0f81323c98d..c393e8247f1 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -122,6 +122,7 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { definitions['ProxyExeBasename'] = embedded.nameShort; definitions['ProxyAppUserId'] = embedded.win32AppUserModelId; definitions['ProxyNameLong'] = embedded.nameLong; + definitions['ProxyExeUrlProtocol'] = embedded.urlProtocol; } if (quality === 'stable' || quality === 'insider') { diff --git a/build/win32/code.iss b/build/win32/code.iss index a61eef9c066..53016d814ae 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1294,6 +1294,15 @@ Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu +; URL Protocol handler for proxy executable +#ifdef ProxyExeBasename +#ifdef ProxyExeUrlProtocol +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: ""; ValueData: "URL:{#ProxyExeUrlProtocol}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#ProxyExeBasename}.exe"" --open-url -- ""%1"""; Flags: uninsdeletekey +#endif +#endif + ; Environment #if "user" == InstallTarget #define EnvironmentRootKey "HKCU" diff --git a/src/vs/platform/url/electron-main/electronUrlListener.ts b/src/vs/platform/url/electron-main/electronUrlListener.ts index 49c508e8450..fe9ce0b757d 100644 --- a/src/vs/platform/url/electron-main/electronUrlListener.ts +++ b/src/vs/platform/url/electron-main/electronUrlListener.ts @@ -7,7 +7,7 @@ import { app, Event as ElectronEvent } from 'electron'; import { disposableTimeout } from '../../../base/common/async.js'; import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { isWindows } from '../../../base/common/platform.js'; +import { INodeProcess, isWindows } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILogService } from '../../log/common/log.js'; @@ -50,8 +50,9 @@ export class ElectronURLListener extends Disposable { // Windows: install as protocol handler // Skip in portable mode: the registered command wouldn't preserve - // portable mode settings, causing issues with OAuth flows - if (isWindows && !environmentMainService.isPortable) { + // portable mode settings, causing issues with OAuth flows. + // Skip for embedded apps: protocol handler is registered at install time. + if (isWindows && !environmentMainService.isPortable && !(process as INodeProcess).isEmbeddedApp) { const windowsParameters = environmentMainService.isBuilt ? [] : [`"${environmentMainService.appRoot}"`]; windowsParameters.push('--open-url', '--'); app.setAsDefaultProtocolClient(productService.urlProtocol, process.execPath, windowsParameters); From 00fdd06d8a9ee1d1fc4904b4278bf73dcd295871 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:04:25 +0000 Subject: [PATCH 089/183] Initial plan From a7b748aa7351ef57c8e7be6d7b638741e54674a2 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:10:13 +0000 Subject: [PATCH 090/183] Sessions - fix regression with persisting view mode (#303404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Moreno --- src/vs/sessions/contrib/changes/browser/changesView.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 946be8667a6..bb3322d1838 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -35,7 +35,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; @@ -224,6 +224,7 @@ class ChangesViewModel extends Disposable { return; } this.viewModeObs.set(mode, undefined); + this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); } constructor( From c49415cf1f9ec96660faa745dd79bd10172efb79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:12:32 +0000 Subject: [PATCH 091/183] Add history navigation (arrow up/down) to inline chat v3 input widget Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/f58ffb33-62c1-4de4-866d-cdf17e6d849b --- .../inlineChat/browser/inlineChatActions.ts | 4 ++ .../browser/inlineChatOverlayWidget.ts | 49 +++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 9c8ba1610ba..f232d3a8d37 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -471,6 +471,7 @@ export class SubmitInlineChatInputAction extends AbstractInlineChatAction { override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { const value = ctrl.inputWidget.value; if (value) { + ctrl.inputWidget.addToHistory(value); ctrl.inputWidget.hide(); ctrl.run({ message: value, autoSend: true }); } @@ -591,6 +592,9 @@ export class QueueInChatAction extends AbstractInlineChatAction { } const value = ctrl.inputWidget.value; + if (value) { + ctrl.inputWidget.addToHistory(value); + } ctrl.inputWidget.hide(); if (!value) { return; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 434a4b2e7fe..10295552d91 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -12,6 +12,7 @@ import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actio import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { HistoryNavigator2 } from '../../../../base/common/history.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -62,6 +63,7 @@ export class InlineChatInputWidget extends Disposable { private _anchorLeft: number = 0; private _anchorAbove: boolean = false; + private readonly _historyNavigator = new HistoryNavigator2([''], 50); constructor( private readonly _editorObs: ObservableCodeEditor, @@ -214,15 +216,28 @@ export class InlineChatInputWidget extends Disposable { this._store.add(this._input.onDidBlurEditorText(() => inputWidgetFocused.set(false))); this._store.add(toDisposable(() => inputWidgetFocused.reset())); - // Handle key events: ArrowDown to move to actions + // Handle key events: ArrowUp/ArrowDown for history navigation and action bar focus this._store.add(this._input.onKeyDown(e => { - if (e.keyCode === KeyCode.DownArrow && !actionBar.isEmpty()) { + if (e.keyCode === KeyCode.UpArrow) { + const position = this._input.getPosition(); + if (position && position.lineNumber === 1) { + this._showPreviousHistoryValue(); + e.preventDefault(); + e.stopPropagation(); + } + } else if (e.keyCode === KeyCode.DownArrow) { const model = this._input.getModel(); const position = this._input.getPosition(); if (position && position.lineNumber === model.getLineCount()) { - e.preventDefault(); - e.stopPropagation(); - actionBar.focus(0); + if (!this._historyNavigator.isAtEnd()) { + this._showNextHistoryValue(); + e.preventDefault(); + e.stopPropagation(); + } else if (!actionBar.isEmpty()) { + e.preventDefault(); + e.stopPropagation(); + actionBar.focus(0); + } } } })); @@ -254,6 +269,27 @@ export class InlineChatInputWidget extends Disposable { return this._input.getModel().getValue().trim(); } + addToHistory(value: string): void { + this._historyNavigator.replaceLast(value); + this._historyNavigator.add(''); + } + + private _showPreviousHistoryValue(): void { + if (this._historyNavigator.isAtEnd()) { + this._historyNavigator.replaceLast(this._input.getModel().getValue()); + } + const value = this._historyNavigator.previous(); + this._input.getModel().setValue(value); + } + + private _showNextHistoryValue(): void { + if (this._historyNavigator.isAtEnd()) { + return; + } + const value = this._historyNavigator.next(); + this._input.getModel().setValue(value); + } + /** * Show the widget at the specified line. * @param lineNumber The line number to anchor the widget to @@ -263,6 +299,9 @@ export class InlineChatInputWidget extends Disposable { show(lineNumber: number, left: number, anchorAbove: boolean, placeholder: string, value?: string): void { this._showStore.clear(); + // Reset history cursor to the end (current uncommitted text) + this._historyNavigator.resetCursor(); + // Clear input state this._input.updateOptions({ wordWrap: 'off', placeholder }); this._input.getModel().setValue(value ?? ''); From 1cc94a1ec4b4072efcb3b1e10843dfec771e1090 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 20 Mar 2026 10:14:47 +0100 Subject: [PATCH 092/183] chat customizations: new grouping for instructions (#303335) * chat customizations: new grouping for instructions * fix import * Update src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix * update * update --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../aiCustomizationListWidget.ts | 177 ++++++++++++------ .../computeAutomaticInstructions.ts | 32 ++-- .../aiCustomizationListWidget.fixture.ts | 74 ++++---- 3 files changed, 168 insertions(+), 115 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 46a7200b5cb..f0ddbf2cb49 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -19,7 +19,7 @@ import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; -import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY } from './aiCustomizationManagement.js'; @@ -54,6 +54,8 @@ import { OS } from '../../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ICustomizationHarnessService, matchesWorkspaceSubpath, matchesInstructionFileFilter } from '../../common/customizationHarnessService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { getCleanPromptName, isInClaudeRulesFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js'; export { truncateToFirstSentence } from './aiCustomizationListWidgetUtils.js'; @@ -95,6 +97,10 @@ export interface IAICustomizationListItem { readonly groupKey?: string; /** URI of the parent plugin, when this item comes from an installed plugin. */ readonly pluginUri?: URI; + /** When set, overrides the formatted name for display. */ + readonly displayName?: string; + /** When set, overrides the default prompt-type icon. */ + readonly typeIcon?: ThemeIcon; nameMatches?: IMatch[]; descriptionMatches?: IMatch[]; } @@ -238,6 +244,19 @@ function promptTypeToIcon(type: PromptsType): ThemeIcon { } } +/** + * Returns the icon for a given storage type. + */ +function storageToIcon(storage: PromptsStorage): ThemeIcon { + switch (storage) { + case PromptsStorage.local: return workspaceIcon; + case PromptsStorage.user: return userIcon; + case PromptsStorage.extension: return extensionIcon; + case PromptsStorage.plugin: return pluginIcon; + default: return instructionsIcon; + } +} + /** * Formats a name for display: strips a trailing .md extension, converts dashes/underscores * to spaces and applies title case. @@ -300,9 +319,9 @@ class AICustomizationItemRenderer implements IListRenderer { @@ -325,7 +344,7 @@ class AICustomizationItemRenderer implements IListRenderer f.uri)); - // Also include agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) - if (promptType === PromptsType.instructions) { - const agentInstructions = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); - const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - if (activeRoot) { - workspaceFolderUris.push(activeRoot); + // Add agent instruction items + const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); + const activeRoot = this.workspaceService.getActiveProjectRoot(); + if (activeRoot) { + workspaceFolderUris.push(activeRoot); + } + for (const file of agentInstructionFiles) { + const storage = PromptsStorage.local; + const filename = basename(file.uri); + items.push({ + id: file.uri.toString(), + uri: file.uri, + name: filename, + filename: this.labelService.getUriLabel(file.uri, { relative: true }), + displayName: filename, + storage, + promptType, + typeIcon: storageToIcon(storage), + groupKey: 'agent-instructions', + disabled: disabledUris.has(file.uri), + }); + } + + // Parse prompt files to separate into context vs on-demand + const promptFilesToParse = promptFiles.filter(item => !agentInstructionUris.has(item.uri)); + const parseResults = await Promise.all(promptFilesToParse.map(async item => { + try { + const parsed = await this.promptsService.parseNew(item.uri, CancellationToken.None); + return { item, parsed }; + } catch { + // Parse failed — treat as on-demand + return { item, parsed: undefined }; } - for (const file of agentInstructions) { - const isWorkspaceFile = workspaceFolderUris.some(root => isEqualOrParent(file.uri, root)); - allItems.push({ - uri: file.uri, - storage: isWorkspaceFile ? PromptsStorage.local : PromptsStorage.user, - type: PromptsType.instructions, - name: basename(file.uri), + })); + + for (const { item, parsed } of parseResults) { + const applyTo = evaluateApplyToPattern(parsed?.header, isInClaudeRulesFolder(item.uri)); + const name = parsed?.header?.name; + let description = parsed?.header?.description; + const friendlyName = this.getFriendlyName(name || item.name || getCleanPromptName(item.uri)); + description = description || item.description; + + if (applyTo !== undefined) { + // Context instruction + const suffix = applyTo === '**' + ? localize('alwaysAdded', "always added", applyTo) + : localize('onContext', "context matching '{0}'", applyTo); + items.push({ + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename: this.labelService.getUriLabel(item.uri, { relative: true }), + displayName: `${friendlyName} - ${suffix}`, + description: description, + storage: item.storage, + promptType, + typeIcon: storageToIcon(item.storage), + groupKey: 'context-instructions', + pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, + disabled: disabledUris.has(item.uri), + }); + } else { + // On-demand instruction + items.push({ + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename: basename(item.uri), + displayName: friendlyName, + description: description, + storage: item.storage, + promptType, + typeIcon: storageToIcon(item.storage), + groupKey: 'on-demand-instructions', + pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, + disabled: disabledUris.has(item.uri), }); } } - - const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local); - 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); - // For instructions, derive a friendly name from filename - const friendlyName = item.name || this.getFriendlyName(filename); - return { - id: item.uri.toString(), - uri: item.uri, - name: friendlyName, - filename, - description: item.description, - storage: item.storage, - promptType, - pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, - disabled: disabledUris.has(item.uri), - }; - }; - - items.push(...workspaceItems.map(mapToListItem)); - 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) @@ -1326,7 +1378,7 @@ export class AICustomizationListWidget extends Disposable { for (const item of this.allItems) { // Compute matches against the formatted display name so highlight positions // are correct even after .md stripping and title-casing. - const displayName = formatDisplayName(item.name); + const displayName = item.displayName ?? formatDisplayName(item.name); const nameMatches = matchesContiguousSubString(query, displayName); const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; const filenameMatches = matchesContiguousSubString(query, item.filename); @@ -1341,17 +1393,24 @@ export class AICustomizationListWidget extends Disposable { } } - // Group items by storage + // Group items — instructions use category-based grouping; other sections use storage-based const promptType = sectionToPromptType(this.currentSection); const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); - const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ - { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { 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'); + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = + this.currentSection === AICustomizationManagementSection.Instructions + ? [ + { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, + { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, + { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, + ] + : [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { 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'); for (const item of matchedItems) { const key = item.groupKey ?? item.storage; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 0cfdc0b14d3..dafa487c65b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -23,7 +23,7 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariable import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; import { isInClaudeAgentsFolder, isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; -import { ParsedPromptFile } from './promptFileParser.js'; +import { ParsedPromptFile, PromptHeader } from './promptFileParser.js'; import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; @@ -233,21 +233,6 @@ export class ComputeAutomaticInstructions { } } - /** - * Combines the `applyTo` and `paths` attributes into a single comma-separated - * pattern string that can be matched by {@link _matches}. - * Used for the instructions list XML output where both should be shown. - */ - private _getApplyToPattern(applyTo: string | undefined, paths: readonly string[] | undefined): string | undefined { - if (applyTo) { - return applyTo; - } - if (paths && paths.length > 0) { - return paths.join(', '); - } - return undefined; - } - private _matches(files: ResourceSet, applyToPattern: string): { pattern: string; file?: URI } | undefined { const patterns = splitGlobAware(applyToPattern, ','); const patterMatches = (pattern: string): { pattern: string; file?: URI } | undefined => { @@ -322,12 +307,12 @@ export class ComputeAutomaticInstructions { if (parsedFile) { entries.push(''); if (parsedFile.header) { - const { description, applyTo, paths } = parsedFile.header; + const { description } = parsedFile.header; if (description) { entries.push(`${description}`); } entries.push(`${filePath(uri)}`); - const applyToPattern = this._getApplyToPattern(applyTo, paths); + const applyToPattern = evaluateApplyToPattern(parsedFile.header, isInClaudeRulesFolder(uri)); if (applyToPattern) { entries.push(`${applyToPattern}`); } @@ -533,3 +518,14 @@ export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined): st } return uri.toString(); } + +/** + * Returns `applyTo` or `paths` attributes based on whether the instruction file is a Claude rules file or a regular instruction file + */ +export function evaluateApplyToPattern(header: PromptHeader | undefined, isClaudeRules: boolean): string | undefined { + if (isClaudeRules) { + // For Claude rules files, `paths` is the primary attribute (defaulting to '**' when omitted) + return header?.paths?.join(', ') ?? '**'; + } + return header?.applyTo ?? undefined; // For regular instruction files, only show `applyTo` patterns, and skip if it's omitted +} diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts index b588f0de2e1..4fa628e23b8 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; -import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; +import { ResourceSet } from '../../../../base/common/map.js'; import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { mock } from '../../../../base/test/common/mock.js'; @@ -17,12 +16,14 @@ import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../.. import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../contrib/chat/common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; -import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; +import { ParsedPromptFile, PromptHeader } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { isEqual } from '../../../../base/common/resources.js'; // Ensure color registrations are loaded import '../../../../platform/theme/common/colors/inputColors.js'; @@ -37,24 +38,13 @@ const defaultFilter: IStorageSourceFilter = { }; interface IFixtureInstructionFile { - readonly uri: URI; - readonly storage: PromptsStorage; - readonly type: PromptsType; + readonly promptPath: IPromptPath; readonly name?: string; readonly description?: string; - /** If set, this instruction file has an applyTo pattern (on-demand). */ - readonly applyTo?: string; + readonly applyTo?: string; /** If set, this instruction file has an applyTo pattern that controls automatic inclusion when the context matches (or `**` for always). */ } function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): IPromptsService { - // Build a map from URI to applyTo for parseNew - const applyToMap = new ResourceMap(); - const descriptionMap = new ResourceMap(); - for (const file of instructionFiles) { - applyToMap.set(file.uri, file.applyTo); - descriptionMap.set(file.uri, file.description); - } - return new class extends mock() { override readonly onDidChangeCustomAgents = Event.None; override readonly onDidChangeSlashCommands = Event.None; @@ -62,26 +52,34 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } override async listPromptFiles(type: PromptsType) { if (type === PromptsType.instructions) { - return instructionFiles.map(f => ({ - uri: f.uri, - storage: f.storage as PromptsStorage.local, - type: f.type, - name: f.name, - description: f.description, - })); + return instructionFiles.map(f => f.promptPath); } return []; } override async listAgentInstructions() { return agentInstructionFiles; } override async getCustomAgents() { return []; } - override async parseNew(uri: URI, _token: CancellationToken): Promise { - const applyTo = applyToMap.get(uri); - const description = descriptionMap.get(uri); - const header = { - get applyTo() { return applyTo; }, - get description() { return description; }, - }; - return new ParsedPromptFile(uri, header as never); + override async parseNew(uri: URI): Promise { + const file = instructionFiles.find(f => isEqual(f.promptPath.uri, uri)); + const headerLines = []; + headerLines.push('---\n'); + if (file) { + if (file.name) { + headerLines.push(`name: ${file.name}\n`); + } + if (file.description) { + headerLines.push(`description: ${file.description}\n`); + } + if (file.applyTo) { + headerLines.push(`applyTo: "${file.applyTo}"\n`); + } + } + headerLines.push('---\n'); + const header = new PromptHeader( + new Range(2, 1, headerLines.length, 1), + uri, + headerLines + ); + return new ParsedPromptFile(uri, header); } }(); } @@ -184,14 +182,14 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { labels: { kind: 'screenshot' }, render: ctx => renderInstructionsTab(ctx, [ // Always-active instructions (no applyTo) - { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, - { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style preferences' }, + { promptPath: { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Coding Standards', description: 'Repository-wide coding standards' }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions }, name: 'My Style', description: 'Personal coding style preferences' }, // Always-included instruction (applyTo: **) - { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'General Guidelines', description: 'General development guidelines', applyTo: '**' }, + { promptPath: { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'General Guidelines', description: 'General development guidelines', applyTo: '**' }, // On-demand instructions (with applyTo pattern) - { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing Guidelines', description: 'Testing best practices', applyTo: '**/*.test.ts' }, - { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security Review', description: 'Security review checklist', applyTo: 'src/auth/**' }, - { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Typescript Rules', description: 'TypeScript conventions', applyTo: '**/*.ts' }, + { promptPath: { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Testing Guidelines', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { promptPath: { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Security Review', description: 'Security review checklist', applyTo: 'src/auth/**' }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, extension: undefined!, source: undefined! }, name: 'TypeScript Rules', description: 'TypeScript conventions', applyTo: '**/*.ts' }, ], [ // Agent instruction files (AGENTS.md, copilot-instructions.md) { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, From 687da2d34830474a176d13b99f6e0548c0abf252 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 20 Mar 2026 10:22:20 +0100 Subject: [PATCH 093/183] Restructure agent sessions filter menu with sort and group radios (#303399) * feat - add sorting functionality for agent sessions * style - update titles for sorting and grouping actions * style - update sorting and grouping action titles * style - update reset filter action title * tests - add groupAgentSessionsByDate sortBy tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix - use lastRequestEnded for sort-by-updated The 'Sort by Updated Date' option now uses lastRequestEnded (the last response time) instead of lastRequestStarted, matching the signal the model uses for read-state tracking. This applies to both sorting order and date group bucketing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix - show updated date in session description row When sorting by updated date, the time label in each session's description row now reflects lastRequestEnded instead of created, keeping the displayed date consistent with the sort order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ccr * . * . --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/sessionsViewPane.ts | 155 ++++++++++++++---- .../agentSessions/agentSessionsControl.ts | 5 +- .../agentSessions/agentSessionsFilter.ts | 9 +- .../agentSessions/agentSessionsViewer.ts | 42 ++++- .../agentSessionsDataSource.test.ts | 129 ++++++++++++++- 5 files changed, 298 insertions(+), 42 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 46e99e5977a..7d964ee9465 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -8,7 +8,7 @@ import * as DOM from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun } from '../../../../base/common/observable.js'; -import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorsVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -23,7 +23,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; -import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -41,8 +41,11 @@ import { IHostService } from '../../../../workbench/services/host/browser/host.j const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); -const IsGroupedByRepositoryContext = new RawContextKey('sessionsView.isGroupedByRepository', true); +const SessionsViewFilterOptionsSubMenu = new MenuId('AgentSessionsViewFilterOptionsSubMenu'); +const SessionsViewGroupingContext = new RawContextKey('sessionsView.grouping', AgentSessionsGrouping.Repository); +const SessionsViewSortingContext = new RawContextKey('sessionsView.sorting', AgentSessionsSorting.Created); const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; +const SORTING_STORAGE_KEY = 'agentSessions.sorting'; export class AgenticSessionsViewPane extends ViewPane { @@ -50,7 +53,9 @@ export class AgenticSessionsViewPane extends ViewPane { private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Repository; - private isGroupedByRepoKey: ReturnType | undefined; + private currentSorting: AgentSessionsSorting = AgentSessionsSorting.Created; + private groupingContextKey: IContextKey | undefined; + private sortingContextKey: IContextKey | undefined; constructor( options: IViewPaneOptions, @@ -71,14 +76,22 @@ export class AgenticSessionsViewPane extends ViewPane { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); // Restore persisted grouping - const stored = this.storageService.get(GROUPING_STORAGE_KEY, StorageScope.PROFILE); - if (stored && Object.values(AgentSessionsGrouping).includes(stored as AgentSessionsGrouping)) { - this.currentGrouping = stored as AgentSessionsGrouping; + const storedGrouping = this.storageService.get(GROUPING_STORAGE_KEY, StorageScope.PROFILE); + if (storedGrouping && Object.values(AgentSessionsGrouping).includes(storedGrouping as AgentSessionsGrouping)) { + this.currentGrouping = storedGrouping as AgentSessionsGrouping; } - // Ensure the view-title context reflects the restored grouping immediately - this.isGroupedByRepoKey = IsGroupedByRepositoryContext.bindTo(contextKeyService); - this.isGroupedByRepoKey.set(this.currentGrouping === AgentSessionsGrouping.Repository); + // Restore persisted sorting + const storedSorting = this.storageService.get(SORTING_STORAGE_KEY, StorageScope.PROFILE); + if (storedSorting && Object.values(AgentSessionsSorting).includes(storedSorting as AgentSessionsSorting)) { + this.currentSorting = storedSorting as AgentSessionsSorting; + } + + // Ensure context keys reflect restored state immediately + this.groupingContextKey = SessionsViewGroupingContext.bindTo(contextKeyService); + this.groupingContextKey.set(this.currentGrouping); + this.sortingContextKey = SessionsViewSortingContext.bindTo(contextKeyService); + this.sortingContextKey.set(this.currentSorting); } protected override renderBody(parent: HTMLElement): void { @@ -105,14 +118,11 @@ export class AgenticSessionsViewPane extends ViewPane { private createControls(parent: HTMLElement): void { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); - // Track grouping state via context key for the toggle button - const isGroupedByRepoKey = this.isGroupedByRepoKey = IsGroupedByRepositoryContext.bindTo(this.contextKeyService); - isGroupedByRepoKey.set(this.currentGrouping === AgentSessionsGrouping.Repository); - - // Sessions Filter (actions go to view title bar via menu registration) + // Sessions Filter (actions go to the nested filter submenu) const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { - filterMenuId: SessionsViewFilterSubMenu, + filterMenuId: SessionsViewFilterOptionsSubMenu, groupResults: () => this.currentGrouping, + sortResults: () => this.currentSorting, allowedProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], providerLabelOverrides: new Map([ [AgentSessionProviders.Background, localize('chat.session.providerLabel.background', "Copilot CLI")], @@ -233,19 +243,28 @@ export class AgenticSessionsViewPane extends ViewPane { this.sessionsControl?.openFind(); } - toggleGroupByRepository(): void { - if (this.currentGrouping === AgentSessionsGrouping.Repository) { - this.currentGrouping = AgentSessionsGrouping.Date; - } else { - this.currentGrouping = AgentSessionsGrouping.Repository; + setGrouping(grouping: AgentSessionsGrouping): void { + if (this.currentGrouping === grouping) { + return; } + this.currentGrouping = grouping; this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); - this.isGroupedByRepoKey?.set(this.currentGrouping === AgentSessionsGrouping.Repository); - // TODO @osortega: Unsure if this is going to be annoying or helpful so that you can quickly see the active sessions + this.groupingContextKey?.set(this.currentGrouping); this.sessionsControl?.resetSectionCollapseState(); this.sessionsControl?.update(); } + + setSorting(sorting: AgentSessionsSorting): void { + if (this.currentSorting === sorting) { + return; + } + + this.currentSorting = sorting; + this.storageService.store(SORTING_STORAGE_KEY, this.currentSorting, StorageScope.PROFILE, StorageTarget.USER); + this.sortingContextKey?.set(this.currentSorting); + this.sessionsControl?.update(); + } } // Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window @@ -291,16 +310,25 @@ MenuRegistry.appendMenuItem(MenuId.ViewTitle, { when: ContextKeyExpr.equals('view', SessionsViewId) } satisfies ISubmenuItem); -registerAction2(class GroupByRepositoryAction extends Action2 { +// Nest the filter toggles (providers, statuses, properties, reset) inside a "Filter" submenu +MenuRegistry.appendMenuItem(SessionsViewFilterSubMenu, { + submenu: SessionsViewFilterOptionsSubMenu, + title: localize2('filter', "Filter"), + group: '1_filter', + order: 0, +} satisfies ISubmenuItem); + +// Sort By: Created Date (radio) +registerAction2(class SortByCreatedAction extends Action2 { constructor() { super({ - id: 'sessionsView.groupByRepository', - title: localize2('groupByRepository', "Group by Project"), + id: 'sessionsView.sortByCreated', + title: localize2('sortByCreated', "Sort by Created"), category: SessionsCategories.Sessions, - toggled: IsGroupedByRepositoryContext, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, AgentSessionsSorting.Created), menu: [{ id: SessionsViewFilterSubMenu, - group: 'grouping', + group: '2_sort', order: 0, }] }); @@ -309,7 +337,76 @@ registerAction2(class GroupByRepositoryAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleGroupByRepository(); + view?.setSorting(AgentSessionsSorting.Created); + } +}); + +// Sort By: Updated Date (radio) +registerAction2(class SortByUpdatedAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.sortByUpdated', + title: localize2('sortByUpdated', "Sort by Updated"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, AgentSessionsSorting.Updated), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '2_sort', + order: 1, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setSorting(AgentSessionsSorting.Updated); + } +}); + +// Group By: Project (radio) +registerAction2(class GroupByProjectAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByProject', + title: localize2('groupByProject', "Group by Project"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_group', + order: 0, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(AgentSessionsGrouping.Repository); + } +}); + +// Group By: Time (radio) +registerAction2(class GroupByTimeAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByTime', + title: localize2('groupByTime', "Group by Time"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Date), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_group', + order: 1, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(AgentSessionsGrouping.Date); } }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index f9a35ad2a61..1f073a38862 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -14,7 +14,7 @@ import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; -import { AgentSessionsGrouping } from './agentSessionsFilter.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -252,12 +252,13 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return false; }; - const sorter = new AgentSessionsSorter(); + const sorter = new AgentSessionsSorter(() => this.options.filter.sortResults?.() ?? AgentSessionsSorting.Created); const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined; const activeSessionResource = observableValue(this, undefined); const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, { ...this.options, isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, + isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, }, approvalModel, activeSessionResource)); const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index ca1fb92a7af..eba6abdd656 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -21,6 +21,11 @@ export enum AgentSessionsGrouping { Repository = 'repository' } +export enum AgentSessionsSorting { + Created = 'created', + Updated = 'updated' +} + export interface IAgentSessionsFilterOptions extends Partial { readonly filterMenuId?: MenuId; @@ -41,6 +46,7 @@ export interface IAgentSessionsFilterOptions extends Partial AgentSessionsGrouping | undefined; + readonly sortResults?: () => AgentSessionsSorting | undefined; overrideExclude?(session: IAgentSession): boolean | undefined; } @@ -61,6 +67,7 @@ export class AgentSessionsFilter extends Disposable implements Required this.options.limitResults?.(); readonly groupResults = () => this.options.groupResults?.(); + readonly sortResults = () => this.options.sortResults?.(); private excludes = DEFAULT_EXCLUDES; private isStoringExcludes = false; @@ -279,7 +286,7 @@ export class AgentSessionsFilter extends Disposable implements Required { @@ -414,7 +416,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } if (!timeLabel) { - const date = session.timing.created; + const date = this.options.isSortedByUpdated?.() + ? session.timing.lastRequestEnded ?? session.timing.created + : session.timing.created; const seconds = Math.round((new Date().getTime() - date) / 1000); if (seconds < 60) { timeLabel = localize('secondsDuration', "now"); @@ -721,6 +725,12 @@ export interface IAgentSessionsFilter { */ readonly groupResults?: () => AgentSessionsGrouping | undefined; + /** + * The field to sort sessions by. + * Defaults to created date when undefined. + */ + readonly sortResults?: () => AgentSessionsSorting | undefined; + /** * A callback to notify the filter about the number of * results after filtering. @@ -878,7 +888,8 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou private groupSessionsByDate(sortedSessions: IAgentSession[]): AgentSessionListItem[] { const result: AgentSessionListItem[] = []; - const groupedSessions = groupAgentSessionsByDate(sortedSessions); + const sortBy = this.filter?.sortResults?.(); + const groupedSessions = groupAgentSessionsByDate(sortedSessions, sortBy); for (const { sessions, section, label } of groupedSessions.values()) { if (sessions.length === 0) { @@ -1116,7 +1127,7 @@ export const AgentSessionSectionLabels = { const DAY_THRESHOLD = 24 * 60 * 60 * 1000; const WEEK_THRESHOLD = 7 * DAY_THRESHOLD; -export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map { +export function groupAgentSessionsByDate(sessions: IAgentSession[], sortBy?: AgentSessionsSorting): Map { const now = Date.now(); const startOfToday = new Date(now).setHours(0, 0, 0, 0); const startOfYesterday = startOfToday - DAY_THRESHOLD; @@ -1135,7 +1146,9 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -1216,6 +1229,12 @@ export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegat export class AgentSessionsSorter implements ITreeSorter { + private readonly getSortBy: () => AgentSessionsSorting; + + constructor(getSortBy?: () => AgentSessionsSorting) { + this.getSortBy = getSortBy ?? (() => AgentSessionsSorting.Created); + } + compare(sessionA: IAgentSession, sessionB: IAgentSession, prioritizeActiveSessions = false): number { // Special sorting if enabled @@ -1254,8 +1273,17 @@ export class AgentSessionsSorter implements ITreeSorter { } // Sort by time - const timeA = prioritizeActiveSessions ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created : sessionA.timing.created; - const timeB = prioritizeActiveSessions ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created : sessionB.timing.created; + const sortBy = this.getSortBy(); + const timeA = prioritizeActiveSessions + ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created + : sortBy === AgentSessionsSorting.Updated + ? sessionA.timing.lastRequestEnded ?? sessionA.timing.created + : sessionA.timing.created; + const timeB = prioritizeActiveSessions + ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created + : sortBy === AgentSessionsSorting.Updated + ? sessionB.timing.lastRequestEnded ?? sessionB.timing.created + : sessionB.timing.created; return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 434ff42e758..56f217f3c3a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -6,13 +6,13 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter } from '../../../browser/agentSessions/agentSessionsViewer.js'; +import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter, groupAgentSessionsByDate } from '../../../browser/agentSessions/agentSessionsViewer.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection } from '../../../browser/agentSessions/agentSessionsModel.js'; import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; -import { AgentSessionsGrouping } from '../../../browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } from '../../../browser/agentSessions/agentSessionsFilter.js'; suite('sessionDateFromNow', () => { @@ -1070,6 +1070,7 @@ suite('AgentSessionsSorter', () => { isPinned: boolean; created: number; lastRequestStarted: number; + lastRequestEnded: number; }>): IAgentSession { const now = Date.now(); return { @@ -1081,7 +1082,7 @@ suite('AgentSessionsSorter', () => { icon: Codicon.terminal, timing: { created: overrides.created ?? now, - lastRequestEnded: undefined, + lastRequestEnded: overrides.lastRequestEnded, lastRequestStarted: overrides.lastRequestStarted, }, changes: undefined, @@ -1167,4 +1168,126 @@ suite('AgentSessionsSorter', () => { const sorted = [archivedPinned, regular].sort((a, b) => sorter.compare(a, b)); assert.deepStrictEqual(sorted.map(s => s.label), ['Session regular', 'Session archived-pinned']); }); + + test('sortBy Created: sorts by creation time regardless of lastRequestEnded', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Created); + const olderCreated = createSession({ id: 'older', created: 1000, lastRequestEnded: 5000 }); + const newerCreated = createSession({ id: 'newer', created: 3000, lastRequestEnded: 2000 }); + + const sorted = [olderCreated, newerCreated].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session newer', 'Session older']); + }); + + test('sortBy Updated: sorts by lastRequestEnded', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Updated); + const recentlyUpdated = createSession({ id: 'updated', created: 1000, lastRequestEnded: 5000 }); + const recentlyCreated = createSession({ id: 'created', created: 3000, lastRequestEnded: 2000 }); + + const sorted = [recentlyCreated, recentlyUpdated].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session updated', 'Session created']); + }); + + test('sortBy Updated: falls back to created when lastRequestEnded is undefined', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Updated); + const withRequest = createSession({ id: 'with-request', created: 1000, lastRequestEnded: 3000 }); + const withoutRequest = createSession({ id: 'no-request', created: 4000 }); + + const sorted = [withRequest, withoutRequest].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session no-request', 'Session with-request']); + }); +}); + +suite('groupAgentSessionsByDate with sortBy', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(overrides: Partial<{ + id: string; + isArchived: boolean; + isPinned: boolean; + created: number; + lastRequestEnded: number; + }>): IAgentSession { + return { + providerType: 'test', + providerLabel: 'Test', + resource: URI.parse(`test://session/${overrides.id ?? 'default'}`), + status: ChatSessionStatus.Completed, + label: `Session ${overrides.id ?? 'default'}`, + icon: Codicon.terminal, + timing: { + created: overrides.created ?? Date.now(), + lastRequestEnded: overrides.lastRequestEnded, + lastRequestStarted: undefined, + }, + changes: undefined, + metadata: undefined, + isArchived: () => overrides.isArchived ?? false, + setArchived: () => { }, + isPinned: () => overrides.isPinned ?? false, + setPinned: () => { }, + isRead: () => true, + isMarkedUnread: () => false, + setRead: () => { }, + }; + } + + test('default (Created): buckets by created time', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldSession = createSession({ id: 'old', created: tenDaysAgo, lastRequestEnded: now }); + + const grouped = groupAgentSessionsByDate([oldSession]); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 0); + assert.deepStrictEqual(olderSessions.length, 1); + }); + + test('Updated: session created long ago but recently updated goes into Today', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldButUpdated = createSession({ id: 'old-updated', created: tenDaysAgo, lastRequestEnded: now }); + + const grouped = groupAgentSessionsByDate([oldButUpdated], AgentSessionsSorting.Updated); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 1); + assert.deepStrictEqual(olderSessions.length, 0); + }); + + test('Updated: falls back to created when lastRequestEnded is undefined', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldNoUpdate = createSession({ id: 'old-no-update', created: tenDaysAgo }); + + const grouped = groupAgentSessionsByDate([oldNoUpdate], AgentSessionsSorting.Updated); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 0); + assert.deepStrictEqual(olderSessions.length, 1); + }); + + test('Updated: pinned and archived sessions are not affected by sortBy', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const pinnedOld = createSession({ id: 'pinned', created: tenDaysAgo, lastRequestEnded: now, isPinned: true }); + const archivedOld = createSession({ id: 'archived', created: tenDaysAgo, lastRequestEnded: now, isArchived: true }); + + const grouped = groupAgentSessionsByDate([pinnedOld, archivedOld], AgentSessionsSorting.Updated); + const pinnedSessions = grouped.get(AgentSessionSection.Pinned)!.sessions; + const archivedSessions = grouped.get(AgentSessionSection.Archived)!.sessions; + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + + assert.deepStrictEqual(pinnedSessions.length, 1); + assert.deepStrictEqual(archivedSessions.length, 1); + assert.deepStrictEqual(todaySessions.length, 0); + }); }); From 1846413d5e44c42b12df9a499421f37b1b738a79 Mon Sep 17 00:00:00 2001 From: Isidor Date: Fri, 20 Mar 2026 10:53:55 +0100 Subject: [PATCH 094/183] update distro pointer --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a8107a7cc72..b7ad7635f91 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.113.0", - "distro": "a469262cd3af261072efec49f751e7f9587d41a4", + "distro": "6b93ffda14819d043903eeed60601e289e01b8f6", "author": { "name": "Microsoft Corporation" }, From c39fcb016c7174c0f883c843d61c959c0bfd831b Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 20 Mar 2026 10:53:48 +0100 Subject: [PATCH 095/183] marker hover: add MenuId for status bar actions and register Fix action --- src/vs/base/browser/ui/hover/hoverWidget.css | 1 + .../hover/browser/markerHoverParticipant.ts | 44 ++++++++++++++++++- src/vs/platform/actions/common/actions.ts | 1 + .../inlineChat/browser/inlineChatActions.ts | 6 +++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/vs/base/browser/ui/hover/hoverWidget.css b/src/vs/base/browser/ui/hover/hoverWidget.css index 85379221cf2..c2837659d5f 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.css +++ b/src/vs/base/browser/ui/hover/hoverWidget.css @@ -139,6 +139,7 @@ .monaco-hover .hover-row.status-bar .actions .action-container .action .icon { padding-right: 4px; vertical-align: middle; + font-size: inherit; } .monaco-hover .hover-row.status-bar .actions .action-container a { diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index c9f7c4479de..4964af49280 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -22,6 +22,8 @@ import { CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSour import { MarkerController, NextMarkerAction } from '../../gotoError/browser/gotoError.js'; import { HoverAnchor, HoverAnchorType, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart, IRenderedHoverPart, IRenderedHoverParts, RenderedHoverParts } from './hoverTypes.js'; import * as nls from '../../../../nls.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IMarker, IMarkerData, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -65,6 +67,8 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant { + for (const action of menuActions) { + context.statusBar.addAction({ + label: action.label, + commandId: action.id, + iconClass: action.class, + run: () => { + context.hide(); + this._editor.setSelection(Range.lift(markerHover.range)); + action.run(); + } + }); + } + }; + if (!this._editor.getOption(EditorOption.readOnly)) { const quickfixPlaceholderElement = context.statusBar.append($('div')); if (this.recentMarkerCodeActionsInfo) { if (IMarkerData.makeKey(this.recentMarkerCodeActionsInfo.marker) === IMarkerData.makeKey(markerHover.marker)) { if (!this.recentMarkerCodeActionsInfo.hasCodeActions) { - quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + if (menuActions.length === 0) { + quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + } } } else { this.recentMarkerCodeActionsInfo = undefined; @@ -230,7 +260,12 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant Date: Fri, 20 Mar 2026 10:59:46 +0100 Subject: [PATCH 096/183] skills: do not filter skills with missing name/description (#303173) * skills: do not filter skills with missing name/description * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update * update * update * update --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/base/common/resources.ts | 4 +- .../promptSyntax/newPromptFileActions.ts | 4 +- .../promptSyntax/pickers/promptFilePickers.ts | 4 +- .../config/promptFileLocations.ts | 34 ++++---- .../languageProviders/promptValidator.ts | 57 +++++++++---- .../service/promptsServiceImpl.ts | 30 +++---- .../languageProviders/promptValidator.test.ts | 56 +++++++++++-- .../computeAutomaticInstructions.test.ts | 80 +++++++++++++++++++ .../service/promptsService.test.ts | 50 +++++++++--- 9 files changed, 243 insertions(+), 76 deletions(-) diff --git a/src/vs/base/common/resources.ts b/src/vs/base/common/resources.ts index 3b8370c160b..a064a287736 100644 --- a/src/vs/base/common/resources.ts +++ b/src/vs/base/common/resources.ts @@ -190,8 +190,8 @@ export class ExtUri implements IExtUri { return basename(resource) || resource.authority; } - basename(resource: URI): string { - return paths.posix.basename(resource.path); + basename(resource: URI, suffix?: string): string { + return paths.posix.basename(resource.path, suffix); } extname(resource: URI): string { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 45c8fa8c0a5..27cc2591d9c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -25,7 +25,7 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, SKILL_FILENAME, VALID_SKILL_NAME_REGEX } from '../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; @@ -327,7 +327,7 @@ class NewSkillFileAction extends Action2 { return localize('commands.new.skill.name.tooLong', "Skill name must be 64 characters or less"); } // Per spec: lowercase alphanumeric and hyphens only - if (!/^[a-z0-9-]+$/.test(name)) { + if (!VALID_SKILL_NAME_REGEX.test(name)) { return localize('commands.new.skill.name.invalidChars', "Skill name may only contain lowercase letters, numbers, and hyphens"); } if (name.startsWith('-') || name.endsWith('-')) { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index a2cfc90f7cd..b28c2c70f36 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -14,7 +14,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, getSkillFolderName } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../newPromptFileActions.js'; import { GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, GENERATE_PROMPT_COMMAND_ID, GENERATE_SKILL_COMMAND_ID, GENERATE_AGENT_COMMAND_ID } from '../../actions/chatActions.js'; @@ -569,7 +569,7 @@ export class PromptFilePickers { private async _createPromptPickItem(promptFile: IPromptPath, buttons: IQuickInputButton[] | undefined, visibility: boolean | undefined, token: CancellationToken): Promise { const parsedPromptFile = await this._promptsService.parseNew(promptFile.uri, token).catch(() => undefined); - let promptName = parsedPromptFile?.header?.name ?? promptFile.name ?? getCleanPromptName(promptFile.uri); + let promptName = (parsedPromptFile?.header?.name ?? promptFile.name) || (promptFile.type === PromptsType.skill ? getSkillFolderName(promptFile.uri) : getCleanPromptName(promptFile.uri)); const promptDescription = parsedPromptFile?.header?.description ?? promptFile.description; let tooltip: string | undefined; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 6e360f9d8b3..bc63b772fe5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -4,12 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { posix } from '../../../../../../base/common/path.js'; +import { basename, dirname } from '../../../../../../base/common/resources.js'; import { PromptsType } from '../promptTypes.js'; import { PromptsStorage } from '../service/promptsService.js'; -const { basename, dirname } = posix; - /** * File extension for the reusable prompt files. */ @@ -35,6 +33,11 @@ export const AGENT_FILE_EXTENSION = '.agent.md'; */ export const SKILL_FILENAME = 'SKILL.md'; +/** + * Regex for valid skill names: lowercase alphanumeric and hyphens only. + */ +export const VALID_SKILL_NAME_REGEX = /^[a-z0-9-]+$/; + /** * AGENT file name */ @@ -217,7 +220,7 @@ export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ * Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders). */ function isInAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER) || isInCopilotAgentsFolder(fileUri); } @@ -225,7 +228,7 @@ function isInAgentsFolder(fileUri: URI): boolean { * Helper function to check if a file is directly in the .claude/agents/ folder. */ export function isInClaudeAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); } @@ -233,7 +236,7 @@ export function isInClaudeAgentsFolder(fileUri: URI): boolean { * Helper function to check if a file is directly in the ~/.copilot/agents/ folder. */ export function isInCopilotAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith(COPILOT_USER_AGENTS_SOURCE_FOLDER.substring(1)); } @@ -255,7 +258,7 @@ export function isInClaudeRulesFolder(fileUri: URI): boolean { * PromptsType.hook regardless of its location. */ export function getPromptFileType(fileUri: URI): PromptsType | undefined { - const filename = basename(fileUri.path); + const filename = basename(fileUri); if (filename.endsWith(PROMPT_FILE_EXTENSION)) { return PromptsType.prompt; @@ -335,12 +338,15 @@ export function getPromptFileDefaultLocations(type: PromptsType): readonly IProm } } +export function getSkillFolderName(fileUri: URI): string { + return basename(dirname(fileUri)); +} /** * Gets clean prompt name without file extension. */ export function getCleanPromptName(fileUri: URI): string { - const fileName = basename(fileUri.path); + const fileName = basename(fileUri); const extensions = [ PROMPT_FILE_EXTENSION, @@ -351,33 +357,33 @@ export function getCleanPromptName(fileUri: URI): string { for (const ext of extensions) { if (fileName.endsWith(ext)) { - return basename(fileUri.path, ext); + return basename(fileUri, ext); } } if (fileName === COPILOT_CUSTOM_INSTRUCTIONS_FILENAME) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For SKILL.md files (case insensitive), return 'SKILL' if (fileName.toLowerCase() === SKILL_FILENAME.toLowerCase()) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For .md files in .github/agents/ folder, treat them as agent files // Exclude README.md to allow documentation files if (fileName.endsWith('.md') && fileName !== 'README.md' && isInAgentsFolder(fileUri)) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For .md files in .claude/rules/ folder, treat them as instruction files if (fileName.endsWith('.md') && fileName !== 'README.md' && isInClaudeRulesFolder(fileUri)) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // because we now rely on the `prompt` language ID that can be explicitly // set for any document in the editor, any file can be a "prompt" file, so // to account for that, we return the full file name including the file // extension for all other cases - return basename(fileUri.path); + return basename(fileUri); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 664dab89291..30cad1ba8fe 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -24,7 +24,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IPromptsService } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION, VALID_SKILL_NAME_REGEX } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { dirname } from '../../../../../../base/common/resources.js'; @@ -52,7 +52,7 @@ export class PromptValidator { await this.validateHeader(promptAST, promptType, target, report); await this.validateBody(promptAST, target, report); await this.validateFileName(promptAST, promptType, report); - await this.validateSkillFolderName(promptAST, promptType, report); + await this.validateSkillAttributes(promptAST, promptType, report); } private async validateFileName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { @@ -66,32 +66,55 @@ export class PromptValidator { } } - private async validateSkillFolderName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + private async validateSkillAttributes(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { if (promptType !== PromptsType.skill) { return; } const nameAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.name); - if (!nameAttribute || nameAttribute.value.type !== 'scalar') { + if (!nameAttribute) { + report(toMarker( + localize('promptValidator.skillNameMissing', "Skill must provide a name."), + new Range(1, 1, 1, 4), + MarkerSeverity.Error + )); return; } - const skillName = nameAttribute.value.value.trim(); - if (!skillName) { + const descriptionAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.description); + if (!descriptionAttribute) { + report(toMarker( + localize('promptValidator.skillDescriptionMissing', "Skill must provide a description."), + new Range(1, 1, 1, 4), + MarkerSeverity.Error + )); return; } - // Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill) - const pathParts = promptAST.uri.path.split('/'); - const skillIndex = pathParts.findIndex(part => part === 'SKILL.md'); - if (skillIndex > 0) { - const folderName = pathParts[skillIndex - 1]; - if (folderName && skillName !== folderName) { - report(toMarker( - localize('promptValidator.skillNameFolderMismatch', "The skill name '{0}' should match the folder name '{1}'.", skillName, folderName), - nameAttribute.value.range, - MarkerSeverity.Warning - )); + if (nameAttribute.value.type === 'scalar') { + const skillName = nameAttribute.value.value.trim(); + if (skillName.length > 0) { + if (!VALID_SKILL_NAME_REGEX.test(skillName)) { + report(toMarker( + localize('promptValidator.skillNameInvalidChars', "Skill name may only contain lowercase letters, numbers, and hyphens."), + nameAttribute.value.range, + MarkerSeverity.Error + )); + } + + // Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill) + const pathParts = promptAST.uri.path.split('/'); + const skillIndex = pathParts.findIndex(part => part === 'SKILL.md'); + if (skillIndex > 0) { + const folderName = pathParts[skillIndex - 1]; + if (folderName && skillName !== folderName) { + report(toMarker( + localize('promptValidator.skillNameFolderMismatch', "The skill name '{0}' should match the folder name '{1}'.", skillName, folderName), + nameAttribute.value.range, + MarkerSeverity.Warning + )); + } + } } } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 521578682ed..2bf10c2f97c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -29,7 +29,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; -import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, GITHUB_CONFIG_FOLDER, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, getSkillFolderName, GITHUB_CONFIG_FOLDER, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IWorkspaceInstructionFile, @@ -1059,8 +1059,7 @@ export class PromptsService extends Disposable implements IPromptsService { const sanitizedName = this.truncateAgentSkillName(name, uri); // Validate that the sanitized name matches the parent folder name (per agentskills.io specification) - const skillFolderUri = dirname(uri); - const folderName = basename(skillFolderUri); + const folderName = getSkillFolderName(uri); if (sanitizedName !== folderName) { this.logger.error(`[validateAndSanitizeSkillFile] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); throw new SkillNameMismatchError(uri, sanitizedName, folderName); @@ -1464,20 +1463,18 @@ export class PromptsService extends Disposable implements IPromptsService { try { const parsedFile = await this.parseNew(uri, token); - const name = parsedFile.header?.name; - if (!name) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill file missing name attribute: ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'missing-name', extensionId, source }); - continue; - } + const folderName = getSkillFolderName(uri); + let name = parsedFile.header?.name; + + if (!name) { + this.logger.warn(`[computeSkillDiscoveryInfo] Agent skill file missing name attribute, using folder name "${folderName}": ${uri}`); + name = folderName; + } const sanitizedName = this.truncateAgentSkillName(name, uri); - const skillFolderUri = dirname(uri); - const folderName = basename(skillFolderUri); if (sanitizedName !== folderName) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'name-mismatch', name: sanitizedName, extensionId, source }); - continue; + this.logger.warn(`[computeSkillDiscoveryInfo] Agent skill name "${sanitizedName}" does not match folder name "${folderName}", using folder name: ${uri}`); + } if (seenNames.has(sanitizedName)) { @@ -1487,11 +1484,6 @@ export class PromptsService extends Disposable implements IPromptsService { } const description = parsedFile.header?.description; - if (!description) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill file missing description attribute: ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'missing-description', name: sanitizedName, extensionId, source }); - continue; - } seenNames.add(sanitizedName); nameToUri.set(sanitizedName, uri); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 74617590be8..b16251caf33 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -2165,7 +2165,7 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The skill name 'different-name' should match the folder name 'my-skill'.`); }); - test('skill without name attribute does not error', async () => { + test('skill without name attribute should error', async () => { const content = [ '---', 'description: Test Skill', @@ -2173,10 +2173,12 @@ suite('PromptValidator', () => { 'This is a skill without a name.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - assert.deepStrictEqual(markers, [], 'Expected no validation issues when name is missing'); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Skill must provide a name.`); }); - test('skill with empty name does not validate folder match', async () => { + test('skill with empty name should error', async () => { const content = [ '---', 'name: ""', @@ -2185,9 +2187,49 @@ suite('PromptValidator', () => { 'This is a skill.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - // Should get error for empty name, but no folder mismatch warning since name is empty - assert.ok(markers.some(m => m.message.includes('must not be empty')), 'Expected error for empty name'); - assert.ok(!markers.some(m => m.message.includes('should match the folder name')), 'Should not warn about folder mismatch for empty name'); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'name' attribute must not be empty.`); + }); + + test('skill without description attribute should error', async () => { + const content = [ + '---', + 'name: my-skill', + '---', + 'This is a skill without a description.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Skill must provide a description.`); + }); + + test('skill with empty description should error', async () => { + const content = [ + '---', + 'name: my-skill', + 'description: ""', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'description' attribute should not be empty.`); + }); + + + test('skill name with invalid characters should error', async () => { + const content = [ + '---', + 'name: My Skill', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.ok(markers.some(m => m.severity === MarkerSeverity.Error && m.message === 'Skill name may only contain lowercase letters, numbers, and hyphens.')); }); test('skill name with whitespace trimmed matches folder name', async () => { @@ -2240,7 +2282,7 @@ suite('PromptValidator', () => { 'This is a skill.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my_special-skill.v2/SKILL.md')); - assert.deepStrictEqual(markers, [], 'Expected no issues when name with special chars matches folder'); + assert.ok(markers.some(m => m.severity === MarkerSeverity.Error && m.message === 'Skill name may only contain lowercase letters, numbers, and hyphens.'), 'Expected error for invalid characters in skill name'); }); test('skill with non-string name type does not validate folder match', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index d8d5f2b33ab..f78133dcdba 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -1456,6 +1456,86 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`/home/user/.claude/skills/claude-personal/SKILL.md`)); assert.equal(xmlContents(skills[1], 'name')[0], 'claude-personal'); }); + + test('should include skills with missing name, missing description, or mismatched folder name', async () => { + const rootFolderName = 'skills-missing-metadata-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + await mockFiles(fileService, [ + { + // Skill with no name attribute - should use folder name as fallback + path: `${rootFolder}/.claude/skills/no-name-skill/SKILL.md`, + contents: [ + '---', + 'description: \'A skill without a name\'', + '---', + 'Skill content without name', + ] + }, + { + // Skill with no description attribute - should still be included + path: `${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`, + contents: [ + '---', + 'name: \'no-desc-skill\'', + '---', + 'Skill content without description', + ] + }, + { + // Skill where name does not match folder name - should still be included + path: `${rootFolder}/.claude/skills/actual-folder/SKILL.md`, + contents: [ + '---', + 'name: \'mismatched-name\'', + 'description: \'A skill with mismatched name\'', + '---', + 'Skill content with mismatched name', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_readFile': true }, // Enable readFile tool + undefined, + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for skills list'); + + const skillsList = xmlContents(textVariables[0].value, 'skills'); + assert.equal(skillsList.length, 1, 'There should be one skills list'); + + const skills = xmlContents(skillsList[0], 'skill'); + assert.equal(skills.length, 3, 'All three skills should be included despite missing/mismatched metadata'); + + // Skill with missing name should use folder name as fallback + assert.equal(xmlContents(skills[0], 'name')[0], 'no-name-skill'); + assert.equal(xmlContents(skills[0], 'description')[0], 'A skill without a name'); + assert.equal(xmlContents(skills[0], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-name-skill/SKILL.md`)); + + // Skill with missing description should still be listed + assert.equal(xmlContents(skills[1], 'name')[0], 'no-desc-skill'); + assert.equal(xmlContents(skills[1], 'description').length, 0, 'Should have no description element'); + assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`)); + + // Skill with mismatched name should use folder name + assert.equal(xmlContents(skills[2], 'name')[0], 'mismatched-name'); + assert.equal(xmlContents(skills[2], 'description')[0], 'A skill with mismatched name'); + assert.equal(xmlContents(skills[2], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/actual-folder/SKILL.md`)); + }); }); suite('edge cases', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 5924b079206..1f4a09bd0d3 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -2427,11 +2427,11 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results when agent skills are enabled'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 4, 'Should find 4 skills total'); + assert.strictEqual(result.length, 5, 'Should find 5 skills total'); // Check project skills (both from .github/skills and .claude/skills) const projectSkills = result.filter(skill => skill.storage === PromptsStorage.local); - assert.strictEqual(projectSkills.length, 2, 'Should find 2 project skills'); + assert.strictEqual(projectSkills.length, 3, 'Should find 3 project skills'); const githubSkill1 = projectSkills.find(skill => skill.name === 'GitHub Skill 1'); assert.ok(githubSkill1, 'Should find GitHub skill 1'); @@ -2443,6 +2443,12 @@ suite('PromptsService', () => { assert.strictEqual(claudeSkill1.description, 'A Claude skill for testing'); assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/Claude Skill 1/SKILL.md`); + // The invalid-skill (no name attribute) should now use folder name as fallback + const invalidSkill = projectSkills.find(skill => skill.name === 'invalid-skill'); + assert.ok(invalidSkill, 'Should find invalid-skill using folder name as fallback'); + assert.strictEqual(invalidSkill.description, 'Invalid skill, no name'); + assert.strictEqual(invalidSkill.uri.path, `${rootFolder}/.claude/skills/invalid-skill/SKILL.md`); + // Check personal skills const personalSkills = result.filter(skill => skill.storage === PromptsStorage.user); assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); @@ -2494,12 +2500,18 @@ suite('PromptsService', () => { const allResult = await service.findAgentSkills(CancellationToken.None); - // Should still return the valid skill, even if one has parsing errors + // Should return both skills - the malformed one uses folder name as fallback assert.ok(allResult, 'Should return results even with parsing errors'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find 1 valid skill'); - assert.strictEqual(result[0].name, 'Valid Skill'); - assert.strictEqual(result[0].storage, PromptsStorage.local); + assert.strictEqual(result.length, 2, 'Should find 2 skills'); + + const validSkill = result.find(s => s.name === 'Valid Skill'); + assert.ok(validSkill, 'Should find the valid skill'); + assert.strictEqual(validSkill.storage, PromptsStorage.local); + + const invalidSkill = result.find(s => s.name === 'invalid-skill'); + assert.ok(invalidSkill, 'Should find skill with folder name as fallback despite malformed YAML'); + assert.strictEqual(invalidSkill.storage, PromptsStorage.local); }); test('should return empty array when no skills found', async () => { @@ -2736,7 +2748,7 @@ suite('PromptsService', () => { assert.strictEqual(result[0].storage, PromptsStorage.local); }); - test('should skip skills where name does not match folder name', async () => { + test('should include skills where name does not match folder name using folder name as fallback', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); @@ -2753,7 +2765,7 @@ suite('PromptsService', () => { contents: [ '---', 'name: "Correct Skill Name"', - 'description: "This skill should be skipped due to name mismatch"', + 'description: "This skill should use folder name as fallback"', '---', 'Skill content', ], @@ -2775,11 +2787,17 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find only 1 skill (mismatched one skipped)'); - assert.strictEqual(result[0].name, 'Valid Skill', 'Should only find the valid skill'); + assert.strictEqual(result.length, 2, 'Should find both skills'); + + const mismatchedSkill = result.find(s => s.name === 'Correct Skill Name'); + assert.ok(mismatchedSkill, 'Should find skill with folder name as fallback'); + assert.strictEqual(mismatchedSkill.description, 'This skill should use folder name as fallback'); + + const validSkill = result.find(s => s.name === 'Valid Skill'); + assert.ok(validSkill, 'Should find the valid skill'); }); - test('should skip skills with missing name attribute', async () => { + test('should include skills with missing name attribute using folder name as fallback', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); @@ -2815,8 +2833,14 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find only 1 skill (one without name skipped)'); - assert.strictEqual(result[0].name, 'Valid Named Skill', 'Should only find skill with name attribute'); + assert.strictEqual(result.length, 2, 'Should find both skills'); + + const noNameSkill = result.find(s => s.name === 'no-name-skill'); + assert.ok(noNameSkill, 'Should find skill with folder name as fallback'); + assert.strictEqual(noNameSkill.description, 'This skill has no name attribute'); + + const validSkill = result.find(s => s.name === 'Valid Named Skill'); + assert.ok(validSkill, 'Should find skill with name attribute'); }); test('should include extension-provided skills in findAgentSkills', async () => { From 4b258535ff833325f8d2a1ad5a0cf941407b6a64 Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 20 Mar 2026 19:01:58 +0900 Subject: [PATCH 097/183] fix: skip file and folder uris for sessions app (#303432) --- src/vs/code/electron-main/app.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index e1d18bf87c5..2f57f7bf1d5 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -743,6 +743,7 @@ export class CodeApplication extends Disposable { const openables: IWindowOpenable[] = []; const urls: IProtocolUrl[] = []; + for (const protocolUrl of protocolUrls) { if (!protocolUrl) { continue; // invalid @@ -750,6 +751,12 @@ export class CodeApplication extends Disposable { const windowOpenable = this.getWindowOpenableFromProtocolUrl(protocolUrl.uri); if (windowOpenable) { + // Sessions app: skip all window openables (file/folder/workspace) + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#resolveInitialProtocolUrls() sessions app skipping window openable:', protocolUrl.uri.toString(true)); + continue; + } + if (await this.shouldBlockOpenable(windowOpenable, windowsMainService, dialogMainService)) { this.logService.trace('app#resolveInitialProtocolUrls() protocol url was blocked:', protocolUrl.uri.toString(true)); @@ -895,13 +902,24 @@ export class CodeApplication extends Disposable { private async handleProtocolUrl(windowsMainService: IWindowsMainService, dialogMainService: IDialogMainService, urlService: IURLService, uri: URI, options?: IOpenURLOptions): Promise { this.logService.trace('app#handleProtocolUrl():', uri.toString(true), options); - // Sessions app: "open a sessions window", regardless of other parameters. + // Sessions app: ensure the sessions window is open, then let other handlers process the URL. if ((process as INodeProcess).isEmbeddedApp) { - this.logService.trace('app#handleProtocolUrl() opening sessions window for bare protocol URL:', uri.toString(true)); + this.logService.trace('app#handleProtocolUrl() sessions app handling protocol URL:', uri.toString(true)); - await windowsMainService.openSessionsWindow({ context: OpenContext.LINK, contextWindowId: undefined }); + // Skip window openables (file/folder/workspace) for security + const windowOpenable = this.getWindowOpenableFromProtocolUrl(uri); + if (windowOpenable) { + this.logService.trace('app#handleProtocolUrl() sessions app skipping window openable:', uri.toString(true)); + return true; + } - return true; + // Ensure sessions window is open to receive the URL + const windows = await windowsMainService.openSessionsWindow({ context: OpenContext.LINK, contextWindowId: undefined }); + const window = windows.at(0); + await window?.ready(); + + // Return false to let subsequent handlers (e.g., URLHandlerChannelClient) forward the URL + return false; } // Support 'workspace' URLs (https://github.com/microsoft/vscode/issues/124263) From 582873ecf0af149dbe5e16d57e91eba359e804ff Mon Sep 17 00:00:00 2001 From: Nick Trogh Date: Fri, 20 Mar 2026 11:06:31 +0100 Subject: [PATCH 098/183] Add support for exporting default keybindings (#299026) * feat(keybindings): add support for exporting default keybindings for all platforms * Refactor code and rename command * Update src/vs/workbench/services/keybinding/browser/keybindingService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Make export only available for Insiders * Refactor code to avoid loading in web * Refactor for minimal impact * Fix eslint warning * Remove console.log --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/platform/environment/common/argv.ts | 1 + .../environment/common/environment.ts | 1 + .../environment/common/environmentService.ts | 4 + src/vs/platform/environment/node/argv.ts | 1 + .../keybinding/common/keybindingsRegistry.ts | 69 +++++++- .../keybindingsExport.contribution.ts | 152 ++++++++++++++++++ .../browser/keyboardLayouts/en.darwin.ts | 7 +- .../browser/keyboardLayouts/en.linux.ts | 7 +- .../browser/keyboardLayouts/en.win.ts | 7 +- src/vs/workbench/workbench.desktop.main.ts | 3 + 10 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 7af7bce71bc..53e4b5f77f2 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -109,6 +109,7 @@ export interface NativeParsedArgs { 'disable-telemetry'?: boolean; 'export-default-configuration'?: string; 'export-policy-data'?: string; + 'export-default-keybindings'?: string; 'install-source'?: string; 'add-mcp'?: string[]; 'disable-updates'?: boolean; diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 137a08dab33..883ed24b0fc 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -149,6 +149,7 @@ export interface INativeEnvironmentService extends IEnvironmentService { crossOriginIsolated?: boolean; exportPolicyData?: string; + exportDefaultKeybindings?: string; // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index c6869a109f1..3502a718fa0 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -264,6 +264,10 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return this.args['export-policy-data']; } + get exportDefaultKeybindings(): string | undefined { + return this.args['export-default-keybindings']; + } + get continueOn(): string | undefined { return this.args['continueOn']; } diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 9a2575a40f4..7fbfac5b2b0 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -166,6 +166,7 @@ export const OPTIONS: OptionDescriptions> = { 'inspect-brk-sharedprocess': { type: 'string', allowEmptyValue: true }, 'export-default-configuration': { type: 'string' }, 'export-policy-data': { type: 'string', allowEmptyValue: true }, + 'export-default-keybindings': { type: 'string', allowEmptyValue: true }, 'install-source': { type: 'string' }, 'enable-smoke-test-driver': { type: 'boolean' }, 'skip-sessions-welcome': { type: 'boolean' }, diff --git a/src/vs/platform/keybinding/common/keybindingsRegistry.ts b/src/vs/platform/keybinding/common/keybindingsRegistry.ts index 05660234e61..097d8a73a84 100644 --- a/src/vs/platform/keybinding/common/keybindingsRegistry.ts +++ b/src/vs/platform/keybinding/common/keybindingsRegistry.ts @@ -77,6 +77,7 @@ export interface IKeybindingsRegistry { setExtensionKeybindings(rules: IExtensionKeybindingRule[]): void; registerCommandAndKeybindingRule(desc: ICommandAndKeybindingRule): IDisposable; getDefaultKeybindings(): IKeybindingItem[]; + getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[]; } /** @@ -85,24 +86,23 @@ export interface IKeybindingsRegistry { class KeybindingsRegistryImpl implements IKeybindingsRegistry { private _coreKeybindings: LinkedList; + private _coreKeybindingRules: LinkedList; private _extensionKeybindings: IKeybindingItem[]; private _cachedMergedKeybindings: IKeybindingItem[] | null; constructor() { this._coreKeybindings = new LinkedList(); + this._coreKeybindingRules = new LinkedList(); this._extensionKeybindings = []; this._cachedMergedKeybindings = null; } - /** - * Take current platform into account and reduce to primary & secondary. - */ - private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { - if (OS === OperatingSystem.Windows) { + private static bindToPlatform(kb: IKeybindings, os: OperatingSystem): { primary?: number; secondary?: number[] } { + if (os === OperatingSystem.Windows) { if (kb && kb.win) { return kb.win; } - } else if (OS === OperatingSystem.Macintosh) { + } else if (os === OperatingSystem.Macintosh) { if (kb && kb.mac) { return kb.mac; } @@ -111,10 +111,16 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { return kb.linux; } } - return kb; } + /** + * Take current platform into account and reduce to primary & secondary. + */ + private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { + return KeybindingsRegistryImpl.bindToPlatform(kb, OS); + } + public registerKeybindingRule(rule: IKeybindingRule): IDisposable { const actualKb = KeybindingsRegistryImpl.bindToCurrentPlatform(rule); const result = new DisposableStore(); @@ -135,6 +141,10 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } } } + + const removeRule = this._coreKeybindingRules.push(rule); + result.add(toDisposable(() => { removeRule(); })); + return result; } @@ -193,6 +203,51 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } return this._cachedMergedKeybindings.slice(0); } + + public getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[] { + const result: IKeybindingItem[] = []; + for (const rule of this._coreKeybindingRules) { + const actualKb = KeybindingsRegistryImpl.bindToPlatform(rule, os); + + if (actualKb && actualKb.primary) { + const kk = decodeKeybinding(actualKb.primary, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: 0, + extensionId: null, + isBuiltinExtension: false + }); + } + } + + if (actualKb && Array.isArray(actualKb.secondary)) { + for (let i = 0, len = actualKb.secondary.length; i < len; i++) { + const k = actualKb.secondary[i]; + const kk = decodeKeybinding(k, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: -i - 1, + extensionId: null, + isBuiltinExtension: false + }); + } + } + } + } + + result.sort(sorter); + return result; + } } export const KeybindingsRegistry: IKeybindingsRegistry = new KeybindingsRegistryImpl(); diff --git a/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts b/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts new file mode 100644 index 00000000000..6235b798361 --- /dev/null +++ b/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { join } from '../../../../base/common/path.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IKeybindingItem, KeybindingsRegistry } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeybindingResolver } from '../../../../platform/keybinding/common/keybindingResolver.js'; +import { ResolvedKeybindingItem } from '../../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { IKeyboardMapper } from '../../../../platform/keyboardLayout/common/keyboardMapper.js'; +import { IMacLinuxKeyboardMapping, IWindowsKeyboardMapping } from '../../../../platform/keyboardLayout/common/keyboardLayout.js'; +import { MacLinuxKeyboardMapper } from '../../../services/keybinding/common/macLinuxKeyboardMapper.js'; +import { WindowsKeyboardMapper } from '../../../services/keybinding/common/windowsKeyboardMapper.js'; +import { IKeymapInfo, KeymapInfo } from '../../../services/keybinding/common/keymapInfo.js'; +import { EN_US_WIN_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.win.js'; +import { EN_US_DARWIN_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.darwin.js'; +import { EN_US_LINUX_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.linux.js'; +import { KeybindingIO, OutputBuilder } from '../../../services/keybinding/common/keybindingIO.js'; +import { getAllUnboundCommands } from '../../../services/keybinding/browser/unboundCommands.js'; + +export class KeybindingsExportContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.keybindingsExport'; + + constructor( + @INativeEnvironmentService private readonly nativeEnvironmentService: INativeEnvironmentService, + @IFileService private readonly fileService: IFileService, + @INativeHostService private readonly nativeHostService: INativeHostService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + if (this.productService.quality === 'stable') { + return; + } + + const outputPath = this.nativeEnvironmentService.exportDefaultKeybindings; + if (outputPath !== undefined) { + const defaultPath = join(this.nativeEnvironmentService.appRoot, 'doc'); + void this.exportDefaultKeybindingsAndQuit(outputPath || defaultPath); + } + } + + private async exportDefaultKeybindingsAndQuit(outputPath: string): Promise { + try { + const platforms: { os: OperatingSystem; filename: string }[] = [ + { os: OperatingSystem.Windows, filename: 'doc.keybindings.win.json' }, + { os: OperatingSystem.Macintosh, filename: 'doc.keybindings.osx.json' }, + { os: OperatingSystem.Linux, filename: 'doc.keybindings.linux.json' }, + ]; + + for (const { os, filename } of platforms) { + const content = KeybindingsExportContribution._getDefaultKeybindingsContentForOS(os); + const filePath = join(outputPath, filename); + await this.fileService.writeFile(URI.file(filePath), VSBuffer.fromString(content)); + this.logService.info(`[${KeybindingsExportContribution.ID}] Wrote ${filePath}`); + } + + await this.nativeHostService.exit(0); + } catch (error) { + this.logService.error(`[${KeybindingsExportContribution.ID}] Failed to generate default keybindings`, error); + await this.nativeHostService.exit(1); + } + } + + private static _getDefaultKeybindingsContentForOS(os: OperatingSystem): string { + const items = KeybindingsRegistry.getDefaultKeybindingsForOS(os); + const mapper = KeybindingsExportContribution._createKeyboardMapperForOS(os); + const resolved = KeybindingsExportContribution._resolveKeybindingItemsWithMapper(items, mapper); + const resolver = new KeybindingResolver(resolved, [], () => { }); + const defaultKeybindings = resolver.getDefaultKeybindings(); + const boundCommands = resolver.getDefaultBoundCommands(); + return ( + KeybindingsExportContribution._formatDefaultKeybindings(defaultKeybindings) + + '\n\n' + + KeybindingsExportContribution._formatAllCommandsAsComment(boundCommands) + ); + } + + private static _createKeyboardMapperForOS(os: OperatingSystem): IKeyboardMapper { + const layoutMap: Record = { + [OperatingSystem.Windows]: EN_US_WIN_LAYOUT, + [OperatingSystem.Macintosh]: EN_US_DARWIN_LAYOUT, + [OperatingSystem.Linux]: EN_US_LINUX_LAYOUT, + }; + const layout = layoutMap[os]; + const keymapInfo = new KeymapInfo(layout.layout, layout.secondaryLayouts, layout.mapping); + switch (os) { + case OperatingSystem.Windows: + return new WindowsKeyboardMapper(true, keymapInfo.mapping, false); + case OperatingSystem.Macintosh: + return new MacLinuxKeyboardMapper(true, keymapInfo.mapping, false, OperatingSystem.Macintosh); + case OperatingSystem.Linux: + return new MacLinuxKeyboardMapper(true, keymapInfo.mapping, false, OperatingSystem.Linux); + } + } + + private static _resolveKeybindingItemsWithMapper(items: IKeybindingItem[], mapper: IKeyboardMapper): ResolvedKeybindingItem[] { + const result: ResolvedKeybindingItem[] = []; + for (const item of items) { + const when = item.when || undefined; + const keybinding = item.keybinding; + if (!keybinding) { + result.push(new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, true, item.extensionId, item.isBuiltinExtension)); + } else { + const resolvedKeybindings = mapper.resolveKeybinding(keybinding); + for (let i = resolvedKeybindings.length - 1; i >= 0; i--) { + result.push(new ResolvedKeybindingItem(resolvedKeybindings[i], item.command, item.commandArgs, when, true, item.extensionId, item.isBuiltinExtension)); + } + } + } + return result; + } + + private static _formatDefaultKeybindings(defaultKeybindings: readonly ResolvedKeybindingItem[]): string { + const out = new OutputBuilder(); + out.writeLine('['); + const lastIndex = defaultKeybindings.length - 1; + defaultKeybindings.forEach((k, index) => { + KeybindingIO.writeKeybindingItem(out, k); + if (index !== lastIndex) { + out.writeLine(','); + } else { + out.writeLine(); + } + }); + out.writeLine(']'); + return out.toString(); + } + + private static _formatAllCommandsAsComment(boundCommands: Map): string { + const unboundCommands = getAllUnboundCommands(boundCommands); + const pretty = unboundCommands.sort().join('\n// - '); + return '// Here are other available commands: ' + '\n// - ' + pretty; + } +} + +registerWorkbenchContribution2( + KeybindingsExportContribution.ID, + KeybindingsExportContribution, + WorkbenchPhase.Eventually, +); diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts index e2e795a73b7..7cd617c5c87 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_DARWIN_LAYOUT: IKeymapInfo = { layout: { id: 'com.apple.keylayout.US', lang: 'en', localizedName: 'U.S.', isUSStandard: true }, secondaryLayouts: [ { id: 'com.apple.keylayout.ABC', lang: 'en', localizedName: 'ABC' }, @@ -137,4 +138,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ AltRight: [], MetaRight: [] } -}); \ No newline at end of file +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_DARWIN_LAYOUT); diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts index 639e7a4a86b..d1ea215b423 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_LINUX_LAYOUT: IKeymapInfo = { layout: { model: 'pc105', group: 0, layout: 'us', variant: '', options: '', rules: 'evdev', isUSStandard: true }, secondaryLayouts: [ { model: 'pc105', group: 0, layout: 'cn', variant: '', options: '', rules: 'evdev' }, @@ -187,4 +188,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ MailSend: [] } -}); +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_LINUX_LAYOUT); diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts index b1d6216120d..495d52aa52e 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_WIN_LAYOUT: IKeymapInfo = { layout: { name: '00000409', id: '', text: 'US', isUSStandard: true }, secondaryLayouts: [ { name: '00000804', id: '', text: 'Chinese (Simplified) - US Keyboard' }, @@ -171,4 +172,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ BrowserRefresh: [], BrowserFavorites: [] } -}); \ No newline at end of file +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_WIN_LAYOUT); diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 6c887e147dd..5a5c36698c1 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -192,6 +192,9 @@ import './contrib/mcp/electron-browser/mcp.contribution.js'; // Policy Export import './contrib/policyExport/electron-browser/policyExport.contribution.js'; +// Keybindings Export +import './contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.js'; + //#endregion From 74054b851c84d2cb2abe969607d997490d3f1f32 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:15:22 +0100 Subject: [PATCH 099/183] Submit active session feedback action and CI check improvements (#303336) submit and ci check primary actions --- .../browser/agentFeedback.contribution.ts | 65 +++++- .../browser/agentFeedbackEditorActions.ts | 61 ++++++ .../browser/changesView.contribution.ts | 1 + .../contrib/changes/browser/ciStatusWidget.ts | 69 +----- .../changes/browser/fixCIChecksAction.ts | 207 ++++++++++++++++++ 5 files changed, 333 insertions(+), 70 deletions(-) create mode 100644 src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 7ff02612cc1..bcae4ce6990 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -6,17 +6,78 @@ import './agentFeedbackEditorInputContribution.js'; import './agentFeedbackEditorWidgetContribution.js'; import './agentFeedbackOverviewRulerContribution.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; import { AgentFeedbackEditorOverlay } from './agentFeedbackEditorOverlay.js'; -import { registerAgentFeedbackEditorActions } from './agentFeedbackEditorActions.js'; +import { hasActiveSessionAgentFeedback, registerAgentFeedbackEditorActions, submitActiveSessionFeedbackActionId } from './agentFeedbackEditorActions.js'; import { IChatAttachmentWidgetRegistry } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +/** + * Sets the `hasActiveSessionAgentFeedback` context key to true when the + * currently active session has pending agent feedback items. + */ +class ActiveSessionFeedbackContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFeedbackContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + const contextKey = hasActiveSessionAgentFeedback.bindTo(contextKeyService); + const menuRegistration = this._register(new MutableDisposable()); + + const feedbackChanged = observableFromEvent( + this, + agentFeedbackService.onDidChangeFeedback, + e => e, + ); + + this._register(autorun(reader => { + feedbackChanged.read(reader); + const activeSession = sessionManagementService.activeSession.read(reader); + menuRegistration.clear(); + if (!activeSession) { + contextKey.set(false); + return; + } + const feedback = agentFeedbackService.getFeedback(activeSession.resource); + const count = feedback.length; + contextKey.set(count > 0); + + if (count > 0) { + menuRegistration.value = MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionApplySubmenu, { + command: { + id: submitActiveSessionFeedbackActionId, + icon: Codicon.comment, + title: localize('agentFeedback.submitFeedbackCount', "Submit Feedback ({0})", count), + }, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionAgentFeedback), + }); + } + })); + } +} + +registerWorkbenchContribution2(ActiveSessionFeedbackContextContribution.ID, ActiveSessionFeedbackContextContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index baa0d345790..f7808619d75 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -24,6 +24,7 @@ import { IChatEditingService } from '../../../../workbench/contrib/chat/common/e import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; import { getSessionEditorComments } from './sessionEditorComments.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; @@ -32,6 +33,8 @@ 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); +export const hasActiveSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasActiveSessionAgentFeedback', false); +export const submitActiveSessionFeedbackActionId = 'agentFeedbackEditor.action.submitActiveSession'; abstract class AgentFeedbackEditorAction extends Action2 { @@ -190,8 +193,66 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { } } +class SubmitActiveSessionFeedbackAction extends Action2 { + + static readonly ID = submitActiveSessionFeedbackActionId; + + constructor() { + super({ + id: SubmitActiveSessionFeedbackAction.ID, + title: localize2('agentFeedback.submitFeedback', 'Submit Feedback'), + icon: Codicon.comment, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionAgentFeedback), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const chatWidgetService = accessor.get(IChatWidgetService); + const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const feedbackItems = agentFeedbackService.getFeedback(sessionResource); + if (feedbackItems.length === 0) { + return; + } + + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); + return; + } + + // Close all editors belonging to the session resource + const editorsToClose: IEditorIdentifier[] = []; + for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { + const candidates = getActiveResourceCandidates(editor); + const belongsToSession = candidates.some(uri => + isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) + ); + if (belongsToSession) { + editorsToClose.push({ editor, groupId }); + } + } + if (editorsToClose.length) { + await editorService.closeEditors(editorsToClose); + } + + await widget.acceptInput('act on feedback'); + } +} + export function registerAgentFeedbackEditorActions(): void { registerAction2(SubmitFeedbackAction); + registerAction2(SubmitActiveSessionFeedbackAction); registerAction2(class extends NavigateFeedbackAction { constructor() { super(false); } }); registerAction2(class extends NavigateFeedbackAction { constructor() { super(true); } }); registerAction2(ClearAllFeedbackAction); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 386fd6603fc..6eae1fdb979 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts @@ -12,6 +12,7 @@ import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../work import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import './changesViewActions.js'; +import './fixCIChecksAction.js'; import { ChangesViewController } from './changesViewController.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); diff --git a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts index 48e508aa528..9b81b8bf26c 100644 --- a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts @@ -23,16 +23,10 @@ import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../github/common/types.js'; import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; +import { CICheckGroup, buildFixChecksPrompt, getCheckGroup, getCheckStateLabel, getFailedChecks } from './fixCIChecksAction.js'; const $ = dom.$; -const enum CICheckGroup { - Running, - Pending, - Failed, - Successful, -} - interface ICICheckListItem { readonly check: IGitHubCICheck; readonly group: CICheckGroup; @@ -397,17 +391,6 @@ function compareChecks(a: IGitHubCICheck, b: IGitHubCICheck): number { return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); } -function getCheckGroup(check: IGitHubCICheck): CICheckGroup { - switch (check.status) { - case GitHubCheckStatus.InProgress: - return CICheckGroup.Running; - case GitHubCheckStatus.Queued: - return CICheckGroup.Pending; - case GitHubCheckStatus.Completed: - return isFailedConclusion(check.conclusion) ? CICheckGroup.Failed : CICheckGroup.Successful; - } -} - function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts { let running = 0; let pending = 0; @@ -434,10 +417,6 @@ function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts { return { running, pending, failed, successful }; } -function getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { - return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); -} - function getChecksSummary(checks: readonly IGitHubCICheck[]): string { const counts = getCheckCounts(checks); const parts: string[] = []; @@ -469,33 +448,6 @@ function getChecksSummary(checks: readonly IGitHubCICheck[]): string { return parts.join(', '); } -function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>): string { - const sections = failedChecks.map(({ check, annotations }) => { - const parts = [ - `Check: ${check.name}`, - `Status: ${getCheckStateLabel(check)}`, - `Conclusion: ${check.conclusion ?? 'unknown'}`, - ]; - - if (check.detailsUrl) { - parts.push(`Details: ${check.detailsUrl}`); - } - - parts.push('', 'Annotations and output:', annotations || 'No output available for this check run.'); - return parts.join('\n'); - }); - - return [ - 'Please fix the failed CI checks for this session immediately.', - 'Use the failed check information below, including annotations and check output, to identify the root causes and make the necessary code changes.', - 'Focus on resolving these CI failures. Avoid unrelated changes unless they are required to fix the checks.', - '', - 'Failed CI checks:', - '', - sections.join('\n\n---\n\n'), - ].join('\n'); -} - function getHeaderIconAndClass(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): { icon: ThemeIcon; className: string } { const counts = getCheckCounts(checks); if (counts.running > 0) { @@ -552,22 +504,3 @@ function getCheckStatusClass(check: IGitHubCICheck): string { return 'ci-status-success'; } } - -function getCheckStateLabel(check: IGitHubCICheck): string { - switch (getCheckGroup(check)) { - case CICheckGroup.Running: - return localize('ci.runningState', "running"); - case CICheckGroup.Pending: - return localize('ci.pendingState', "pending"); - case CICheckGroup.Failed: - return localize('ci.failedState', "failed"); - case CICheckGroup.Successful: - return localize('ci.successfulState', "successful"); - } -} - -function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { - return conclusion === GitHubCheckConclusion.Failure - || conclusion === GitHubCheckConclusion.TimedOut - || conclusion === GitHubCheckConclusion.ActionRequired; -} diff --git a/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts new file mode 100644 index 00000000000..a818cdb020a --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../../base/common/lifecycle.js'; +import { derived } from '../../../../base/common/observable.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, IGitHubCICheck } from '../../github/common/types.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +export const hasActiveSessionFailedCIChecks = new RawContextKey('sessions.hasActiveSessionFailedCIChecks', false); + +// --- Shared CI check utilities ------------------------------------------------ + +export const enum CICheckGroup { + Running, + Pending, + Failed, + Successful, +} + +export function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { + return conclusion === GitHubCheckConclusion.Failure + || conclusion === GitHubCheckConclusion.TimedOut + || conclusion === GitHubCheckConclusion.ActionRequired; +} + +export function getCheckGroup(check: IGitHubCICheck): CICheckGroup { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return CICheckGroup.Running; + case GitHubCheckStatus.Queued: + return CICheckGroup.Pending; + case GitHubCheckStatus.Completed: + return isFailedConclusion(check.conclusion) ? CICheckGroup.Failed : CICheckGroup.Successful; + } +} + +export function getCheckStateLabel(check: IGitHubCICheck): string { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + return localize('ci.runningState', "running"); + case CICheckGroup.Pending: + return localize('ci.pendingState', "pending"); + case CICheckGroup.Failed: + return localize('ci.failedState', "failed"); + case CICheckGroup.Successful: + return localize('ci.successfulState', "successful"); + } +} + +export function getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { + return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); +} + +export function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>): string { + const sections = failedChecks.map(({ check, annotations }) => { + const parts = [ + `Check: ${check.name}`, + `Status: ${getCheckStateLabel(check)}`, + `Conclusion: ${check.conclusion ?? 'unknown'}`, + ]; + + if (check.detailsUrl) { + parts.push(`Details: ${check.detailsUrl}`); + } + + parts.push('', 'Annotations and output:', annotations || 'No output available for this check run.'); + return parts.join('\n'); + }); + + return [ + 'Please fix the failed CI checks for this session immediately.', + 'Use the failed check information below, including annotations and check output, to identify the root causes and make the necessary code changes.', + 'Focus on resolving these CI failures. Avoid unrelated changes unless they are required to fix the checks.', + '', + 'Failed CI checks:', + '', + sections.join('\n\n---\n\n'), + ].join('\n'); +} + +/** + * Sets the `hasActiveSessionFailedCIChecks` context key to true when the + * active session has a PR with CI checks and at least one has failed. + */ +class ActiveSessionFailedCIChecksContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFailedCIChecksContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + @IGitHubService gitHubService: IGitHubService, + ) { + super(); + + const ciModelObs = derived(this, reader => { + const session = sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + const context = sessionManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return undefined; + } + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + return undefined; + } + return gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + }); + + this._register(bindContextKey(hasActiveSessionFailedCIChecks, contextKeyService, reader => { + const ciModel = ciModelObs.read(reader); + if (!ciModel) { + return false; + } + const checks = ciModel.checks.read(reader); + return getFailedChecks(checks).length > 0; + })); + } +} + +class FixCIChecksAction extends Action2 { + + static readonly ID = 'sessions.action.fixCIChecks'; + + constructor() { + super({ + id: FixCIChecksAction.ID, + title: localize2('fixCIChecks', 'Fix CI Checks'), + icon: Codicon.lightbulbAutofix, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionFailedCIChecks), + menu: [{ + id: MenuId.ChatEditingSessionApplySubmenu, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionFailedCIChecks), + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const gitHubService = accessor.get(IGitHubService); + const chatWidgetService = accessor.get(IChatWidgetService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const context = sessionManagementService.getGitHubContextForSession(sessionResource); + if (!context || context.prNumber === undefined) { + return; + } + + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.get(); + if (!pr) { + return; + } + + const ciModel = gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + const checks = ciModel.checks.get(); + const failedChecks = getFailedChecks(checks); + if (failedChecks.length === 0) { + return; + } + + const failedCheckDetails = await Promise.all(failedChecks.map(async check => { + const annotations = await ciModel.getCheckRunAnnotations(check.id); + return { check, annotations }; + })); + + const prompt = buildFixChecksPrompt(failedCheckDetails); + const chatWidget = chatWidgetService.getWidgetBySessionResource(sessionResource) + ?? await chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget) { + logService.error('[FixCIChecks] Cannot fix CI checks: no chat widget found for session', sessionResource.toString()); + return; + } + + await chatWidget.acceptInput(prompt, { noCommandDetection: true }); + } +} + +registerWorkbenchContribution2(ActiveSessionFailedCIChecksContextContribution.ID, ActiveSessionFailedCIChecksContextContribution, WorkbenchPhase.AfterRestored); +registerAction2(FixCIChecksAction); From 3f9004083bcd9525b72f0bb69cb360e109c93681 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 20 Mar 2026 11:45:12 +0100 Subject: [PATCH 100/183] Support for nested subagents (#302944) * Support for nested subagents * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update * update * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * rename setting to 'chat.subagents.maxDepth' * fix test --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chat.contribution.ts | 10 ++ .../contrib/chat/common/constants.ts | 1 + .../tools/builtinTools/runSubagentTool.ts | 66 ++++++-- .../builtinTools/runSubagentTool.test.ts | 145 +++++++++++++++++- 4 files changed, 204 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index fb3a73d679e..de484ac80f6 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1325,6 +1325,16 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.SubagentsMaxDepth]: { + type: 'number', + description: nls.localize('chat.subagents.maxDepth', "Maximum nesting depth for subagents. Set to 0 to disable nested subagents. A subagent at this depth will not be able to launch further subagents."), + default: 0, + minimum: 0, + maximum: 20, + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.ChatCustomizationMenuEnabled]: { type: 'boolean', tags: ['preview'], diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index a2303956550..ef61cbbdbb8 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -48,6 +48,7 @@ export enum ChatConfiguration { ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', ChatContextUsageEnabled = 'chat.contextUsage.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', + SubagentsMaxDepth = 'chat.subagents.maxDepth', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index cd558dccf5b..16ae908fb53 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -21,7 +21,7 @@ import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; +import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js'; import { HookType } from '../../promptSyntax/hookTypes.js'; @@ -67,6 +67,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { /** Hack to port data between prepare/invoke */ private readonly _resolvedModels = new Map(); + /** Tracks the current subagent nesting depth per session to detect and limit recursion. */ + private readonly _sessionDepth = new Map(); + constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, @@ -80,7 +83,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { @IProductService private readonly productService: IProductService, ) { super(); - this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents)); + this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e => + e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents) + ); } getToolData(): IToolData { @@ -242,14 +247,32 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } }; - if (modeTools) { - modeTools[RunSubagentTool.Id] = false; - modeTools[ManageTodoListToolToolId] = false; - modeTools['copilot_askQuestions'] = false; + // Determine whether the subagent should be allowed to spawn its own subagents. + const maxDepth = this.configurationService.getValue(ChatConfiguration.SubagentsMaxDepth) ?? 0; + const sessionKey = invocation.context.sessionResource.toString(); + const currentDepth = this._sessionDepth.get(sessionKey) ?? 0; + const depthAllowed = currentDepth + 1 <= maxDepth; + + if (!modeTools) { + // Initialize modeTools so that we can still enforce the max depth restriction + modeTools = {}; + } + + // Only further-restrict RunSubagentTool: do not re-enable it if it was explicitly disabled. + const existingRunSubagentEnablement = modeTools[RunSubagentTool.Id]; + if (existingRunSubagentEnablement !== false) { + modeTools[RunSubagentTool.Id] = depthAllowed; // only enable the Run Subagent tool if we are under the max depth limit + } + + modeTools[ManageTodoListToolToolId] = false; + modeTools['copilot_askQuestions'] = false; + + if (maxDepth > 0) { + this.logService.debug(`RunSubagentTool: Nested subagents enabling ${modeTools[RunSubagentTool.Id]}: session ${sessionKey}, currentDepth: ${currentDepth}, maxDepth: ${maxDepth}`); } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, invocation.context.sessionResource); // agents can not call subagents + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, invocation.context.sessionResource); await computer.collect(variableSet, token); // Collect hooks from hook .json files @@ -301,17 +324,28 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } })); - // Invoke the agent - const result = await this.chatAgentService.invokeAgent( - defaultAgent.id, - agentRequest, - progressCallback, - [], - token - ); + // Invoke the agent, tracking nesting depth for recursion detection + this._sessionDepth.set(sessionKey, currentDepth + 1); + let result: IChatAgentResult | undefined; + try { + result = await this.chatAgentService.invokeAgent( + defaultAgent.id, + agentRequest, + progressCallback, + [], + token + ); + } finally { + const newDepth = (this._sessionDepth.get(sessionKey) ?? 1) - 1; + if (newDepth <= 0) { + this._sessionDepth.delete(sessionKey); + } else { + this._sessionDepth.set(sessionKey, newDepth); + } + } // Check for errors - if (result.errorDetails) { + if (result?.errorDetails) { return createToolSimpleTextResult(`Agent error: ${result.errorDetails.message}`); } diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 79d99695eb5..61035aeab15 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -11,8 +11,8 @@ import { NullLogService } from '../../../../../../../platform/log/common/log.js' import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; -import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; -import { IChatService } from '../../../../common/chatService/chatService.js'; +import { IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService, UserSelectedTools } from '../../../../common/participants/chatAgents.js'; +import { IChatProgress, IChatService } from '../../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; @@ -20,6 +20,9 @@ import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/se import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; +import { IToolInvocation, ToolProgress } from '../../../../common/tools/languageModelToolsService.js'; +import { IChatModel } from '../../../../common/model/chatModel.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -491,4 +494,142 @@ suite('RunSubagentTool', () => { }); }); }); + + suite('nested subagent depth tracking', () => { + /** + * Creates a RunSubagentTool with mocked services suitable for invoke() testing. + * The returned `capturedRequests` array collects every IChatAgentRequest passed to invokeAgent. + */ + let callIdCounter = 0; + function createInvokableTool(opts: { + maxDepth: number; + capturedRequests: IChatAgentRequest[]; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService({ + [ChatConfiguration.SubagentsMaxDepth]: opts.maxDepth, + }); + const promptsService = new MockPromptsService(); + + const mockChatAgentService: Pick = { + getDefaultAgent() { + return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; + }, + async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { + opts.capturedRequests.push(request); + return {}; + }, + }; + + const mockChatService: Pick = { + getSession() { + return { + getRequests: () => [{ id: 'req-1' }], + acceptResponseProgress: () => { }, + } as unknown as IChatModel; + }, + }; + + const mockInstantiationService: Pick = { + createInstance(..._args: never[]): { collect: () => Promise } { + return { collect: async () => { } }; + }, + }; + + const tool = testDisposables.add(new RunSubagentTool( + mockChatAgentService as IChatAgentService, + mockChatService as IChatService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + promptsService, + mockInstantiationService as IInstantiationService, + {} as IProductService, + )); + + return { tool, mockChatAgentService }; + } + + function createInvocation(sessionUri: URI, userSelectedTools?: UserSelectedTools): IToolInvocation { + return { + callId: `call-${++callIdCounter}`, + toolId: 'runSubagent', + parameters: { prompt: 'do something', description: 'test' }, + context: { sessionResource: sessionUri }, + userSelectedTools: userSelectedTools ?? { runSubagent: true }, + } as IToolInvocation; + } + + const countTokens = async () => 0; + const noProgress: ToolProgress = { report() { } }; + + test('disables runSubagent tool when maxDepth is 0', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 0, capturedRequests }); + const sessionUri = URI.parse('test://session/depth0'); + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 1); + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], false); + }); + + test('enables runSubagent tool at depth 0 when maxDepth >= 1', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 3, capturedRequests }); + const sessionUri = URI.parse('test://session/depth-enabled'); + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 1); + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + }); + + test('disables runSubagent tool when depth reaches maxDepth', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const sessionUri = URI.parse('test://session/depth-limit'); + + // maxDepth=1, so the first invoke (depth 0→1) should allow nesting, + // but the second invoke (depth 1→2) should not since 1+1 <= 1 is false. + const { tool, mockChatAgentService } = createInvokableTool({ maxDepth: 1, capturedRequests }); + + // Simulate nested invocation: the first invoke's invokeAgent callback + // triggers a second invoke on the same tool (same session). + capturedRequests.length = 0; + mockChatAgentService.invokeAgent = async (_id: string, request: IChatAgentRequest) => { + capturedRequests.push(request); + // On the first call (depth 0), simulate a nested subagent call + if (capturedRequests.length === 1) { + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + } + return {}; + }; + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 2); + // First call at depth 0: should enable (0 + 1 <= 1) + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + // Second call at depth 1: should disable (1 + 1 <= 1 is false) + assert.strictEqual(capturedRequests[1].userSelectedTools?.['runSubagent'], false); + }); + + test('depth is decremented after invoke completes', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 2, capturedRequests }); + const sessionUri = URI.parse('test://session/depth-decrement'); + + // First invoke + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + // Second invoke on same session should start at depth 0 again + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 2); + // Both should have runSubagent enabled since depth resets after each invoke + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + assert.strictEqual(capturedRequests[1].userSelectedTools?.['runSubagent'], true); + }); + }); }); From deca02023f9de816671617b0aca90e72eb4e0fee Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:46:00 +0100 Subject: [PATCH 101/183] Fix CI status widget header label vertical alignment (#303455) * fix: enhance title styling in CI Status Widget for better alignment * fix: set monaco-icon-label height to 18px in CI status widget title Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/contrib/changes/browser/media/ciStatusWidget.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css index 87983d53cb6..451457dd543 100644 --- a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css @@ -32,12 +32,15 @@ /* Title - single line, overflow ellipsis */ .ci-status-widget-title { flex: 1; + display: flex; + align-items: center; overflow: hidden; color: var(--vscode-foreground); } .ci-status-widget-title .monaco-icon-label { width: 100%; + height: 18px; } .ci-status-widget-title .monaco-icon-label-container, From e4c0ce53e5fadd5d7ee51ac66bac40e301ab8ebb Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 20 Mar 2026 11:08:27 +0000 Subject: [PATCH 102/183] Add chat session header component and styles Co-authored-by: Copilot --- .../contrib/chat/browser/chatSessionHeader.ts | 253 ++++++++++++++++++ .../chat/browser/media/chatSessionHeader.css | 148 ++++++++++ .../sessions/browser/sessions.contribution.ts | 4 - src/vs/sessions/sessions.desktop.main.ts | 1 + 4 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts create mode 100644 src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css diff --git a/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts new file mode 100644 index 00000000000..ca06ca96efc --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatSessionHeader.css'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ChatViewId } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; + +/** + * Renders a PR-style header at the top of the chat messages area. + * Shows the session title large initially, then shrinks as the user scrolls. + * Displays: session title + folder name (no diff numbers). + */ +class ChatSessionHeaderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.chatSessionHeader'; + + private readonly headerElement: HTMLElement; + private readonly titleElement: HTMLElement; + private readonly repoElement: HTMLElement; + private readonly iconElement: HTMLElement; + private readonly markDoneButton: Button; + private readonly modelChangeListener = this._register(new MutableDisposable()); + private lastRenderState: string | undefined; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IChatService private readonly chatService: IChatService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IViewsService private readonly viewsService: IViewsService, + ) { + super(); + + // Create header DOM (will be inserted when a chat container is found) + this.headerElement = $('.chat-session-header'); + + const headerContent = append(this.headerElement, $('.chat-session-header-content')); + headerContent.setAttribute('role', 'button'); + headerContent.setAttribute('aria-label', localize('showSessions', "Show Sessions")); + headerContent.tabIndex = 0; + + // Title row: title + done button + const titleRow = append(headerContent, $('.chat-session-header-title-row')); + this.titleElement = append(titleRow, $('span.chat-session-header-title')); + + // Mark as Done button + const buttonContainer = append(titleRow, $('.chat-session-header-actions')); + this.markDoneButton = this._register(new Button(buttonContainer, { supportIcons: true, ...defaultButtonStyles })); + this.markDoneButton.label = `$(check) ${localize('markAsDone', "Mark as Done")}`; + this._register(this.markDoneButton.onDidClick(() => this.markAsDone())); + + // Repo row: icon + folder name + const repoRow = append(headerContent, $('span.chat-session-header-repo-row')); + this.iconElement = append(repoRow, $('span.chat-session-header-icon')); + this.repoElement = append(repoRow, $('span.chat-session-header-repo')); + + // Click handler — show sessions picker (same as titlebar) + this._register(addDisposableListener(headerContent, EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + this.showSessionsPicker(); + })); + + this._register(addDisposableListener(headerContent, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.showSessionsPicker(); + } + })); + + // Watch active session changes + this._register(autorun(reader => { + const activeSession = this.sessionsManagementService.activeSession.read(reader); + this.trackModelChanges(activeSession?.resource); + this.lastRenderState = undefined; + this.render(); + })); + + // Watch session data changes + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this.lastRenderState = undefined; + this.render(); + })); + + // Periodically try to inject into the DOM (chat widget may not exist yet) + this.ensureInjected(); + } + + private tryInject(): boolean { + const view = this.viewsService.getViewWithId(ChatViewId); + if (!view?.element) { + return false; + } + // Re-inject if the header is not a child of the current view element + // (view may have been recreated on session switch) + if (this.headerElement.parentElement !== view.element) { + view.element.insertBefore(this.headerElement, view.element.firstChild); + } + return true; + } + + private ensureInjected(): void { + if (!this.tryInject()) { + // Retry when the chat view becomes visible + this._register(this.viewsService.onDidChangeViewVisibility(e => { + if (e.id === ChatViewId && e.visible) { + this.tryInject(); + } + })); + } + } + + private render(): void { + // Ensure header is in the DOM (may have been created before the view mounted) + this.tryInject(); + + const label = this.getLabel(); + const icon = this.getIcon(); + const repoLabel = this.getRepoLabel(); + + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; + if (this.lastRenderState === renderState) { + return; + } + this.lastRenderState = renderState; + + // Icon + this.iconElement.className = 'chat-session-header-icon'; + if (icon) { + this.iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + this.iconElement.style.display = ''; + } else { + this.iconElement.style.display = 'none'; + } + + // Title + this.titleElement.textContent = label; + + // Repo folder + if (repoLabel) { + this.repoElement.textContent = repoLabel; + this.repoElement.style.display = ''; + } else { + this.repoElement.style.display = 'none'; + } + + // Show the button only when there is an active session with an agent session + const activeSession = this.sessionsManagementService.getActiveSession(); + const hasAgentSession = activeSession ? !!this.agentSessionsService.getSession(activeSession.resource) : false; + this.markDoneButton.element.style.display = hasAgentSession ? '' : 'none'; + } + + private getLabel(): string { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (activeSession?.label) { + return activeSession.label; + } + if (activeSession) { + const model = this.chatService.getSession(activeSession.resource); + if (model?.title) { + return model.title; + } + } + return localize('newSession', "New Session"); + } + + private getIcon(): ThemeIcon | undefined { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession) { + return undefined; + } + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + if (agentSession.providerType === AgentSessionProviders.Background) { + const hasWorktree = typeof agentSession.metadata?.worktreePath === 'string'; + return hasWorktree ? Codicon.worktree : Codicon.folder; + } + return agentSession.icon; + } + const provider = getAgentSessionProvider(activeSession.resource); + if (provider !== undefined) { + return getAgentSessionProviderIcon(provider); + } + return undefined; + } + + private getRepoLabel(): string | undefined { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession?.repository) { + return undefined; + } + return decodeURIComponent(basename(activeSession.repository)); + } + + private markAsDone(): void { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession) { + return; + } + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + agentSession.setArchived(true); + } + this.sessionsManagementService.openNewSessionView(); + } + + private showSessionsPicker(): void { + const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { + overrideSessionOpen: (session, openOptions) => this.sessionsManagementService.openSession(session.resource, openOptions) + }); + picker.pickAgentSession(); + } + + private trackModelChanges(resource: URI | undefined): void { + this.modelChangeListener.clear(); + if (!resource) { + return; + } + const model = this.chatService.getSession(resource); + if (!model) { + return; + } + this.modelChangeListener.value = model.onDidChange(e => { + if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { + this.lastRenderState = undefined; + this.render(); + } + }); + } +} + +registerWorkbenchContribution2(ChatSessionHeaderContribution.ID, ChatSessionHeaderContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css new file mode 100644 index 00000000000..cde83312663 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Chat Session Header (PR-style) ---- */ + +.agent-sessions-workbench .chat-session-header { + position: relative; + width: 100%; + max-width: 950px; + margin: 0 auto; + padding: 0px 8px; + box-sizing: border-box; + transition: padding 200ms ease; +} + +.agent-sessions-workbench .chat-session-header::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -40px; + height: 40px; + background: linear-gradient(to bottom, var(--vscode-sideBar-background), transparent); + pointer-events: none; + z-index: 1; +} + +.agent-sessions-workbench .chat-session-header-content { + display: flex; + flex-direction: column; + min-width: 0; + cursor: pointer; + padding: 2px 8px 6px; +} + +.agent-sessions-workbench .chat-session-header-title-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6px; +} + +/* Title — large by default, like a PR title */ +.agent-sessions-workbench .chat-session-header-title { + align-self: flex-start; + font-size: 22px; + font-weight: 600; + line-height: 1.3; + color: var(--vscode-foreground); + word-break: break-word; + border-radius: 4px 4px 4px 0; + padding: 2px 4px; + transition: font-size 200ms ease, background 150ms ease; +} + +.agent-sessions-workbench .chat-session-header-title:hover, +.agent-sessions-workbench .chat-session-header-content:has(.chat-session-header-repo-row:hover) .chat-session-header-title { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .chat-session-header-repo-row:hover, +.agent-sessions-workbench .chat-session-header-content:has(.chat-session-header-title:hover) .chat-session-header-repo-row { + background: var(--vscode-toolbar-hoverBackground); +} + +/* Repo row: icon + folder name */ +.agent-sessions-workbench .chat-session-header-repo-row { + align-self: flex-start; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + border-radius: 0 0 4px 4px; + padding: 2px 4px; + transition: background 150ms ease; +} + +/* Icon */ +.agent-sessions-workbench .chat-session-header-icon { + font-size: 14px; + opacity: 0.6; + flex-shrink: 0; + transition: font-size 200ms ease; +} + +/* Repo folder — secondary text */ +.agent-sessions-workbench .chat-session-header-repo { + font-size: 13px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + transition: font-size 200ms ease; +} + +/* ---- Shrunk state (after scrolling) ---- */ + +.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk { + padding: 12px 24px 8px 24px; +} + +.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk .chat-session-header-title { + font-size: 14px; + font-weight: 600; +} + +.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk .chat-session-header-icon { + font-size: 12px; +} + +.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk .chat-session-header-repo { + font-size: 12px; +} + +/* ---- Mark as Done button ---- */ + +.agent-sessions-workbench .chat-session-header-actions { + flex-shrink: 0; +} + +.agent-sessions-workbench .chat-session-header-actions .monaco-button { + white-space: nowrap; + padding: 4px 6px 4px 0px; + margin-top: 6px; + background: none !important; + border: none !important; + color: var(--vscode-textLink-foreground) !important; + font-size: 12px; + cursor: pointer; +} + +.agent-sessions-workbench .chat-session-header-actions .monaco-button:hover { + color: var(--vscode-textLink-activeForeground) !important; + text-decoration: underline; + outline: 1px solid var(--vscode-focusBorder); + border-radius: 4px; +} + +/* ---- Reduced motion ---- */ + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .chat-session-header, + .agent-sessions-workbench .chat-session-header-icon, + .agent-sessions-workbench .chat-session-header-title, + .agent-sessions-workbench .chat-session-header-repo { + transition: none; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 52bd4417115..b7f87b60b54 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -10,9 +10,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; @@ -45,6 +43,4 @@ const agentSessionsViewDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); -registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); - registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index ffcc3b6c194..846bd4d3c06 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -202,6 +202,7 @@ import './browser/layoutActions.js'; import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/chatSessionHeader.js'; import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; From cb83ad104cbfce8fe9893d20ffc71f91649dcd3a Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 20 Mar 2026 11:10:54 +0000 Subject: [PATCH 103/183] Add click event listener to prevent propagation on Mark as Done button Co-authored-by: Copilot --- src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts index ca06ca96efc..0762afbf082 100644 --- a/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts +++ b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts @@ -65,6 +65,9 @@ class ChatSessionHeaderContribution extends Disposable implements IWorkbenchCont // Mark as Done button const buttonContainer = append(titleRow, $('.chat-session-header-actions')); + this._register(addDisposableListener(buttonContainer, EventType.CLICK, e => { + e.stopPropagation(); + })); this.markDoneButton = this._register(new Button(buttonContainer, { supportIcons: true, ...defaultButtonStyles })); this.markDoneButton.label = `$(check) ${localize('markAsDone', "Mark as Done")}`; this._register(this.markDoneButton.onDidClick(() => this.markAsDone())); From bf939eec57eb510ee6e51b220e5f53a51bd7afc6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:22:05 +0000 Subject: [PATCH 104/183] Initial plan From b4317ecbafa742b06fc9dac7b74f1bd5f04739aa Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:23:27 +0000 Subject: [PATCH 105/183] Sessions - add commit skill and adopt it in the pull requestion creation instructions (#303443) * Sessions - add commit skill and adopt it in the pull requestion creation instructions * Pull request feedback --- .../prompts/create-draft-pr.prompt.md | 10 ++- src/vs/sessions/prompts/create-pr.prompt.md | 9 ++- src/vs/sessions/skills/commit/SKILL.md | 80 +++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 src/vs/sessions/skills/commit/SKILL.md diff --git a/src/vs/sessions/prompts/create-draft-pr.prompt.md b/src/vs/sessions/prompts/create-draft-pr.prompt.md index 4def295b9fc..c2529a264d4 100644 --- a/src/vs/sessions/prompts/create-draft-pr.prompt.md +++ b/src/vs/sessions/prompts/create-draft-pr.prompt.md @@ -5,7 +5,9 @@ description: Create a draft pull request for the current session Use the GitHub MCP server to create a draft 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 draft pull request +1. Run the compile and hygiene tasks (fixing any errors) +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. Create the draft pull request diff --git a/src/vs/sessions/prompts/create-pr.prompt.md b/src/vs/sessions/prompts/create-pr.prompt.md index 02208021e3a..4991f4ff582 100644 --- a/src/vs/sessions/prompts/create-pr.prompt.md +++ b/src/vs/sessions/prompts/create-pr.prompt.md @@ -6,7 +6,8 @@ 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. Run the compile and hygiene tasks (fixing any errors) -2. Review all changes in the current session -3. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") -4. Write a description covering what changed, why, and anything reviewers should know -5. Create the pull request +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. Create the pull request diff --git a/src/vs/sessions/skills/commit/SKILL.md b/src/vs/sessions/skills/commit/SKILL.md new file mode 100644 index 00000000000..2bd73ac44c9 --- /dev/null +++ b/src/vs/sessions/skills/commit/SKILL.md @@ -0,0 +1,80 @@ +--- +name: commit +description: Commit staged or unstaged changes with an AI-generated commit message that matches the repository's existing commit style. Use when the user asks to 'commit', 'commit changes', 'create a commit', 'save my work', or 'check in code'. +--- + + +# Commit Changes + +Help the user commit code changes with a well-crafted commit message derived from the diff, following the conventions already established in the repository. + +## Guidelines + +- **Never amend existing commits** without asking. +- **Never force-push or push** without explicit user approval. +- **Never skip pre-commit hooks** (do not use `--no-verify`). +- **Never skip signing commits** (do not use `--no-gpg-sign`). +- **Never revert, reset, or discard user changes** unless the user explicitly asked for that. +- Check for obvious secrets or generated artifacts that should not be committed. If something looks risky - ask the user. +- When in doubt about staging, convention, or message content — ask the user. + +## Workflow + +### 1. Discover the repository's commit convention + +Run the following to sample recent commits and the user's own commits: + +``` +# Recent repo commits (for overall style) +git log --oneline -20 + +# User's recent commits (for personal style) +git log --oneline --author="$(git config user.name)" -10 +``` + +Analyse the output to determine the commit message convention used in the repository (e.g. Conventional Commits, Gitmoji, ticket-prefixed, free-form). All generated messages **must** follow the detected convention. + +### 2. Check repository status + +``` +git status --short +``` + +- If there are **no changes** (working tree clean, nothing staged), inform the user and stop. +- If there are **staged changes**, proceed with those and do not stage any unstaged changes. +- If there are **only unstaged changes**, stage everything (`git add -A`), and proceed with those. + +### 3. Generate the commit message + +Obtain the full diff of what will be committed: + +```bash +git diff --cached --stat +git diff --cached +``` + +Using the diff and the commit convention detected in step 1, draft a commit message with: + +- A **subject line** (≤ 72 characters) that summarises the change, following the repository's convention. +- An optional **body** that explains *why* the change was made, only when the diff is non-trivial. +- Reference issue/ticket numbers when they appear in branch names or related context. +- Focus on the intent of the change, not a file-by-file inventory. + +### 4. Commit + +Construct the `git commit` command with the generated message. + +Execute the commit: + +``` +git commit -m "" -m "" +``` + +### 5. Confirm + +After the commit: + +- Run `git status --short` to confirm the commit completed. +- Run `git log --oneline -1` to show the new commit. +- If pre-commit hooks changed files or blocked the commit, summarize exactly what happened. +- If hooks rewrote files after the commit attempt, do not amend automatically. Tell the user what changed and ask whether they want you to stage and commit those follow-up edits. From fe68d3a6818df5a705df349b5db790efac94f24b Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 20 Mar 2026 11:24:18 +0000 Subject: [PATCH 106/183] Refactor chat session header rendering logic and update styles Co-authored-by: Copilot --- .../contrib/chat/browser/chatSessionHeader.ts | 78 ++++++++++--------- .../chat/browser/media/chatSessionHeader.css | 46 ++--------- src/vs/sessions/sessions.web.main.ts | 1 + 3 files changed, 52 insertions(+), 73 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts index 0762afbf082..ef48023c1eb 100644 --- a/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts +++ b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts @@ -27,7 +27,6 @@ import { ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js' /** * Renders a PR-style header at the top of the chat messages area. - * Shows the session title large initially, then shrinks as the user scrolls. * Displays: session title + folder name (no diff numbers). */ class ChatSessionHeaderContribution extends Disposable implements IWorkbenchContribution { @@ -41,6 +40,7 @@ class ChatSessionHeaderContribution extends Disposable implements IWorkbenchCont private readonly markDoneButton: Button; private readonly modelChangeListener = this._register(new MutableDisposable()); private lastRenderState: string | undefined; + private isRendering = false; constructor( @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @@ -135,43 +135,51 @@ class ChatSessionHeaderContribution extends Disposable implements IWorkbenchCont } private render(): void { - // Ensure header is in the DOM (may have been created before the view mounted) - this.tryInject(); - - const label = this.getLabel(); - const icon = this.getIcon(); - const repoLabel = this.getRepoLabel(); - - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; - if (this.lastRenderState === renderState) { + if (this.isRendering) { return; } - this.lastRenderState = renderState; + this.isRendering = true; + try { + // Ensure header is in the DOM (may have been created before the view mounted) + this.tryInject(); - // Icon - this.iconElement.className = 'chat-session-header-icon'; - if (icon) { - this.iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); - this.iconElement.style.display = ''; - } else { - this.iconElement.style.display = 'none'; + const label = this.getLabel(); + const icon = this.getIcon(); + const repoLabel = this.getRepoLabel(); + + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; + if (this.lastRenderState === renderState) { + return; + } + this.lastRenderState = renderState; + + // Icon + this.iconElement.className = 'chat-session-header-icon'; + if (icon) { + this.iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + this.iconElement.style.display = ''; + } else { + this.iconElement.style.display = 'none'; + } + + // Title + this.titleElement.textContent = label; + + // Repo folder + if (repoLabel) { + this.repoElement.textContent = repoLabel; + this.repoElement.style.display = ''; + } else { + this.repoElement.style.display = 'none'; + } + + // Show the button only when there is an active session with an agent session + const activeSession = this.sessionsManagementService.getActiveSession(); + const hasAgentSession = activeSession ? !!this.agentSessionsService.getSession(activeSession.resource) : false; + this.markDoneButton.element.style.display = hasAgentSession ? '' : 'none'; + } finally { + this.isRendering = false; } - - // Title - this.titleElement.textContent = label; - - // Repo folder - if (repoLabel) { - this.repoElement.textContent = repoLabel; - this.repoElement.style.display = ''; - } else { - this.repoElement.style.display = 'none'; - } - - // Show the button only when there is an active session with an agent session - const activeSession = this.sessionsManagementService.getActiveSession(); - const hasAgentSession = activeSession ? !!this.agentSessionsService.getSession(activeSession.resource) : false; - this.markDoneButton.element.style.display = hasAgentSession ? '' : 'none'; } private getLabel(): string { @@ -213,7 +221,7 @@ class ChatSessionHeaderContribution extends Disposable implements IWorkbenchCont if (!activeSession?.repository) { return undefined; } - return decodeURIComponent(basename(activeSession.repository)); + return basename(activeSession.repository); } private markAsDone(): void { diff --git a/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css index cde83312663..f0e0a6ea769 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css @@ -12,19 +12,6 @@ margin: 0 auto; padding: 0px 8px; box-sizing: border-box; - transition: padding 200ms ease; -} - -.agent-sessions-workbench .chat-session-header::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: -40px; - height: 40px; - background: linear-gradient(to bottom, var(--vscode-sideBar-background), transparent); - pointer-events: none; - z-index: 1; } .agent-sessions-workbench .chat-session-header-content { @@ -35,6 +22,12 @@ padding: 2px 8px 6px; } +.agent-sessions-workbench .chat-session-header-content:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + border-radius: 4px; +} + .agent-sessions-workbench .chat-session-header-title-row { display: flex; flex-direction: row; @@ -52,7 +45,7 @@ word-break: break-word; border-radius: 4px 4px 4px 0; padding: 2px 4px; - transition: font-size 200ms ease, background 150ms ease; + transition: background 150ms ease; } .agent-sessions-workbench .chat-session-header-title:hover, @@ -82,7 +75,6 @@ font-size: 14px; opacity: 0.6; flex-shrink: 0; - transition: font-size 200ms ease; } /* Repo folder — secondary text */ @@ -90,26 +82,6 @@ font-size: 13px; color: var(--vscode-descriptionForeground); white-space: nowrap; - transition: font-size 200ms ease; -} - -/* ---- Shrunk state (after scrolling) ---- */ - -.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk { - padding: 12px 24px 8px 24px; -} - -.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk .chat-session-header-title { - font-size: 14px; - font-weight: 600; -} - -.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk .chat-session-header-icon { - font-size: 12px; -} - -.agent-sessions-workbench .chat-session-header.chat-session-header-shrunk .chat-session-header-repo { - font-size: 12px; } /* ---- Mark as Done button ---- */ @@ -139,10 +111,8 @@ /* ---- Reduced motion ---- */ @media (prefers-reduced-motion: reduce) { - .agent-sessions-workbench .chat-session-header, - .agent-sessions-workbench .chat-session-header-icon, .agent-sessions-workbench .chat-session-header-title, - .agent-sessions-workbench .chat-session-header-repo { + .agent-sessions-workbench .chat-session-header-repo-row { transition: none; } } diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index f5223d4a29d..fc29d84bc36 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -156,6 +156,7 @@ import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/chatSessionHeader.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; From b5b6be62d7577db213cea743c24b45c97bfff094 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 20 Mar 2026 10:14:18 +0100 Subject: [PATCH 107/183] Avoid getting redacted due to keyword matches (#287739) --- src/vs/workbench/api/node/proxyResolver.ts | 32 +++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 545b517b921..acc7d93e4c7 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -321,7 +321,7 @@ function collectNodeSystemCertErrors(useNodeSystemCerts: boolean, logService: IL if (Array.isArray(entries)) { for (const entry of entries as { errorMessage?: string; errorCode?: number }[]) { const code = entry.errorCode ?? 'missing code'; - const error = `${category}: ${entry.errorMessage ?? 'missing message'}`; + const error = `${category}: ${sanitizeCertErrorMessage(entry.errorMessage ?? 'missing message')}`; const key = `${error} (${code})`; const existing = counts.get(key); if (existing) { @@ -341,6 +341,36 @@ function collectNodeSystemCertErrors(useNodeSystemCerts: boolean, logService: IL } } +// Sanitize known error messages to avoid false-positive redaction by the +// telemetry scrubbing regex in telemetryUtils.ts (the Generic Secret pattern +// matches "key", "sig", "signature" followed by a non-alphanumeric character). +// Source strings from Node.js RecordCertError() and OpenSSL's x509_err.c / asn1_err.c. +const certErrorReplacements: [string, string][] = [ + // Node.js RecordCertError: + ['key usage flags', 'k usage flags'], + // x509_err.c: + ['check dh key', 'check dh k'], + ['key type mismatch', 'k type mismatch'], + ['key values mismatch', 'k values mismatch'], + ['public key decode error', 'public k decode error'], + ['public key encode error', 'public k encode error'], + ['unable to get certs public key', 'unable to get certs public k'], + ['unknown key type', 'unknown k type'], + // asn1_err.c: + ['key type not supported', 'k type not supported'], + ['public key type', 'public k type'], + ['sig parse error', 's parse error'], + ['sig invalid mime type', 's invalid mime type'], + ['sig content type', 's content type'], + ['signature algorithm', 's algorithm'], +]; +function sanitizeCertErrorMessage(message: string): string { + for (const [search, replacement] of certErrorReplacements) { + message = message.replaceAll(search, replacement); + } + return message; +} + type ProxyResolveStatsClassification = { owner: 'chrmarti'; comment: 'Performance statistics for proxy resolution'; From 8e050e3e0e45ece8fa19ae387ee8f7742117484e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:29:17 +0000 Subject: [PATCH 108/183] feat: add chat.editing.autoNavigation setting to control auto-jump after keep/undo Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/093ee54e-8c45-4887-8f91-d4381f832d9f --- .../workbench/contrib/chat/browser/chat.contribution.ts | 5 +++++ .../chat/browser/chatEditing/chatEditingEditorActions.ts | 9 +++++++-- src/vs/workbench/contrib/chat/common/constants.ts | 1 + 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index de484ac80f6..7405f5d78cf 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -315,6 +315,11 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.AutoNavigation]: { + type: 'boolean', + markdownDescription: nls.localize('chat.editing.autoNavigation', "Controls whether the editor automatically navigates to the next change after keeping or undoing a chat edit."), + default: true, + }, 'chat.tips.enabled': { type: 'boolean', scope: ConfigurationScope.APPLICATION, diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index 442e73d33f4..d38a8fd30a7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -17,6 +17,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IListService } from '../../../../../platform/list/browser/listService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { resolveCommandsContext } from '../../../../browser/parts/editor/editorCommandsContext.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { EditorResourceAccessor, SideBySideEditor, TEXT_DIFF_EDITOR_ID } from '../../../../common/editor.js'; @@ -211,6 +212,7 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration): Promise { const instaService = accessor.get(IInstantiationService); + const configService = accessor.get(IConfigurationService); if (this._keep) { session.accept(entry.modifiedURI); @@ -218,7 +220,9 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { session.reject(entry.modifiedURI); } - await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); + if (configService.getValue(ChatConfiguration.AutoNavigation)) { + await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); + } } } @@ -270,6 +274,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise { const instaService = accessor.get(IInstantiationService); + const configService = accessor.get(IConfigurationService); if (this._accept) { await ctrl.acceptNearestChange(args[0] as IModifiedFileEntryChangeHunk | undefined); @@ -277,7 +282,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { await ctrl.rejectNearestChange(args[0] as IModifiedFileEntryChangeHunk | undefined); } - if (entry.changesCount.get() === 0) { + if (configService.getValue(ChatConfiguration.AutoNavigation) && entry.changesCount.get() === 0) { // no more changes, move to next file await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index ef61cbbdbb8..af3aff1d2fd 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -53,6 +53,7 @@ export enum ChatConfiguration { RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', + AutoNavigation = 'chat.editing.autoNavigation', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', From f1347904addde9d63a87f57fec777e91030d2c3a Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 20 Mar 2026 12:40:17 +0100 Subject: [PATCH 109/183] inlineChat: use stepped resize for input widget width Resize the inline chat input widget width in discrete steps (minWidth -> midWidth -> maxWidth) using the golden ratio instead of continuously growing with content. This reduces visual jitter by snapping to only three predefined sizes. --- .../inlineChat/browser/inlineChatOverlayWidget.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 10295552d91..2d0b0d6f810 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -163,9 +163,17 @@ export class InlineChatInputWidget extends Disposable { const totalWidth = contentWidth.read(r) + editorPad + toolbarWidth.read(r); const minWidth = 220; const maxWidth = 600; - const clampedWidth = this._input.getOption(EditorOption.wordWrap) === 'on' - ? maxWidth - : Math.max(minWidth, Math.min(totalWidth, maxWidth)); + const midWidth = Math.round(maxWidth / 1.618); + let clampedWidth: number; + if (this._input.getOption(EditorOption.wordWrap) === 'on') { + clampedWidth = maxWidth; + } else if (totalWidth <= minWidth) { + clampedWidth = minWidth; + } else if (totalWidth <= midWidth) { + clampedWidth = midWidth; + } else { + clampedWidth = maxWidth; + } const lineHeight = this._input.getOption(EditorOption.lineHeight); const clampedHeight = Math.min(contentHeight.read(r), (3 * lineHeight)); From 7846f89729be2b0d46080855e9aaaefc566f353c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:00:42 +0000 Subject: [PATCH 110/183] rename setting to chat.editing.revealNextChangeOnResolve Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/2c5ccc2a-643b-41eb-b392-860c442cc78c --- src/vs/workbench/contrib/chat/browser/chat.contribution.ts | 4 ++-- .../chat/browser/chatEditing/chatEditingEditorActions.ts | 4 ++-- src/vs/workbench/contrib/chat/common/constants.ts | 2 +- 3 files changed, 5 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 7405f5d78cf..0117acedace 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -315,9 +315,9 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, - [ChatConfiguration.AutoNavigation]: { + [ChatConfiguration.RevealNextChangeOnResolve]: { type: 'boolean', - markdownDescription: nls.localize('chat.editing.autoNavigation', "Controls whether the editor automatically navigates to the next change after keeping or undoing a chat edit."), + markdownDescription: nls.localize('chat.editing.revealNextChangeOnResolve', "Controls whether the editor automatically reveals the next change after keeping or undoing a chat edit."), default: true, }, 'chat.tips.enabled': { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index d38a8fd30a7..e539ca72eef 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -220,7 +220,7 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { session.reject(entry.modifiedURI); } - if (configService.getValue(ChatConfiguration.AutoNavigation)) { + if (configService.getValue(ChatConfiguration.RevealNextChangeOnResolve)) { await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); } } @@ -282,7 +282,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { await ctrl.rejectNearestChange(args[0] as IModifiedFileEntryChangeHunk | undefined); } - if (configService.getValue(ChatConfiguration.AutoNavigation) && entry.changesCount.get() === 0) { + if (configService.getValue(ChatConfiguration.RevealNextChangeOnResolve) && entry.changesCount.get() === 0) { // no more changes, move to next file await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index af3aff1d2fd..1a41af35f77 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -53,7 +53,7 @@ export enum ChatConfiguration { RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', - AutoNavigation = 'chat.editing.autoNavigation', + RevealNextChangeOnResolve = 'chat.editing.revealNextChangeOnResolve', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', From 17f2de9b29bb9104f9715b6d89cce1b228f72215 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:18:35 +0000 Subject: [PATCH 111/183] Sessions - fix code review toolbar contribution (#303464) --- .../contrib/codeReview/browser/codeReview.contributions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index 504888849bd..551cbc4d128 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -5,7 +5,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { autorun, observableSignalFromEvent } 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'; @@ -127,7 +127,7 @@ class CodeReviewToolbarContribution extends Disposable implements IWorkbenchCont super(); const canRunCodeReviewContext = canRunSessionCodeReviewContextKey.bindTo(contextKeyService); - const sessionsChangedSignal = observableFromEvent(this, this._agentSessionsService.model.onDidChangeSessions, () => undefined); + const sessionsChangedSignal = observableSignalFromEvent(this, this._agentSessionsService.model.onDidChangeSessions); this._register(autorun(reader => { const activeSession = this._sessionManagementService.activeSession.read(reader); From 1556677accc4cbf127064667fc0b4d353231f9d7 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 20 Mar 2026 12:56:55 +0000 Subject: [PATCH 112/183] Improve chat session header injection logic to target specific container elements --- src/vs/sessions/browser/media/style.css | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 932170f362b..a5ab7f38d0d 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -22,6 +22,7 @@ .agent-sessions-workbench .part.sidebar { background: var(--vscode-sideBar-background); border-right: 1px solid var(--vscode-sideBar-border, transparent); + animation: sessions-card-enter-left 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; } .agent-sessions-workbench .part.auxiliarybar { @@ -29,6 +30,7 @@ background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + animation: sessions-card-enter-right 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; } .agent-sessions-workbench .part.panel { @@ -36,6 +38,49 @@ background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + animation: sessions-card-enter-up 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; +} + +/* Card entrance animations */ +@keyframes sessions-card-enter-left { + from { + opacity: 0; + transform: translateX(-12px) scale(0.97); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes sessions-card-enter-right { + from { + opacity: 0; + transform: translateX(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes sessions-card-enter-up { + from { + opacity: 0; + transform: translateY(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .part.sidebar, + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel { + animation: none; + } } /* Grid background matches the chat bar / sidebar background */ From e084e0729bc5a96f2539eadf8a759c9273d95588 Mon Sep 17 00:00:00 2001 From: kno Date: Fri, 20 Mar 2026 14:33:39 +0100 Subject: [PATCH 113/183] Fix SCM count badge to use visible repositories (#300796) Use `scmViewService.visibleRepositories` instead of filtering `scmService.repositories` manually. This ensures the count badge respects repository visibility and reacts to visibility changes via `onDidChangeVisibleRepositories`. Co-authored-by: aruizdesamaniego-sh --- src/vs/workbench/contrib/scm/browser/activity.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/scm/browser/activity.ts b/src/vs/workbench/contrib/scm/browser/activity.ts index d38a51b0654..1dc0f055c9c 100644 --- a/src/vs/workbench/contrib/scm/browser/activity.ts +++ b/src/vs/workbench/contrib/scm/browser/activity.ts @@ -15,7 +15,6 @@ import { IStatusbarEntry, IStatusbarService, StatusbarAlignment as MainThreadSta import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { EditorResourceAccessor } from '../../../common/editor.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { Iterable } from '../../../../base/common/iterator.js'; import { ITitleService } from '../../../services/title/browser/titleService.js'; import { IEditorGroupContextKeyProvider, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; @@ -30,7 +29,7 @@ const ActiveRepositoryContextKeys = { }; export class SCMActiveRepositoryController extends Disposable implements IWorkbenchContribution { - private readonly _repositories: IObservable>; + private readonly _visibleRepositories: IObservable; private readonly _activeRepositoryHistoryItemRefName: IObservable; private readonly _countBadgeConfig: IObservable<'all' | 'focused' | 'off'>; private readonly _countBadgeRepositories: IObservable }[]>; @@ -60,9 +59,9 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe this._countBadgeConfig = observableConfigValue<'all' | 'focused' | 'off'>('scm.countBadge', 'all', this.configurationService); - this._repositories = observableFromEvent(this, - Event.any(this.scmService.onDidAddRepository, this.scmService.onDidRemoveRepository), - () => Iterable.filter(this.scmService.repositories, r => r.provider.isHidden !== true)); + this._visibleRepositories = observableFromEvent(this, + Event.any(this.scmViewService.onDidChangeVisibleRepositories, this.scmService.onDidAddRepository, this.scmService.onDidRemoveRepository), + () => this.scmViewService.visibleRepositories); this._activeRepositoryHistoryItemRefName = derived(reader => { const activeRepository = this.scmViewService.activeRepository.read(reader); @@ -75,8 +74,8 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe this._countBadgeRepositories = derived(this, reader => { switch (this._countBadgeConfig.read(reader)) { case 'all': { - const repositories = this._repositories.read(reader); - return [...Iterable.map(repositories, r => ({ provider: r.provider, resourceCount: this._getRepositoryResourceCount(r) }))]; + const repositories = this._visibleRepositories.read(reader); + return repositories.map(r => ({ provider: r.provider, resourceCount: this._getRepositoryResourceCount(r) })); } case 'focused': { const activeRepository = this.scmViewService.activeRepository.read(reader); From 490922dd0d150827e975ce2a357ea899ee56f2a8 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 20 Mar 2026 15:04:15 +0100 Subject: [PATCH 114/183] multi select adoption for compressed tree --- src/vs/base/browser/ui/tree/asyncDataTree.ts | 7 +- .../ui/tree/compressedObjectTreeModel.ts | 12 ++- .../test/browser/ui/tree/objectTree.test.ts | 90 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 6c604269ac5..9d6d200f68f 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDragAndDropData } from '../../dnd.js'; -import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate } from '../list/list.js'; +import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate, NotSelectableGroupIdType } from '../list/list.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../list/listView.js'; import { IListStyles } from '../list/listWidget.js'; import { ComposedTreeDelegate, TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent, IFindControllerOptions, IStickyScrollDelegate, AbstractTree } from './abstractTree.js'; @@ -1309,7 +1309,10 @@ export class AsyncDataTree implements IDisposable diffIdentityProvider: options.diffIdentityProvider && { getId(node: IAsyncDataTreeNode): { toString(): string } { return options.diffIdentityProvider!.getId(node.element as T); - } + }, + getGroupId: options.diffIdentityProvider!.getGroupId ? (node: IAsyncDataTreeNode): number | NotSelectableGroupIdType => { + return options.diffIdentityProvider!.getGroupId!(node.element as T); + } : undefined } }; diff --git a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts index e4adc832676..0bcaa01c426 100644 --- a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts +++ b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IIdentityProvider } from '../list/list.js'; +import { IIdentityProvider, NotSelectableGroupIdType } from '../list/list.js'; import { getVisibleState, IIndexTreeModelSpliceOptions, isFilterResult } from './indexTreeModel.js'; import { IObjectTreeModel, IObjectTreeModelOptions, IObjectTreeModelSetChildrenOptions, ObjectTreeModel } from './objectTreeModel.js'; import { ICollapseStateChangeEvent, IObjectTreeElement, ITreeListSpliceData, ITreeModel, ITreeModelSpliceEvent, ITreeNode, TreeError, TreeFilterResult, TreeVisibility, WeakMapper } from './tree.js'; @@ -113,7 +113,10 @@ interface ICompressedObjectTreeModelOptions extends IObjectTreeM const wrapIdentityProvider = (base: IIdentityProvider): IIdentityProvider> => ({ getId(node) { return node.elements.map(e => base.getId(e).toString()).join('\0'); - } + }, + getGroupId: base.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return base.getGroupId!(node.elements[node.elements.length - 1]); + } : undefined }); // Exported only for test reasons, do not use directly @@ -380,7 +383,10 @@ function mapOptions(compressedNodeUnwrapper: CompressedNodeUnwra identityProvider: options.identityProvider && { getId(node: ICompressedTreeNode): { toString(): string } { return options.identityProvider!.getId(compressedNodeUnwrapper(node)); - } + }, + getGroupId: options.identityProvider!.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return options.identityProvider!.getGroupId!(compressedNodeUnwrapper(node)); + } : undefined }, sorter: options.sorter && { compare(node: ICompressedTreeNode, otherNode: ICompressedTreeNode): number { diff --git a/src/vs/base/test/browser/ui/tree/objectTree.test.ts b/src/vs/base/test/browser/ui/tree/objectTree.test.ts index aa11fbe6036..8902791afce 100644 --- a/src/vs/base/test/browser/ui/tree/objectTree.test.ts +++ b/src/vs/base/test/browser/ui/tree/objectTree.test.ts @@ -8,6 +8,7 @@ import { IIdentityProvider, IListVirtualDelegate } from '../../../../browser/ui/ import { ICompressedTreeNode } from '../../../../browser/ui/tree/compressedObjectTreeModel.js'; import { CompressibleObjectTree, ICompressibleTreeRenderer, ObjectTree } from '../../../../browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeRenderer } from '../../../../browser/ui/tree/tree.js'; +import { runWithFakedTimers } from '../../../common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; function getRowsTextContent(container: HTMLElement): string[] { @@ -16,6 +17,17 @@ function getRowsTextContent(container: HTMLElement): string[] { return rows.map(row => row.querySelector('.monaco-tl-contents')!.textContent!); } +function clickElement(element: HTMLElement, ctrlKey = false): void { + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, ctrlKey, button: 0 })); + element.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey, button: 0 })); +} + +function dispatchKeydown(element: HTMLElement, key: string, code: string, keyCode: number): void { + const keyboardEvent = new KeyboardEvent('keydown', { bubbles: true, key, code }); + Object.defineProperty(keyboardEvent, 'keyCode', { get: () => keyCode }); + element.dispatchEvent(keyboardEvent); +} + suite('ObjectTree', function () { suite('TreeNavigator', function () { @@ -231,6 +243,84 @@ suite('ObjectTree', function () { tree.setChildren(null, [{ element: 100 }, { element: 101 }, { element: 102 }, { element: 103 }]); assert.deepStrictEqual(tree.getFocus(), [101]); }); + + test('updateOptions preserves wrapped identity provider in view options', function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const identityProvider = { + getId(element: number): { toString(): string } { + return `${element}`; + }, + getGroupId(element: number): number { + return element % 2; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { identityProvider }); + + try { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }, { element: 1 }, { element: 2 }, { element: 3 }]); + + const firstRow = container.querySelector('.monaco-list-row[data-index="0"]') as HTMLElement; + const secondRow = container.querySelector('.monaco-list-row[data-index="1"]') as HTMLElement; + clickElement(firstRow); + assert.deepStrictEqual(tree.getSelection(), [0]); + + tree.updateOptions({ indent: 12 }); + + clickElement(secondRow, true); + + assert.deepStrictEqual(tree.getSelection(), [1]); + } finally { + tree.dispose(); + } + }); + + test('updateOptions preserves wrapped accessibility provider for type navigation re-announce', async function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const accessibilityProvider = { + getAriaLabel(element: number): string { + assert.strictEqual(typeof element, 'number'); + return `aria ${element}`; + }, + getWidgetAriaLabel(): string { + return 'tree'; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { + accessibilityProvider, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: () => 'a' + } + }); + + try { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }]); + tree.setFocus([0]); + tree.domFocus(); + + tree.updateOptions({ indent: 12 }); + + dispatchKeydown(tree.getHTMLElement(), 'a', 'KeyA', 65); + await Promise.resolve(); + }); + } finally { + tree.dispose(); + } + }); }); suite('CompressibleObjectTree', function () { From d05f2f29537c6e7a840fa64e38ec5ba6ea99ba85 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 20 Mar 2026 15:51:18 +0100 Subject: [PATCH 115/183] inlineChat: shared history service with persistence (#303471) --- .../browser/inlineChat.contribution.ts | 2 + .../browser/inlineChatHistoryService.ts | 94 +++++++++++++++++++ .../browser/inlineChatOverlayWidget.ts | 30 +++--- 3 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index e5ad9450dc1..acb788bced6 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -17,6 +17,7 @@ import { InlineChatNotebookContribution } from './inlineChatNotebook.js'; import { IWorkbenchContributionsRegistry, registerWorkbenchContribution2, Extensions as WorkbenchExtensions, WorkbenchPhase } from '../../../common/contributions.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { InlineChatEnabler, InlineChatEscapeToolContribution, InlineChatSessionServiceImpl } from './inlineChatSessionServiceImpl.js'; +import { IInlineChatHistoryService, InlineChatHistoryService } from './inlineChatHistoryService.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { CancelAction, ChatSubmitAction } from '../../chat/browser/actions/chatExecuteActions.js'; import { localize } from '../../../../nls.js'; @@ -36,6 +37,7 @@ registerAction2(InlineChatActions.RephraseInlineChatSessionAction); // --- browser registerSingleton(IInlineChatSessionService, InlineChatSessionServiceImpl, InstantiationType.Delayed); +registerSingleton(IInlineChatHistoryService, InlineChatHistoryService, InstantiationType.Delayed); // --- MENU special --- diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts new file mode 100644 index 00000000000..3b501b8a03e --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HistoryNavigator2 } from '../../../../base/common/history.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const IInlineChatHistoryService = createDecorator('IInlineChatHistoryService'); + +export interface IInlineChatHistoryService { + readonly _serviceBrand: undefined; + + addToHistory(value: string): void; + previousValue(): string | undefined; + nextValue(): string | undefined; + isAtEnd(): boolean; + replaceLast(value: string): void; + resetCursor(): void; +} + +const _storageKey = 'inlineChat.history'; +const _capacity = 50; + +export class InlineChatHistoryService extends Disposable implements IInlineChatHistoryService { + declare readonly _serviceBrand: undefined; + + private readonly _history: HistoryNavigator2; + + constructor( + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + const raw = this._storageService.get(_storageKey, StorageScope.PROFILE); + let entries: string[] = ['']; + if (raw) { + try { + const parsed: string[] = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + entries = parsed; + // Ensure there's always an empty uncommitted entry at the end + if (entries[entries.length - 1] !== '') { + entries.push(''); + } + } + } catch { + // ignore invalid data + } + } + + this._history = new HistoryNavigator2(entries, _capacity); + + this._store.add(this._storageService.onWillSaveState(() => { + this._saveToStorage(); + })); + } + + private _saveToStorage(): void { + const values = [...this._history].filter(v => v.length > 0); + if (values.length === 0) { + this._storageService.remove(_storageKey, StorageScope.PROFILE); + } else { + this._storageService.store(_storageKey, JSON.stringify(values), StorageScope.PROFILE, StorageTarget.USER); + } + } + + addToHistory(value: string): void { + this._history.replaceLast(value); + this._history.add(''); + } + + previousValue(): string | undefined { + return this._history.previous(); + } + + nextValue(): string | undefined { + return this._history.next(); + } + + isAtEnd(): boolean { + return this._history.isAtEnd(); + } + + replaceLast(value: string): void { + this._history.replaceLast(value); + } + + resetCursor(): void { + this._history.resetCursor(); + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 2d0b0d6f810..3675acc488e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -12,7 +12,6 @@ import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actio import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { HistoryNavigator2 } from '../../../../base/common/history.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -42,6 +41,7 @@ import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOpt import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; import { IInlineChatSession2 } from './inlineChatSessionService.js'; import { assertType } from '../../../../base/common/types.js'; +import { IInlineChatHistoryService } from './inlineChatHistoryService.js'; /** * Overlay widget that displays a vertical action bar menu. @@ -63,8 +63,6 @@ export class InlineChatInputWidget extends Disposable { private _anchorLeft: number = 0; private _anchorAbove: boolean = false; - private readonly _historyNavigator = new HistoryNavigator2([''], 50); - constructor( private readonly _editorObs: ObservableCodeEditor, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @@ -72,6 +70,7 @@ export class InlineChatInputWidget extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @IModelService modelService: IModelService, @IConfigurationService configurationService: IConfigurationService, + @IInlineChatHistoryService private readonly _historyService: IInlineChatHistoryService, ) { super(); @@ -237,7 +236,7 @@ export class InlineChatInputWidget extends Disposable { const model = this._input.getModel(); const position = this._input.getPosition(); if (position && position.lineNumber === model.getLineCount()) { - if (!this._historyNavigator.isAtEnd()) { + if (!this._historyService.isAtEnd()) { this._showNextHistoryValue(); e.preventDefault(); e.stopPropagation(); @@ -278,24 +277,27 @@ export class InlineChatInputWidget extends Disposable { } addToHistory(value: string): void { - this._historyNavigator.replaceLast(value); - this._historyNavigator.add(''); + this._historyService.addToHistory(value); } private _showPreviousHistoryValue(): void { - if (this._historyNavigator.isAtEnd()) { - this._historyNavigator.replaceLast(this._input.getModel().getValue()); + if (this._historyService.isAtEnd()) { + this._historyService.replaceLast(this._input.getModel().getValue()); + } + const value = this._historyService.previousValue(); + if (value !== undefined) { + this._input.getModel().setValue(value); } - const value = this._historyNavigator.previous(); - this._input.getModel().setValue(value); } private _showNextHistoryValue(): void { - if (this._historyNavigator.isAtEnd()) { + if (this._historyService.isAtEnd()) { return; } - const value = this._historyNavigator.next(); - this._input.getModel().setValue(value); + const value = this._historyService.nextValue(); + if (value !== undefined) { + this._input.getModel().setValue(value); + } } /** @@ -308,7 +310,7 @@ export class InlineChatInputWidget extends Disposable { this._showStore.clear(); // Reset history cursor to the end (current uncommitted text) - this._historyNavigator.resetCursor(); + this._historyService.resetCursor(); // Clear input state this._input.updateOptions({ wordWrap: 'off', placeholder }); From 702949b9358c1188bf6dd8e491ebfd6ede690537 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:54:37 -0700 Subject: [PATCH 116/183] Bump fast-xml-parser from 5.5.6 to 5.5.7 in /build (#303324) Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 5.5.6 to 5.5.7. - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.6...v5.5.7) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 5.5.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/build/package-lock.json b/build/package-lock.json index d2846d70878..cc1acf90b97 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -3510,9 +3510,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", - "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", "dev": true, "funding": [ { @@ -3524,7 +3524,7 @@ "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", - "strnum": "^2.1.2" + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6165,9 +6165,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", "dev": true, "funding": [ { From d5a777045135170e854a2820406585c0126a1984 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 20 Mar 2026 14:57:34 +0000 Subject: [PATCH 117/183] Sessions: Enhance auxiliary bar widget functionality and styling (#303510) * Enhance auxiliary bar widget functionality and styling - Update CollapsedAuxiliaryBarWidget to always be visible and act as a toggle for the auxiliary bar. - Improve layout and interaction by adding a button for session changes. - Adjust CSS for the auxiliary bar widget and titlebar layout actions for better visibility and animation. - Refactor related components to ensure proper integration and state management. * Add context key and views service stubs for SessionsTerminalContribution tests --------- Co-authored-by: mrleemurray --- .../sessions/browser/collapsedPartWidgets.ts | 70 +++++++++++-------- .../browser/media/collapsedPanelWidget.css | 7 +- .../browser/parts/media/titlebarpart.css | 34 ++++++++- src/vs/sessions/browser/parts/titlebarPart.ts | 2 + src/vs/sessions/browser/workbench.ts | 16 ++--- .../contrib/chat/browser/chat.contribution.ts | 4 +- .../browser/sessionsTerminalContribution.ts | 36 +++++++++- .../sessionsTerminalContribution.test.ts | 10 +++ 8 files changed, 132 insertions(+), 47 deletions(-) diff --git a/src/vs/sessions/browser/collapsedPartWidgets.ts b/src/vs/sessions/browser/collapsedPartWidgets.ts index 10de969c260..27fce20f559 100644 --- a/src/vs/sessions/browser/collapsedPartWidgets.ts +++ b/src/vs/sessions/browser/collapsedPartWidgets.ts @@ -5,7 +5,7 @@ import './media/collapsedPanelWidget.css'; import { $, addDisposableListener, append, EventType } from '../../base/browser/dom.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; import { IHoverService } from '../../platform/hover/browser/hover.js'; import { createInstantHoverDelegate } from '../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -182,13 +182,14 @@ export class CollapsedSidebarWidget extends Disposable { } /** - * Collapsed widget shown in the bottom-right corner when the auxiliary bar is hidden. - * Shows file change counts (files, insertions, deletions) from the active session. + * Widget shown in the titlebar right area showing file change counts + * (files, insertions, deletions) from the active session. + * Always visible — acts as a toggle for the auxiliary bar. */ export class CollapsedAuxiliaryBarWidget extends Disposable { private readonly element: HTMLElement; - private readonly indicatorContainer: HTMLElement; + private readonly changesBtn: HTMLElement; private readonly indicatorDisposables = this._register(new DisposableStore()); private readonly hoverDelegate = this._register(createInstantHoverDelegate()); private activeSessionResource: (() => URI | undefined) | undefined; @@ -196,6 +197,7 @@ export class CollapsedAuxiliaryBarWidget extends Disposable { constructor( parent: HTMLElement, + windowControlsContainer: HTMLElement | undefined, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IHoverService private readonly hoverService: IHoverService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @@ -203,16 +205,35 @@ export class CollapsedAuxiliaryBarWidget extends Disposable { ) { super(); - this.element = append(parent, $('.collapsed-panel-widget.collapsed-auxbar-widget')); - this.indicatorContainer = append(this.element, $('.collapsed-panel-buttons')); + this.element = $('div.collapsed-panel-widget.collapsed-auxbar-widget'); + + // Insert before the window-controls-container so the widget is not + // hidden behind the WCO on Windows. + if (windowControlsContainer && windowControlsContainer.parentElement === parent) { + parent.insertBefore(this.element, windowControlsContainer); + } else { + append(parent, this.element); + } + + this._register(toDisposable(() => this.element.remove())); + + const indicatorContainer = append(this.element, $('.collapsed-panel-buttons')); + this.changesBtn = append(indicatorContainer, $('.collapsed-panel-button.collapsed-auxbar-indicator')); + + // Click handler lives on the persistent button + this._register(addDisposableListener(this.changesBtn, EventType.CLICK, () => { + const isVisible = !this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + this.layoutService.setPartHidden(!isVisible, Parts.AUXILIARYBAR_PART); + if (isVisible) { + this.paneCompositeService.openPaneComposite(CHANGES_VIEW_CONTAINER_ID, ViewContainerLocation.AuxiliaryBar); + } + })); // Listen for session changes to update indicators this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); // Initial build this.rebuildIndicators(); - - this.hide(); } /** @@ -227,51 +248,44 @@ export class CollapsedAuxiliaryBarWidget extends Disposable { private rebuildIndicators(): void { this.indicatorDisposables.clear(); - this.indicatorContainer.textContent = ''; + this.changesBtn.textContent = ''; // Get change summary from the active session const resource = this.activeSessionResource?.(); const session = resource ? this.agentSessionsService.getSession(resource) : undefined; const summary = session ? getAgentChangesSummary(session.changes) : undefined; - // Combined changes button: [diff icon] +insertions -deletions fileCount - const changesBtn = append(this.indicatorContainer, $('.collapsed-panel-button.collapsed-auxbar-indicator')); - - append(changesBtn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); + // Rebuild inner content: [diff icon] +insertions -deletions + append(this.changesBtn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); if (summary && summary.insertions > 0) { - const insLabel = append(changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-insertions')); + const insLabel = append(this.changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-insertions')); insLabel.textContent = `+${summary.insertions}`; } if (summary && summary.deletions > 0) { - const delLabel = append(changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-deletions')); + const delLabel = append(this.changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-deletions')); delLabel.textContent = `-${summary.deletions}`; } if (summary) { this.indicatorDisposables.add(this.hoverService.setupManagedHover( - this.hoverDelegate, changesBtn, + this.hoverDelegate, this.changesBtn, localize('changesSummary', "{0} file(s) changed, {1} insertion(s), {2} deletion(s)", summary.files, summary.insertions, summary.deletions) )); } else { this.indicatorDisposables.add(this.hoverService.setupManagedHover( - this.hoverDelegate, changesBtn, + this.hoverDelegate, this.changesBtn, localize('showChanges', "Show Changes") )); } - - this.indicatorDisposables.add(addDisposableListener(changesBtn, EventType.CLICK, () => { - this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); - this.paneCompositeService.openPaneComposite(CHANGES_VIEW_CONTAINER_ID, ViewContainerLocation.AuxiliaryBar); - })); } - show(): void { - this.element.classList.remove('collapsed-panel-hidden'); - } - - hide(): void { - this.element.classList.add('collapsed-panel-hidden'); + /** + * Update the active visual state of the widget based on + * whether the auxiliary bar is currently visible. + */ + updateActiveState(auxiliaryBarVisible: boolean): void { + this.element.classList.toggle('active', auxiliaryBarVisible); } } diff --git a/src/vs/sessions/browser/media/collapsedPanelWidget.css b/src/vs/sessions/browser/media/collapsedPanelWidget.css index ee946bdb7d8..40e46bd9cef 100644 --- a/src/vs/sessions/browser/media/collapsedPanelWidget.css +++ b/src/vs/sessions/browser/media/collapsedPanelWidget.css @@ -31,7 +31,12 @@ /* ---- Auxiliary Bar widget (in titlebar-right) ---- */ .agent-sessions-workbench .collapsed-auxbar-widget { - order: 0; + order: 1; +} + +.agent-sessions-workbench .collapsed-auxbar-widget.active .collapsed-panel-button { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); } /* ---- Buttons (match titlebar action-item sizing) ---- */ diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 7755e7c95b0..644cbbf6c6a 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -54,13 +54,37 @@ height: 100%; } +/* Layout actions toolbar appears after the diff widget */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container { + order: 2; + /* Always render so we can animate in/out instead of display:none */ + display: flex !important; + align-items: center; + overflow: hidden; + width: 0; + opacity: 0; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container:not(.has-no-actions) { + /* TODO: Hardcoded to separator (9px) + single action button (28px). + Update if more actions are added to TitleBarRightLayout. */ + width: 37px; + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container { + transition: width 0.15s ease-out, opacity 0.15s ease-out; + } +} + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container:not(.has-no-actions) { display: flex; align-items: center; } -/* Separator between session actions and layout actions toolbar */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-layout-actions-container:not(.has-no-actions)::before { +/* Separator before layout actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container:not(.has-no-actions)::before { content: ''; width: 1px; height: 16px; @@ -68,6 +92,12 @@ background-color: var(--vscode-disabledForeground); } +/* Toggled action buttons in session actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.checked { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); +} + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .codicon { color: inherit; } diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index f481cb17807..736b483b5d8 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -83,6 +83,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { get leftContainer(): HTMLElement { return this.leftContent; } get rightContainer(): HTMLElement { return this.rightContent; } + get rightWindowControlsContainer(): HTMLElement | undefined { return this.windowControlsContainer; } private readonly titleBarStyle: TitlebarStyle; private isInactive: boolean = false; @@ -210,6 +211,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { const rightToolbarContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-layout-actions-container')); this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRightLayout, { contextMenu: Menus.TitleBarContext, + hiddenItemStrategy: HiddenItemStrategy.NoHide, telemetrySource: 'titlePart.right', toolbarOptions: { primaryGroup: () => true }, })); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 8c4f963dd47..e749a082f98 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -392,11 +392,9 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.collapsedSidebarWidget.show(); } - // Collapsed Auxiliary Bar Widget (shown when auxiliary bar is hidden) - this.collapsedAuxiliaryBarWidget = this._register(instantiationService.createInstance(CollapsedAuxiliaryBarWidget, titlebarPart.rightContainer)); - if (!this.partVisibility.auxiliaryBar) { - this.collapsedAuxiliaryBarWidget.show(); - } + // Auxiliary bar changes widget (always visible, acts as a toggle) + this.collapsedAuxiliaryBarWidget = this._register(instantiationService.createInstance(CollapsedAuxiliaryBarWidget, titlebarPart.rightContainer, titlebarPart.rightWindowControlsContainer)); + this.collapsedAuxiliaryBarWidget.updateActiveState(this.partVisibility.auxiliaryBar); // Wire active session provider after restore, when ISessionsManagementService is available. // Resolved via createDecorator to avoid a layering import from vs/sessions/contrib/. @@ -1143,12 +1141,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !hidden, ); - // Toggle collapsed auxiliary bar widget - if (hidden) { - this.collapsedAuxiliaryBarWidget?.show(); - } else { - this.collapsedAuxiliaryBarWidget?.hide(); - } + // Update collapsed auxiliary bar widget active state + this.collapsedAuxiliaryBarWidget?.updateActiveState(!hidden); // If auxiliary bar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index d7c33c437c4..ccc6c567ee3 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -51,9 +51,9 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { icon: Codicon.vscodeInsiders, precondition: IsActiveSessionBackgroundProviderContext, menu: [{ - id: Menus.TitleBarSessionMenu, + id: Menus.TitleBarRightLayout, group: 'navigation', - order: 10, + order: 0, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), }] }); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 96d17055897..44668fe89ec 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -20,10 +20,13 @@ import { IPathService } from '../../../../workbench/services/path/common/pathSer 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 { ContextKeyExpr, IContextKeyService, RawContextKey } 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'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; + +const SessionsTerminalViewVisibleContext = new RawContextKey('sessionsTerminalViewVisible', false); /** * Returns the cwd URI for the given session: worktree or repository path for @@ -55,9 +58,21 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, @ILogService private readonly _logService: ILogService, @IPathService private readonly _pathService: IPathService, + @IViewsService viewsService: IViewsService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + // Track whether the terminal view is visible so the titlebar toggle + // button shows the correct checked state. + const terminalViewVisible = SessionsTerminalViewVisibleContext.bindTo(contextKeyService); + terminalViewVisible.set(viewsService.isViewVisible(TERMINAL_VIEW_ID)); + this._register(viewsService.onDidChangeViewVisibility(e => { + if (e.id === TERMINAL_VIEW_ID) { + terminalViewVisible.set(e.visible); + } + })); + // React to active session changes — use worktree/repo for background sessions, home dir otherwise this._register(autorun(reader => { const session = this._sessionsManagementService.activeSession.read(reader); @@ -285,6 +300,10 @@ class OpenSessionInTerminalAction extends Action2 { id: 'agentSession.openInTerminal', title: localize2('openInTerminal', "Open Terminal"), icon: Codicon.terminal, + toggled: { + condition: SessionsTerminalViewVisibleContext, + title: localize('hideTerminal', "Hide Terminal"), + }, menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', @@ -295,10 +314,21 @@ class OpenSessionInTerminalAction extends Action2 { } override async run(_accessor: ServicesAccessor): Promise { + const layoutService = _accessor.get(IWorkbenchLayoutService); + const viewsService = _accessor.get(IViewsService); + + // Toggle: if panel is visible and the terminal view is active, hide it. + // If the panel is visible but showing another view, open the terminal instead. + if (layoutService.isVisible(Parts.PANEL_PART)) { + if (viewsService.isViewVisible(TERMINAL_VIEW_ID)) { + layoutService.setPartHidden(true, Parts.PANEL_PART); + return; + } + } + 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(); 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 b171cf31cc2..927730c8c9d 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -21,6 +21,9 @@ import { IActiveSessionItem, ISessionsManagementService } from '../../../session import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; const HOME_DIR = URI.file('/home/user'); @@ -199,6 +202,13 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(IPathService, new TestPathService(HOME_DIR)); + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + + instantiationService.stub(IViewsService, new class extends mock() { + override isViewVisible(): boolean { return false; } + override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event; + }); + contribution = store.add(instantiationService.createInstance(SessionsTerminalContribution)); }); From c8fda5b0f615eae06e7e55b7d1ee469d0dd4399e Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 20 Mar 2026 14:58:27 +0000 Subject: [PATCH 118/183] Sessions: Add session count display to agent session sections (#303514) Add session count display to agent session sections Co-authored-by: mrleemurray --- .../chat/browser/agentSessions/agentSessionsViewer.ts | 8 +++++++- .../agentSessions/media/agentsessionsviewer.css | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index a57689d4c99..46185e866ab 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -573,6 +573,7 @@ export function toStatusLabel(status: AgentSessionStatus): string { interface IAgentSessionSectionTemplate { readonly container: HTMLElement; readonly label: HTMLSpanElement; + readonly count: HTMLSpanElement; readonly toolbar: MenuWorkbenchToolBar; readonly contextKeyService: IContextKeyService; readonly disposables: IDisposable; @@ -596,6 +597,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer Date: Fri, 20 Mar 2026 15:32:08 +0000 Subject: [PATCH 119/183] Sessions - add new context keys for ahead/behind (#303520) --- .../contrib/changes/browser/changesView.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index bb3322d1838..1d0b7bb50d4 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -91,6 +91,8 @@ const enum ChangesVersionMode { const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); const isMergeBaseBranchProtectedContextKey = new RawContextKey('sessions.isMergeBaseBranchProtected', false); const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); +const hasIncomingChangesContextKey = new RawContextKey('sessions.hasIncomingChanges', false); +const hasOutgoingChangesContextKey = new RawContextKey('sessions.hasOutgoingChanges', false); // --- List Item @@ -646,6 +648,18 @@ export class ChangesViewPane extends ViewPane { return metadata?.pullRequestUrl !== undefined; })); + this.renderDisposables.add(bindContextKey(hasIncomingChangesContextKey, this.scopedContextKeyService, reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return (repositoryState?.HEAD?.behind ?? 0) > 0; + })); + + this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return (repositoryState?.HEAD?.ahead ?? 0) > 0; + })); + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); this.renderDisposables.add(scopedInstantiationService); From 4743b9431ba4d9228a531cba073eaaf80feba625 Mon Sep 17 00:00:00 2001 From: Berke Batmaz Date: Sat, 21 Mar 2026 02:33:55 +1100 Subject: [PATCH 120/183] fix(git): correctly pluralise `line_length` input validation (#301071) Correctly pluralise line_length git diagnostic --- extensions/git/src/diagnostics.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/git/src/diagnostics.ts b/extensions/git/src/diagnostics.ts index a8c1a3deea3..64bf11076fe 100644 --- a/extensions/git/src/diagnostics.ts +++ b/extensions/git/src/diagnostics.ts @@ -85,7 +85,11 @@ export class GitCommitInputBoxDiagnosticsManager { const threshold = index === 0 ? inputValidationSubjectLength ?? inputValidationLength : inputValidationLength; if (line.text.length > threshold) { - const diagnostic = new Diagnostic(line.range, l10n.t('{0} characters over {1} in current line', line.text.length - threshold, threshold), this.severity); + const charactersOver = line.text.length - threshold; + const lineLengthMessage = charactersOver === 1 + ? l10n.t('{0} character over {1} in current line', charactersOver, threshold) + : l10n.t('{0} characters over {1} in current line', charactersOver, threshold); + const diagnostic = new Diagnostic(line.range, lineLengthMessage, this.severity); diagnostic.code = DiagnosticCodes.line_length; diagnostics.push(diagnostic); From 21a0998f4917ade3c20c402fe091e24194e74377 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 20 Mar 2026 17:32:26 +0100 Subject: [PATCH 121/183] Update milestone version to 1.113.0 in GitHub issues configuration (#303538) --- .vscode/notebooks/my-endgame.github-issues | 2 +- .vscode/notebooks/my-work.github-issues | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index e3ddd3af411..b6e40685d5a 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.112.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.113.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index c4bc569e9da..a41aa7f69b7 100644 --- a/.vscode/notebooks/my-work.github-issues +++ b/.vscode/notebooks/my-work.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"March 2026\"\n" + "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"1.113.0\"\n" }, { "kind": 1, From 95b003130c5dc3fe15f9589f03e3fcee395e1eb2 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 20 Mar 2026 17:33:05 +0100 Subject: [PATCH 122/183] event: classify listener leak errors as dominated or popular (#303543) Classify listener leak errors as dominated or popular --- src/vs/base/common/event.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index ab8644403c0..929ed2f9e03 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -1029,7 +1029,8 @@ class LeakageMonitor { console.warn(message); console.warn(topStack); - const error = new ListenerLeakError(message, topStack); + const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerLeakError(kind, message, topStack); this._errorHandler(error); } @@ -1077,8 +1078,8 @@ export class ListenerLeakError extends Error { * `message` so that all leak errors group under the same title in telemetry. */ readonly details: string; - constructor(details: string, stack: string) { - super('potential listener LEAK detected'); + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind}`); this.name = 'ListenerLeakError'; this.details = details; this.stack = stack; @@ -1091,11 +1092,11 @@ export class ListenerRefusalError extends Error { /** * The detailed message including listener count and most frequent stack. * Available locally for debugging but intentionally not used as the error - * `message` so that all refusal errors group under the same title in telemetry. + * `message` so that all leak errors group under the same title in telemetry. */ readonly details: string; - constructor(details: string, stack: string) { - super('potential listener LEAK detected (REFUSED to add)'); + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind} (REFUSED to add)`); this.name = 'ListenerRefusalError'; this.details = details; this.stack = stack; @@ -1235,7 +1236,8 @@ export class Emitter { console.warn(message); const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1]; - const error = new ListenerRefusalError(`${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); + const kind = tuple[1] / this._size > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerRefusalError(kind, `${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); const errorHandler = this._options?.onListenerError || onUnexpectedError; errorHandler(error); From 939ffba04f11b68160510d6c8cabe137150bf90a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 20 Mar 2026 17:36:21 +0100 Subject: [PATCH 123/183] Sessions: remove diff changes from title (#303544) remove changes summary display and related logic from SessionsTitleBarWidget --- .../browser/media/sessionsTitleBarWidget.css | 14 ------ .../browser/sessionsTitleBarWidget.ts | 43 +------------------ 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index b9f14273091..87dde988060 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -77,18 +77,4 @@ flex-shrink: 0; } -/* Changes summary */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes { - display: flex; - align-items: center; - flex-shrink: 0; - gap: 3px; -} -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-added { - color: var(--vscode-gitDecoration-addedResourceForeground); -} - -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-removed { - color: var(--vscode-gitDecoration-deletedResourceForeground); -} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index b8096dff925..774577eeb23 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -19,7 +19,7 @@ import { IMenuService, MenuId, MenuRegistry, SubmenuItemAction } from '../../../ import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { IMarshalledAgentSessionContext, getAgentChangesSummary, hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IMarshalledAgentSessionContext } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; @@ -128,10 +128,8 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const label = this._getActiveSessionLabel(); const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); - const changesSummary = this._getChangesSummary(); - // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${changesSummary?.insertions ?? ''}|${changesSummary?.deletions ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -176,25 +174,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { centerGroup.appendChild(repoEl); } - // Changes summary shown next to the repo - if (changesSummary) { - const separator2 = $('span.agent-sessions-titlebar-separator'); - separator2.textContent = '\u00B7'; - centerGroup.appendChild(separator2); - - const changesEl = $('span.agent-sessions-titlebar-changes'); - - const addedEl = $('span.agent-sessions-titlebar-changes-added'); - addedEl.textContent = `+${changesSummary.insertions}`; - changesEl.appendChild(addedEl); - - const removedEl = $('span.agent-sessions-titlebar-changes-removed'); - removedEl.textContent = `-${changesSummary.deletions}`; - changesEl.appendChild(removedEl); - - centerGroup.appendChild(changesEl); - } - sessionPill.appendChild(centerGroup); // Click handler on pill - show sessions picker @@ -363,24 +342,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { menu.dispose(); } - /** - * Get the changes summary for the active session. - */ - private _getChangesSummary(): { insertions: number; deletions: number } | undefined { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { - return undefined; - } - - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - const changes = agentSession?.changes; - if (!changes || !hasValidDiff(changes)) { - return undefined; - } - - return getAgentChangesSummary(changes); - } - private _showSessionsPicker(): void { const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource, openOptions) From 7dcd2c9178c0786ad668b5e4aaa4835e590ddef4 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:47:24 +0000 Subject: [PATCH 124/183] Git - update DotGit file watcher to ignore worktree index.lock files (#303504) * Git - update DotGit file watcher to ignore worktree index.lock files * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/git/src/repository.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 687dffa25f4..a20a8b0002f 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -465,9 +465,9 @@ class DotGitWatcher implements IFileWatcher { const rootWatcher = watch(repository.dotGit.path); this.disposables.push(rootWatcher); - // Ignore changes to the "index.lock" file, and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. + // Ignore changes to the "index.lock" file (including worktree index.lock files), and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. // Watchman creates a cookie file inside the git directory whenever a query is run (https://facebook.github.io/watchman/docs/cookies.html). - const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); + const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock|\/worktrees\/[^/]+\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); this.event = anyEvent(filteredRootWatcher, this.emitter.event); repository.onDidRunGitStatus(this.updateTransientWatchers, this, this.disposables); @@ -932,7 +932,7 @@ export class Repository implements Disposable { // FS changes should trigger `git status`: // - any change inside the repository working tree - // - any change whithin the first level of the `.git` folder, except the folder itself and `index.lock` + // - any change within the first level of the `.git` folder, except the folder itself and `index.lock` (repository and worktree) const onFileChange = anyEvent(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange); onFileChange(this.onFileChange, this, this.disposables); From c99f8109a67528cd9ccb6de2d513bd5a7d52aa1f Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 20 Mar 2026 16:51:41 +0000 Subject: [PATCH 125/183] Sessions: Enhance collapsed sidebar widget with session status indicators and panel toggle button (#303521) * Enhance collapsed sidebar widget with session status indicators and panel toggle button * Refactor sidebar toggle button and update CSS class for session status Co-authored-by: Copilot --------- Co-authored-by: mrleemurray Co-authored-by: Copilot --- .../sessions/browser/collapsedPartWidgets.ts | 126 ++++++++++-------- .../browser/media/collapsedPanelWidget.css | 59 +++++++- 2 files changed, 128 insertions(+), 57 deletions(-) diff --git a/src/vs/sessions/browser/collapsedPartWidgets.ts b/src/vs/sessions/browser/collapsedPartWidgets.ts index 27fce20f559..e5c1789651b 100644 --- a/src/vs/sessions/browser/collapsedPartWidgets.ts +++ b/src/vs/sessions/browser/collapsedPartWidgets.ts @@ -44,11 +44,16 @@ export class CollapsedSidebarWidget extends Disposable { super(); this.element = append(parent, $('.collapsed-panel-widget.collapsed-sidebar-widget')); - this.indicatorContainer = append(this.element, $('.collapsed-panel-buttons')); - // New session button + // Sidebar toggle button (leftmost) + this._register(this.createSidebarToggleButton()); + + // New session button (next to panel toggle) this._register(this.createNewSessionButton()); + // Session status indicators (rightmost) + this.indicatorContainer = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-status')); + // Listen for session changes this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); @@ -72,6 +77,35 @@ export class CollapsedSidebarWidget extends Disposable { return store; } + private createSidebarToggleButton(): DisposableStore { + const store = new DisposableStore(); + const btn = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-panel-toggle')); + let iconElement: HTMLElement | undefined; + + const updateIcon = () => { + const sidebarVisible = this.layoutService.isVisible(Parts.SIDEBAR_PART); + const icon = sidebarVisible ? Codicon.layoutSidebarLeft : Codicon.layoutSidebarLeftOff; + iconElement?.remove(); + iconElement = append(btn, $(ThemeIcon.asCSSSelector(icon))); + }; + + updateIcon(); + + store.add(this.hoverService.setupManagedHover(this.hoverDelegate, btn, localize('toggleSidebar', "Toggle Side Bar"))); + + store.add(addDisposableListener(btn, EventType.CLICK, () => { + this.commandService.executeCommand('workbench.action.agentToggleSidebarVisibility'); + })); + + store.add(this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.SIDEBAR_PART) { + updateIcon(); + } + })); + + return store; + } + private rebuildIndicators(): void { this.indicatorDisposables.clear(); this.indicatorContainer.textContent = ''; @@ -79,75 +113,61 @@ export class CollapsedSidebarWidget extends Disposable { const sessions = this.agentSessionsService.model.sessions; const counts = this.countSessionsByStatus(sessions); - // In-progress indicator + const tooltipParts: string[] = []; + + // In-progress (matches agentSessionsViewer: sessionInProgress) if (counts.inProgress > 0) { - this.createIndicator( - Codicon.loading, - `${counts.inProgress}`, - localize('sessionsInProgress', "{0} session(s) in progress", counts.inProgress), - 'collapsed-sidebar-indicator-active' - ); + this.appendStatusSegment(Codicon.sessionInProgress, `${counts.inProgress}`, 'collapsed-sidebar-indicator-active'); + tooltipParts.push(localize('sessionsInProgress', "{0} session(s) in progress", counts.inProgress)); } - // Needs input indicator + // Needs input (matches agentSessionsViewer: circleFilled) if (counts.needsInput > 0) { - this.createIndicator( - Codicon.bell, - `${counts.needsInput}`, - localize('sessionsNeedInput', "{0} session(s) need input", counts.needsInput), - 'collapsed-sidebar-indicator-input' - ); + this.appendStatusSegment(Codicon.circleFilled, `${counts.needsInput}`, 'collapsed-sidebar-indicator-input'); + tooltipParts.push(localize('sessionsNeedInput', "{0} session(s) need input", counts.needsInput)); } - // Error indicator + // Failed (matches agentSessionsViewer: error) if (counts.failed > 0) { - this.createIndicator( - Codicon.error, - `${counts.failed}`, - localize('sessionsFailed', "{0} session(s) with errors", counts.failed), - 'collapsed-sidebar-indicator-error' - ); + this.appendStatusSegment(Codicon.error, `${counts.failed}`, 'collapsed-sidebar-indicator-error'); + tooltipParts.push(localize('sessionsFailed', "{0} session(s) with errors", counts.failed)); } - // Completed indicator - if (counts.completed > 0) { - this.createIndicator( - Codicon.check, - `${counts.completed}`, - localize('sessionsCompleted', "{0} session(s) completed", counts.completed), - 'collapsed-sidebar-indicator-done' - ); + // Unread (matches agentSessionsViewer: circleFilled with textLink-foreground) + if (counts.unread > 0) { + this.appendStatusSegment(Codicon.circleFilled, `${counts.unread}`, 'collapsed-sidebar-indicator-unread'); + tooltipParts.push(localize('sessionsUnread', "{0} unread session(s)", counts.unread)); } - // If no sessions at all, show a total count + // If no sessions at all if (sessions.length === 0) { - this.createIndicator( - Codicon.commentDiscussion, - '0', - localize('noSessions', "No sessions"), - 'collapsed-sidebar-indicator-empty' - ); + this.appendStatusSegment(Codicon.commentDiscussion, '0', 'collapsed-sidebar-indicator-empty'); + tooltipParts.push(localize('noSessions', "No sessions")); + } + + if (tooltipParts.length > 0) { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, this.indicatorContainer, tooltipParts.join('\n') + )); + + this.indicatorDisposables.add(addDisposableListener(this.indicatorContainer, EventType.CLICK, () => { + this.layoutService.setPartHidden(false, Parts.SIDEBAR_PART); + })); } } - private createIndicator(icon: ThemeIcon, count: string, tooltip: string, className: string): void { - const indicator = append(this.indicatorContainer, $(`.collapsed-panel-button.${className}`)); - append(indicator, $(ThemeIcon.asCSSSelector(icon))); - const label = append(indicator, $('span.collapsed-sidebar-count')); + private appendStatusSegment(icon: ThemeIcon, count: string, className: string): void { + const segment = append(this.indicatorContainer, $(`span.collapsed-sidebar-segment.${className}`)); + append(segment, $(ThemeIcon.asCSSSelector(icon))); + const label = append(segment, $('span.collapsed-sidebar-count')); label.textContent = count; - - this.indicatorDisposables.add(this.hoverService.setupManagedHover(this.hoverDelegate, indicator, tooltip)); - - this.indicatorDisposables.add(addDisposableListener(indicator, EventType.CLICK, () => { - this.layoutService.setPartHidden(false, Parts.SIDEBAR_PART); - })); } - private countSessionsByStatus(sessions: IAgentSession[]): { inProgress: number; needsInput: number; failed: number; completed: number } { + private countSessionsByStatus(sessions: IAgentSession[]): { inProgress: number; needsInput: number; failed: number; unread: number } { let inProgress = 0; let needsInput = 0; let failed = 0; - let completed = 0; + let unread = 0; for (const session of sessions) { if (session.isArchived()) { @@ -164,12 +184,14 @@ export class CollapsedSidebarWidget extends Disposable { failed++; break; case AgentSessionStatus.Completed: - completed++; + if (!session.isRead()) { + unread++; + } break; } } - return { inProgress, needsInput, failed, completed }; + return { inProgress, needsInput, failed, unread }; } show(): void { diff --git a/src/vs/sessions/browser/media/collapsedPanelWidget.css b/src/vs/sessions/browser/media/collapsedPanelWidget.css index 40e46bd9cef..b7d2d4e78c4 100644 --- a/src/vs/sessions/browser/media/collapsedPanelWidget.css +++ b/src/vs/sessions/browser/media/collapsedPanelWidget.css @@ -74,6 +74,22 @@ color: inherit; } +/* ---- Consolidated session status button ---- */ + +.agent-sessions-workbench .collapsed-panel-button.collapsed-sidebar-status { + gap: 6px; +} + +.agent-sessions-workbench .collapsed-sidebar-segment { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.agent-sessions-workbench .collapsed-panel-button .collapsed-sidebar-segment .codicon { + font-size: 14px; +} + /* ---- Sidebar indicators ---- */ .agent-sessions-workbench .collapsed-sidebar-count, @@ -84,16 +100,49 @@ color: inherit; } -.agent-sessions-workbench .collapsed-sidebar-indicator-active .codicon { - animation: codicon-spin 1.5s infinite linear; +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-active .codicon { + color: var(--vscode-textLink-foreground); } -.agent-sessions-workbench .collapsed-sidebar-indicator-error { +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-error { color: var(--vscode-errorForeground); } -.agent-sessions-workbench .collapsed-sidebar-indicator-input { - color: var(--vscode-notificationsInfoIcon-foreground); +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input { + color: var(--vscode-list-warningForeground); +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input .codicon { + animation: collapsed-sidebar-needs-input-pulse 2s ease-in-out infinite; +} + +@keyframes collapsed-sidebar-needs-input-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input .codicon { + animation: none; + } +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-unread { + color: var(--vscode-textLink-foreground); +} + +/* ---- Panel toggle button ---- */ + +.agent-sessions-workbench .collapsed-sidebar-panel-toggle { + opacity: 0.7; +} + +.agent-sessions-workbench .collapsed-sidebar-panel-toggle:hover { + opacity: 1; } /* ---- Auxiliary bar indicators ---- */ From 193de6c4e4b786fb2b5beee95b753c18b21d8540 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 20 Mar 2026 14:01:45 +0100 Subject: [PATCH 126/183] Fixes artifact view styling issues. --- .../browser/widget/chatArtifactsWidget.ts | 43 ++++++---- .../chat/browser/widget/media/chat.css | 81 ++++++++++++------- .../componentFixtures/chatInput.fixture.ts | 14 +++- 3 files changed, 90 insertions(+), 48 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts index 30394bfef46..709ee187d2e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; @@ -36,6 +36,8 @@ export class ChatArtifactsWidget extends Disposable { private _isCollapsed = true; private _list: WorkbenchList | undefined; private readonly _listStore = this._register(new DisposableStore()); + private _expandIcon!: HTMLElement; + private _titleElement!: HTMLElement; public static readonly ELEMENT_HEIGHT = 22; private static readonly MAX_ITEMS_SHOWN = 6; @@ -62,18 +64,24 @@ export class ChatArtifactsWidget extends Disposable { dom.clearNode(this.domNode); this._listStore.clear(); - const headerNode = dom.$('.chat-artifacts-header'); - this.domNode.appendChild(headerNode); + const expandoContainer = dom.$('.chat-artifacts-expand'); + const headerButton = this._listStore.add(new Button(expandoContainer, { supportIcons: true })); + headerButton.element.setAttribute('aria-expanded', String(!this._isCollapsed)); - const labelContainer = headerNode.appendChild(dom.$('.chat-artifacts-label')); - const headerButton = this._listStore.add(new ButtonWithIcon(labelContainer, {})); + const titleSection = dom.$('.chat-artifacts-title-section'); + this._expandIcon = dom.$('.expand-icon.codicon'); + this._expandIcon.classList.add(this._isCollapsed ? 'codicon-chevron-right' : 'codicon-chevron-down'); + this._expandIcon.setAttribute('aria-hidden', 'true'); + this._titleElement = dom.$('.chat-artifacts-title'); - this._listStore.add(headerButton.onDidClick(() => { - this._isCollapsed = !this._isCollapsed; - this._updateExpansionState(headerButton); - })); + titleSection.appendChild(this._expandIcon); + titleSection.appendChild(this._titleElement); + headerButton.element.appendChild(titleSection); + + this.domNode.appendChild(expandoContainer); const listContainer = dom.$('.chat-artifacts-list'); + listContainer.style.display = this._isCollapsed ? 'none' : 'block'; this.domNode.appendChild(listContainer); this._list = this._listStore.add(this._instantiationService.createInstance( @@ -95,7 +103,13 @@ export class ChatArtifactsWidget extends Disposable { } })); - this._updateExpansionState(headerButton); + this._listStore.add(headerButton.onDidClick(() => { + this._isCollapsed = !this._isCollapsed; + this._expandIcon.classList.toggle('codicon-chevron-down', !this._isCollapsed); + this._expandIcon.classList.toggle('codicon-chevron-right', this._isCollapsed); + headerButton.element.setAttribute('aria-expanded', String(!this._isCollapsed)); + listContainer.style.display = this._isCollapsed ? 'none' : 'block'; + })); this._autorunDisposable.value = autorun((reader: IReader) => { const artifacts: readonly IChatArtifact[] = this._currentObs!.read(reader); @@ -105,14 +119,14 @@ export class ChatArtifactsWidget extends Disposable { } this.domNode.style.display = ''; - headerButton.label = artifacts.length === 1 + this._titleElement.textContent = artifacts.length === 1 ? localize('chat.artifacts.one', "1 Artifact") : localize('chat.artifacts.count', "{0} Artifacts", artifacts.length); const itemsShown = Math.min(artifacts.length, ChatArtifactsWidget.MAX_ITEMS_SHOWN); const listHeight = itemsShown * ChatArtifactsWidget.ELEMENT_HEIGHT; this._list!.layout(listHeight); - listContainer.style.height = listHeight + 4 /* bottom padding */ + 'px'; + this._list!.getHTMLElement().style.height = `${listHeight}px`; this._list!.splice(0, this._list!.length, [...artifacts]); }); } @@ -144,11 +158,6 @@ export class ChatArtifactsWidget extends Disposable { }); } - private _updateExpansionState(headerButton: ButtonWithIcon): void { - headerButton.icon = this._isCollapsed ? Codicon.chevronRight : Codicon.chevronDown; - this.domNode.classList.toggle('chat-artifacts-collapsed', this._isCollapsed); - } - hide(): void { this._autorunDisposable.clear(); this.domNode.style.display = 'none'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 9755ced0b24..8ec205d7f37 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -850,7 +850,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container, .interactive-input-part:has(.chat-todo-list-widget-container > .chat-todo-list-widget.has-todos) .chat-input-container, -.interactive-input-part:has(.chat-artifacts-widget-container > .chat-artifacts-widget) .chat-input-container, +.interactive-input-part:has(.chat-artifacts-widget-container > .chat-artifacts-widget:not([style*="display: none"])) .chat-input-container, .interactive-input-part:has(.chat-input-widgets-container > .chat-status-widget:not([style*="display: none"])) .chat-input-container, .interactive-input-part:has(.chat-getting-started-tip-container > .chat-tip-widget) .chat-input-container { /* Remove top border radius when editing session, todo list, or status widget is present */ @@ -879,12 +879,11 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; } -.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-editing-session .chat-editing-session-container { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.interactive-session .interactive-input-part > .chat-artifacts-widget-container + .chat-editing-session .chat-editing-session-container { +/* Remove top radius from widgets that follow another visible widget */ +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-artifacts-widget-container .chat-artifacts-widget, +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-editing-session .chat-editing-session-container, +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-artifacts-widget-container + .chat-editing-session .chat-editing-session-container, +.interactive-session .interactive-input-part > .chat-artifacts-widget-container:has(.chat-artifacts-widget:not([style*="display: none"])) + .chat-editing-session .chat-editing-session-container { border-top-left-radius: 0; border-top-right-radius: 0; } @@ -1114,8 +1113,10 @@ have to be updated for changes to the rules above, or to support more deeply nes } -.interactive-session .interactive-input-part > .chat-artifacts-widget-container:empty { - display: none; +.interactive-session .interactive-input-part > .chat-artifacts-widget-container { + margin-bottom: -4px; + width: 100%; + position: relative; } @@ -2161,43 +2162,67 @@ have to be updated for changes to the rules above, or to support more deeply nes /* Chat artifacts widget — collapsible list of session artifacts */ .chat-artifacts-widget { + padding: 4px 3px 4px 3px; + box-sizing: border-box; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-bottom: none; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; display: flex; flex-direction: column; - border-radius: 4px; - border: 1px solid var(--vscode-chat-requestBorder); + gap: 2px; + overflow: hidden; } -.chat-artifacts-widget .chat-artifacts-header { - padding: 3px; +.chat-artifacts-widget .chat-artifacts-expand { width: 100%; +} + +.chat-artifacts-widget .chat-artifacts-expand .monaco-button { display: flex; - box-sizing: border-box; -} - -.chat-artifacts-widget .chat-artifacts-label { + align-items: center; + gap: 4px; + cursor: pointer; + justify-content: space-between; width: 100%; + background-color: transparent; + border-color: transparent; + color: var(--vscode-foreground); + padding: 0; + min-width: unset; } -.chat-artifacts-widget .chat-artifacts-label .monaco-button { - width: 100%; - border: none; - text-align: initial; - justify-content: initial; - gap: 0; +.chat-artifacts-widget .chat-artifacts-expand .monaco-button:focus:not(:focus-visible) { + outline: none; } -.chat-artifacts-widget .chat-artifacts-label .monaco-button .codicon { +.chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section { + padding-left: 3px; + display: flex; + align-items: center; + flex: 1; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 22px; +} + +.chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section .codicon { font-size: 16px; + line-height: 22px; + flex-shrink: 0; + margin-right: 3px; } .chat-artifacts-widget .chat-artifacts-list { width: 100%; - padding: 0 3px 4px; + padding: 0; box-sizing: border-box; } .chat-artifacts-widget .chat-artifacts-list .monaco-list .monaco-list-row { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .chat-artifacts-widget .chat-artifacts-list .monaco-list .monaco-list-row:hover { @@ -2226,10 +2251,6 @@ have to be updated for changes to the rules above, or to support more deeply nes font-size: 13px; } -.chat-artifacts-widget.chat-artifacts-collapsed .chat-artifacts-list { - display: none; -} - .interactive-session .checkpoint-file-changes-summary { display: flex; flex-direction: column; diff --git a/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts index cf16bc14f0c..2705eb47e21 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts @@ -10,6 +10,8 @@ import { mock } from '../../../../base/test/common/mock.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IMenuService, IMenu, MenuId, MenuItemAction, IMenuItem } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -32,7 +34,7 @@ import { IChatArtifactsService } from '../../../contrib/chat/common/tools/chatAr import { ChatEditingSessionState, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatRequestDisablement } from '../../../contrib/chat/common/model/chatModel.js'; import { IChatTodo, IChatTodoListService } from '../../../contrib/chat/common/tools/chatTodoListService.js'; -import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; +import { ChatAgentLocation, ChatConfiguration } from '../../../contrib/chat/common/constants.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { IChatModeService } from '../../../contrib/chat/common/chatModes.js'; import { IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; @@ -46,6 +48,7 @@ import { IWorkbenchLayoutService } from '../../../services/layout/browser/layout import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; import { INotebookDocumentService } from '../../../services/notebook/common/notebookDocumentService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; @@ -133,6 +136,7 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: reg.defineInstance(IActionWidgetService, new class extends mock() { override show() { } override hide() { } override get isVisible() { return false; } }()); reg.defineInstance(IProductService, new class extends mock() { }()); reg.defineInstance(IUpdateService, new class extends mock() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }()); + reg.defineInstance(IUriIdentityService, new class extends mock() { }()); reg.defineInstance(IChatArtifactsService, new class extends mock() { override readonly onDidUpdateArtifacts = Event.None; override getArtifacts() { return [...artifacts]; } @@ -149,6 +153,11 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: }, }); + if (artifacts.length > 0) { + const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configService.setUserConfiguration(ChatConfiguration.ArtifactsEnabled, true); + } + container.style.width = '500px'; container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; container.classList.add('monaco-workbench'); @@ -263,6 +272,9 @@ export default defineThemedFixtureGroup({ path: 'chat/input/' }, { WithTodos: defineComponentFixture({ render: context => renderChatInput(context, { todos: sampleTodos }) }), + WithTodosAndFileChanges: defineComponentFixture({ + render: context => renderChatInput(context, { todos: sampleTodos, editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) + }), WithArtifactsAndFileChanges: defineComponentFixture({ render: context => renderChatInput(context, { artifacts: sampleArtifacts, editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) }), From f74be765664247595b218c6f29bed3d4f45e38e9 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 20 Mar 2026 18:06:46 +0100 Subject: [PATCH 127/183] Mark chat.artifacts.enabled setting as experimental instead of preview --- src/vs/workbench/contrib/chat/browser/chat.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 0117acedace..fbba16bb389 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -509,7 +509,7 @@ configurationRegistry.registerConfiguration({ default: false, description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), type: 'boolean', - tags: ['preview'] + tags: ['experimental'] }, 'chat.undoRequests.restoreInput': { default: true, From bc389a0b2299eab5e3da7af51bd2e0ad1f329303 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:57:00 +0000 Subject: [PATCH 128/183] Sessions - refactor updating a pull request (#303558) * Sessions - refactor updating a pull request * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/changes/browser/changesView.ts | 17 +++++++++++++++-- .../contrib/git/browser/git.contribution.ts | 6 ++++-- src/vs/sessions/prompts/update-pr.prompt.md | 13 +++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 src/vs/sessions/prompts/update-pr.prompt.md diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 1d0b7bb50d4..e7a8acd9750 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -654,10 +654,15 @@ export class ChangesViewPane extends ViewPane { return (repositoryState?.HEAD?.behind ?? 0) > 0; })); - this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { + const outgoingChangesObs = derived(reader => { const repository = this.viewModel.activeSessionRepositoryObs.read(reader); const repositoryState = repository?.state.read(reader); - return (repositoryState?.HEAD?.ahead ?? 0) > 0; + return repositoryState?.HEAD?.ahead ?? 0; + }); + + this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { + const outgoingChanges = outgoingChangesObs.read(reader); + return outgoingChanges > 0; })); const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); @@ -666,6 +671,7 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(autorun(reader => { const { added, removed } = topLevelStats.read(reader); + const outgoingChanges = outgoingChangesObs.read(reader); const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); // Read code review state to update the button label dynamically @@ -726,6 +732,13 @@ export class ChangesViewPane extends ViewPane { if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR') { return { showIcon: true, showLabel: true, isSecondary: false }; } + if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR') { + const customLabel = outgoingChanges > 0 + ? localize('updatePRWithOutgoingChanges', 'Update Pull Request {0}↑', outgoingChanges) + : localize('updatePR', 'Update Pull Request'); + + return { customLabel, showIcon: true, showLabel: true, isSecondary: false }; + } if (action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR') { return { showIcon: true, showLabel: false, isSecondary: true }; } diff --git a/src/vs/sessions/contrib/git/browser/git.contribution.ts b/src/vs/sessions/contrib/git/browser/git.contribution.ts index ad28526fdb6..d51bab64260 100644 --- a/src/vs/sessions/contrib/git/browser/git.contribution.ts +++ b/src/vs/sessions/contrib/git/browser/git.contribution.ts @@ -10,7 +10,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; @@ -113,7 +113,9 @@ function registerSyncAction(branch: GitBranch, isSyncing: boolean, setSyncing: ( id: MenuId.ChatEditingSessionApplySubmenu, group: 'navigation', order: 0, - when: hasUpstreamBranchContextKey, + when: ContextKeyExpr.and( + hasUpstreamBranchContextKey, + ContextKeyExpr.false()) }, ], }); diff --git a/src/vs/sessions/prompts/update-pr.prompt.md b/src/vs/sessions/prompts/update-pr.prompt.md new file mode 100644 index 00000000000..22ecf6ccf52 --- /dev/null +++ b/src/vs/sessions/prompts/update-pr.prompt.md @@ -0,0 +1,13 @@ +--- +description: Update the pull request for the current session +--- + + +Update the existing pull request for the current session. +The context block appended to the prompt contains the pull request information. + +1. Check whether the pull request has any commits that are not yet present on the current branch (incoming changes). If there are any incoming changes, pull them into the current branch and resolve any merge conflicts +2. Run the compile and hygiene tasks (fixing any errors) +3. If there are any uncommitted changes, use the `/commit` skill to commit them +4. If the outgoing changes introduce significant changes to the pull request, update the pull request title and description to reflect those changes +5. Update the pull request with the new commits and information From 30a711eccb8a737ca51e73b827cb302b581e4b2b Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 20 Mar 2026 18:55:06 +0100 Subject: [PATCH 129/183] Set Session Artifacts -> artifacts --- .../contrib/chat/common/tools/builtinTools/setArtifactsTool.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts index 97301e6d51b..26d62156ac4 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts @@ -55,6 +55,8 @@ const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = { export const SetArtifactsToolData: IToolData = { id: SetArtifactsToolId, + toolReferenceName: 'artifacts', + legacyToolReferenceFullNames: ['Set Session Artifacts'], displayName: localize('tool.setArtifacts.displayName', 'Set Session Artifacts'), modelDescription: 'Set the list of artifacts for the current session. Each artifact has a label and either a uri or a toolCallId+dataPartIndex reference, plus an optional type (devServer, screenshot, plan). This overwrites the entire artifact list. Use this to surface important links, screenshots, plans, drafts, or temporary markdown documents to the user. URIs must be fully qualified with a scheme (e.g. https://localhost:3000, file:///tmp/plan.md). To reference a screenshot or image from a previous tool result, use toolCallId and dataPartIndex instead of uri.', canBeReferencedInPrompt: true, From 289b95b8b6ffb842af524b2c07511b02e2accf57 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:55:57 -0700 Subject: [PATCH 130/183] fix subagent tool id mismatch (#303580) --- .../chat/common/tools/builtinTools/runSubagentTool.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 16ae908fb53..12daaa059da 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -215,8 +215,10 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Track whether we should collect markdown (after the last tool invocation) const markdownParts: string[] = []; - // Generate a stable subAgentInvocationId for routing edits to this subagent's content part - const subAgentInvocationId = invocation.callId ?? `subagent-${generateUuid()}`; + // Generate a stable subAgentInvocationId for routing edits to this subagent's content part. + // Use chatStreamToolCallId when available because that is what ChatToolInvocation.toolCallId + // uses in the renderer (see PR #302863), and the subagent grouping matches on toolCallId. + const subAgentInvocationId = invocation.chatStreamToolCallId ?? invocation.callId ?? `subagent-${generateUuid()}`; let inEdit = false; const progressCallback = (parts: IChatProgress[]) => { @@ -306,7 +308,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { message: args.prompt, variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, - subAgentInvocationId: invocation.callId, + subAgentInvocationId: subAgentInvocationId, subAgentName: subAgentName, userSelectedModelId: modeModelId, modelConfiguration: modeModelId ? this.languageModelsService.getModelConfiguration(modeModelId) : undefined, From 2372a22ba1cd2782756ebee74e07a37a5738084a Mon Sep 17 00:00:00 2001 From: Isidor Date: Fri, 20 Mar 2026 20:27:44 +0100 Subject: [PATCH 131/183] fixes #303425 --- .../chatAgentTools/browser/tools/runInTerminalTool.ts | 6 +----- .../test/electron-browser/runInTerminalTool.test.ts | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index a7f21d62662..f4db29e2a73 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -720,12 +720,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } if (requiresUnsandboxConfirmation) { - disclaimer = new MarkdownString([ - disclaimer?.value, - localize('runInTerminal.unsandboxed.disclaimer', "$(warning) This command will run outside the terminal sandbox and may access files, network resources, or system state that sandboxed commands cannot reach.") - ].filter(Boolean).join(' '), { supportThemeIcons: true, isTrusted: disclaimer?.isTrusted }); confirmationTitle = args.isBackground - ? localize('runInTerminal.unsandboxed.background', "Run `{0}` command outside the sandbox in background?", shellType) + ? localize('runInTerminal.unsandboxed.background', "Run `{0}` background command outside the sandbox?", shellType) : localize('runInTerminal.unsandboxed', "Run `{0}` command outside the sandbox?", shellType); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index eafc29f9891..db841082d00 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -480,12 +480,7 @@ suite('RunInTerminalTool', () => { } ok(confirmationMessage.value.includes('Reason for leaving the sandbox: Needs network access outside the sandbox')); - const disclaimer = result?.confirmationMessages?.disclaimer; - ok(disclaimer && typeof disclaimer !== 'string'); - if (!disclaimer || typeof disclaimer === 'string') { - throw new Error('Expected markdown disclaimer'); - } - ok(disclaimer.value.includes('outside the terminal sandbox')); + strictEqual(result?.confirmationMessages?.disclaimer, undefined); strictEqual(result?.confirmationMessages?.terminalCustomActions, undefined); }); }); From d8238df7352b9fff96122f8b808a1aa0c6389e42 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:31:50 +0000 Subject: [PATCH 132/183] Sessions - remove code that is not needed + increase width for the secondary panel (#303586) * Sessions - remove code that is not needed + increase width for the secondary panel * Fix the build --- .../browser/parts/auxiliaryBarPart.ts | 2 +- src/vs/sessions/browser/workbench.ts | 2 +- .../contrib/git/browser/git.contribution.ts | 139 ------------------ src/vs/sessions/sessions.desktop.main.ts | 1 - 4 files changed, 2 insertions(+), 142 deletions(-) delete mode 100644 src/vs/sessions/contrib/git/browser/git.contribution.ts diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 4d8f2825a55..24526901a2e 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -83,7 +83,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return undefined; } - return Math.max(width, 340); + return Math.max(width, 380); } readonly priority = LayoutPriority.Low; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index e749a082f98..2436595fd59 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -828,7 +828,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Default sizes const sideBarSize = 300; - const auxiliaryBarSize = 340; + const auxiliaryBarSize = 380; const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; diff --git a/src/vs/sessions/contrib/git/browser/git.contribution.ts b/src/vs/sessions/contrib/git/browser/git.contribution.ts deleted file mode 100644 index d51bab64260..00000000000 --- a/src/vs/sessions/contrib/git/browser/git.contribution.ts +++ /dev/null @@ -1,139 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { GitBranch, GitRepositoryState, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; - -const hasUpstreamBranchContextKey = new RawContextKey('agentSessionGitHasUpstreamBranch', false, { - type: 'boolean', - description: localize('agentSessionGitHasUpstreamBranch', "True when the active agent session worktree has an upstream branch."), -}); - -class GitSyncContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'sessions.contrib.gitSync'; - - private readonly _isSyncingObs = observableValue(this, false); - - constructor( - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, - @IGitService private readonly gitService: IGitService, - ) { - super(); - - const hasUpstreamBranch = hasUpstreamBranchContextKey.bindTo(this.contextKeyService); - - const activeSessionWorktreeObs = derived(reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - return activeSession?.worktree; - }); - - const activeSessionRepositoryPromiseObs = derived(reader => { - const worktreeUri = activeSessionWorktreeObs.read(reader); - if (!worktreeUri) { - return constObservable(undefined); - } - - return new ObservablePromise(this.gitService.openRepository(worktreeUri)).resolvedValue; - }); - - const activeSessionRepositoryStateObs = derived(reader => { - const activeSessionRepository = activeSessionRepositoryPromiseObs.read(reader).read(reader); - if (activeSessionRepository === undefined) { - return undefined; - } - - return activeSessionRepository.state.read(reader); - }); - - this._register(autorun(reader => { - const isSyncing = this._isSyncingObs.read(reader); - const activeSessionRepositoryState = activeSessionRepositoryStateObs.read(reader); - if (!activeSessionRepositoryState) { - hasUpstreamBranch.set(false); - return; - } - - const head = activeSessionRepositoryState.HEAD; - hasUpstreamBranch.set(head?.upstream !== undefined); - - if (!head?.upstream) { - return; - } - - reader.store.add(registerSyncAction(head, isSyncing, (syncing) => { - this._isSyncingObs.set(syncing, undefined); - })); - })); - } -} - -function registerSyncAction(branch: GitBranch, isSyncing: boolean, setSyncing: (syncing: boolean) => void): IDisposable { - const ahead = branch.ahead ?? 0; - const behind = branch.behind ?? 0; - - const titleSegments = [localize('synchronizeChangesTitle', "Sync Changes")]; - if (behind > 0) { - titleSegments.push(`${behind}↓`); - } - if (ahead > 0) { - titleSegments.push(`${ahead}↑`); - } - - const icon = isSyncing - ? ThemeIcon.modify(Codicon.sync, 'spin') - : Codicon.sync; - - class SynchronizeChangesAction extends Action2 { - static readonly ID = 'chatEditing.synchronizeChanges'; - - constructor() { - super({ - id: SynchronizeChangesAction.ID, - title: titleSegments.join(' '), - tooltip: localize('synchronizeChanges', "Synchronize Changes with Git (Behind {0}, Ahead {1})", behind, ahead), - icon, - category: CHAT_CATEGORY, - menu: [ - { - id: MenuId.ChatEditingSessionApplySubmenu, - group: 'navigation', - order: 0, - when: ContextKeyExpr.and( - hasUpstreamBranchContextKey, - ContextKeyExpr.false()) - }, - ], - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const commandService = accessor.get(ICommandService); - const sessionManagementService = accessor.get(ISessionsManagementService); - const worktreeUri = sessionManagementService.getActiveSession()?.worktree; - setSyncing(true); - try { - await commandService.executeCommand('git.sync', worktreeUri); - } finally { - setSyncing(false); - } - } - } - return registerAction2(SynchronizeChangesAction); -} - -registerWorkbenchContribution2(GitSyncContribution.ID, GitSyncContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 846bd4d3c06..15a27157211 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -209,7 +209,6 @@ import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changes/browser/changesView.contribution.js'; import './contrib/codeReview/browser/codeReview.contributions.js'; import './contrib/files/browser/files.contribution.js'; -import './contrib/git/browser/git.contribution.js'; import './contrib/github/browser/github.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed From c4593178ac2eb9d12f05bec2c01463a71567ddaa Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 20 Mar 2026 12:35:44 -0700 Subject: [PATCH 133/183] Add eslint rule for telemetry props that override common props (#303592) --- .../code-no-telemetry-common-property.ts | 103 ++++++++++++++++++ eslint.config.js | 12 ++ 2 files changed, 115 insertions(+) create mode 100644 .eslint-plugin-local/code-no-telemetry-common-property.ts diff --git a/.eslint-plugin-local/code-no-telemetry-common-property.ts b/.eslint-plugin-local/code-no-telemetry-common-property.ts new file mode 100644 index 00000000000..2627a09c0a4 --- /dev/null +++ b/.eslint-plugin-local/code-no-telemetry-common-property.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import type * as ESTree from 'estree'; + +const telemetryMethods = new Set(['publicLog', 'publicLog2', 'publicLogError', 'publicLogError2']); + +/** + * Common telemetry property names that are automatically added to every event. + * Telemetry events must not set these because they would collide with / be + * overwritten by the common properties that the telemetry pipeline injects. + * + * Collected from: + * - src/vs/platform/telemetry/common/commonProperties.ts (resolveCommonProperties) + * - src/vs/workbench/services/telemetry/common/workbenchCommonProperties.ts + * - src/vs/workbench/services/telemetry/browser/workbenchCommonProperties.ts + */ +const commonTelemetryProperties = new Set([ + 'common.machineid', + 'common.sqmid', + 'common.devdeviceid', + 'sessionid', + 'commithash', + 'version', + 'common.releasedate', + 'common.platformversion', + 'common.platform', + 'common.nodeplatform', + 'common.nodearch', + 'common.product', + 'common.msftinternal', + 'timestamp', + 'common.timesincesessionstart', + 'common.sequence', + 'common.snap', + 'common.platformdetail', + 'common.version.shell', + 'common.version.renderer', + 'common.firstsessiondate', + 'common.lastsessiondate', + 'common.isnewsession', + 'common.remoteauthority', + 'common.cli', + 'common.useragent', + 'common.istouchdevice', +]); + +export default new class NoTelemetryCommonProperty implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noCommonProperty: 'Telemetry events must not contain the common property "{{name}}". Common properties are automatically added by the telemetry pipeline and will be dropped.', + }, + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + /** + * Check whether any property key in an object expression is a reserved common telemetry property. + */ + function checkObjectForCommonProperties(node: ESTree.ObjectExpression) { + for (const prop of node.properties) { + if (prop.type === 'Property') { + let name: string | undefined; + if (prop.key.type === 'Identifier') { + name = prop.key.name; + } else if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') { + name = prop.key.value; + } + if (name && commonTelemetryProperties.has(name.toLowerCase())) { + context.report({ + node: prop.key, + messageId: 'noCommonProperty', + data: { name }, + }); + } + } + } + } + + return { + ['CallExpression[callee.property.type="Identifier"]'](node: ESTree.CallExpression) { + const callee = node.callee; + if (callee.type !== 'MemberExpression') { + return; + } + const prop = callee.property; + if (prop.type !== 'Identifier' || !telemetryMethods.has(prop.name)) { + return; + } + // The data argument is the second argument for publicLog/publicLog2/publicLogError/publicLogError2 + const dataArg = node.arguments[1]; + if (dataArg && dataArg.type === 'ObjectExpression') { + checkObjectForCommonProperties(dataArg); + } + }, + }; + } +}; diff --git a/eslint.config.js b/eslint.config.js index ff390f5c4b2..681a41a26d4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -183,6 +183,18 @@ export default tseslint.config( ] } }, + // Disallow common telemetry properties in event data + { + files: [ + 'src/**/*.ts', + ], + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-telemetry-common-property': 'warn', + } + }, // Disallow 'in' operator except in type predicates { files: [ From 1e6a57bfb4aee9999650a37618d29e1352bb7d91 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:37:47 -0700 Subject: [PATCH 134/183] =?UTF-8?q?feat:=20group=20chat=20extension=20cust?= =?UTF-8?q?omizations=20under=20'Built-in'=20in=20managem=E2=80=A6=20(#303?= =?UTF-8?q?584)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: group chat extension customizations under 'Built-in' in management editor Items contributed by the default chat extension (GitHub Copilot Chat) are now shown under a 'Built-in' group header instead of 'Extensions' in the Chat Customizations editor. This applies to agents, skills, prompts, and instructions — matching the existing pattern used by the MCP list widget. The grouping is determined at the UI layer using IProductService to identify the chat extension ID, setting groupKey on matching items. No data model changes needed. - Move BUILTIN_STORAGE/AICustomizationPromptsStorage to common layer - Add BUILTIN_STORAGE to VS Code harness filter sources - Add isChatExtensionItem() helper using productService.defaultChatAgent - Set groupKey: BUILTIN_STORAGE for items from the chat extension - Add tests for builtin source filtering - Update AI_CUSTOMIZATIONS.md spec --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 21 ++++- .../chat/common/builtinPromptsStorage.ts | 15 +--- .../aiCustomizationListWidget.ts | 87 ++++++++++++++++++- .../aiCustomizationManagement.ts | 14 +-- .../customizationHarnessService.ts | 6 +- .../common/aiCustomizationWorkspaceService.ts | 11 +++ .../applyStorageSourceFilter.test.ts | 34 +++++++- 7 files changed, 156 insertions(+), 32 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index bf46c8c1f66..b4a19a06821 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -28,7 +28,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ └── aiCustomizationManagement.css src/vs/workbench/contrib/chat/common/ -├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter +├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE └── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers ``` @@ -76,7 +76,7 @@ Storage answers "where did this come from?"; harness answers "who consumes it?". The service is defined in `common/customizationHarnessService.ts` which also provides: - **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. - **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. +- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. - **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. - **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. @@ -128,7 +128,7 @@ The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, **Core VS Code filter behavior:** -Local harness: all types use `[local, user, extension, plugin]` with no user root filter. +Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. CLI harness (core): @@ -152,6 +152,21 @@ Claude additionally applies: - `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) - `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension +### Built-in Extension Grouping (Core VS Code) + +In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". + +This follows the same pattern as the MCP list widget, which determines grouping at the UI layer by inspecting collection sources. The list widget uses `IProductService` to identify the chat extension and sets `groupKey: BUILTIN_STORAGE` on matching items: + +- **Agents**: checks `agent.source.extensionId` against the chat extension ID +- **Skills**: builds a URI→ExtensionIdentifier lookup from `listPromptFiles(PromptsType.skill)`, then checks each skill's URI +- **Prompts**: checks `command.promptPath.extension?.identifier` +- **Instructions/Hooks**: checks `item.extension?.identifier` via `IPromptPath` + +The underlying `storage` remains `PromptsStorage.extension` — the grouping is a UI-level override via `groupKey` that keeps `applyStorageSourceFilter` working with existing storage types while visually distinguishing chat-extension items from third-party extension items. + +`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. + ### AgenticPromptsService (Sessions) Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts index e7a79bda11c..a4eb5afd410 100644 --- a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -4,19 +4,12 @@ *--------------------------------------------------------------------------------------------*/ 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'; +import { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/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'; +// Re-export from common for backward compatibility +export type { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; /** * Prompt path for built-in prompts bundled with the Sessions app. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index f0ddbf2cb49..2c8ac552b61 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -56,6 +56,8 @@ import { ICustomizationHarnessService, matchesWorkspaceSubpath, matchesInstructi import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { getCleanPromptName, isInClaudeRulesFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; export { truncateToFirstSentence } from './aiCustomizationListWidgetUtils.js'; @@ -514,6 +516,7 @@ export class AICustomizationListWidget extends Disposable { @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @ICommandService private readonly commandService: ICommandService, + @IProductService private readonly productService: IProductService, ) { super(); this.element = $('.ai-customization-list-widget'); @@ -1020,6 +1023,61 @@ export class AICustomizationListWidget extends Disposable { return items.length; } + /** + * Returns true if the given extension identifier matches the default + * chat extension (e.g. GitHub Copilot Chat). Used to group items from + * the chat extension under "Built-in" instead of "Extensions", similar + * to how MCP categorizes built-in servers. + */ + private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { + const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; + return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); + } + + /** + * Resolves the display group key for an extension-storage item. + * Items from the default chat extension are re-grouped under "Built-in"; + * all other extension items keep their original storage as group key. + * + * Returns `undefined` when no override is needed (the item will fall back + * to its `storage` value for grouping). + * + * This is the single point where extension → group mapping is decided, + * making it easy to add dynamic filter layers in the future. + */ + private resolveExtensionGroupKey(extensionId: ExtensionIdentifier | undefined): string | undefined { + if (extensionId && this.isChatExtensionItem(extensionId)) { + return BUILTIN_STORAGE; + } + return undefined; + } + + /** + * Post-processes items to assign groupKey overrides for extension-sourced + * items. Applies the built-in grouping consistently across all item types. + * + * Items that already have an explicit groupKey (e.g. instruction categories, + * agent hooks) are left untouched — groupKey overrides are only applied to + * items whose current groupKey is `undefined`. + */ + private applyBuiltinGroupKeys(items: IAICustomizationListItem[], extensionIdByUri: ReadonlyMap): void { + for (const item of items) { + if (item.groupKey !== undefined) { + continue; // respect explicit groupKey from upstream (e.g. instruction categories) + } + if (item.storage !== PromptsStorage.extension) { + continue; + } + const extId = extensionIdByUri.get(item.uri.toString()); + const override = this.resolveExtensionGroupKey(extId); + if (override) { + // IAICustomizationListItem.groupKey is readonly for consumers but + // we own the items array here, so the mutation is safe. + (item as { groupKey?: string }).groupKey = override; + } + } + } + /** * Fetches and filters items for a given section. * Shared between `loadItems` (active section) and `computeItemCountForSection` (any section). @@ -1028,6 +1086,7 @@ export class AICustomizationListWidget extends Disposable { const promptType = sectionToPromptType(section); const items: IAICustomizationListItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const extensionIdByUri = new Map(); if (promptType === PromptsType.agent) { @@ -1046,10 +1105,21 @@ export class AICustomizationListWidget extends Disposable { pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, disabled: disabledUris.has(agent.uri), }); + // Track extension ID for built-in grouping + if (agent.source.storage === PromptsStorage.extension) { + extensionIdByUri.set(agent.uri.toString(), agent.source.extensionId); + } } } else if (promptType === PromptsType.skill) { // Use findAgentSkills for enabled skills (has parsed name/description from frontmatter) const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + // Build extension ID lookup from raw file list (like MCP builds collectionSources) + const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); + for (const file of allSkillFiles) { + if (file.extension) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + } + } const seenUris = new ResourceSet(); for (const skill of skills || []) { const filename = basename(skill.uri); @@ -1069,7 +1139,6 @@ export class AICustomizationListWidget extends Disposable { } // Also include disabled skills from the raw file list if (disabledUris.size > 0) { - const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); for (const file of allSkillFiles) { if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { const filename = basename(file.uri); @@ -1106,6 +1175,9 @@ export class AICustomizationListWidget extends Disposable { pluginUri: command.promptPath.storage === PromptsStorage.plugin ? command.promptPath.pluginUri : undefined, disabled: disabledUris.has(command.promptPath.uri), }); + if (command.promptPath.extension) { + extensionIdByUri.set(command.promptPath.uri.toString(), command.promptPath.extension.identifier); + } } } else if (promptType === PromptsType.hook) { // Try to parse individual hooks from each file; fall back to showing the file itself @@ -1212,6 +1284,11 @@ export class AICustomizationListWidget extends Disposable { } else { // For instructions, group by category: agent instructions, context instructions, on-demand instructions const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + for (const file of promptFiles) { + if (file.extension) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + } + } const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); @@ -1296,6 +1373,12 @@ export class AICustomizationListWidget extends Disposable { } } + // Assign built-in groupKeys — items from the default chat extension + // are re-grouped under "Built-in" instead of "Extensions". + // This is a single-pass transformation applied after all items are + // collected, keeping the item-building code free of grouping logic. + this.applyBuiltinGroupKeys(items, extensionIdByUri); + // Apply storage source filter (removes items not in visible sources or excluded user roots) const filter = this.workspaceService.getStorageSourceFilter(promptType); const filteredItems = applyStorageSourceFilter(items, filter); @@ -1406,8 +1489,8 @@ export class AICustomizationListWidget extends Disposable { : [ { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, { 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: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), 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 d997ccb513b..d6439837a8e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -5,23 +5,13 @@ 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'; +export type { AICustomizationPromptsStorage } from '../../common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; /** * Editor pane ID for the AI Customizations Management Editor. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index f87b510db74..3f03253741d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -17,6 +17,7 @@ import { getClaudeUserRoots, } from '../../common/customizationHarnessService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IChatAgentService } from '../../common/participants/chatAgents.js'; @@ -31,9 +32,10 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { @IChatAgentService chatAgentService: IChatAgentService, ) { const userHome = pathService.userHome({ preferLocal: true }); - // Only the Local harness includes extension-contributed customizations. + // The Local harness includes extension-contributed and built-in customizations. + // Built-in items come from the default chat extension (productService.defaultChatAgent). // CLI and Claude harnesses don't consume extension contributions. - const localExtras = [PromptsStorage.extension]; + const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; const restrictedExtras: readonly string[] = []; const allHarnesses: readonly IHarnessDescriptor[] = [ createVSCodeHarnessDescriptor(localExtras), diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 2b6c01066fe..9b2aadb806a 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -13,6 +13,17 @@ import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/ export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); +/** + * 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 customizations shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + /** * Possible section IDs for the AI Customization Management Editor sidebar. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts index 95988b49868..55f7cb956f7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts @@ -7,9 +7,9 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { applyStorageSourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { applyStorageSourceFilter, BUILTIN_STORAGE, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; -function item(path: string, storage: PromptsStorage): { uri: URI; storage: PromptsStorage } { +function item(path: string, storage: PromptsStorage | string): { uri: URI; storage: string } { return { uri: URI.file(path), storage }; } @@ -218,6 +218,36 @@ suite('applyStorageSourceFilter', () => { }; assert.strictEqual(applyStorageSourceFilter(items, filter).length, 4); }); + + test('core-like filter with builtin: extension items pass when both extension and builtin are in sources', () => { + // Items from the chat extension have storage=extension but groupKey=builtin. + // The filter operates on storage, so extension items pass through regardless of groupKey. + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/e/builtin-agent.md', PromptsStorage.extension), + item('/e/third-party.md', PromptsStorage.extension), + item('/b/sessions-builtin.md', BUILTIN_STORAGE), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.extension, BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 4); + }); + + test('builtin source is respected independently', () => { + const items = [ + item('/e/from-extension.md', PromptsStorage.extension), + item('/b/from-sessions.md', BUILTIN_STORAGE), + ]; + // Only builtin in sources — extension items excluded + const filter: IStorageSourceFilter = { + sources: [BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].storage, BUILTIN_STORAGE); + }); }); suite('type safety', () => { From 1d47f2365bc0fbf7701a31b906b29246b0c7a6aa Mon Sep 17 00:00:00 2001 From: Isidor Nikolic Date: Fri, 20 Mar 2026 20:38:38 +0100 Subject: [PATCH 135/183] Update src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chatAgentTools/browser/tools/runInTerminalTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index f4db29e2a73..c9a623cc009 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -721,7 +721,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (requiresUnsandboxConfirmation) { confirmationTitle = args.isBackground - ? localize('runInTerminal.unsandboxed.background', "Run `{0}` background command outside the sandbox?", shellType) + ? localize('runInTerminal.unsandboxed.background', "Run `{0}` command outside the sandbox in background?", shellType) : localize('runInTerminal.unsandboxed', "Run `{0}` command outside the sandbox?", shellType); } From 877aceccc29b35640a6bd0479ffc53c4c6fb6f33 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 20 Mar 2026 19:56:09 +0000 Subject: [PATCH 136/183] Sessions: Adjust auxiliary bar margins for improved layout (#303501) Sessions - adjust auxiliary bar margins for improved layout Co-authored-by: mrleemurray Co-authored-by: Copilot --- src/vs/sessions/browser/media/style.css | 2 +- src/vs/sessions/browser/parts/auxiliaryBarPart.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index a5ab7f38d0d..ca601ca71db 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -26,7 +26,7 @@ } .agent-sessions-workbench .part.auxiliarybar { - margin: 16px 16px 18px 0; + margin: 0 16px 2px 0; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 24526901a2e..fcc12cec6fd 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -49,7 +49,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { /** Visual margin values for the card-like appearance */ static readonly MARGIN_TOP = 16; - static readonly MARGIN_BOTTOM = 18; + static readonly MARGIN_BOTTOM = 2; static readonly MARGIN_RIGHT = 16; // Action ID for run script - defined here to avoid layering issues From 69ca0c3f58cd2614a1af522889375b4c84ed62c9 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 20 Mar 2026 20:04:51 +0000 Subject: [PATCH 137/183] Sessions: Replace badge with inline title count in Changes view (#303482) * Sessions: Replace Changes view badge with inline title count Replace the NumberBadge on the Changes view tab with an inline title that shows the file count directly, e.g. '7 Changes' instead of a badge overlay. - Export dynamic changesContainerTitle with a getter-based value - Add refreshContainerInfo() to IViewContainerModel interface and ViewContainerModel implementation - Remove IActivityService/NumberBadge dependency from ChangesViewPane * Fix incorrect file count by using topLevelStats The inline title count was reading from activeSessionChangesObs (raw session changes only) instead of topLevelStats which accounts for deduplication and version mode filtering. Move the title update into onVisible() where topLevelStats is available, and reset the title when the view is hidden. * Address review feedback - Add blank line separator after changesContainerTitle block - Add constructor-level fallback autorun to keep title in sync when the view is hidden and the active session changes - Reset title to 'Changes' on dispose to avoid stale counts * Keep inline file count when switching tabs Remove the updateContainerTitle(0) call from the hide handler so the count persists when the user switches to another tab. The fallback autorun in the constructor still handles session switches while the view is hidden. * Fix grammar: use singular '1 Change' instead of '1 Changes' --------- Co-authored-by: mrleemurray --- .../browser/changesView.contribution.ts | 4 +- .../contrib/changes/browser/changesView.ts | 62 ++++++++++++++----- src/vs/workbench/common/views.ts | 7 +++ .../views/common/viewContainerModel.ts | 4 ++ 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 6eae1fdb979..ceb8a6032d1 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts @@ -10,7 +10,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; -import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, changesContainerTitle, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import './changesViewActions.js'; import './fixCIChecksAction.js'; import { ChangesViewController } from './changesViewController.js'; @@ -21,7 +21,7 @@ const viewContainersRegistry = Registry.as(ViewContaine const changesViewContainer = viewContainersRegistry.registerViewContainer({ id: CHANGES_VIEW_CONTAINER_ID, - title: localize2('changes', 'Changes'), + title: changesContainerTitle, ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), icon: changesViewIcon, order: 10, diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index e7a8acd9750..cb1c159f5cd 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -12,13 +12,14 @@ import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.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, localize2 } from '../../../../nls.js'; +import { ILocalizedString } from '../../../../platform/action/common/action.js'; import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId, Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -52,7 +53,6 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; -import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -70,6 +70,16 @@ const $ = dom.$; export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; + +// Dynamic title for the Changes view container tab. +// Uses a getter so that ViewContainerModel.updateContainerInfo() picks up +// the latest value each time it re-reads viewContainer.title.value. +let _changesContainerTitleValue = localize('changes', 'Changes'); +export const changesContainerTitle: ILocalizedString = { + original: 'Changes', + get value() { return _changesContainerTitleValue; } +}; + const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; // --- View Mode @@ -335,7 +345,6 @@ export class ChangesViewPane extends ViewPane { @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @IEditorService private readonly editorService: IEditorService, - @IActivityService private readonly activityService: IActivityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @@ -365,20 +374,16 @@ export class ChangesViewPane extends ViewPane { return activeSession?.providerType ?? ''; })); - // Badge - const badgeDisposable = this._register(new MutableDisposable()); - + // Fallback title update: when the view is not visible (renderDisposables + // cleared), keep the container title in sync with the raw session changes + // so the tab still shows a count when the user switches sessions. this._register(autorun(reader => { - const changes = this.viewModel.activeSessionChangesObs.read(reader); - if (changes.length === 0) { - badgeDisposable.clear(); + if (this.isBodyVisible()) { + // onVisible() drives the title from topLevelStats while visible return; } - - const message = changes.length === 1 - ? localize('changesView.oneFileChanged', '1 file changed') - : localize('changesView.filesChanged', '{0} files changed', changes.length); - badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(changes.length, () => message) }); + const changes = this.viewModel.activeSessionChangesObs.read(reader); + this.updateContainerTitle(changes.length); })); } @@ -433,6 +438,27 @@ export class ChangesViewPane extends ViewPane { } } + private updateContainerTitle(fileCount: number): void { + let nextTitle: string; + if (fileCount === 0) { + nextTitle = localize('changes', 'Changes'); + } else if (fileCount === 1) { + nextTitle = localize('changesView.titleWithCountOne', '1 Change'); + } else { + nextTitle = localize('changesView.titleWithCount', '{0} Changes', fileCount); + } + + if (nextTitle === _changesContainerTitleValue) { + return; + } + + _changesContainerTitleValue = nextTitle; + const viewContainer = this.viewDescriptorService.getViewContainerById(CHANGES_VIEW_CONTAINER_ID); + if (viewContainer) { + this.viewDescriptorService.getViewContainerModel(viewContainer).refreshContainerInfo(); + } + } + private onVisible(): void { this.renderDisposables.clear(); @@ -762,7 +788,12 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(!hasEntries, this.welcomeContainer!); })); - // Update summary text (line counts only, file count is shown in badge) + // Update inline title count from the same stats the tree uses + this.renderDisposables.add(autorun(reader => { + this.updateContainerTitle(topLevelStats.read(reader).files); + })); + + // Update summary text (line counts only) if (this.summaryContainer) { dom.clearNode(this.summaryContainer); @@ -1026,6 +1057,7 @@ export class ChangesViewPane extends ViewPane { } override dispose(): void { + this.updateContainerTitle(0); this.tree?.dispose(); this.tree = undefined; super.dispose(); diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index b439e870035..6bd3eab98cb 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -364,6 +364,13 @@ export interface IViewContainerModel { readonly keybindingId: string | undefined; readonly onDidChangeContainerInfo: Event<{ title?: boolean; icon?: boolean; keybindingId?: boolean; badgeEnablement?: boolean }>; + /** + * Re-reads the container info (title, icon, keybinding) and fires + * `onDidChangeContainerInfo` if anything changed. Call this when + * the container's dynamic title has been updated externally. + */ + refreshContainerInfo(): void; + readonly allViewDescriptors: ReadonlyArray; readonly onDidChangeAllViewDescriptors: Event<{ added: ReadonlyArray; removed: ReadonlyArray }>; diff --git a/src/vs/workbench/services/views/common/viewContainerModel.ts b/src/vs/workbench/services/views/common/viewContainerModel.ts index fa24dca15c2..a6363627d1e 100644 --- a/src/vs/workbench/services/views/common/viewContainerModel.ts +++ b/src/vs/workbench/services/views/common/viewContainerModel.ts @@ -372,6 +372,10 @@ export class ViewContainerModel extends Disposable implements IViewContainerMode } } + refreshContainerInfo(): void { + this.updateContainerInfo(); + } + private isEqualIcon(icon: URI | ThemeIcon | undefined): boolean { if (URI.isUri(icon)) { return URI.isUri(this._icon) && isEqual(icon, this._icon); From 1285a5245df32ef481f75aaa3e251d8e80aa1c02 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:22:06 -0700 Subject: [PATCH 138/183] Remove `as sinon.SinonStub` casts This is secretly like an any cast as the result isn't typed --- eslint.config.js | 4 + src/vs/base/test/common/sinonUtils.ts | 10 + .../browser/mainThreadChatSessions.test.ts | 171 ++++++++++-------- .../common/mcpServerRequestHandler.test.ts | 5 +- 4 files changed, 117 insertions(+), 73 deletions(-) create mode 100644 src/vs/base/test/common/sinonUtils.ts diff --git a/eslint.config.js b/eslint.config.js index ff390f5c4b2..951e587d86c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2373,6 +2373,10 @@ export default tseslint.config( 'selector': `NewExpression[callee.object.name='Intl']`, 'message': 'Use safeIntl helper instead for safe and lazy use of potentially expensive Intl methods.' }, + { + 'selector': 'TSAsExpression[typeAnnotation.type="TSTypeReference"][typeAnnotation.typeName.type="TSQualifiedName"][typeAnnotation.typeName.left.type="Identifier"][typeAnnotation.typeName.left.name="sinon"][typeAnnotation.typeName.right.name="SinonStub"]', + 'message': `Avoid casting with 'as sinon.SinonStub'. Prefer typed stubs from 'sinon.stub(...)' or capture the stub in a typed variable.` + }, ], } }); diff --git a/src/vs/base/test/common/sinonUtils.ts b/src/vs/base/test/common/sinonUtils.ts new file mode 100644 index 00000000000..ef256b115a0 --- /dev/null +++ b/src/vs/base/test/common/sinonUtils.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; + +export function asSinonMethodStub unknown>(method: T): sinon.SinonStubbedMember { + return method as unknown as sinon.SinonStubbedMember; +} diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index ffbce98d40f..b33298534ed 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -18,7 +18,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../platform/log/common/log.js'; import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions/chatSessions.contribution.js'; -import { IChatAgentRequest } from '../../../contrib/chat/common/participants/chatAgents.js'; +import { IChatAgentRequest, IChatAgentResult } from '../../../contrib/chat/common/participants/chatAgents.js'; import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; import { IChatSessionRequestHistoryItem, IChatSessionProviderOptionGroup, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; @@ -43,6 +43,7 @@ import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessio import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { Event } from '../../../../base/common/event.js'; import { AnyCallRPCProtocol } from '../common/testRPCProtocol.js'; +import { asSinonMethodStub } from '../../../../base/test/common/sinonUtils.js'; suite('ObservableChatSession', function () { let disposables: DisposableStore; @@ -89,21 +90,24 @@ suite('ObservableChatSession', function () { hasActiveResponseCallback?: boolean; hasRequestHandler?: boolean; hasForkHandler?: boolean; - } = {}) { + } = {}): ChatSessionDto { + const id = options.id || 'test-id'; return { - id: options.id || 'test-id', + id, + resource: LocalChatSessionUri.forSession(id), title: options.title, history: options.history || [], hasActiveResponseCallback: options.hasActiveResponseCallback ?? false, hasRequestHandler: options.hasRequestHandler ?? false, - hasForkHandler: options.hasForkHandler ?? false + hasForkHandler: options.hasForkHandler ?? false, + supportsInterruption: false, }; } async function createInitializedSession(sessionContent: any, sessionId = 'test-id'): Promise { const resource = LocalChatSessionUri.forSession(sessionId); const session = new ObservableChatSession(resource, 1, proxy, logService, dialogService); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions: [] }); return session; } @@ -140,7 +144,7 @@ suite('ObservableChatSession', function () { // Initialize the session const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions: [] }); // Now progress should be visible @@ -194,11 +198,11 @@ suite('ObservableChatSession', function () { deletions: 2, }], }; - (proxy.$forkChatSession as sinon.SinonStub).resolves(forkedItem); + asSinonMethodStub(proxy.$forkChatSession).resolves(forkedItem); const request: IChatSessionRequestHistoryItem = { type: 'request', id: 'request-1', prompt: 'Previous question', participant: 'participant' }; const expectedRequestDto = { - type: 'request', + type: 'request' as const, id: 'request-1', prompt: 'Previous question', participant: 'participant', @@ -208,7 +212,7 @@ suite('ObservableChatSession', function () { }; const result = await session.forkSession?.(request, CancellationToken.None); - assert.ok((proxy.$forkChatSession as sinon.SinonStub).calledOnceWithExactly(1, session.sessionResource, expectedRequestDto, CancellationToken.None)); + assert.ok(asSinonMethodStub(proxy.$forkChatSession).calledOnceWithExactly(1, session.sessionResource, expectedRequestDto, CancellationToken.None)); assert.ok(result); assert.ok(result.resource instanceof URI); assert.ok(Array.isArray(result.changes)); @@ -239,7 +243,7 @@ suite('ObservableChatSession', function () { const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService)); const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const promise1 = session.initialize(CancellationToken.None, { initialSessionOptions: [] }); const promise2 = session.initialize(CancellationToken.None, { initialSessionOptions: [] }); @@ -248,7 +252,7 @@ suite('ObservableChatSession', function () { await promise1; // Should only call proxy once even though initialize was called twice - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce); + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnce); }); test('initialization forwards initial session options context', async function () { @@ -258,11 +262,11 @@ suite('ObservableChatSession', function () { const initialSessionOptions = [{ optionId: 'model', value: 'gpt-4.1' }]; const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions }); - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnceWith( + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnceWith( 1, resource, { initialSessionOptions }, @@ -332,7 +336,7 @@ suite('ObservableChatSession', function () { await session.requestHandler!(request, progressCallback, [], CancellationToken.None); - assert.ok((proxy.$invokeChatSessionRequestHandler as sinon.SinonStubbedMember).calledOnceWith(1, session.sessionResource, request, [], CancellationToken.None)); + assert.ok(asSinonMethodStub(proxy.$invokeChatSessionRequestHandler).calledOnceWith(1, session.sessionResource, request, [], CancellationToken.None)); }); test('request handler forwards progress updates to external callback', async function () { @@ -351,12 +355,12 @@ suite('ObservableChatSession', function () { }; const progressCallback = sinon.stub(); - let resolveRequest: () => void; - const requestPromise = new Promise(resolve => { + let resolveRequest: (value: IChatAgentResult) => void; + const requestPromise = new Promise(resolve => { resolveRequest = resolve; }); - (proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).returns(requestPromise); + asSinonMethodStub(proxy.$invokeChatSessionRequestHandler).returns(requestPromise); const requestHandlerPromise = session.requestHandler!(request, progressCallback, [], CancellationToken.None); @@ -374,7 +378,7 @@ suite('ObservableChatSession', function () { assert.deepStrictEqual(progressCallback.secondCall.args[0], [progress2]); // Complete the request - resolveRequest!(); + resolveRequest!({}); await requestHandlerPromise; assert.strictEqual(session.isCompleteObs.get(), true); @@ -393,7 +397,7 @@ suite('ObservableChatSession', function () { session.dispose(); assert.ok(disposeEventFired); - assert.ok((proxy.$disposeChatSessionContent as sinon.SinonStubbedMember).calledOnceWith(1, resource)); + assert.ok(asSinonMethodStub(proxy.$disposeChatSessionContent).calledOnceWith(1, resource)); disposable.dispose(); }); @@ -512,16 +516,18 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: ChatSessionDto = { id: 'test-session', + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - const resource = URI.parse(`${sessionScheme}:/test-session`); - - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session1 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.ok(session1); @@ -529,7 +535,7 @@ suite('MainThreadChatSessions', function () { const session2 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.strictEqual(session1, session2); - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce); + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnce); mainThread.$unregisterChatSessionContentProvider(1); }); @@ -537,17 +543,19 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: ChatSessionDto = { id: 'test-session', + resource, title: 'My Session Title', history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - const resource = URI.parse(`${sessionScheme}:/test-session`); - - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.strictEqual(session.title, 'My Session Title'); @@ -560,16 +568,19 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: ChatSessionDto = { id: 'test-session', + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } }; @@ -585,16 +596,19 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: ChatSessionDto = { id: 'test-session', + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } }; @@ -610,21 +624,23 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/multi-turn-session`); + const sessionContent: ChatSessionDto = { id: 'multi-turn-session', + resource, history: [ - { type: 'request', prompt: 'First question' }, - { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] }, - { type: 'request', prompt: 'Second question' }, - { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] } + { type: 'request', prompt: 'First question', participant: 'test-participant' }, + { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }], participant: 'test-participant' }, + { type: 'request', prompt: 'Second question', participant: 'test-participant' }, + { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }], participant: 'test-participant' } ], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); - - const resource = URI.parse(`${sessionScheme}:/multi-turn-session`); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; // Verify the session loaded correctly @@ -660,7 +676,7 @@ suite('MainThreadChatSessions', function () { items: [{ id: 'modelB', name: 'Model B' }] }]; - const provideOptionsStub = proxy.$provideChatSessionProviderOptions as sinon.SinonStub; + const provideOptionsStub = asSinonMethodStub(proxy.$provideChatSessionProviderOptions); provideOptionsStub.onFirstCall().resolves({ optionGroups: optionGroups1 } as IChatSessionProviderOptions); provideOptionsStub.onSecondCall().resolves({ optionGroups: optionGroups2 } as IChatSessionProviderOptions); @@ -688,17 +704,19 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: ChatSessionDto = { id: 'test-session', + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, - // No options provided + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // getSessionOption should return undefined for unset options @@ -712,20 +730,23 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: ChatSessionDto = { id: 'test-session', + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4', 'region': { id: 'us-east', name: 'US East' } } }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // getSessionOption should return the configured values @@ -757,20 +778,20 @@ suite('MainThreadChatSessions', function () { } }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // Clear the stub call history - (proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory(); + asSinonMethodStub(proxy.$provideHandleOptionsChange).resetHistory(); // Simulate an option change chatSessionsService.setSessionOption(resource, 'models', 'gpt-4-turbo'); // Verify the extension was notified - assert.ok((proxy.$provideHandleOptionsChange as sinon.SinonStub).calledOnce); - const call = (proxy.$provideHandleOptionsChange as sinon.SinonStub).firstCall; + assert.ok(asSinonMethodStub(proxy.$provideHandleOptionsChange).calledOnce); + const call = asSinonMethodStub(proxy.$provideHandleOptionsChange).firstCall; assert.strictEqual(call.args[0], handle); assert.deepStrictEqual(call.args[1], resource); assert.deepStrictEqual(call.args[2], { models: 'gpt-4-turbo' }); @@ -786,7 +807,7 @@ suite('MainThreadChatSessions', function () { const resource = URI.parse(`${sessionScheme}:/test-session`); // Clear any previous calls - (proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory(); + asSinonMethodStub(proxy.$provideHandleOptionsChange).resetHistory(); // Attempt to notify option change for an unregistered scheme // This should not throw, but also should not call the proxy @@ -795,24 +816,26 @@ suite('MainThreadChatSessions', function () { ])); // Verify the extension was NOT notified (no provider registered) - assert.strictEqual((proxy.$provideHandleOptionsChange as sinon.SinonStub).callCount, 0); + assert.strictEqual(asSinonMethodStub(proxy.$provideHandleOptionsChange).callCount, 0); }); test('setSessionOption updates option and getSessionOption reflects change', async function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: ChatSessionDto = { id: 'test-session', + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, - // Start with no options + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // Initially no options set @@ -831,30 +854,36 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); + const resourceWithOptions = URI.parse(`${sessionScheme}:/session-with-options`); + const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`); + // Session with options - const sessionContentWithOptions = { + const sessionContentWithOptions: ChatSessionDto = { id: 'session-with-options', + resource: resourceWithOptions, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4' } }; // Session without options - const sessionContentWithoutOptions = { + const sessionContentWithoutOptions: ChatSessionDto = { id: 'session-without-options', + resource: resourceWithoutOptions, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub) + asSinonMethodStub(proxy.$provideChatSessionContent) .onFirstCall().resolves(sessionContentWithOptions) .onSecondCall().resolves(sessionContentWithoutOptions); - const resourceWithOptions = URI.parse(`${sessionScheme}:/session-with-options`); - const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`); - await chatSessionsService.getOrCreateChatSession(resourceWithOptions, CancellationToken.None); await chatSessionsService.getOrCreateChatSession(resourceWithoutOptions, CancellationToken.None); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index b051bac13ef..c74df032d41 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -407,9 +407,10 @@ suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://githu } test('should resolve when task completes', async () => { + const getTaskResultStub = sinon.stub().resolves({ content: [{ type: 'text', text: 'result' }] }); const mockHandler = upcastPartial({ getTask: sinon.stub().resolves(createTask({ status: 'completed' })), - getTaskResult: sinon.stub().resolves({ content: [{ type: 'text', text: 'result' }] }) + getTaskResult: getTaskResultStub }); const task = store.add(new McpTask(createTask())); @@ -423,7 +424,7 @@ suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://githu const result = await task.result; assert.deepStrictEqual(result, { content: [{ type: 'text', text: 'result' }] }); - assert.ok((mockHandler.getTaskResult as sinon.SinonStub).calledWith({ taskId: 'task1' })); + assert.ok(getTaskResultStub.calledWith({ taskId: 'task1' })); }); test('should poll for task updates', async () => { From 6ee11601986fbdce4165b695e34303594f09098b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:28:16 -0700 Subject: [PATCH 139/183] Bump tar from 0.4.44 to 0.4.45 in /cli (#303573) Bumps [tar](https://github.com/alexcrichton/tar-rs) from 0.4.44 to 0.4.45. - [Commits](https://github.com/alexcrichton/tar-rs/compare/0.4.44...0.4.45) --- updated-dependencies: - dependency-name: tar dependency-version: 0.4.45 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- cli/Cargo.lock | 4 ++-- cli/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index afe353213b1..e50f85de23a 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -2865,9 +2865,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 423224e10c5..6f54ec61cbb 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -53,7 +53,7 @@ cfg-if = "1.0.0" pin-project = "1.1.0" console = "0.15.7" bytes = "1.11.1" -tar = "0.4.38" +tar = "0.4.45" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } From bde0340390f78fbd1bdf1d54842c100c5107555e Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:29:43 -0700 Subject: [PATCH 140/183] Use proper typings --- .../api/test/browser/mainThreadChatSessions.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index b33298534ed..7fca163965d 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -34,7 +34,7 @@ import { ExtHostChatSessions } from '../../common/extHostChatSessions.js'; import { ExtHostCommands } from '../../common/extHostCommands.js'; import { ExtHostLanguageModels } from '../../common/extHostLanguageModels.js'; import * as extHostTypes from '../../common/extHostTypes.js'; -import { ChatSessionDto, ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js'; +import { ChatSessionDto, ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions, IChatSessionRequestHistoryItemDto } from '../../common/extHost.protocol.js'; import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; import { IExtHostTelemetry } from '../../common/extHostTelemetry.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; @@ -201,8 +201,8 @@ suite('ObservableChatSession', function () { asSinonMethodStub(proxy.$forkChatSession).resolves(forkedItem); const request: IChatSessionRequestHistoryItem = { type: 'request', id: 'request-1', prompt: 'Previous question', participant: 'participant' }; - const expectedRequestDto = { - type: 'request' as const, + const expectedRequestDto: IChatSessionRequestHistoryItemDto = { + type: 'request', id: 'request-1', prompt: 'Previous question', participant: 'participant', From 71b623764eefac659b939dc1e5b308f8cc476439 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:33:10 -0700 Subject: [PATCH 141/183] Move find to browser feature contribution (#303562) * Move find to browser feature contribution * feedback --- .../electron-browser/browserEditor.ts | 101 ++--- .../electron-browser/browserFindWidget.ts | 189 -------- .../browserView.contribution.ts | 1 + .../electron-browser/browserViewActions.ts | 125 +----- .../features/browserEditorFindFeature.ts | 408 ++++++++++++++++++ .../electron-browser/media/browser.css | 6 +- 6 files changed, 437 insertions(+), 393 deletions(-) delete mode 100644 src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 9f4bfff07a6..ec89c291587 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -31,13 +31,11 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { BrowserOverlayManager, BrowserOverlayType, IBrowserOverlayInfo } from './overlayManager.js'; import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; -import { BrowserFindWidget, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserFindWidget.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; @@ -52,9 +50,6 @@ export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocuse export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error")); -// Re-export find widget context keys for use in actions -export { CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE }; - /** * Get the original implementation of HTMLElement focus (without window auto-focusing) * before it gets overridden by the workbench. @@ -100,6 +95,17 @@ export abstract class BrowserEditorContribution extends Disposable { * Contributions can override this getter to provide widgets. */ get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { return []; } + + /** + * Optional toolbar-like elements to insert into the editor root between the navbar and the + * browser container. Contributions can override this getter to provide elements. + */ + get toolbarElements(): readonly HTMLElement[] { return []; } + + /** + * Called when the editor is laid out with a new dimension. + */ + layout(_width: number): void { } } /** @@ -354,8 +360,6 @@ export class BrowserEditor extends EditorPane { private _overlayPauseDetail!: HTMLElement; private _errorContainer!: HTMLElement; private _welcomeContainer!: HTMLElement; - private _findWidgetContainer!: HTMLElement; - private _findWidget!: Lazy; private _canGoBackContext!: IContextKey; private _canGoForwardContext!: IContextKey; private _hasUrlContext!: IContextKey; @@ -412,11 +416,11 @@ export class BrowserEditor extends EditorPane { const root = $('.browser-root'); parent.appendChild(root); - // Create toolbar with navigation buttons and URL input - const toolbar = $('.browser-toolbar'); + // Create navbar with navigation buttons and URL input + const navbar = $('.browser-navbar'); // Create navigation bar widget with scoped context - this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService)); + this._navigationBar = this._register(new BrowserNavigationBar(this, navbar, this.instantiationService, contextKeyService)); // Inject URL bar widgets from contributions const allWidgets: IBrowserEditorWidgetContribution[] = []; @@ -425,27 +429,14 @@ export class BrowserEditor extends EditorPane { } this._navigationBar.addUrlBarWidgets(allWidgets); - root.appendChild(toolbar); + root.appendChild(navbar); - // Create find widget container (between toolbar and browser container) - this._findWidgetContainer = $('.browser-find-widget-wrapper'); - root.appendChild(this._findWidgetContainer); - - // Create find widget (lazy initialization) - this._findWidget = new Lazy(() => { - const findWidget = this.instantiationService.createInstance( - BrowserFindWidget, - this._findWidgetContainer - ); - if (this._model) { - findWidget.setModel(this._model); + // Collect toolbar elements from contributions (e.g. find widget container) + for (const contribution of this._contributionInstances.values()) { + for (const element of contribution.toolbarElements) { + root.appendChild(element); } - findWidget.onDidChangeHeight(() => { - this.layoutBrowserContainer(); - }); - return findWidget; - }); - this._register(toDisposable(() => this._findWidget.rawValue?.dispose())); + } // Create browser container wrapper (flex item that fills remaining space) this._browserContainerWrapper = $('.browser-container-wrapper'); @@ -527,9 +518,6 @@ export class BrowserEditor extends EditorPane { this._model = model; this._onDidChangeModel.fire(model); - // Update find widget with new model - this._findWidget.rawValue?.setModel(this._model); - // Initialize UI state and context keys from model this.updateNavigationState({ url: this._model.url, @@ -889,40 +877,6 @@ export class BrowserEditor extends EditorPane { return this._model?.clearStorage(); } - /** - * Show the find widget, optionally pre-populated with selected text from the browser view - */ - async showFind(): Promise { - // Get selected text from the browser view to pre-populate the search box. - const selectedText = (await this._model?.getSelectedText())?.trim(); - - // Only use the selected text if it doesn't contain newlines (single line selection) - const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; - this._findWidget.value.reveal(textToReveal); - this._findWidget.value.layout(this._findWidgetContainer.clientWidth); - } - - /** - * Hide the find widget - */ - hideFind(): void { - this._findWidget.rawValue?.hide(); - } - - /** - * Find the next match - */ - findNext(): void { - this._findWidget.rawValue?.find(false); - } - - /** - * Find the previous match - */ - findPrevious(): void { - this._findWidget.rawValue?.find(true); - } - /** * Update navigation state and context keys */ @@ -1034,9 +988,10 @@ export class BrowserEditor extends EditorPane { } override layout(dimension?: Dimension, _position?: IDomPosition): void { - // Layout find widget if it exists - if (dimension && this._findWidget.rawValue) { - this._findWidget.rawValue.layout(dimension.width); + if (dimension) { + for (const contribution of this._contributionInstances.values()) { + contribution.layout(dimension.width); + } } const whenContainerStylesLoaded = this.layoutService.whenContainerStylesLoaded(this.window); @@ -1054,7 +1009,7 @@ export class BrowserEditor extends EditorPane { * 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. */ - private layoutBrowserContainer(): void { + layoutBrowserContainer(): void { if (this._model) { this.checkOverlays(); @@ -1079,10 +1034,6 @@ export class BrowserEditor extends EditorPane { // Cancel any scheduled screenshots this.cancelScheduledScreenshot(); - // Clear find widget model - this._findWidget.rawValue?.setModel(undefined); - this._findWidget.rawValue?.hide(); - void this._model?.setVisible(false); this._model = undefined; this._onDidChangeModel.fire(undefined); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts deleted file mode 100644 index dc4aba47e85..00000000000 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts +++ /dev/null @@ -1,189 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { SimpleFindWidget } from '../../codeEditor/browser/find/simpleFindWidget.js'; -import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; -import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; -import { IBrowserViewModel } from '../common/browserView.js'; -import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; -import { localize } from '../../../../nls.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { getWindow } from '../../../../base/browser/dom.js'; - -export const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); -export const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserFindWidgetFocused', false, localize('browser.findWidgetFocused', "Whether the browser find widget is focused")); - -/** - * Find widget for the integrated browser view. - * Uses the SimpleFindWidget base class and communicates with the browser view model - * to perform find operations in the rendered web page. - */ -export class BrowserFindWidget extends SimpleFindWidget { - private _model: IBrowserViewModel | undefined; - private readonly _modelDisposables = this._register(new DisposableStore()); - private readonly _findWidgetVisible: IContextKey; - private readonly _findWidgetFocused: IContextKey; - private _lastFindResult: { resultIndex: number; resultCount: number } | undefined; - private _hasFoundMatch = false; - - private readonly _onDidChangeHeight = this._register(new Emitter()); - readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; - - constructor( - private readonly container: HTMLElement, - @IContextViewService contextViewService: IContextViewService, - @IContextKeyService contextKeyService: IContextKeyService, - @IHoverService hoverService: IHoverService, - @IKeybindingService keybindingService: IKeybindingService, - @IConfigurationService configurationService: IConfigurationService, - @IAccessibilityService accessibilityService: IAccessibilityService - ) { - super({ - showCommonFindToggles: true, - checkImeCompletionState: true, - showResultCount: true, - enableSash: true, - initialWidth: 350, - previousMatchActionId: BrowserViewCommandId.FindPrevious, - nextMatchActionId: BrowserViewCommandId.FindNext, - closeWidgetActionId: BrowserViewCommandId.HideFind - }, contextViewService, contextKeyService, hoverService, keybindingService, configurationService, accessibilityService); - - this._findWidgetVisible = CONTEXT_BROWSER_FIND_WIDGET_VISIBLE.bindTo(contextKeyService); - this._findWidgetFocused = CONTEXT_BROWSER_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); - - const domNode = this.getDomNode(); - container.appendChild(domNode); - - let lastHeight = domNode.offsetHeight; - const resizeObserver = new (getWindow(container).ResizeObserver)(() => { - const newHeight = domNode.offsetHeight; - if (newHeight !== lastHeight) { - lastHeight = newHeight; - this._onDidChangeHeight.fire(); - } - }); - resizeObserver.observe(domNode); - this._register(toDisposable(() => resizeObserver.disconnect())); - } - - /** - * Set the browser view model to use for find operations. - * This should be called whenever the editor input changes. - */ - setModel(model: IBrowserViewModel | undefined): void { - this._modelDisposables.clear(); - this._model = model; - this._lastFindResult = undefined; - this._hasFoundMatch = false; - - if (model) { - this._modelDisposables.add(model.onDidFindInPage(result => { - this._lastFindResult = { - resultIndex: result.activeMatchOrdinal - 1, // Convert to 0-based index - resultCount: result.matches - }; - this._hasFoundMatch = result.matches > 0; - this.updateButtons(this._hasFoundMatch); - this.updateResultCount(); - })); - - this._modelDisposables.add(model.onWillDispose(() => { - this.setModel(undefined); - })); - } - } - - override reveal(initialInput?: string): void { - const wasVisible = this.isVisible(); - super.reveal(initialInput); - this._findWidgetVisible.set(true); - this.container.classList.toggle('find-visible', true); - - // Focus the find input - this.focusFindBox(); - - // If there's existing input and the widget wasn't already visible, trigger a search - if (this.inputValue && !wasVisible) { - this._onInputChanged(); - } - } - - override hide(): void { - super.hide(false); - this._findWidgetVisible.reset(); - this.container.classList.toggle('find-visible', false); - - // Stop find and clear highlights in the browser view - this._model?.stopFindInPage(true); - this._model?.focus(); - this._lastFindResult = undefined; - this._hasFoundMatch = false; - } - - find(previous: boolean): void { - const value = this.inputValue; - if (value && this._model) { - this._model.findInPage(value, { - forward: !previous, - recompute: false, - matchCase: this._getCaseSensitiveValue() - }); - } - } - - findFirst(): void { - const value = this.inputValue; - if (value && this._model) { - this._model.findInPage(value, { - forward: true, - recompute: true, - matchCase: this._getCaseSensitiveValue() - }); - } - } - - clear(): void { - if (this._model) { - this._model.stopFindInPage(false); - this._lastFindResult = undefined; - this._hasFoundMatch = false; - } - } - - protected _onInputChanged(): boolean { - if (this.inputValue) { - this.findFirst(); - } else if (this._model) { - this.clear(); - } - return false; - } - - protected async _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined> { - return this._lastFindResult; - } - - protected _onFocusTrackerFocus(): void { - this._findWidgetFocused.set(true); - } - - protected _onFocusTrackerBlur(): void { - this._findWidgetFocused.reset(); - } - - protected _onFindInputFocusTrackerFocus(): void { - // No-op - } - - protected _onFindInputFocusTrackerBlur(): void { - // No-op - } -} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index f0fd9ca1d74..ac62107adcd 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -26,6 +26,7 @@ import './features/browserDataStorageFeatures.js'; import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; import './features/browserEditorZoomFeature.js'; +import './features/browserEditorFindFeature.js'; import './features/browserTabManagementFeatures.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 9a9f4b2d754..eb04e8d3a73 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,7 +11,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_URL } from './browserEditor.js'; import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; @@ -242,124 +242,6 @@ class OpenBrowserSettingsAction extends Action2 { } } -// Find actions - -class ShowBrowserFindAction extends Action2 { - static readonly ID = BrowserViewCommandId.ShowFind; - - constructor() { - super({ - id: ShowBrowserFindAction.ID, - title: localize2('browser.showFindAction', 'Find in Page'), - category: BrowserActionCategory, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Page, - order: 1, - }, - keybinding: { - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyF - } - }); - } - - run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { - if (browserEditor instanceof BrowserEditor) { - browserEditor.showFind(); - } - } -} - -class HideBrowserFindAction extends Action2 { - static readonly ID = BrowserViewCommandId.HideFind; - - constructor() { - super({ - id: HideBrowserFindAction.ID, - title: localize2('browser.hideFindAction', 'Close Find Widget'), - category: BrowserActionCategory, - f1: false, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), - keybinding: { - weight: KeybindingWeight.EditorContrib + 5, - primary: KeyCode.Escape - } - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.hideFind(); - } - } -} - -class BrowserFindNextAction extends Action2 { - static readonly ID = BrowserViewCommandId.FindNext; - - constructor() { - super({ - id: BrowserFindNextAction.ID, - title: localize2('browser.findNextAction', 'Find Next'), - category: BrowserActionCategory, - f1: false, - precondition: BROWSER_EDITOR_ACTIVE, - keybinding: [{ - when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, - weight: KeybindingWeight.EditorContrib, - primary: KeyCode.Enter - }, { - when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, - weight: KeybindingWeight.EditorContrib, - primary: KeyCode.F3, - mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } - }] - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.findNext(); - } - } -} - -class BrowserFindPreviousAction extends Action2 { - static readonly ID = BrowserViewCommandId.FindPrevious; - - constructor() { - super({ - id: BrowserFindPreviousAction.ID, - title: localize2('browser.findPreviousAction', 'Find Previous'), - category: BrowserActionCategory, - f1: false, - precondition: BROWSER_EDITOR_ACTIVE, - keybinding: [{ - when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.Shift | KeyCode.Enter - }, { - when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.Shift | KeyCode.F3, - mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } - }] - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.findPrevious(); - } - } -} - // Register actions registerAction2(GoBackAction); registerAction2(GoForwardAction); @@ -369,8 +251,3 @@ registerAction2(HardReloadAction); registerAction2(FocusUrlInputAction); registerAction2(OpenInExternalBrowserAction); registerAction2(OpenBrowserSettingsAction); - -registerAction2(ShowBrowserFindAction); -registerAction2(HideBrowserFindAction); -registerAction2(BrowserFindNextAction); -registerAction2(BrowserFindPreviousAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts new file mode 100644 index 00000000000..241f78d087c --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -0,0 +1,408 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { $, getWindow } from '../../../../../base/browser/dom.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Lazy } from '../../../../../base/common/lazy.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { SimpleFindWidget } from '../../../codeEditor/browser/find/simpleFindWidget.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; + +const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); +const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserFindWidgetFocused', false, localize('browser.findWidgetFocused', "Whether the browser find widget is focused")); + +/** + * Find widget for the integrated browser view. + * Uses the SimpleFindWidget base class and communicates with the browser view model + * to perform find operations in the rendered web page. + */ +class BrowserFindWidget extends SimpleFindWidget { + private _model: IBrowserViewModel | undefined; + private readonly _modelDisposables = this._register(new DisposableStore()); + private readonly _findWidgetVisible: IContextKey; + private readonly _findWidgetFocused: IContextKey; + private _lastFindResult: { resultIndex: number; resultCount: number } | undefined; + private _hasFoundMatch = false; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; + + constructor( + container: HTMLElement, + @IContextViewService contextViewService: IContextViewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHoverService hoverService: IHoverService, + @IKeybindingService keybindingService: IKeybindingService, + @IConfigurationService configurationService: IConfigurationService, + @IAccessibilityService accessibilityService: IAccessibilityService + ) { + super({ + showCommonFindToggles: true, + checkImeCompletionState: true, + showResultCount: true, + enableSash: true, + initialWidth: 350, + previousMatchActionId: BrowserViewCommandId.FindPrevious, + nextMatchActionId: BrowserViewCommandId.FindNext, + closeWidgetActionId: BrowserViewCommandId.HideFind + }, contextViewService, contextKeyService, hoverService, keybindingService, configurationService, accessibilityService); + + this._findWidgetVisible = CONTEXT_BROWSER_FIND_WIDGET_VISIBLE.bindTo(contextKeyService); + this._findWidgetFocused = CONTEXT_BROWSER_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); + + const domNode = this.getDomNode(); + container.appendChild(domNode); + + let lastHeight = domNode.offsetHeight; + const resizeObserver = new (getWindow(container).ResizeObserver)(() => { + const newHeight = domNode.offsetHeight; + if (newHeight !== lastHeight) { + lastHeight = newHeight; + this._onDidChangeHeight.fire(); + } + }); + resizeObserver.observe(domNode); + this._register(toDisposable(() => resizeObserver.disconnect())); + } + + /** + * Set the browser view model to use for find operations. + * This should be called whenever the editor input changes. + */ + setModel(model: IBrowserViewModel | undefined): void { + this._modelDisposables.clear(); + this._model = model; + this._lastFindResult = undefined; + this._hasFoundMatch = false; + + if (model) { + this._modelDisposables.add(model.onDidFindInPage(result => { + this._lastFindResult = { + resultIndex: result.activeMatchOrdinal - 1, // Convert to 0-based index + resultCount: result.matches + }; + this._hasFoundMatch = result.matches > 0; + this.updateButtons(this._hasFoundMatch); + this.updateResultCount(); + })); + + this._modelDisposables.add(model.onWillDispose(() => { + this.setModel(undefined); + })); + } + } + + override reveal(initialInput?: string): void { + const wasVisible = this.isVisible(); + super.reveal(initialInput); + this._findWidgetVisible.set(true); + + // Focus the find input + this.focusFindBox(); + + // If there's existing input and the widget wasn't already visible, trigger a search + if (this.inputValue && !wasVisible) { + this._onInputChanged(); + } + } + + override hide(): void { + super.hide(false); + this._findWidgetVisible.reset(); + + // Stop find and clear highlights in the browser view + this._model?.stopFindInPage(true); + this._model?.focus(); + this._lastFindResult = undefined; + this._hasFoundMatch = false; + } + + find(previous: boolean): void { + const value = this.inputValue; + if (value && this._model) { + this._model.findInPage(value, { + forward: !previous, + recompute: false, + matchCase: this._getCaseSensitiveValue() + }); + } + } + + findFirst(): void { + const value = this.inputValue; + if (value && this._model) { + this._model.findInPage(value, { + forward: true, + recompute: true, + matchCase: this._getCaseSensitiveValue() + }); + } + } + + clear(): void { + if (this._model) { + this._model.stopFindInPage(false); + this._lastFindResult = undefined; + this._hasFoundMatch = false; + } + } + + protected _onInputChanged(): boolean { + if (this.inputValue) { + this.findFirst(); + } else if (this._model) { + this.clear(); + } + return false; + } + + protected async _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined> { + return this._lastFindResult; + } + + protected _onFocusTrackerFocus(): void { + this._findWidgetFocused.set(true); + } + + protected _onFocusTrackerBlur(): void { + this._findWidgetFocused.reset(); + } + + protected _onFindInputFocusTrackerFocus(): void { + // No-op + } + + protected _onFindInputFocusTrackerBlur(): void { + // No-op + } +} + +/** + * Browser editor contribution that manages the find-in-page widget. + * + * Creates a container just below the toolbar and lazily instantiates the + * {@link BrowserFindWidget}. When the find widget's height changes the + * browser container is re-laid-out so that the web-contents view stays in + * sync. + */ +export class BrowserEditorFindContribution extends BrowserEditorContribution { + private readonly _findWidgetContainer: HTMLElement; + private readonly _findWidget: Lazy; + + constructor( + editor: BrowserEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(editor); + + this._findWidgetContainer = $('.browser-find-widget-wrapper'); + + this._findWidget = new Lazy(() => { + const findWidget = this.instantiationService.createInstance( + BrowserFindWidget, + this._findWidgetContainer + ); + if (editor.model) { + findWidget.setModel(editor.model); + } + findWidget.onDidChangeHeight(() => { + editor.layoutBrowserContainer(); + }); + return findWidget; + }); + this._register(toDisposable(() => this._findWidget.rawValue?.dispose())); + } + + /** + * The container element to insert below the toolbar. + */ + override get toolbarElements(): readonly HTMLElement[] { + return [this._findWidgetContainer]; + } + + protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + this._findWidget.rawValue?.setModel(model); + } + + override clear(): void { + this._findWidget.rawValue?.setModel(undefined); + this._findWidget.rawValue?.hide(); + } + + override layout(width: number): void { + this._findWidget.rawValue?.layout(width); + } + + /** + * Show the find widget, optionally pre-populated with selected text from the browser view + */ + async showFind(): Promise { + const selectedText = (await this.editor.model?.getSelectedText())?.trim(); + const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; + this._findWidget.value.reveal(textToReveal); + this._findWidget.value.layout(this._findWidgetContainer.clientWidth); + } + + /** + * Hide the find widget + */ + hideFind(): void { + this._findWidget.rawValue?.hide(); + } + + /** + * Find the next match + */ + findNext(): void { + this._findWidget.rawValue?.find(false); + } + + /** + * Find the previous match + */ + findPrevious(): void { + this._findWidget.rawValue?.find(true); + } +} + +BrowserEditor.registerContribution(BrowserEditorFindContribution); + +// -- Actions ---------------------------------------------------------------- + +class ShowBrowserFindAction extends Action2 { + static readonly ID = BrowserViewCommandId.ShowFind; + + constructor() { + super({ + id: ShowBrowserFindAction.ID, + title: localize2('browser.showFindAction', 'Find in Page'), + category: BrowserActionCategory, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Page, + order: 1, + }, + keybinding: { + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyF + } + }); + } + + run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { + if (browserEditor instanceof BrowserEditor) { + void browserEditor.getContribution(BrowserEditorFindContribution)?.showFind(); + } + } +} + +class HideBrowserFindAction extends Action2 { + static readonly ID = BrowserViewCommandId.HideFind; + + constructor() { + super({ + id: HideBrowserFindAction.ID, + title: localize2('browser.hideFindAction', 'Close Find Widget'), + category: BrowserActionCategory, + f1: false, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), + keybinding: { + weight: KeybindingWeight.EditorContrib + 5, + primary: KeyCode.Escape + } + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.hideFind(); + } + } +} + +class BrowserFindNextAction extends Action2 { + static readonly ID = BrowserViewCommandId.FindNext; + + constructor() { + super({ + id: BrowserFindNextAction.ID, + title: localize2('browser.findNextAction', 'Find Next'), + category: BrowserActionCategory, + f1: false, + precondition: BROWSER_EDITOR_ACTIVE, + keybinding: [{ + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Enter + }, { + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.F3, + mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } + }] + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.findNext(); + } + } +} + +class BrowserFindPreviousAction extends Action2 { + static readonly ID = BrowserViewCommandId.FindPrevious; + + constructor() { + super({ + id: BrowserFindPreviousAction.ID, + title: localize2('browser.findPreviousAction', 'Find Previous'), + category: BrowserActionCategory, + f1: false, + precondition: BROWSER_EDITOR_ACTIVE, + keybinding: [{ + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.Shift | KeyCode.Enter + }, { + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.Shift | KeyCode.F3, + mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } + }] + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.findPrevious(); + } + } +} + +registerAction2(ShowBrowserFindAction); +registerAction2(HideBrowserFindAction); +registerAction2(BrowserFindNextAction); +registerAction2(BrowserFindPreviousAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 929d720d448..f5856137557 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -30,7 +30,7 @@ width: 100%; height: 100%; - .browser-toolbar { + .browser-navbar { display: flex; align-items: center; padding: 6px 8px; @@ -365,10 +365,6 @@ z-index: 10; overflow: hidden; - &.find-visible { - border-bottom: 1px solid var(--vscode-widget-border); - } - /* Override SimpleFindWidget absolute positioning to flow in layout */ .simple-find-part-wrapper { position: relative; From 79d5a7921895e625516ff803f00cf3ab56cfc28b Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:48:34 +0000 Subject: [PATCH 142/183] Sessions - add prompt for merging changes (#303607) * Sessions - add prompt for merging changes * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/sessions/prompts/merge-changes.prompt.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/vs/sessions/prompts/merge-changes.prompt.md diff --git a/src/vs/sessions/prompts/merge-changes.prompt.md b/src/vs/sessions/prompts/merge-changes.prompt.md new file mode 100644 index 00000000000..065cb18ad18 --- /dev/null +++ b/src/vs/sessions/prompts/merge-changes.prompt.md @@ -0,0 +1,10 @@ +--- +description: Merge changes from the topic branch to the merge base branch +--- + + +Merge changes from the topic branch to the merge base branch. +The context block appended to the prompt contains the source and target branch information. + +1. If there are any uncommitted changes, use the `/commit` skill to commit them +2. Merge the topic branch into the merge base branch. If there are any merge conflicts, resolve them and commit the merge. When in doubt on how to resolve a merge conflict, ask the user for guidance on how to proceed From b7462b4a0060506202351fe624d3ff74adfc7115 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 20 Mar 2026 13:54:33 -0700 Subject: [PATCH 143/183] Merge pull request #303597 from microsoft/connor4312/agent-host-server-fixups agentHost: fixup build for server --- .../win32/product-build-win32-cli.yml | 3 ++ build/next/index.ts | 1 + cli/src/commands/agent_host.rs | 50 +++++++++++++------ src/vs/platform/environment/common/argv.ts | 1 + src/vs/platform/environment/node/argv.ts | 11 +++- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/build/azure-pipelines/win32/product-build-win32-cli.yml b/build/azure-pipelines/win32/product-build-win32-cli.yml index 78461a959ed..20e49d34866 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli.yml @@ -120,6 +120,9 @@ jobs: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: ✍️ Codesign + - powershell: Remove-Item -Path "$(Build.ArtifactStagingDirectory)/sign/CodeSignSummary*.md" -Force -ErrorAction SilentlyContinue + displayName: Remove CodeSignSummary + - task: ArchiveFiles@2 displayName: Archive signed CLI inputs: diff --git a/build/next/index.ts b/build/next/index.ts index 77388b57a26..565bafc72ec 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -129,6 +129,7 @@ const serverEntryPoints = [ 'vs/workbench/api/node/extensionHostProcess', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', + 'vs/platform/agentHost/node/agentHostMain', ]; // Bootstrap files per target diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index b5330e4df76..955e13f7c68 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -21,7 +21,7 @@ use crate::constants::VSCODE_CLI_QUALITY; use crate::download_cache::DownloadCache; use crate::log; use crate::options::Quality; -use crate::tunnels::paths::SERVER_FOLDER_NAME; +use crate::tunnels::paths::{get_server_folder_name, SERVER_FOLDER_NAME}; use crate::tunnels::shutdown_signal::ShutdownRequest; use crate::update_service::{ unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, @@ -74,8 +74,13 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< // Eagerly resolve the latest version so the first connection is fast. // Skip when using a dev override since updates don't apply. if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_none() { - if let Err(e) = manager.get_latest_release().await { - warning!(ctx.log, "Error resolving initial server version: {}", e); + match manager.get_latest_release().await { + Ok(release) => { + if let Err(e) = manager.ensure_downloaded(&release).await { + warning!(ctx.log, "Error downloading latest server version: {}", e); + } + } + Err(e) => warning!(ctx.log, "Error resolving initial server version: {}", e), } // Start background update checker @@ -253,9 +258,12 @@ impl AgentHostManager { cmd.stdin(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); + cmd.arg("--socket-path"); + cmd.arg(get_socket_name()); cmd.arg("--agent-host-path"); cmd.arg(&agent_host_socket); cmd.args([ + "--start-server", "--accept-server-license-terms", "--enable-remote-auto-shutdown", ]); @@ -394,7 +402,8 @@ impl AgentHostManager { // Best case: the latest known release is already downloaded if let Some((_, release)) = &*self.latest_release.lock().await { - if let Some(dir) = self.cache.exists(&release.commit) { + let name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&name) { return Ok((release.clone(), dir)); } } @@ -405,15 +414,23 @@ impl AgentHostManager { Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) })?; - // Fall back to any cached version (still instant, just not the newest) - for commit in self.cache.get() { - if let Some(dir) = self.cache.exists(&commit) { + // Fall back to any cached version (still instant, just not the newest). + // Cache entries are named "-" via get_server_folder_name. + for entry in self.cache.get() { + if let Some(dir) = self.cache.exists(&entry) { + let (entry_quality, commit) = match entry.split_once('-') { + Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) { + Ok(parsed) => (parsed, c.to_string()), + Err(_) => (quality, entry.clone()), + }, + None => (quality, entry.clone()), + }; let release = Release { name: String::new(), commit, platform: self.platform, target: TargetKind::Server, - quality, + quality: entry_quality, }; return Ok((release, dir)); } @@ -428,7 +445,8 @@ impl AgentHostManager { /// Ensures the release is downloaded, returning the server directory. async fn ensure_downloaded(&self, release: &Release) -> Result { - if let Some(dir) = self.cache.exists(&release.commit) { + let cache_name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&cache_name) { return Ok(dir); } @@ -436,9 +454,8 @@ impl AgentHostManager { let release = release.clone(); let log = self.log.clone(); let update_service = self.update_service.clone(); - let commit = release.commit.clone(); self.cache - .create(&commit, |target_dir| async move { + .create(&cache_name, |target_dir| async move { let tmpdir = tempfile::tempdir().unwrap(); let response = update_service.get_download_stream(&release).await?; let name = response.url_path_basename().unwrap(); @@ -449,7 +466,8 @@ impl AgentHostManager { response, ) .await?; - unzip_downloaded_release(&archive_path, &target_dir, SilentCopyProgress())?; + let server_dir = target_dir.join(SERVER_FOLDER_NAME); + unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?; Ok(()) }) .await @@ -504,7 +522,8 @@ impl AgentHostManager { }; // Check if we already have this version - if self.cache.exists(&new_release.commit).is_some() { + let name = get_server_folder_name(new_release.quality, &new_release.commit); + if self.cache.exists(&name).is_some() { continue; } @@ -562,7 +581,10 @@ async fn handle_request( let rw = match get_socket_rw_stream(&socket_path).await { Ok(rw) => rw, Err(e) => { - error!(manager.log, "Error connecting to agent host socket: {:?}", e); + error!( + manager.log, + "Error connecting to agent host socket: {:?}", e + ); return Ok(Response::builder() .status(503) .body(Body::from(format!("Error connecting to agent host: {e:?}"))) diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 53e4b5f77f2..72752cf9ed1 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -24,6 +24,7 @@ export interface NativeParsedArgs { }; }; 'serve-web'?: INativeCliOptions; + 'agent-host'?: INativeCliOptions; chat?: { _: string[]; 'add-file'?: string[]; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 7fbfac5b2b0..3b7f625a6ab 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -45,7 +45,7 @@ export type OptionDescriptions = { Subcommand }; -export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web'] as const; +export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web', 'agent-host'] as const; export const OPTIONS: OptionDescriptions> = { 'chat': { @@ -71,6 +71,15 @@ export const OPTIONS: OptionDescriptions> = { 'telemetry-level': { type: 'string' }, } }, + 'agent-host': { + type: 'subcommand', + description: 'Run a server that hosts agents.', + options: { + 'cli-data-dir': { type: 'string', args: 'dir', description: localize('cliDataDir', "Directory where CLI metadata should be stored.") }, + 'disable-telemetry': { type: 'boolean' }, + 'telemetry-level': { type: 'string' }, + } + }, 'tunnel': { type: 'subcommand', description: 'Make the current machine accessible from vscode.dev or other machines through a secure tunnel.', From ef1e52a086908dad9ef0f15e4762b05b0317b023 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:55:09 -0700 Subject: [PATCH 144/183] Add inline badge to customization list items for context instructions (#303598) * Add generic badge support to customization list items Instructions with applyTo patterns now show context info (e.g. 'always added', 'context matching *.ts') as an inline badge next to the item name instead of baking it into the display name string. The badge uses the same visual style as the MCP 'Bridged' badge. The badge field is generic on IAICustomizationListItem so other customization types can use it in the future. Badge text is also included in search filtering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Show pattern directly in badge with explanatory hover tooltip Badge now shows just the applyTo pattern (e.g. '**/*.ts') or 'always added', instead of 'context matching ...'. Hovering the badge shows a tooltip explaining the behavior. Added badgeTooltip field to IAICustomizationListItem for generic reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Include badge tooltip in item hover Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add blank line before badge tooltip in hover Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: shared badge CSS, flex ellipsis fix, update spec - Extract shared .inline-badge class used by both MCP bridged badge and item badges to avoid style drift. - Add min-width: 0 and flex: 1 1 auto to .item-name so long names truncate correctly inside the flex row. - Update AI_CUSTOMIZATIONS.md to reflect that badges show the raw applyTo pattern with tooltip explanation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Keep badge adjacent to name instead of right-aligned Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 4 ++ .../aiCustomizationListWidget.ts | 48 ++++++++++++++++--- .../browser/aiCustomization/mcpListWidget.ts | 2 +- .../media/aiCustomizationManagement.css | 24 ++++++---- 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index b4a19a06821..acb2203f9af 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -199,6 +199,10 @@ Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. T | Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | | Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` | +### Item Badges + +`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. The badge text is also included in search filtering. + ### Debug Panel Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a 4-stage pipeline view: diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 2c8ac552b61..014615bd6ed 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -41,6 +41,7 @@ import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../. import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; @@ -101,6 +102,10 @@ export interface IAICustomizationListItem { readonly pluginUri?: URI; /** When set, overrides the formatted name for display. */ readonly displayName?: string; + /** When set, shows a small inline badge next to the item name. */ + readonly badge?: string; + /** Tooltip shown when hovering the badge. */ + readonly badgeTooltip?: string; /** When set, overrides the default prompt-type icon. */ readonly typeIcon?: ThemeIcon; nameMatches?: IMatch[]; @@ -154,6 +159,7 @@ interface IAICustomizationItemTemplateData { readonly actionBar: ActionBar; readonly typeIcon: HTMLElement; readonly nameLabel: HighlightedLabel; + readonly badge: HTMLElement; readonly description: HighlightedLabel; readonly disposables: DisposableStore; readonly elementDisposables: DisposableStore; @@ -296,7 +302,9 @@ class AICustomizationItemRenderer implements IListRenderer { const uriLabel = this.labelService.getUriLabel(element.uri, { relative: false }); let content = `${element.name}\n${uriLabel}`; + if (element.badgeTooltip) { + content += `\n\n${element.badgeTooltip}`; + } const plugin = element.pluginUri && this.agentPluginService.plugins.get().find(p => isEqual(p.uri, element.pluginUri)); if (plugin) { content += `\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`; @@ -349,6 +361,22 @@ class AICustomizationItemRenderer implements IListRenderer Date: Fri, 20 Mar 2026 13:55:31 -0700 Subject: [PATCH 145/183] sessions: prevent welcome overlay flash on transient entitlement state (#303583) * Enhance regression handling in SessionsWelcomeContribution: delay overlay display to prevent flashing during transient state changes * Refactor SessionsWelcomeContribution: replace regression watching with entitlement state monitoring to prevent flashing during transient state changes * Refactor SessionsWelcomeContribution: replace regression timeout with pending overlay timer to prevent flashing during transient state changes * Refactor SessionsWelcomeContribution: simplify entitlement state monitoring and remove overlay delay to improve responsiveness * Refactor SessionsWelcomeContribution: change class visibility to export for better accessibility * Add test for overlay visibility on first launch with no entitlement * test(sessions): simplify instantiation service ownership in welcome tests Address PR review: avoid redundant disposables.add() wrapping around workbenchInstantiationService(), which already registers itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../welcome/browser/welcome.contribution.ts | 29 ++- .../test/browser/welcome.contribution.test.ts | 209 ++++++++++++++++++ 2 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index f6b57dbbd24..29634287b09 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -124,7 +124,7 @@ class SessionsWelcomeOverlay extends Disposable { } } -class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { +export class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsWelcome'; @@ -169,31 +169,42 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri if (this._needsChatSetup()) { this.showOverlay(); } else { - this.watchForRegressions(); + this.watchEntitlementState(); } } - private watchForRegressions(): void { - let wasComplete = !this._needsChatSetup(); + /** + * Watches entitlement and sentiment observables after setup has already + * completed. If the user's state changes such that setup is needed again + * (e.g. extension uninstalled/disabled), shows the welcome overlay. + * + * {@link ChatEntitlement.Unknown} is intentionally ignored here: it is + * almost always a transient state caused by a stale OAuth token being + * refreshed after an update. A genuine sign-out will be caught on the + * next app launch via the initial {@link showOverlayIfNeeded} check. + */ + private watchEntitlementState(): void { + let setupComplete = !this._needsChatSetup(false); this.watcherRef.value = autorun(reader => { this.chatEntitlementService.sentimentObs.read(reader); this.chatEntitlementService.entitlementObs.read(reader); - const needsSetup = this._needsChatSetup(); - if (wasComplete && needsSetup) { + const needsSetup = this._needsChatSetup(false); + if (setupComplete && needsSetup) { this.showOverlay(); } - wasComplete = !needsSetup; + setupComplete = !needsSetup; }); } - private _needsChatSetup(): boolean { + private _needsChatSetup(includeUnknown: boolean = true): boolean { const { sentiment, entitlement } = this.chatEntitlementService; if ( !sentiment?.installed || // Extension not installed: run setup to install sentiment?.disabled || // Extension disabled: run setup to enable entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up ( + includeUnknown && entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up !this.chatEntitlementService.anonymous // unless anonymous access is enabled ) @@ -231,7 +242,7 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); overlay.dismiss(); this.overlayRef.clear(); - this.watchForRegressions(); + this.watchEntitlementState(); } })); } diff --git a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts new file mode 100644 index 00000000000..7105288c548 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; +import { SessionsWelcomeContribution } from '../../browser/welcome.contribution.js'; + +const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; + +class MockChatEntitlementService implements Partial { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeEntitlement = Event.None; + readonly onDidChangeSentiment = Event.None; + readonly onDidChangeAnonymous = Event.None; + readonly onDidChangeQuotaExceeded = Event.None; + readonly onDidChangeQuotaRemaining = Event.None; + + readonly entitlementObs: ISettableObservable = observableValue('entitlement', ChatEntitlement.Free); + readonly sentimentObs: ISettableObservable = observableValue('sentiment', { installed: true } as IChatSentiment); + readonly anonymousObs: ISettableObservable = observableValue('anonymous', false); + + readonly organisations = undefined; + readonly isInternal = false; + readonly sku = undefined; + readonly copilotTrackingId = undefined; + readonly quotas = {}; + readonly previewFeaturesDisabled = false; + + get entitlement(): ChatEntitlement { return this.entitlementObs.get(); } + get sentiment(): IChatSentiment { return this.sentimentObs.get(); } + get anonymous(): boolean { return this.anonymousObs.get(); } + + update(): Promise { return Promise.resolve(); } + markAnonymousRateLimited(): void { } +} + +suite('SessionsWelcomeContribution', () => { + + const disposables = new DisposableStore(); + let instantiationService: TestInstantiationService; + let mockEntitlementService: MockChatEntitlementService; + + setup(() => { + instantiationService = workbenchInstantiationService(undefined, disposables); + mockEntitlementService = new MockChatEntitlementService(); + instantiationService.stub(IChatEntitlementService, mockEntitlementService as unknown as IChatEntitlementService); + + // Ensure product has a defaultChatAgent so the contribution activates + const productService = instantiationService.get(IProductService); + instantiationService.stub(IProductService, { + ...productService, + defaultChatAgent: { ...productService.defaultChatAgent, chatExtensionId: 'test.chat' } + } as IProductService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function markReturningUser(): void { + const storageService = instantiationService.get(IStorageService); + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + function isOverlayVisible(): boolean { + const contextKeyService = instantiationService.get(IContextKeyService); + return SessionsWelcomeVisibleContext.getValue(contextKeyService) === true; + } + + test('first launch shows overlay', () => { + // First launch with no entitlement — should show overlay + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true); + }); + + test('returning user with valid entitlement does not show overlay', () => { + markReturningUser(); + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false); + }); + + test('returning user: transient Unknown entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate transient Unknown (stale token → 401) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for transient Unknown'); + + // Simulate recovery (token refreshed → entitlement restored) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should remain hidden after recovery'); + }); + + test('returning user: transient Unresolved entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Pro, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate Unresolved (intermediate state during account resolution) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unresolved, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for Unresolved'); + }); + + test('returning user: extension uninstalled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate extension being uninstalled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is uninstalled'); + }); + + test('returning user: extension disabled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate extension being disabled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: true, disabled: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is disabled'); + }); + + test('overlay dismisses when setup completes', () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true, 'should show on first launch'); + + // Simulate completing setup + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should dismiss after setup completes'); + }); + + test('returning user: entitlement going to Available DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Available means user can sign up for free — this is a real state, + // not transient, so the overlay should show + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Available, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay for Available entitlement'); + }); +}); From c9335c2873d281ae79ac654198cefb8ae696ae55 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 20 Mar 2026 21:21:34 +0100 Subject: [PATCH 146/183] node_modules is stale -> ignore node-version changes --- .../vscode-extras/src/npmUpToDateFeature.ts | 2 +- build/npm/installStateHash.ts | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts index 8927b0b7064..f21e36604fb 100644 --- a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts +++ b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts @@ -112,7 +112,7 @@ export class NpmUpToDateFeature extends vscode.Disposable { } try { const script = path.join(workspaceRoot, 'build', 'npm', 'installStateHash.ts'); - const output = cp.execFileSync(process.execPath, [script], { + const output = cp.execFileSync(process.execPath, [script, '--ignore-node-version'], { cwd: workspaceRoot, timeout: 10_000, encoding: 'utf8', diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index f52c0a4696d..0b3d9898015 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -87,7 +87,7 @@ function hashContent(content: string): string { return hash.digest('hex'); } -export function computeState(): PostinstallState { +export function computeState(options?: { ignoreNodeVersion?: boolean }): PostinstallState { const fileHashes: Record = {}; for (const filePath of collectInputFiles()) { const key = path.relative(root, filePath); @@ -97,7 +97,7 @@ export function computeState(): PostinstallState { // file may not be readable } } - return { nodeVersion: process.versions.node, fileHashes }; + return { nodeVersion: options?.ignoreNodeVersion ? '' : process.versions.node, fileHashes }; } export function computeContents(): Record { @@ -141,18 +141,23 @@ export function readSavedContents(): Record | undefined { // When run directly, output state as JSON for tooling (e.g. the vscode-extras extension). if (import.meta.filename === process.argv[1]) { - if (process.argv[2] === '--normalize-file') { - const filePath = process.argv[3]; + const args = new Set(process.argv.slice(2)); + + if (args.has('--normalize-file')) { + const filePath = process.argv[process.argv.indexOf('--normalize-file') + 1]; if (!filePath) { process.exit(1); } process.stdout.write(normalizeFileContent(filePath)); } else { + const ignoreNodeVersion = args.has('--ignore-node-version'); + const current = computeState({ ignoreNodeVersion }); + const saved = readSavedState(); console.log(JSON.stringify({ root, stateContentsFile, - current: computeState(), - saved: readSavedState(), + current, + saved: saved && ignoreNodeVersion ? { nodeVersion: '', fileHashes: saved.fileHashes } : saved, files: [...collectInputFiles(), stateFile], })); } From a4b2a1610d046132cb5e4037aeaadac2f3b97574 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:04:42 -0700 Subject: [PATCH 147/183] chore: bump flatted (#303340) --- build/vite/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index faa4cce0c45..8d0937b8faf 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -2117,9 +2117,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, From 5396fe38fc1c629e4d5d36e6f7910187c11e35c5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 20 Mar 2026 14:05:26 -0700 Subject: [PATCH 148/183] agentPlugins: allow paths in github sources (#303599) * agentPlugins: allow paths in github sources Though nonstandard, Github CLI plugins have started to do this. So this supports that. * fixup --- .../browser/agentPluginRepositoryService.ts | 16 ++++- .../contrib/chat/browser/pluginSources.ts | 39 +++++++++-- .../plugins/agentPluginRepositoryService.ts | 6 +- .../common/plugins/agentPluginServiceImpl.ts | 9 ++- .../plugins/pluginMarketplaceService.ts | 12 +++- .../agentPluginRepositoryService.test.ts | 67 +++++++++++++++++++ .../plugins/pluginMarketplaceService.test.ts | 17 ++++- 7 files changed, 153 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 92d569bc9be..155b5b23178 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -286,13 +286,27 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } } - async cleanupPluginSource(plugin: IMarketplacePlugin): Promise { + async cleanupPluginSource(plugin: IMarketplacePlugin, otherInstalledDescriptors?: readonly IPluginSourceDescriptor[]): Promise { const repo = this.getPluginSource(plugin.sourceDescriptor.kind); const cleanupDir = repo.getCleanupTarget(this._cacheRoot, plugin.sourceDescriptor); if (!cleanupDir) { return; } + // Skip deletion when another installed plugin shares the same + // cleanup target (e.g. same cloned repository with different sub-paths). + if (otherInstalledDescriptors) { + const shared = otherInstalledDescriptors.some(other => { + const otherRepo = this.getPluginSource(other.kind); + const otherTarget = otherRepo.getCleanupTarget(this._cacheRoot, other); + return otherTarget && isEqual(otherTarget, cleanupDir); + }); + if (shared) { + this._logService.info(`[${plugin.sourceDescriptor.kind}] Skipping cleanup of shared cache: ${cleanupDir.toString()}`); + return; + } + } + try { const exists = await this._fileService.exists(cleanupDir); if (exists) { diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts index 8ea0f30210d..383e3112e63 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginSources.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -8,7 +8,7 @@ import { CancelablePromise, timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { isWindows } from '../../../../base/common/platform.js'; -import { dirname, joinPath } from '../../../../base/common/resources.js'; +import { dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -79,18 +79,27 @@ abstract class AbstractGitPluginSource implements IPluginSource { protected abstract _displayLabel(descriptor: IPluginSourceDescriptor): string; getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined { + return this._getRepoDir(cacheRoot, descriptor); + } + + /** + * Returns the on-disk directory of the cloned repository. Subclasses that + * support a sub-path within a repository should override this to return the + * repository root, while {@link getInstallUri} returns root + sub-path. + */ + protected _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { return this.getInstallUri(cacheRoot, descriptor); } async ensure(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise { const descriptor = plugin.sourceDescriptor; - const repoDir = this.getInstallUri(cacheRoot, descriptor); + const repoDir = this._getRepoDir(cacheRoot, descriptor); const repoExists = await this._fileService.exists(repoDir); const label = this._displayLabel(descriptor); if (repoExists) { await this._checkoutRevision(repoDir, descriptor, options?.failureLabel ?? label); - return repoDir; + return this.getInstallUri(cacheRoot, descriptor); } const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", label); @@ -99,12 +108,12 @@ abstract class AbstractGitPluginSource implements IPluginSource { await this._cloneRepository(repoDir, this._cloneUrl(descriptor), progressTitle, failureLabel, ref); await this._checkoutRevision(repoDir, descriptor, failureLabel); - return repoDir; + return this.getInstallUri(cacheRoot, descriptor); } async update(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise { const descriptor = plugin.sourceDescriptor; - const repoDir = this.getInstallUri(cacheRoot, descriptor); + const repoDir = this._getRepoDir(cacheRoot, descriptor); const repoExists = await this._fileService.exists(repoDir); if (!repoExists) { this._logService.warn(`[${this.kind}] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`); @@ -242,14 +251,32 @@ export class RelativePathPluginSource implements IPluginSource { export class GitHubPluginSource extends AbstractGitPluginSource { readonly kind = PluginSourceKind.GitHub; + /** Returns the URI where the plugin content lives (repo root + optional sub-path). */ getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const repoDir = this._getRepoDir(cacheRoot, descriptor); + const gh = descriptor as IGitHubPluginSource; + if (gh.path) { + const normalizedPath = gh.path.trim().replace(/^\.?\/+|\/+$/g, ''); + if (normalizedPath) { + const target = joinPath(repoDir, normalizedPath); + if (isEqualOrParent(target, repoDir)) { + return target; + } + } + } + return repoDir; + } + + /** Returns the cloned repository root (without sub-path). */ + protected override _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { const gh = descriptor as IGitHubPluginSource; const [owner, repo] = gh.repo.split('/'); return joinPath(cacheRoot, 'github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha)); } getLabel(descriptor: IPluginSourceDescriptor): string { - return (descriptor as IGitHubPluginSource).repo; + const gh = descriptor as IGitHubPluginSource; + return gh.path ? `${gh.repo}/${gh.path}` : gh.repo; } protected _cloneUrl(descriptor: IPluginSourceDescriptor): string { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts index ea90fb6f832..7ad90d2fd26 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts @@ -102,9 +102,13 @@ export interface IAgentPluginRepositoryService { * the marketplace repository cache). For direct sources (github, url, npm, * pip) the cache directory is deleted. * + * When {@link otherInstalledDescriptors} is provided, deletion is skipped + * if any of those descriptors share the same cleanup target directory + * (e.g. multiple plugins installed from the same cloned repository). + * * This is best-effort: failures are logged but do not throw. */ - cleanupPluginSource(plugin: IMarketplacePlugin): Promise; + cleanupPluginSource(plugin: IMarketplacePlugin, otherInstalledDescriptors?: readonly IPluginSourceDescriptor[]): Promise; /** * Silently fetches remote refs for a cloned marketplace repository and diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index e06a82098a0..934888a9cce 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -1120,7 +1120,14 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover remove: () => { this._enablementModel.remove(stat.resource.toString()); this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri); - this._pluginRepositoryService.cleanupPluginSource(entry.plugin).catch(error => { + + // Pass remaining installed descriptors so the repository service + // can skip deletion when other plugins share the same cache dir. + const remaining = this._pluginMarketplaceService.installedPlugins.get(); + this._pluginRepositoryService.cleanupPluginSource( + entry.plugin, + remaining.map(e => e.plugin.sourceDescriptor), + ).catch(error => { this._logService.error('[MarketplaceAgentPluginDiscovery] Failed to clean up plugin source', error); }); }, diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 49a873828a9..b67baada8a6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -57,6 +57,7 @@ export interface IGitHubPluginSource { readonly repo: string; readonly ref?: string; readonly sha?: string; + readonly path?: string; } export interface IGitUrlPluginSource { @@ -113,6 +114,7 @@ interface IJsonPluginSource { readonly package?: string; readonly ref?: string; readonly sha?: string; + readonly path?: string; readonly version?: string; readonly registry?: string; } @@ -840,11 +842,16 @@ export function parsePluginSource( logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'sha' must be a full 40-character commit hash when provided`); return undefined; } + if (!isOptionalString(rawSource.path)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'path' must be a string when provided`); + return undefined; + } return { kind: PluginSourceKind.GitHub, repo: rawSource.repo, ref: rawSource.ref, sha: rawSource.sha, + path: rawSource.path, }; } case 'url': { @@ -930,7 +937,7 @@ export function getPluginSourceLabel(descriptor: IPluginSourceDescriptor): strin case PluginSourceKind.RelativePath: return descriptor.path || '.'; case PluginSourceKind.GitHub: - return descriptor.repo; + return descriptor.path ? `${descriptor.repo}/${descriptor.path}` : descriptor.repo; case PluginSourceKind.GitUrl: return descriptor.url; case PluginSourceKind.Npm: @@ -952,7 +959,8 @@ export function hasSourceChanged(installed: IPluginSourceDescriptor, marketplace switch (installed.kind) { case PluginSourceKind.GitHub: return installed.ref !== (marketplace as typeof installed).ref - || installed.sha !== (marketplace as typeof installed).sha; + || installed.sha !== (marketplace as typeof installed).sha + || installed.path !== (marketplace as typeof installed).path; case PluginSourceKind.GitUrl: return installed.ref !== (marketplace as typeof installed).ref || installed.sha !== (marketplace as typeof installed).sha; diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index 91dbe03786d..3e94219d5ae 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -445,5 +445,72 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(deleted.length, 1); assert.ok(deleted[0].includes('github.com/owner/repo')); }); + + test('skips deletion when another installed plugin shares the same cleanup target', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/a' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + // Another plugin from the same repo still installed + [{ kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/b' }], + ); + + assert.strictEqual(deleted.length, 0); + }); + + test('proceeds with deletion when no other plugin shares the cleanup target', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/a' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + // Only unrelated plugins remain + [{ kind: PluginSourceKind.GitHub, repo: 'other-owner/other-repo' }], + ); + + assert.ok(deleted.length >= 1); + assert.ok(deleted[0].includes('github.com/owner/repo')); + }); + + test('proceeds with deletion when otherInstalledDescriptors is empty', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + [], + ); + + assert.ok(deleted.length >= 1); + assert.ok(deleted[0].includes('github.com/owner/repo')); + }); }); }); 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 5e43f1377df..d66d3e7d785 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 @@ -271,12 +271,17 @@ suite('parsePluginSource', () => { test('parses github object source', () => { const result = parsePluginSource({ source: 'github', repo: 'owner/repo' }, undefined, logContext); - assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined }); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined, path: undefined }); }); test('parses github object source with ref and sha', () => { const result = parsePluginSource({ source: 'github', repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }, undefined, logContext); - assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', path: undefined }); + }); + + test('parses github object source with path', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo', path: 'plugins/my-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined, path: 'plugins/my-plugin' }); }); test('returns undefined for github source missing repo', () => { @@ -291,6 +296,10 @@ suite('parsePluginSource', () => { assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', sha: 'abc123' }, undefined, logContext), undefined); }); + test('returns undefined for github source with non-string path', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', path: 42 } as never, undefined, logContext), undefined); + }); + test('parses url object source', () => { const result = parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin.git' }, undefined, logContext); assert.deepStrictEqual(result, { kind: PluginSourceKind.GitUrl, url: 'https://gitlab.com/team/plugin.git', ref: undefined, sha: undefined }); @@ -364,6 +373,10 @@ suite('getPluginSourceLabel', () => { assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo' }), 'owner/repo'); }); + test('formats github source with path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/foo' }), 'owner/repo/plugins/foo'); + }); + test('formats url source', () => { assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }), 'https://example.com/repo.git'); }); From 8f46cf34c3ffa48a3f1e84cfaaf11623b087e58c Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Fri, 20 Mar 2026 14:12:58 -0700 Subject: [PATCH 149/183] integrate image carousel support in sessions. (#303615) --- .../chat/browser/newChatContextAttachments.ts | 19 +++++++++++++++++-- src/vs/sessions/sessions.common.main.ts | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 9acac072639..0c264d25f9a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -15,6 +15,9 @@ import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatImageCarouselService } from '../../../../workbench/contrib/chat/browser/chatImageCarouselService.js'; +import { coerceImageBuffer } from '../../../../workbench/contrib/chat/common/chatImageExtraction.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; @@ -83,6 +86,7 @@ export class NewChatContextAttachments extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, + @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, ) { super(); this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); @@ -133,8 +137,19 @@ export class NewChatContextAttachments extends Disposable { } } - // Click to open the resource - if (resource) { + // Click to open the resource or image + const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined; + if (imageData) { + pill.style.cursor = 'pointer'; + this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { + if (this.configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { + const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name }); + await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData); + } else if (resource) { + await this.openerService.open(resource, { fromUserGesture: true }); + } + })); + } else if (resource) { pill.style.cursor = 'pointer'; this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { await this.openerService.open(resource, { fromUserGesture: true }); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index e4be5c11daf..0202df84fa9 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -212,6 +212,7 @@ import '../workbench/contrib/chat/browser/chat.contribution.js'; import '../workbench/contrib/mcp/browser/mcp.contribution.js'; import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import '../workbench/contrib/chat/browser/contextContrib/chatContext.contribution.js'; +import '../workbench/contrib/imageCarousel/browser/imageCarousel.contribution.js'; // Interactive import '../workbench/contrib/interactive/browser/interactive.contribution.js'; From a283594a3f0654db5239be18e84428897bb77122 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:15:21 -0700 Subject: [PATCH 150/183] Remove unused `IChatSessionDto.id` field --- .../workbench/api/common/extHost.protocol.ts | 5 +- .../api/common/extHostChatSessions.ts | 8 +-- .../browser/mainThreadChatSessions.test.ts | 60 ++++++++----------- 3 files changed, 28 insertions(+), 45 deletions(-) diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 460af886d26..d02ae88a0f1 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3605,8 +3605,7 @@ export interface IChatNewSessionRequestDto { readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; } -export interface ChatSessionDto { - id: string; +export interface IChatSessionDto { resource: UriComponents; title?: string; history: Array; @@ -3648,7 +3647,7 @@ export interface ExtHostChatSessionsShape { $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; $newChatSessionItem(controllerHandle: number, request: IChatNewSessionRequestDto, token: CancellationToken): Promise | undefined>; - $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise; + $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise; $interruptChatSessionActiveResponse(providerHandle: number, sessionResource: UriComponents, requestId: string): Promise; $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise; $invokeChatSessionRequestHandler(providerHandle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 81d57e5ee1d..93fb3ecebf5 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -22,7 +22,7 @@ import { IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSe import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape, IChatNewSessionRequestDto } from './extHost.protocol.js'; +import { ChatSessionContentContextDto, IChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape, IChatNewSessionRequestDto } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; @@ -303,8 +303,6 @@ class ExtHostChatSession { } export class ExtHostChatSessions extends Disposable implements ExtHostChatSessionsShape { - private static _sessionHandlePool = 0; - private readonly _proxy: Proxied; private _itemControllerHandlePool = 0; @@ -506,7 +504,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { + async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { throw new Error(`No provider for handle ${handle}`); @@ -524,7 +522,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const controllerData = this.getChatSessionItemController(sessionResource.scheme); const sessionDisposables = new DisposableStore(); - const sessionId = ExtHostChatSessions._sessionHandlePool++; const id = sessionResource.toString(); const chatSession = new ExtHostChatSession(session, provider.extension, { sessionResource, @@ -554,7 +551,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } const { capabilities } = provider; return { - id: sessionId + '', resource: URI.revive(sessionResource), title: session.title, hasActiveResponseCallback: !!session.activeResponseCallback, diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 7fca163965d..9d690837308 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -7,8 +7,10 @@ import assert from 'assert'; import * as sinon from 'sinon'; import type * as vscode from 'vscode'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { asSinonMethodStub } from '../../../../base/test/common/sinonUtils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -16,13 +18,17 @@ import { ContextKeyService } from '../../../../platform/contextkey/browser/conte import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../platform/log/common/log.js'; +import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions/chatSessions.contribution.js'; -import { IChatAgentRequest, IChatAgentResult } from '../../../contrib/chat/common/participants/chatAgents.js'; import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionRequestHistoryItem, IChatSessionProviderOptionGroup, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; -import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; +import { IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; +import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; +import { IChatAgentRequest, IChatAgentResult } from '../../../contrib/chat/common/participants/chatAgents.js'; +import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js'; @@ -30,20 +36,14 @@ import { IExtensionService, nullExtensionDescription } from '../../../services/e import { IViewsService } from '../../../services/views/common/viewsService.js'; import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js'; import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js'; +import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionDto, IChatSessionProviderOptions, IChatSessionRequestHistoryItemDto } from '../../common/extHost.protocol.js'; +import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; import { ExtHostChatSessions } from '../../common/extHostChatSessions.js'; import { ExtHostCommands } from '../../common/extHostCommands.js'; import { ExtHostLanguageModels } from '../../common/extHostLanguageModels.js'; -import * as extHostTypes from '../../common/extHostTypes.js'; -import { ChatSessionDto, ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions, IChatSessionRequestHistoryItemDto } from '../../common/extHost.protocol.js'; -import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; import { IExtHostTelemetry } from '../../common/extHostTelemetry.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; -import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js'; -import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { Event } from '../../../../base/common/event.js'; +import * as extHostTypes from '../../common/extHostTypes.js'; import { AnyCallRPCProtocol } from '../common/testRPCProtocol.js'; -import { asSinonMethodStub } from '../../../../base/test/common/sinonUtils.js'; suite('ObservableChatSession', function () { let disposables: DisposableStore; @@ -90,10 +90,9 @@ suite('ObservableChatSession', function () { hasActiveResponseCallback?: boolean; hasRequestHandler?: boolean; hasForkHandler?: boolean; - } = {}): ChatSessionDto { + } = {}): IChatSessionDto { const id = options.id || 'test-id'; return { - id, resource: LocalChatSessionUri.forSession(id), title: options.title, history: options.history || [], @@ -517,8 +516,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/test-session`); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource, history: [], hasActiveResponseCallback: false, @@ -544,8 +542,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/test-session`); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource, title: 'My Session Title', history: [], @@ -569,8 +566,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/test-session`); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource, history: [], hasActiveResponseCallback: false, @@ -597,8 +593,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/test-session`); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource, history: [], hasActiveResponseCallback: false, @@ -625,8 +620,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/multi-turn-session`); - const sessionContent: ChatSessionDto = { - id: 'multi-turn-session', + const sessionContent: IChatSessionDto = { resource, history: [ { type: 'request', prompt: 'First question', participant: 'test-participant' }, @@ -705,8 +699,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/test-session`); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource, history: [], hasActiveResponseCallback: false, @@ -731,8 +724,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/test-session`); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource, history: [], hasActiveResponseCallback: false, @@ -765,8 +757,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(handle, sessionScheme); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource: URI.parse(`${sessionScheme}:/test-session`), history: [], hasActiveResponseCallback: false, @@ -824,8 +815,7 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resource = URI.parse(`${sessionScheme}:/test-session`); - const sessionContent: ChatSessionDto = { - id: 'test-session', + const sessionContent: IChatSessionDto = { resource, history: [], hasActiveResponseCallback: false, @@ -858,8 +848,7 @@ suite('MainThreadChatSessions', function () { const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`); // Session with options - const sessionContentWithOptions: ChatSessionDto = { - id: 'session-with-options', + const sessionContentWithOptions: IChatSessionDto = { resource: resourceWithOptions, history: [], hasActiveResponseCallback: false, @@ -870,8 +859,7 @@ suite('MainThreadChatSessions', function () { }; // Session without options - const sessionContentWithoutOptions: ChatSessionDto = { - id: 'session-without-options', + const sessionContentWithoutOptions: IChatSessionDto = { resource: resourceWithoutOptions, history: [], hasActiveResponseCallback: false, From 5f3aba5e00517abf3618f8e4cf73bdf20e54ce16 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:20:56 -0700 Subject: [PATCH 151/183] Make sure content part disposes of refs Fixes #303547 --- .../widget/chatContentParts/chatMarkdownContentPart.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 8197b51a71d..0ca080497f5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -15,7 +15,7 @@ import { coalesce } from '../../../../../../base/common/arrays.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { autorun, autorunSelfDisposable, derived } from '../../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; @@ -396,6 +396,13 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } + override dispose(): void { + super.dispose(); + + dispose(this.allRefs); + this.allRefs.length = 0; + } + private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined): IDisposableReference { const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock, sessionResource, requestId, inUndoStop); if (codemapperUri) { From cafda08d8f063b0bd2f456cd2333bb348382128d Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:02:15 -0700 Subject: [PATCH 152/183] Try to clean up inProgress handling for chat sessions This api is very strange. Reducing where it's exposed because it really should not exist --- .../chatSessions/chatSessions.contribution.ts | 51 +++++-------------- .../browser/chatStatus/chatStatusDashboard.ts | 26 ++++++++-- .../chat/common/chatSessionsService.ts | 4 +- .../test/common/mockChatSessionsService.ts | 11 ++-- 4 files changed, 38 insertions(+), 54 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index f98c6d170b6..eaa31bb7761 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -47,7 +47,7 @@ import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; -import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -295,7 +295,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _onDidChangeOptionGroups = this._register(new Emitter()); public get onDidChangeOptionGroups() { return this._onDidChangeOptionGroups.event; } - private readonly inProgressMap: Map = new Map(); + private readonly inProgressMap = new Map(); private readonly _sessionTypeOptions = new Map(); private readonly _sessionTypeNewSessionOptions = new Map(); @@ -351,23 +351,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } })); - this._register(this.onDidChangeSessionItems((delta) => { - const changedChatSessionTypes = new Set(); - for (const session of delta.addedOrUpdated ?? []) { - changedChatSessionTypes.add(getChatSessionType(session.resource)); - } - - for (const resource of delta.removed ?? []) { - changedChatSessionTypes.add(getChatSessionType(resource)); - } - - for (const chatSessionType of changedChatSessionTypes) { - this.updateInProgressStatus(chatSessionType).catch(error => { - this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error); - }); - } - })); - this._register(this._labelService.registerFormatter({ scheme: Schemas.copilotPr, formatting: { @@ -378,27 +361,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ })); } - public reportInProgress(chatSessionType: string, count: number): void { - let displayName: string | undefined; - - if (chatSessionType === AgentSessionProviders.Local) { - displayName = localize('chat.session.inProgress.local', "Local Agent"); - } else if (chatSessionType === AgentSessionProviders.Background) { - displayName = localize('chat.session.inProgress.background', "Background Agent"); - } else if (chatSessionType === AgentSessionProviders.Cloud) { - displayName = localize('chat.session.inProgress.cloud', "Cloud Agent"); - } else { - displayName = this._contributions.get(chatSessionType)?.contribution.displayName; + private reportInProgress(chatSessionType: string, count: number): void { + if (!this._itemControllers.has(chatSessionType)) { + this._logService.warn(`Attempted to report in-progress status for unknown chat session type '${chatSessionType}'`); } - if (displayName) { - this.inProgressMap.set(displayName, count); - } + this.inProgressMap.set(chatSessionType, count); this._onDidChangeInProgress.fire(); } - public getInProgress(): { displayName: string; count: number }[] { - return Array.from(this.inProgressMap.entries()).map(([displayName, count]) => ({ displayName, count })); + public getInProgress(): { chatSessionType: string; count: number }[] { + return Array.from(this.inProgressMap.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } private async updateInProgressStatus(chatSessionType: string): Promise { @@ -919,12 +892,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ disposables.add(controller.onDidChangeChatSessionItems(e => { this._onDidChangeSessionItems.fire(e); + this.updateInProgressStatus(chatSessionType); })); - this.updateInProgressStatus(chatSessionType).catch(error => { - this._logService.warn(`Failed to update initial progress status for '${chatSessionType}':`, error); - }); - return { dispose: () => { initialRefreshCts.cancel(); @@ -935,6 +905,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._itemControllers.delete(chatSessionType); this._onDidChangeItemsProviders.fire({ chatSessionType }); } + + // Remove any in-progress tracking for this provider since it's no longer available + this.updateInProgressStatus(chatSessionType); } }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index ad2cb70765e..61e8e655f15 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -48,6 +48,7 @@ import { Color } from '../../../../../base/common/color.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; +import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; const defaultChat = product.defaultChatAgent; @@ -235,12 +236,15 @@ export class ChatStatusDashboard extends DomWidget { } })); - for (const { displayName, count } of inProgress) { + for (const { chatSessionType, count } of inProgress) { if (count > 0) { - const text = localize('inProgressChatSession', "$(loading~spin) {0} in progress", displayName); - const chatSessionsElement = this.element.appendChild($('div.description')); - const parts = renderLabelWithIcons(text); - chatSessionsElement.append(...parts); + const displayName = this.getDisplayNameForChatSessionType(chatSessionType); + if (displayName) { + const text = localize('inProgressChatSession', "$(loading~spin) {0} in progress", displayName); + const chatSessionsElement = this.element.appendChild($('div.description')); + const parts = renderLabelWithIcons(text); + chatSessionsElement.append(...parts); + } } } } @@ -402,6 +406,18 @@ export class ChatStatusDashboard extends DomWidget { } } + private getDisplayNameForChatSessionType(chatSessionType: string): string | undefined { + if (chatSessionType === AgentSessionProviders.Local) { + return localize('chat.session.inProgress.local', "Local Agent"); + } else if (chatSessionType === AgentSessionProviders.Background) { + return localize('chat.session.inProgress.background', "Background Agent"); + } else if (chatSessionType === AgentSessionProviders.Cloud) { + return localize('chat.session.inProgress.cloud', "Cloud Agent"); + } else { + return this.chatSessionsService.getChatSessionContribution(chatSessionType)?.displayName; + } + } + private canUseChat(): boolean { if (!this.chatEntitlementService.sentiment.installed || this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted) { return false; // chat not installed or not enabled diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index f69e3ffb662..b5e5149cf98 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -315,8 +315,8 @@ export interface IChatSessionsService { */ refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise; - reportInProgress(chatSessionType: string, count: number): void; - getInProgress(): { displayName: string; count: number }[]; + /** @deprecated Use `getChatSessionItems` */ + getInProgress(): { chatSessionType: string; count: number }[]; // #endregion diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index c2eb2fef88a..f49d4fbbbd2 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -44,7 +44,7 @@ export class MockChatSessionsService implements IChatSessionsService { private contributions: IChatSessionsExtensionPoint[] = []; private optionGroups = new Map(); private sessionOptions = new ResourceMap(); - private inProgress = new Map(); + private inProgress = new Map(); // For testing: allow triggering events fireDidChangeItemsProviders(event: { chatSessionType: string }): void { @@ -125,13 +125,8 @@ export class MockChatSessionsService implements IChatSessionsService { })); } - reportInProgress(chatSessionType: string, count: number): void { - this.inProgress.set(chatSessionType, count); - this._onDidChangeInProgress.fire(); - } - - getInProgress(): { displayName: string; count: number }[] { - return Array.from(this.inProgress.entries()).map(([displayName, count]) => ({ displayName, count })); + getInProgress(): { chatSessionType: string; count: number }[] { + return Array.from(this.inProgress.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } registerChatSessionContentProvider(chatSessionType: string, provider: IChatSessionContentProvider): IDisposable { From a3512f01a6de2002be3348c2db36f6c15962a8e1 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:18:17 -0700 Subject: [PATCH 153/183] fix: enhance session resolution in sessionSupportsFork method (#303624) ref https://github.com/microsoft/vscode/issues/300501 --- .../chat/browser/chatSessions/chatSessions.contribution.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index f98c6d170b6..171d6bc3529 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1213,8 +1213,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public sessionSupportsFork(sessionResource: URI): boolean { - const resolved = this._resolveResource(sessionResource); - const session = this._sessions.get(resolved); + const session = this._sessions.get(sessionResource) + // Try to resolve in case an alias was used + ?? this._sessions.get(this._resolveResource(sessionResource)); return !!session?.session.forkSession; } From 4c055a03f988b8a23f4b19bf5b827136c99180e0 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:55:33 -0700 Subject: [PATCH 154/183] feat: add context key expressions for slash command visibility (#303626) This is how we should be registering slash commands. Not hard coding targets. It would be a good exercise to apply when clauses to the rest of these in the future. --- .../contrib/chat/browser/chatSlashCommands.ts | 7 ++++- .../input/editor/chatInputCompletions.ts | 31 +++++++++++-------- .../common/participants/chatSlashCommands.ts | 7 +++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 1111a9f58a2..b2bce851d6c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -12,6 +12,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IChatAgentService } from '../common/participants/chatAgents.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; import { IChatService } from '../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../common/constants.js'; @@ -28,6 +29,7 @@ import { IChatWidgetService } from './chat.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; import { Target } from '../common/promptSyntax/promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -163,7 +165,10 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - targets: [Target.VSCode, Target.GitHubCopilot] + when: ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.chatSessionSupportsFork + ), }, async (_prompt, _progress, _history, _location, sessionResource) => { await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource); })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 75ae8c409e4..32f493a7260 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -137,6 +137,9 @@ class SlashCommandCompletions extends Disposable { return { suggestions: slashCommands .filter(c => { + if (c.when && !widget.scopedContextKeyService.contextMatchesRules(c.when)) { + return false; + } if (!widget.lockedAgentId) { return true; } @@ -192,19 +195,21 @@ class SlashCommandCompletions extends Disposable { } return { - suggestions: slashCommands.map((c, i): CompletionItem => { - const withSlash = `${chatSubcommandLeader}${c.command}`; - return { - label: { label: withSlash, description: c.detail }, - insertText: c.executeImmediately ? '' : `${withSlash} `, - documentation: c.detail, - range, - filterText: `${chatAgentLeader}${c.command}`, - sortText: c.sortText ?? 'z'.repeat(i + 1), - kind: CompletionItemKind.Text, // The icons are disabled here anyway, - command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, - }; - }) + suggestions: slashCommands + .filter(c => !c.when || widget.scopedContextKeyService.contextMatchesRules(c.when)) + .map((c, i): CompletionItem => { + const withSlash = `${chatSubcommandLeader}${c.command}`; + return { + label: { label: withSlash, description: c.detail }, + insertText: c.executeImmediately ? '' : `${withSlash} `, + documentation: c.detail, + range, + filterText: `${chatAgentLeader}${c.command}`, + sortText: c.sortText ?? 'z'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway, + command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, + }; + }) }; } })); diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index f907b5afc48..d517710e084 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { IChatMessage } from '../languageModels.js'; @@ -39,6 +40,12 @@ export interface IChatSlashData { locations: ChatAgentLocation[]; modes?: ChatModeKind[]; targets?: Target[]; + + /** + * Optional context key expression that controls visibility of this command. + * When set, the command is only shown if the expression evaluates to true. + */ + when?: ContextKeyExpression; } export interface IChatSlashFragment { From ba9458c65cb489991b45498bb89b92df9a7c38d7 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:17:46 -0700 Subject: [PATCH 155/183] refactor: rename image carousel configuration and update related descriptions (#303630) * refactor: rename image carousel configuration and update related descriptions also make default true * update title * fix: update image carousel title and description to "Images Preview" --- .../contrib/chat/browser/chat.contribution.ts | 6 ----- .../contrib/chat/common/constants.ts | 2 +- .../browser/imageCarousel.contribution.ts | 22 ++++++++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index fbba16bb389..39b5e9d8b74 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -499,12 +499,6 @@ configurationRegistry.registerConfiguration({ type: 'boolean', tags: ['experimental'] }, - [ChatConfiguration.ImageCarouselEnabled]: { - default: true, - description: nls.localize('chat.imageCarousel.enabled', "Controls whether clicking an image attachment in chat opens the image carousel viewer."), - type: 'boolean', - tags: ['preview'] - }, [ChatConfiguration.ArtifactsEnabled]: { default: false, description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 1a41af35f77..75d30b88a44 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -58,7 +58,7 @@ export enum ChatConfiguration { ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', AutopilotEnabled = 'chat.autopilot.enabled', - ImageCarouselEnabled = 'chat.imageCarousel.enabled', + ImageCarouselEnabled = 'imageCarousel.chat.enabled', ArtifactsEnabled = 'chat.artifacts.enabled', } diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts index ad4e2f193d6..1e76ad4ed43 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -35,13 +35,19 @@ import { Limiter } from '../../../../base/common/async.js'; Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'imageCarousel', - title: localize('imageCarouselConfigurationTitle', "Image Carousel"), + title: localize('imageCarouselConfigurationTitle', "Images Preview"), type: 'object', properties: { 'imageCarousel.explorerContextMenu.enabled': { type: 'boolean', - default: false, - markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Image Carousel** option appears in the Explorer context menu. This is an experimental feature."), + default: true, + markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Images Preview** option appears in the Explorer context menu."), + tags: ['experimental'], + }, + 'imageCarousel.chat.enabled': { + type: 'boolean', + default: true, + description: localize('imageCarousel.chat.enabled', "Controls whether clicking an image attachment in chat opens the Images Preview viewer."), tags: ['experimental'], }, } @@ -53,7 +59,7 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane EditorPaneDescriptor.create( ImageCarouselEditor, ImageCarouselEditor.ID, - localize('imageCarouselEditor', "Image Carousel") + localize('imageCarouselEditor', "Images Preview") ), [ new SyncDescriptor(ImageCarouselEditorInput) @@ -112,7 +118,7 @@ class OpenImageInCarouselAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.openImageInCarousel', - title: localize2('openImageInCarousel', "Open Image in Carousel"), + title: localize2('openImageInCarousel', "Open in Images Preview"), f1: false }); } @@ -129,7 +135,7 @@ class OpenImageInCarouselAction extends Action2 { } else if (isSingleImageArgs(args)) { collection = { id: generateUuid(), - title: args.title ?? localize('imageCarousel.title', "Image Carousel"), + title: args.title ?? localize('imageCarousel.title', "Images Preview"), sections: [{ title: '', images: [{ @@ -201,7 +207,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { constructor() { super({ id: 'workbench.action.openImagesInCarousel', - title: localize2('openImagesInCarousel', "Open in Image Carousel"), + title: localize2('openImagesInCarousel', "Open in Images Preview"), f1: false, menu: [{ id: MenuId.ExplorerContext, @@ -308,7 +314,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { const collection: IImageCarouselCollection = { id: generateUuid(), - title: localize('imageCarousel.explorerTitle', "Image Carousel"), + title: localize('imageCarousel.explorerTitle', "Images Preview"), sections: [{ title: '', images, From 5ce6509b44ef4567da385254af426dc6ca79deae Mon Sep 17 00:00:00 2001 From: Shehab Sherif Date: Sat, 21 Mar 2026 01:18:13 +0200 Subject: [PATCH 156/183] Fix missing global flag in sanitizeId regex (#303603) * Fix missing global flag in sanitizeId regex The regex in sanitizeId was missing the 'g' flag, so only the first occurrence of '.' or '/' was replaced with '_'. Since settings IDs contain multiple dots (e.g. 'editor.font.size'), this meant subsequent dots were left in the sanitized ID. * Add regression test for sanitizeId global replacement Export sanitizeId and add a test verifying that all occurrences of '.' and '/' are replaced in generated tree element IDs, not just the first. --------- Co-authored-by: Shehab Sherif --- .../preferences/browser/settingsTreeModels.ts | 4 ++-- .../test/browser/settingsTreeModels.test.ts | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index 9e2704a79b6..eaf1dfe189a 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -751,8 +751,8 @@ export function inspectSetting(key: string, target: SettingsTarget, languageFilt return { isConfigured, inspected, targetSelector, inspectedLanguageOverrides, languageSelector: languageFilter }; } -function sanitizeId(id: string): string { - return id.replace(/[\.\/]/, '_'); +export function sanitizeId(id: string): string { + return id.replace(/[\.\/]/g, '_'); } export function settingKeyToDisplayFormat(key: string, groupId: string = '', isLanguageTagSetting: boolean = false): { category: string; label: string } { diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts index 98421c6ea63..0fd6956d676 100644 --- a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { settingKeyToDisplayFormat, parseQuery, IParsedQuery } from '../../browser/settingsTreeModels.js'; +import { settingKeyToDisplayFormat, parseQuery, IParsedQuery, sanitizeId } from '../../browser/settingsTreeModels.js'; suite('SettingsTree', () => { test('settingKeyToDisplayFormat', () => { @@ -329,5 +329,22 @@ suite('SettingsTree', () => { }); }); + test('sanitizeId replaces all dots and slashes', () => { + assert.deepStrictEqual( + [ + sanitizeId('root.editor.font.size'), + sanitizeId('group/subgroup/setting.key'), + sanitizeId('no-special-chars'), + sanitizeId('single.dot'), + ], + [ + 'root_editor_font_size', + 'group_subgroup_setting_key', + 'no-special-chars', + 'single_dot', + ] + ); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); From 231b7e7d762594c17d0b7be8427b0b2188f51163 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:30:52 -0700 Subject: [PATCH 157/183] Use unknown instead of any --- .../services/extensions/common/extensionsRegistry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index f9d57d5a53e..09dd590ee8a 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -643,7 +643,7 @@ export type removeArray = T extends Array ? X : T; export interface IExtensionPointDescriptor { extensionPoint: string; - deps?: IExtensionPoint[]; + deps?: IExtensionPoint[]; jsonSchema: IJSONSchema; defaultExtensionKind?: ExtensionKind[]; canHandleResolver?: boolean; @@ -674,7 +674,7 @@ export class ExtensionsRegistryImpl { return result; } - public getExtensionPoints(): ExtensionPoint[] { + public getExtensionPoints(): ExtensionPoint[] { return Array.from(this._extensionPoints.values()); } } From 0697f7d8d3502682db62a0494e2582ad4a54fda9 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:44:10 -0700 Subject: [PATCH 158/183] Mark many chat session related types as readonly These types are generally expected to be immutable --- src/vs/sessions/test/web.test.ts | 9 +- .../api/browser/mainThreadChatSessions.ts | 21 +++-- .../chat/common/chatService/chatService.ts | 16 ++-- .../chat/common/chatSessionsService.ts | 84 +++++++++---------- 4 files changed, 66 insertions(+), 64 deletions(-) diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index 760fd0000a0..eb740391a85 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -280,12 +280,11 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu // Add or update session in list const existing = this._sessionItems.find(s => s.resource.toString() === key); - let addedOrUpdated: IChatSessionItem | undefined = existing; - if (existing) { - existing.timing.lastRequestStarted = now; - existing.timing.lastRequestEnded = now; + let addedOrUpdated = existing ? { ...existing } : undefined; + if (addedOrUpdated) { + addedOrUpdated.timing = { ...addedOrUpdated.timing, lastRequestStarted: now, lastRequestEnded: now }; if (changes) { - existing.changes = changes; + addedOrUpdated.changes = changes; } } else { addedOrUpdated = { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 6be57655476..91214f4dd63 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -515,7 +515,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } // We can still get stats if there is no model or if fetching from model failed - if (!item.changes || !model) { + let changes = revive(item.changes); + if (!changes || !model) { const stats = (await this._chatService.getMetadataForSession(uri))?.stats; const diffs: IAgentSession['changes'] = { files: stats?.fileCount || 0, @@ -523,13 +524,13 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat deletions: stats?.removed || 0 }; if (hasValidDiff(diffs)) { - item.changes = diffs; + changes = diffs; } } return { ...item, - changes: revive(item.changes), + changes, resource: uri, iconPath: item.iconPath, tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, @@ -662,18 +663,20 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } private async handleSessionModelOverrides(model: IChatModel, session: Dto): Promise> { - // Override desciription if there's an in-progress count + const outgoingSession = { ...session }; + + // Override description if there's an in-progress count const inProgress = model.getRequests().filter(r => r.response && !r.response.isComplete); if (inProgress.length) { - session.description = this._chatSessionsService.getInProgressSessionDescription(model); + outgoingSession.description = this._chatSessionsService.getInProgressSessionDescription(model); } // Override changes // TODO: @osortega we don't really use statistics anymore, we need to clarify that in the API - if (!(session.changes instanceof Array)) { + if (!(outgoingSession.changes instanceof Array)) { const modelStats = await awaitStatsForSession(model); if (modelStats) { - session.changes = { + outgoingSession.changes = { files: modelStats.fileCount, insertions: modelStats.added, deletions: modelStats.removed @@ -683,10 +686,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // Override status if the models needs input if (model.lastRequest?.response?.state === ResponseModelState.NeedsInput) { - session.status = ChatSessionStatus.NeedsInput; + outgoingSession.status = ChatSessionStatus.NeedsInput; } - return session; + return outgoingSession; } private async _provideChatSessionContent(providerHandle: number, sessionResource: URI, token: CancellationToken): Promise { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 8e7dc8dc246..6516c694984 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1204,35 +1204,35 @@ export interface IChatCompleteResponse { } export interface IChatSessionStats { - fileCount: number; - added: number; - removed: number; + readonly fileCount: number; + readonly added: number; + readonly removed: number; } export type IChatSessionTiming = { /** * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. */ - created: number; + readonly created: number; /** * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * * Should be undefined if no requests have been made yet. */ - lastRequestStarted: number | undefined; + readonly lastRequestStarted: number | undefined; /** * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * * Should be undefined if the most recent request is still in progress or if no requests have been made yet. */ - lastRequestEnded: number | undefined; + readonly lastRequestEnded: number | undefined; }; interface ILegacyChatSessionTiming { - startTime: number; - endTime?: number; + readonly startTime: number; + readonly endTime?: number; } export function convertLegacyChatSessionTiming(timing: IChatSessionTiming | ILegacyChatSessionTiming): IChatSessionTiming { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index b5e5149cf98..0bf0011e3c0 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -25,48 +25,48 @@ export const enum ChatSessionStatus { } export interface IChatSessionCommandContribution { - name: string; - description: string; - when?: string; + readonly name: string; + readonly description: string; + readonly when?: string; } export interface IChatSessionProviderOptionItem { - id: string; - name: string; - description?: string; - locked?: boolean; - icon?: ThemeIcon; - default?: boolean; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly locked?: boolean; + readonly icon?: ThemeIcon; + readonly default?: boolean; // [key: string]: any; } export interface IChatSessionProviderOptionGroupCommand { - command: string; - title: string; - tooltip?: string; - arguments?: unknown[]; + readonly command: string; + readonly title: string; + readonly tooltip?: string; + readonly arguments?: readonly unknown[]; } export interface IChatSessionProviderOptionGroup { - id: string; - name: string; - description?: string; - items: IChatSessionProviderOptionItem[]; - searchable?: boolean; - onSearch?: (query: string, token: CancellationToken) => Thenable; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly items: IChatSessionProviderOptionItem[]; + readonly searchable?: boolean; + readonly onSearch?: (query: string, token: CancellationToken) => Thenable; /** * A context key expression that controls visibility of this option group picker. * When specified, the picker is only visible when the expression evaluates to true. * The expression can reference other option group values via `chatSessionOption.`. * Example: `"chatSessionOption.models == 'gpt-4'"` */ - when?: string; - icon?: ThemeIcon; + readonly when?: string; + readonly icon?: ThemeIcon; /** * Custom commands to show in the option group's picker UI. * These will be shown in a separate section at the end of the picker. */ - commands?: IChatSessionProviderOptionGroupCommand[]; + readonly commands?: IChatSessionProviderOptionGroupCommand[]; } export interface IChatSessionsExtensionPoint { @@ -107,28 +107,28 @@ export interface IChatSessionsExtensionPoint { } export interface IChatSessionItem { - resource: URI; - label: string; - iconPath?: ThemeIcon; - badge?: string | IMarkdownString; - description?: string | IMarkdownString; - status?: ChatSessionStatus; - tooltip?: string | IMarkdownString; - timing: IChatSessionTiming; - changes?: { - files: number; - insertions: number; - deletions: number; + readonly resource: URI; + readonly label: string; + readonly iconPath?: ThemeIcon; + readonly badge?: string | IMarkdownString; + readonly description?: string | IMarkdownString; + readonly status?: ChatSessionStatus; + readonly tooltip?: string | IMarkdownString; + readonly timing: IChatSessionTiming; + readonly changes?: { + readonly files: number; + readonly insertions: number; + readonly deletions: number; } | readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]; - archived?: boolean; - metadata?: { readonly [key: string]: unknown }; + readonly archived?: boolean; + readonly metadata?: { readonly [key: string]: unknown }; } export interface IChatSessionFileChange { - modifiedUri: URI; - originalUri?: URI; - insertions: number; - deletions: number; + readonly modifiedUri: URI; + readonly originalUri?: URI; + readonly insertions: number; + readonly deletions: number; } export interface IChatSessionFileChange2 { @@ -185,8 +185,8 @@ export interface IChatSession extends IDisposable { * Editing session transferred from a previously-untitled chat session in `onDidCommitChatSessionItem`. */ transferredState?: { - editingSession: IChatEditingSession | undefined; - inputState: ISerializableChatModelInputState | undefined; + readonly editingSession: IChatEditingSession | undefined; + readonly inputState: ISerializableChatModelInputState | undefined; }; requestHandler?: ( From 4631c88d066449df1152412d2a0c2c580ffa664b Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:59:21 -0700 Subject: [PATCH 159/183] Update test --- src/vs/sessions/test/web.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index eb740391a85..62208bc751a 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -31,6 +31,7 @@ import { IProcessEnvironment } from '../../base/common/platform.js'; import { Registry } from '../../platform/registry/common/platform.js'; import { InMemoryFileSystemProvider } from '../../platform/files/common/inMemoryFilesystemProvider.js'; import { VSBuffer } from '../../base/common/buffer.js'; +import { isEqual } from '../../base/common/resources.js'; /** * Mock files pre-seeded in the in-memory file system. These match the @@ -279,13 +280,14 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu })); // Add or update session in list - const existing = this._sessionItems.find(s => s.resource.toString() === key); - let addedOrUpdated = existing ? { ...existing } : undefined; + const existingIndex = this._sessionItems.findIndex(s => isEqual(s.resource, resource)); + let addedOrUpdated = existingIndex !== -1 ? { ...this._sessionItems[existingIndex] } : undefined; if (addedOrUpdated) { addedOrUpdated.timing = { ...addedOrUpdated.timing, lastRequestStarted: now, lastRequestEnded: now }; if (changes) { addedOrUpdated.changes = changes; } + this._sessionItems[existingIndex] = addedOrUpdated; } else { addedOrUpdated = { resource, From c2b719fb32e32d1d81b9bbb0f1c3356c03ecdbaa Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:00:12 -0700 Subject: [PATCH 160/183] Add one more readonly --- src/vs/workbench/contrib/chat/common/chatSessionsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 0bf0011e3c0..19f054550b7 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -51,7 +51,7 @@ export interface IChatSessionProviderOptionGroup { readonly id: string; readonly name: string; readonly description?: string; - readonly items: IChatSessionProviderOptionItem[]; + readonly items: readonly IChatSessionProviderOptionItem[]; readonly searchable?: boolean; readonly onSearch?: (query: string, token: CancellationToken) => Thenable; /** From caa7e571163ab4a8bbc5045b3b85527654315dca Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:01:11 -0700 Subject: [PATCH 161/183] Also mark array itself as readonly --- src/vs/workbench/contrib/chat/common/chatSessionsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 19f054550b7..bc68d0d77c7 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -66,7 +66,7 @@ export interface IChatSessionProviderOptionGroup { * Custom commands to show in the option group's picker UI. * These will be shown in a separate section at the end of the picker. */ - readonly commands?: IChatSessionProviderOptionGroupCommand[]; + readonly commands?: readonly IChatSessionProviderOptionGroupCommand[]; } export interface IChatSessionsExtensionPoint { From 2a09c1e0d08165f5a69d0b386bfe29b92c3dc55c Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 20 Mar 2026 17:04:58 -0700 Subject: [PATCH 162/183] Use built-in progress instead of custom shimmer for agent debug panel (#303636) --- .../browser/chatDebug/chatDebugLogsView.ts | 31 ++++++++----------- .../browser/chatDebug/media/chatDebug.css | 29 +++-------------- 2 files changed, 17 insertions(+), 43 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 269ce660fb2..d2f6573afe6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -7,6 +7,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { Dimension } from '../../../../../base/browser/dom.js'; import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { ProgressBar } from '../../../../../base/browser/ui/progressbar/progressbar.js'; import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -20,7 +21,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; -import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles, defaultProgressBarStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js'; import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; import { filterDebugEventsByText } from '../../common/chatDebugEvents.js'; @@ -70,7 +71,7 @@ export class ChatDebugLogsView extends Disposable { private readonly eventListener = this._register(new MutableDisposable()); private readonly sessionStateDisposable = this._register(new MutableDisposable()); private readonly refreshScheduler: RunOnceScheduler; - private shimmerRow!: HTMLElement; + private readonly progressBar: ProgressBar; constructor( parent: HTMLElement, @@ -171,6 +172,12 @@ export class ChatDebugLogsView extends Disposable { DOM.append(this.tableHeader, $('span.chat-debug-col-name', undefined, localize('chatDebug.col.name', "Name"))); DOM.append(this.tableHeader, $('span.chat-debug-col-details', undefined, localize('chatDebug.col.details', "Details"))); + // Progress bar (shown when session is in progress) + this.progressBar = this._register(new ProgressBar(mainColumn, { + ...defaultProgressBarStyles, + ariaLabel: localize('chatDebug.progressAriaLabel', "Chat debug logs loading progress") + })); + // Body container this.bodyContainer = DOM.append(mainColumn, $('.chat-debug-logs-body')); @@ -228,13 +235,6 @@ export class ChatDebugLogsView extends Disposable { { identityProvider, accessibilityProvider } )); - // Shimmer row (positioned right below last row to indicate session is running) - this.shimmerRow = DOM.append(this.bodyContainer, $('.chat-debug-logs-shimmer-row')); - this.shimmerRow.setAttribute('aria-label', localize('chatDebug.loadingMore', "Loading more events…")); - this.shimmerRow.setAttribute('aria-busy', 'true'); - DOM.append(this.shimmerRow, $('span.chat-debug-logs-shimmer-bar')); - DOM.hide(this.shimmerRow); - // Detail panel (sibling of main column so it aligns with table header) this.detailPanel = this._register(this.instantiationService.createInstance(ChatDebugDetailPanel, contentContainer)); this._register(this.detailPanel.onDidChangeWidth(() => { @@ -366,11 +366,6 @@ export class ChatDebugLogsView extends Disposable { } else { this.refreshTree(filtered); } - this.updateShimmerPosition(filtered.length); - } - - private updateShimmerPosition(itemCount: number): void { - this.shimmerRow.style.top = `${itemCount * 28}px`; } addEvent(event: IChatDebugEvent): void { @@ -426,14 +421,14 @@ export class ChatDebugLogsView extends Disposable { private trackSessionState(): void { if (!this.currentSessionResource) { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); this.sessionStateDisposable.clear(); return; } const model = this.chatService.getSession(this.currentSessionResource); if (!model) { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); this.sessionStateDisposable.clear(); return; } @@ -441,9 +436,9 @@ export class ChatDebugLogsView extends Disposable { this.sessionStateDisposable.value = autorun(reader => { const inProgress = model.requestInProgress.read(reader); if (inProgress) { - DOM.show(this.shimmerRow); + this.progressBar.infinite(); } else { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); } }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index eade72c676f..5709e69513e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -331,6 +331,10 @@ .chat-debug-table-header .chat-debug-col-details { flex: 1; } +.chat-debug-logs-main > .monaco-progress-container { + height: 2px; + flex-shrink: 0; +} .chat-debug-logs-content { display: flex; flex-direction: row; @@ -402,31 +406,6 @@ .chat-debug-log-row.chat-debug-log-trace { opacity: 0.7; } -.chat-debug-logs-shimmer-row { - position: absolute; - left: 0; - right: 0; - display: flex; - align-items: center; - padding: 0 16px; - height: 28px; - gap: 40px; - pointer-events: none; -} -.chat-debug-logs-shimmer-bar { - flex: 1; - height: 10px; - border-radius: 3px; - background: linear-gradient( - 90deg, - var(--vscode-descriptionForeground) 25%, - var(--vscode-chat-thinkingShimmer, rgba(255, 255, 255, 0.3)) 50%, - var(--vscode-descriptionForeground) 75% - ); - background-size: 200% 100%; - animation: chat-debug-shimmer 2s linear infinite; - opacity: 0.15; -} .chat-debug-detail-panel { flex-shrink: 0; display: flex; From a7fffd156ca2aa9fd1f9167d4fd46e25f6494b03 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 20 Mar 2026 17:16:33 -0700 Subject: [PATCH 163/183] Enable starting sessions on remote agent hosts in sessions app (#303631) * Enable starting sessions on remote agent hosts in sessions app Co-authored-by: Copilot * Fix Co-authored-by: Copilot * fix Co-authored-by: Copilot * fix Co-authored-by: Copilot * fix test Co-authored-by: Copilot --------- Co-authored-by: Copilot --- build/lib/i18n.resources.json | 4 + .../platform/agentHost/common/agentService.ts | 1 + .../state/protocol/action-origin.generated.ts | 2 +- .../common/state/protocol/actions.ts | 2 +- .../common/state/protocol/commands.ts | 67 +++- .../agentHost/common/state/protocol/errors.ts | 11 +- .../common/state/protocol/messages.ts | 5 +- .../common/state/protocol/notifications.ts | 52 ++- .../common/state/protocol/reducers.ts | 2 +- .../agentHost/common/state/protocol/state.ts | 113 ++++++- .../common/state/protocol/version/registry.ts | 3 +- .../agentHost/common/state/sessionActions.ts | 2 + .../common/state/versions/versionRegistry.ts | 1 + .../remoteAgentHostProtocolClient.ts | 13 +- .../platform/agentHost/node/agentHostMain.ts | 1 + .../platform/agentHost/node/agentService.ts | 1 + .../agentHost/node/agentSideEffects.ts | 1 + .../agentHost/node/copilot/copilotAgent.ts | 3 +- .../agentHost/node/protocolServerHandler.ts | 2 +- .../contrib/chat/browser/newChatViewPane.ts | 50 ++- .../contrib/chat/browser/newSession.ts | 81 ++++- .../contrib/chat/browser/workspacePicker.ts | 171 ++++++++-- .../chat/test/browser/workspacePicker.test.ts | 196 +++++++++++ .../contrib/remoteAgentHost/ARCHITECTURE.md | 309 ++++++++++++++++++ .../browser/agentHostFileSystemProvider.ts | 3 +- .../browser/remoteAgentHost.contribution.ts | 44 ++- .../agentHostFileSystemProvider.test.ts | 7 + .../browser/sessionsManagementService.ts | 12 +- .../sessions/browser/sessionsViewPane.ts | 3 +- .../sessions/common/sessionWorkspace.ts | 16 +- .../test/common/sessionWorkspace.test.ts | 46 +++ .../agentHostSessionListController.ts | 14 + .../browser/agentSessions/agentSessions.ts | 8 + .../agentSessions/agentSessionsViewer.ts | 13 + .../contrib/chat/browser/chat.contribution.ts | 19 +- .../dialogs/browser/simpleFileDialog.ts | 59 +++- 36 files changed, 1253 insertions(+), 84 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts create mode 100644 src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md create mode 100644 src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 76c1462e389..ade53b78639 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -668,6 +668,10 @@ "name": "vs/sessions/contrib/logs", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/remoteAgentHost", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/sessions", "project": "vscode-sessions" diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 7cbe07bdd7e..94b2c6cb56d 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -32,6 +32,7 @@ export interface IAgentSessionMetadata { readonly startTime: number; readonly modifiedTime: number; readonly summary?: string; + readonly workingDirectory?: string; } export type AgentProvider = string; diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 8dfc004dcbd..4c38cb047de 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 40f4b2734f4..3b5eb0b636e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ToolCallConfirmationReason, ToolCallCancellationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type IPermissionRequest } from './state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 676841e0728..34c445623f7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; @@ -172,7 +172,7 @@ export interface ICreateSessionParams { /** Model ID to use */ model?: string; /** Working directory for the session */ - workingDirectory?: string; + workingDirectory?: URI; } // ─── disposeSession ────────────────────────────────────────────────────────── @@ -255,25 +255,31 @@ export const enum ContentEncoding { * { "jsonrpc": "2.0", "id": 10, "result": { * "data": "iVBORw0KGgo...", * "encoding": "base64", - * "mimeType": "image/png" + * "contentType": "image/png" * }} * ``` */ export interface IFetchContentParams { /** Content URI from a `ContentRef` */ uri: string; + /** Preferred encoding for the returned data (default: server-chosen) */ + encoding?: ContentEncoding; } /** * Result of the `fetchContent` command. + * + * The server SHOULD honor the `encoding` requested in the params. If the + * server cannot provide the requested encoding, it MUST fall back to either + * `base64` or `utf-8`. */ export interface IFetchContentResult { /** Content encoded as a string */ data: string; /** How `data` is encoded */ encoding: ContentEncoding; - /** MIME type of the content */ - mimeType?: string; + /** Content type (e.g. `"image/png"`, `"text/plain"`) */ + contentType?: string; } // ─── browseDirectory ──────────────────────────────────────────────────────── @@ -427,3 +433,54 @@ export interface IBrowseDirectoryEntry { /** Whether this entry is a directory */ isDirectory: boolean; } + +// ─── authenticate ──────────────────────────────────────────────────────────── + +/** + * Pushes a Bearer token for a protected resource. The `resource` field MUST + * match an `IProtectedResourceMetadata.resource` value declared by an agent + * in `IAgentInfo.protectedResources`. + * + * Tokens are delivered using [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) + * (Bearer Token Usage) semantics. The client obtains the token from the + * authorization server(s) listed in the resource's metadata and pushes it + * to the server via this command. + * + * @category Commands + * @method authenticate + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 3, "method": "authenticate", + * "params": { "resource": "https://api.github.com", "token": "gho_xxxx" } } + * + * // Server → Client (success) + * { "jsonrpc": "2.0", "id": 3, "result": {} } + * + * // Server → Client (failure — invalid token) + * { "jsonrpc": "2.0", "id": 3, "error": { "code": -32007, "message": "Invalid token" } } + * ``` + */ +export interface IAuthenticateParams { + /** + * The protected resource identifier. MUST match a `resource` value from + * `IProtectedResourceMetadata` declared in `IAgentInfo.protectedResources`. + */ + resource: string; + /** Bearer token obtained from the resource's authorization server */ + token: string; +} + +/** + * Result of the `authenticate` command. + * + * An empty object on success. If the token is invalid or the resource is + * unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` + * `-32007` or `InvalidParams` `-32602`). + */ +export interface IAuthenticateResult { +} diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index 638189c2bc1..d8f1d609b78 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── @@ -48,6 +48,15 @@ export const AhpErrorCodes = { UnsupportedProtocolVersion: -32005, /** The requested content URI does not exist */ ContentNotFound: -32006, + /** + * A command failed because the client has not authenticated for a required + * protected resource. The `data` field of the JSON-RPC error SHOULD contain + * an `IProtectedResourceMetadata[]` array describing the resources that + * require authentication. + * + * @see {@link /specification/authentication | Authentication} + */ + AuthRequired: -32007, } as const; /** Union type of all AHP application error codes. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 395da78f6ea..edbb71701d1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -5,9 +5,9 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams } from './commands.js'; +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; import type { IActionEnvelope } from './actions.js'; import type { IProtocolNotification } from './notifications.js'; @@ -67,6 +67,7 @@ export interface ICommandMap { 'fetchContent': { params: IFetchContentParams; result: IFetchContentResult }; 'browseDirectory': { params: IBrowseDirectoryParams; result: IBrowseDirectoryResult }; 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; + 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; } // ─── Notification Maps ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts index 3a55ca3b658..ea497c9127b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -5,10 +5,22 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISessionSummary } from './state.js'; +/** + * Reason why authentication is required. + * + * @category Protocol Notifications + */ +export const enum AuthRequiredReason { + /** The client has not yet authenticated for the resource */ + Required = 'required', + /** A previously valid token has expired or been revoked */ + Expired = 'expired', +} + // ─── Protocol Notifications ────────────────────────────────────────────────── /** @@ -19,6 +31,7 @@ import type { URI, ISessionSummary } from './state.js'; export const enum NotificationType { SessionAdded = 'notify/sessionAdded', SessionRemoved = 'notify/sessionRemoved', + AuthRequired = 'notify/authRequired', } /** @@ -78,9 +91,44 @@ export interface ISessionRemovedNotification { session: URI; } +/** + * Sent by the server when a protected resource requires (re-)authentication. + * + * This notification is sent when a previously valid token expires or is + * revoked, or when the server discovers a new authentication requirement. + * Clients should obtain a fresh token and push it via the `authenticate` + * command. + * + * @category Protocol Notifications + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "notification", + * "params": { + * "notification": { + * "type": "notify/authRequired", + * "resource": "https://api.github.com", + * "reason": "expired" + * } + * } + * } + * ``` + */ +export interface IAuthRequiredNotification { + type: NotificationType.AuthRequired; + /** The protected resource identifier that requires authentication */ + resource: string; + /** Why authentication is required */ + reason?: AuthRequiredReason; +} + /** * Discriminated union of all protocol notifications. */ export type IProtocolNotification = | ISessionAddedNotification - | ISessionRemovedNotification; + | ISessionRemovedNotification + | IAuthRequiredNotification; diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index ce78d37dc40..4aa21b64e8b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ActionType } from './actions.js'; import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, type IRootState, type ISessionState, type IToolCallState, type IToolCallCompletedState, type IToolCallCancelledState, type ITurn } from './state.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index a037ca22059..a2d6e1f8a50 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -20,6 +20,72 @@ export type URI = string; */ export type StringOrMarkdown = string | { markdown: string }; +// ─── Protected Resource Metadata (RFC 9728) ───────────────────────────────── + +/** + * Describes a protected resource's authentication requirements using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) (OAuth 2.0 + * Protected Resource Metadata) semantics. + * + * Field names use snake_case to match the RFC 9728 JSON format. + * + * @category Authentication + * @see {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} + */ +export interface IProtectedResourceMetadata { + /** + * REQUIRED. The protected resource's resource identifier, a URL using the + * `https` scheme with no fragment component (e.g. `"https://api.github.com"`). + */ + resource: string; + + /** OPTIONAL. Human-readable name of the protected resource. */ + resource_name?: string; + + /** OPTIONAL. JSON array of OAuth authorization server identifier URLs. */ + authorization_servers?: string[]; + + /** OPTIONAL. URL of the protected resource's JWK Set document. */ + jwks_uri?: string; + + /** RECOMMENDED. JSON array of OAuth 2.0 scope values used in authorization requests. */ + scopes_supported?: string[]; + + /** OPTIONAL. JSON array of Bearer Token presentation methods supported. */ + bearer_methods_supported?: string[]; + + /** OPTIONAL. JSON array of JWS signing algorithms supported. */ + resource_signing_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (alg) supported. */ + resource_encryption_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (enc) supported. */ + resource_encryption_enc_values_supported?: string[]; + + /** OPTIONAL. URL of human-readable documentation for the resource. */ + resource_documentation?: string; + + /** OPTIONAL. URL of the resource's data-usage policy. */ + resource_policy_uri?: string; + + /** OPTIONAL. URL of the resource's terms of service. */ + resource_tos_uri?: string; + + /** + * AHP extension. Whether authentication is required for this resource. + * + * - `true` (default) — the agent cannot be used without a valid token. + * The server SHOULD return `AuthRequired` (`-32007`) if the client + * attempts to use the agent without authenticating. + * - `false` — the agent works without authentication but MAY offer + * enhanced capabilities when a token is provided. + * + * Clients SHOULD treat an absent field the same as `true`. + */ + required?: boolean; +} + // ─── Root State ────────────────────────────────────────────────────────────── /** @@ -57,6 +123,18 @@ export interface IAgentInfo { description: string; /** Available models for this agent */ models: ISessionModelInfo[]; + /** + * Protected resources this agent requires authentication for. + * + * Each entry describes an OAuth 2.0 protected resource using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. + * Clients should obtain tokens from the declared `authorization_servers` + * and push them via the `authenticate` command before creating sessions + * with this agent. + * + * @see {@link /specification/authentication | Authentication} + */ + protectedResources?: IProtectedResourceMetadata[]; } /** @@ -117,6 +195,8 @@ export interface ISessionState { serverTools?: IToolDefinition[]; /** The client currently providing tools and interactive capabilities to this session */ activeClient?: ISessionActiveClient; + /** The working directory URI for this session */ + workingDirectory?: URI; /** Completed turns */ turns: ITurn[]; /** Currently in-progress turn */ @@ -158,6 +238,8 @@ export interface ISessionSummary { modifiedAt: number; /** Currently selected model */ model?: string; + /** The working directory URI for this session */ + workingDirectory?: URI; } // ─── Turn Types ────────────────────────────────────────────────────────────── @@ -578,6 +660,7 @@ export interface IToolAnnotations { export const enum ToolResultContentType { Text = 'text', Binary = 'binary', + FileEdit = 'fileEdit', } /** @@ -608,17 +691,41 @@ export interface IToolResultBinaryContent { contentType: string; } +/** + * Describes a file modification performed by a tool. + * + * Clients can use the `beforeURI`/`afterURI` pair to render a diff view. + * + * @category Tool Result Content + */ +export interface IToolResultFileEditContent { + type: ToolResultContentType.FileEdit; + /** URI of the file content before the edit */ + beforeURI: URI; + /** URI of the file content after the edit */ + afterURI: URI; + /** Optional diff display metadata */ + diff?: { + /** Number of items added (e.g., lines for text files, cells for notebooks) */ + added?: number; + /** Number of items removed (e.g., lines for text files, cells for notebooks) */ + removed?: number; + }; +} + /** * Content block in a tool result. * - * Mirrors the content blocks in MCP `CallToolResult.content`, plus `IContentRef` - * for lazy-loading large results (an AHP extension). + * Mirrors the content blocks in MCP `CallToolResult.content`, plus + * `IContentRef` for lazy-loading large results and `IToolResultFileEditContent` + * for file edit diffs (AHP extensions). * * @category Tool Result Content */ export type IToolResultContent = | IToolResultTextContent | IToolResultBinaryContent + | IToolResultFileEditContent | IContentRef; // ─── Permission Types ──────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 94193f19930..1e6dcd41b1c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ 3116861 +// Synced from agent-host-protocol @ 409b385 import { ActionType, type IStateAction } from '../actions.js'; import { NotificationType, type IProtocolNotification } from '../notifications.js'; @@ -69,6 +69,7 @@ export function isActionKnownToVersion(action: IStateAction, clientVersion: numb export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification['type']]: number } = { [NotificationType.SessionAdded]: 1, [NotificationType.SessionRemoved]: 1, + [NotificationType.AuthRequired]: 1, }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index e1242a2a995..7cf64cba44a 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -49,8 +49,10 @@ export { export { NotificationType, + AuthRequiredReason, type ISessionAddedNotification, type ISessionRemovedNotification, + type IAuthRequiredNotification, } from './protocol/notifications.js'; // ---- Local aliases for short names ------------------------------------------ diff --git a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts index ed6aa21ebcd..5412b4f608b 100644 --- a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts +++ b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts @@ -54,6 +54,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe export const NOTIFICATION_INTRODUCED_IN: { readonly [K in INotification['type']]: number } = { 'notify/sessionAdded': 1, 'notify/sessionRemoved': 1, + 'notify/authRequired': 1, }; // ---- Runtime filtering helpers ---------------------------------------------- diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index bbe0e722662..b2cfa7eee0c 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -87,7 +87,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC clientId: this._clientId, }); this._serverSeq = result.serverSeq; - this._defaultDirectory = result.defaultDirectory; + // defaultDirectory arrives from the protocol as either a URI string + // (e.g. "file:///Users/roblou") or a serialized URI object + // ({ scheme, path, ... }). Extract just the filesystem path. + if (result.defaultDirectory) { + const dir = result.defaultDirectory; + if (typeof dir === 'string') { + this._defaultDirectory = URI.parse(dir).path; + } else { + this._defaultDirectory = URI.revive(dir).path; + } + } } /** @@ -179,6 +189,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC startTime: s.createdAt, modifiedTime: s.modifiedAt, summary: s.title, + workingDirectory: typeof s.workingDirectory === 'string' ? s.workingDirectory : undefined, })); } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 69cd971c22e..20df9b72167 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -145,6 +145,7 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog status: SessionStatus.Idle, createdAt: s.startTime, modifiedAt: s.modifiedTime, + workingDirectory: s.workingDirectory, })); }, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8b375c9cc95..7dad86b7ad3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -153,6 +153,7 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: config?.workingDirectory, }; this._stateManager.createSession(summary); this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 3bdab46e55d..efe3e9c8276 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -199,6 +199,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: command.workingDirectory, }; this._stateManager.createSession(summary); this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session }); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 030b3cfe2c7..95c0d966482 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -156,11 +156,12 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing sessions...'); const client = await this._ensureClient(); const sessions = await client.listSessions(); - const result = sessions.map(s => ({ + const result: IAgentSessionMetadata[] = sessions.map(s => ({ session: AgentSession.uri(this.id, s.sessionId), startTime: s.startTime.getTime(), modifiedTime: s.modifiedTime.getTime(), summary: s.summary, + workingDirectory: typeof s.context?.cwd === 'string' ? s.context.cwd : undefined, })); this._logService.info(`[Copilot] Found ${result.length} sessions`); return result; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index ef47a34c59f..a7e1ecc5b59 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -54,7 +54,7 @@ function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { * Methods handled by the request dispatcher. Excludes `initialize` and * `reconnect` which are handled during the handshake phase. */ -type RequestMethod = Exclude; +type RequestMethod = Exclude; /** * Typed handler map: each key is a request method, each value is a handler diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index edb09c2187c..331edec68ab 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -37,7 +37,7 @@ import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js' import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -56,7 +56,7 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { SessionTypePicker, IsolationPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; -import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; +import { AgentHostNewSession, INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; import { CloudModelPicker } from './modelPicker.js'; import { WorkspacePicker } from './workspacePicker.js'; import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; @@ -70,6 +70,8 @@ import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/ import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; import { NewChatPermissionPicker } from './newChatPermissionPicker.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { getRemoteAgentHostSessionTarget } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; const MIN_EDITOR_HEIGHT = 50; @@ -180,6 +182,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { @IGitService private readonly gitService: IGitService, @IStorageService private readonly storageService: IStorageService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, ) { super(); this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); @@ -325,7 +328,20 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } private async _createNewSession(project?: SessionWorkspace): Promise { - const target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + const isAgentHost = project?.isRemoteAgentHost ?? false; + let target: AgentSessionTarget; + if (isAgentHost) { + // Find the matching remote agent host session type from the URI authority + // TODO@roblourens HACK - view should not do this + const remoteTarget = getRemoteAgentHostSessionTarget(this.remoteAgentHostService.connections, project!.uri.authority); + if (!remoteTarget) { + this.logService.error(`Failed to find remote agent host session type for authority: ${project!.uri.authority}`); + return; + } + target = remoteTarget; + } else { + target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + } const resource = getResourceForNewChatSession({ type: target, @@ -334,7 +350,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { }); try { - const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource); + const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource, { agentHost: isAgentHost }); if (project) { session.setProject(project); } @@ -370,7 +386,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._sessionTypePicker.setProject(session.project); - if (session instanceof RemoteNewSession) { + if (session instanceof AgentHostNewSession) { + this._renderAgentHostSessionPickers(); + } else if (session instanceof RemoteNewSession) { this._renderRemoteSessionPickers(session, true); listeners.add(session.onDidChangeOptionGroups(() => { this._renderRemoteSessionPickers(session); @@ -688,6 +706,24 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._workspacePicker.render(pickersRow); } + // --- Agent Host session pickers --- + + /** + * Agent Host sessions use the standard model picker and mode picker + * but don't need repo, folder, isolation, branch, or cloud option pickers. + */ + private _renderAgentHostSessionPickers(): void { + this._clearAllPickers(); + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = ''; + } + this._modePicker.setVisible(true); + this._permissionPicker.setVisible(false); + this._cloudModelPicker.setVisible(false); + this._branchPicker.setVisible(false); + this._isolationPicker.setVisible(false); + } + // --- Local session pickers --- private _renderLocalSessionPickers(): void { @@ -960,11 +996,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { * For Local/Background targets, checks the folder picker. * For other targets, checks extension-contributed repo/folder option groups. */ - private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionProviders): boolean { + private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionTarget): boolean { return !!this._newSession.value?.project; } - private _openRepoOrFolderPicker(_sessionType: AgentSessionProviders): void { + private _openRepoOrFolderPicker(_sessionType: AgentSessionTarget): void { this._workspacePicker.showPicker(); } diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index b6d439ec696..d567b8ffbb8 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IsolationMode } from './sessionTargetPicker.js'; import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -32,7 +32,7 @@ export interface ISessionOptionGroup { */ export interface INewSession extends IDisposable { readonly resource: URI; - readonly target: AgentSessionProviders; + readonly target: AgentSessionTarget; readonly project: SessionWorkspace | undefined; readonly isolationMode: IsolationMode | undefined; readonly branch: string | undefined; @@ -206,7 +206,7 @@ export class RemoteNewSession extends Disposable implements INewSession { constructor( readonly resource: URI, - readonly target: AgentSessionProviders, + readonly target: AgentSessionTarget, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { @@ -365,3 +365,78 @@ function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean { return group.id === 'repositories'; } + +/** + * New session for agent host sessions (local or remote agent host processes). + * Agent host sessions use local model and mode pickers but don't need + * isolation mode, branch selection, or cloud option groups. + */ +export class AgentHostNewSession extends Disposable implements INewSession { + + private _project: SessionWorkspace | undefined; + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + private _query: string | undefined; + private _attachedContext: IChatRequestVariableEntry[] | undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + readonly selectedOptions = new Map(); + + get project(): SessionWorkspace | undefined { return this._project; } + get isolationMode(): undefined { return undefined; } + get branch(): undefined { return undefined; } + get modelId(): string | undefined { return this._modelId; } + get mode(): IChatMode | undefined { return this._mode; } + get query(): string | undefined { return this._query; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get disabled(): boolean { return false; } + + constructor( + readonly resource: URI, + readonly target: AgentSessionTarget, + ) { + super(); + } + + setProject(project: SessionWorkspace): void { + this._project = project; + this._onDidChange.fire('repoUri'); + } + + setIsolationMode(_mode: IsolationMode): void { + // No-op for agent host sessions + } + + setBranch(_branch: string | undefined): void { + // No-op for agent host sessions + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + } + + setMode(mode: IChatMode | undefined): void { + if (this._mode?.id !== mode?.id) { + this._mode = mode; + this._onDidChange.fire('agent'); + } + } + + setQuery(query: string): void { + this._query = query; + } + + setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void { + this._attachedContext = context; + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts index a31509424fe..83d70b4c131 100644 --- a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts @@ -18,6 +18,10 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { agentHostAuthority } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; +import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_PROJECT = 'sessions.lastPickedProject'; @@ -33,6 +37,7 @@ const LEGACY_STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos'; const COMMAND_BROWSE_FOLDERS = 'command:browseFolders'; const COMMAND_BROWSE_REPOS = 'command:browseRepos'; +const COMMAND_BROWSE_REMOTE_AGENT_HOSTS = 'command:browseRemoteAgentHosts'; /** * Serializable form of a project entry for storage. @@ -40,6 +45,8 @@ const COMMAND_BROWSE_REPOS = 'command:browseRepos'; interface IStoredProject { readonly uri: UriComponents; readonly checked?: boolean; + /** Cached display name for remote agent host connections. */ + readonly remoteName?: string; } /** @@ -72,6 +79,8 @@ export class WorkspacePicker extends Disposable { @IFileDialogService private readonly fileDialogService: IFileDialogService, @ICommandService private readonly commandService: ICommandService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, + @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(); @@ -200,6 +209,8 @@ export class WorkspacePicker extends Disposable { this._browseForFolder(); } else if (uriStr === COMMAND_BROWSE_REPOS) { this._browseForRepo(); + } else if (uriStr === COMMAND_BROWSE_REMOTE_AGENT_HOSTS) { + this._browseForRemoteAgentHost(); } else { this._selectProject(this._fromStored(item)); } @@ -256,7 +267,7 @@ export class WorkspacePicker extends Disposable { private _selectProject(project: SessionWorkspace, fireEvent = true): void { this._selectedProject = project; - const stored = this._toStored(project); + const stored = this._withCachedRemoteName(this._toStored(project)); this._addToRecents(stored); this.storageService.store(STORAGE_KEY_LAST_PROJECT, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); @@ -292,7 +303,65 @@ export class WorkspacePicker extends Disposable { } } + private async _browseForRemoteAgentHost(): Promise { + const connections = this.remoteAgentHostService.connections; + if (connections.length === 0) { + return; + } + + // Show remote picker even with a single connection so the user + // can see which remote they are connecting to. + let selectedAddress: string; + let selectedName: string; + let defaultDirectory: string | undefined; + { + const picks = connections.map(c => ({ + label: c.name, + description: c.address, + address: c.address, + defaultDirectory: c.defaultDirectory, + })); + + const picked = await this.quickInputService.pick(picks, { + title: localize('selectRemote', "Select Remote"), + placeHolder: localize('selectRemotePlaceholder', "Choose a remote agent host"), + }); + if (!picked) { + return; + } + selectedAddress = picked.address; + selectedName = picked.label; + defaultDirectory = picked.defaultDirectory; + } + + // Open a folder picker scoped to the remote filesystem. + // The defaultUri carries both the scheme (agenthost) and authority + // (sanitized address), so SimpleFileDialog stays scoped to this + // particular remote connection. + const authority = agentHostAuthority(selectedAddress); + const defaultUri = defaultDirectory + ? agentHostUri(authority, defaultDirectory) + : agentHostUri(authority, '/'); + + try { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectRemoteFolder', "Select Folder on {0}", selectedName), + availableFileSystems: [AGENT_HOST_FS_SCHEME], + defaultUri, + }); + if (selected?.[0]) { + this._selectProject(new SessionWorkspace(selected[0])); + } + } catch { + // dialog was cancelled or failed + } + } + private _addToRecents(stored: IStoredProject): void { + stored = this._withCachedRemoteName(stored); this._recentProjects = [ stored, ...this._recentProjects.filter(p => !this._isSameProject(p, stored)), @@ -305,51 +374,65 @@ export class WorkspacePicker extends Disposable { } private _buildItems(): IActionListItem[] { - const seen = new Set(); const items: IActionListItem[] = []; // Collect all projects (current + recents), deduped const allProjects: IStoredProject[] = []; if (this._selectedProject) { - const stored = this._toStored(this._selectedProject); - seen.add(this._projectKey(stored)); + const stored = this._withCachedRemoteName(this._toStored(this._selectedProject)); allProjects.push(stored); } for (const project of this._recentProjects) { - const key = this._projectKey(project); - if (!seen.has(key)) { - seen.add(key); + if (!allProjects.some(p => this._isSameProject(p, project))) { allProjects.push(project); } } - // Split into folders and repos, sort each group alphabetically - const isStoredFolder = (p: IStoredProject) => URI.revive(p.uri).scheme !== GITHUB_REMOTE_FILE_SCHEME; + // Split into folders, repos, and remotes, sort each group alphabetically + const isStoredFolder = (p: IStoredProject) => { + const scheme = URI.revive(p.uri).scheme; + return scheme !== GITHUB_REMOTE_FILE_SCHEME && scheme !== AGENT_HOST_FS_SCHEME; + }; + const isStoredRemote = (p: IStoredProject) => URI.revive(p.uri).scheme === AGENT_HOST_FS_SCHEME; const folders = allProjects.filter(p => isStoredFolder(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); - const repos = allProjects.filter(p => !isStoredFolder(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); + const repos = allProjects.filter(p => !isStoredFolder(p) && !isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); + const remotes = allProjects.filter(p => isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); - const selectedKey = this._selectedProject ? this._projectKey(this._toStored(this._selectedProject)) : undefined; + const selectedStored = this._selectedProject ? this._toStored(this._selectedProject) : undefined; + const isSelected = (p: IStoredProject) => !!selectedStored && this._isSameProject(p, selectedStored); // Folders first for (const project of folders) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.folder }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } // Then repos for (const project of repos) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.repo }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, + onRemove: () => this._removeProject(project), + }); + } + + // Then remotes + for (const project of remotes) { + const selected = isSelected(project); + items.push({ + kind: ActionListItemKind.Action, + label: this._getStoredProjectLabel(project), + group: { title: '', icon: Codicon.remote }, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } @@ -370,6 +453,14 @@ export class WorkspacePicker extends Disposable { group: { title: '', icon: Codicon.repo }, item: { uri: URI.parse(COMMAND_BROWSE_REPOS).toJSON() }, }); + if (this.remoteAgentHostService.connections.length > 0) { + items.push({ + kind: ActionListItemKind.Action, + label: localize('browseRemotes', "Browse Remotes..."), + group: { title: '', icon: Codicon.remote }, + item: { uri: URI.parse(COMMAND_BROWSE_REMOTE_AGENT_HOSTS).toJSON() }, + }); + } return items; } @@ -387,7 +478,9 @@ export class WorkspacePicker extends Disposable { dom.clearNode(this._triggerElement); const project = this._selectedProject; const label = project ? this._getProjectLabel(project) : localize('pickWorkspace', "Pick a Workspace"); - const icon = project ? (project.isFolder ? Codicon.folder : Codicon.repo) : Codicon.project; + const icon = project + ? (project.isRemoteAgentHost ? Codicon.remote : project.isFolder ? Codicon.folder : Codicon.repo) + : Codicon.project; dom.append(this._triggerElement, renderIcon(icon)); const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); @@ -396,11 +489,17 @@ export class WorkspacePicker extends Disposable { } private _getProjectLabel(project: SessionWorkspace): string { - return this._getStoredProjectLabel({ uri: project.uri.toJSON() }); + return this._getStoredProjectLabel(this._withCachedRemoteName(this._toStored(project))); } private _getStoredProjectLabel(project: IStoredProject): string { const uri = URI.revive(project.uri); + // TODO@roblourens HACK + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const folderName = basename(uri) || uri.path || '/'; + const remoteName = this._getRemoteName(uri.authority) ?? project.remoteName ?? uri.authority; + return `${folderName} [${remoteName}]`; + } if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) { return basename(uri); } @@ -408,18 +507,46 @@ export class WorkspacePicker extends Disposable { return uri.path.substring(1).replace(/\/HEAD$/, ''); } + /** + * Resolves a sanitized authority back to a user-facing remote name. + */ + private _getRemoteName(authority: string): string | undefined { + for (const conn of this.remoteAgentHostService.connections) { + if (agentHostAuthority(conn.address) === authority) { + return conn.name; + } + } + return undefined; + } + private _toStored(project: SessionWorkspace): IStoredProject { - return { - uri: project.uri.toJSON(), - }; + const uri = project.uri; + const stored: IStoredProject = { uri: uri.toJSON() }; + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const remoteName = this._getRemoteName(uri.authority); + if (remoteName) { + return { ...stored, remoteName }; + } + } + return stored; } private _fromStored(stored: IStoredProject): SessionWorkspace { return new SessionWorkspace(URI.revive(stored.uri)); } - private _projectKey(project: IStoredProject): string { - return URI.revive(project.uri).toString(); + /** + * If the stored project is missing a cached remoteName, tries to recover + * it from the recents list so labels remain stable across restarts. + */ + private _withCachedRemoteName(stored: IStoredProject): IStoredProject { + if (!stored.remoteName && URI.revive(stored.uri).scheme === AGENT_HOST_FS_SCHEME) { + const cached = this._recentProjects.find(p => this._isSameProject(p, stored)); + if (cached?.remoteName) { + return { ...stored, remoteName: cached.remoteName }; + } + } + return stored; } private _isSameProject(a: IStoredProject, b: IStoredProject): boolean { diff --git a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts new file mode 100644 index 00000000000..60f669931b3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.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 { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ExtUri } from '../../../../../base/common/resources.js'; +import { IRemoteAgentHostService, IRemoteAgentHostConnectionInfo } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { WorkspacePicker } from '../../browser/workspacePicker.js'; +import { SessionWorkspace, GITHUB_REMOTE_FILE_SCHEME } from '../../../sessions/common/sessionWorkspace.js'; +import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; +import { agentHostAuthority } from '../../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; + +suite('WorkspacePicker', () => { + + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + let instantiationService: TestInstantiationService; + let connections: IRemoteAgentHostConnectionInfo[]; + + setup(() => { + instantiationService = ds.add(new TestInstantiationService()); + connections = []; + + instantiationService.stub(IStorageService, ds.add(new InMemoryStorageService())); + instantiationService.stub(IActionWidgetService, new class extends mock() { + override get isVisible() { return false; } + }); + instantiationService.stub(IFileDialogService, new class extends mock() { }); + instantiationService.stub(ICommandService, new class extends mock() { }); + instantiationService.stub(IUriIdentityService, new class extends mock() { + override readonly extUri = new ExtUri(uri => false); + }); + instantiationService.stub(IRemoteAgentHostService, new class extends mock() { + override readonly onDidChangeConnections = Event.None; + override get connections() { return connections; } + override getConnection() { return undefined; } + }); + instantiationService.stub(IQuickInputService, new class extends mock() { }); + }); + + function createPicker(): WorkspacePicker { + return ds.add(instantiationService.createInstance(WorkspacePicker)); + } + + test('setSelectedProject with local folder', () => { + const picker = createPicker(); + const folder = new SessionWorkspace(URI.file('/home/user/project')); + + picker.setSelectedProject(folder); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isFolder, true); + assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + }); + + test('setSelectedProject with remote agent host URI', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + const project = new SessionWorkspace(remoteUri); + + picker.setSelectedProject(project); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isRemoteAgentHost, true); + assert.strictEqual(picker.selectedProject.uri.scheme, AGENT_HOST_FS_SCHEME); + assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + }); + + test('setSelectedProject with GitHub repo URI', () => { + const picker = createPicker(); + const repoUri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' }); + const project = new SessionWorkspace(repoUri); + + picker.setSelectedProject(project); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isRepo, true); + }); + + test('onDidSelectProject fires when project is selected', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/remote/path'); + const project = new SessionWorkspace(remoteUri); + + let fired: SessionWorkspace | undefined; + ds.add(picker.onDidSelectProject(p => { fired = p; })); + + picker.setSelectedProject(project, true); + + assert.ok(fired); + assert.strictEqual(fired.isRemoteAgentHost, true); + assert.strictEqual(fired.uri.path, '/remote/path'); + }); + + test('onDidSelectProject does not fire when fireEvent is false', () => { + const picker = createPicker(); + const project = new SessionWorkspace(URI.file('/some/folder')); + + let fired = false; + ds.add(picker.onDidSelectProject(() => { fired = true; })); + + picker.setSelectedProject(project, false); + + assert.strictEqual(fired, false); + assert.ok(picker.selectedProject); + }); + + test('clearSelection clears the selected project', () => { + const picker = createPicker(); + picker.setSelectedProject(new SessionWorkspace(URI.file('/folder')), false); + + assert.ok(picker.selectedProject); + + picker.clearSelection(); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents clears selection if it matches', () => { + const picker = createPicker(); + const uri = URI.file('/folder'); + picker.setSelectedProject(new SessionWorkspace(uri), false); + + picker.removeFromRecents(uri); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents preserves selection if it does not match', () => { + const picker = createPicker(); + const selectedUri = URI.file('/selected'); + picker.setSelectedProject(new SessionWorkspace(selectedUri), false); + + picker.removeFromRecents(URI.file('/other')); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.uri.path, '/selected'); + }); + + test('remote project persists and restores from storage', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + // Create picker and select a remote project + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Create a second picker -- it should restore from storage + const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); + assert.ok(picker2.selectedProject); + assert.strictEqual(picker2.selectedProject.isRemoteAgentHost, true); + assert.strictEqual(picker2.selectedProject.uri.path, '/home/user/project'); + assert.strictEqual(picker2.selectedProject.uri.authority, authority); + }); + + test('trigger label uses cached remoteName when connection is unavailable', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + const address = 'http://myremote:3000'; + const authority = agentHostAuthority(address); + + // Simulate a live connection so remoteName gets cached + connections = [{ address, name: 'macbook', clientId: 'test-client' }]; + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Simulate startup with no connections available + connections = []; + const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); + + // Render and check the trigger label uses cached "macbook", not encoded authority + const container = document.createElement('div'); + picker2.render(container); + const label = container.querySelector('.sessions-chat-dropdown-label'); + assert.ok(label); + assert.strictEqual(label.textContent, 'project [macbook]'); + }); +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md new file mode 100644 index 00000000000..aae3798a801 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md @@ -0,0 +1,309 @@ +# Remote Agent Host Chat Agents - Architecture + +This document describes how remote agent host chat agents are registered, how +sessions are created, and the URI/target conventions used throughout the system. + +## Overview + +A **remote agent host** is a VS Code agent host process running on another +machine, connected over WebSocket. The user configures remote addresses in the +`chat.remoteAgentHosts` setting. Each remote host may expose one or more agent +backends (currently only the `copilot` provider is supported). The system +discovers these agents, dynamically registers them as chat session types, and +creates sessions that stream turns via the agent host protocol. + +``` +┌─────────────┐ WebSocket ┌───────────────────┐ +│ VS Code │ ◄──────────────► │ Remote Agent Host │ +│ (client) │ AHP protocol │ (server) │ +└─────────────┘ └───────────────────┘ +``` + +## Connection Lifecycle + +### 1. Configuration + +Connections are configured via the `chat.remoteAgentHosts` setting: + +```jsonc +"chat.remoteAgentHosts": [ + { "address": "http://192.168.1.10:3000", "name": "dev-box", "connectionToken": "..." } +] +``` + +Each entry is an `IRemoteAgentHostEntry` with `address`, `name`, and optional +`connectionToken`. + +### 2. Service Layer + +`IRemoteAgentHostService` (`src/vs/platform/agentHost/common/remoteAgentHostService.ts`) +manages WebSocket connections. The Electron implementation reads the setting, +creates `RemoteAgentHostProtocolClient` instances for each address, and fires +`onDidChangeConnections` when connections are established or lost. + +Each connection satisfies the `IAgentConnection` interface (which extends +`IAgentService`), providing: + +- `subscribe(resource)` / `unsubscribe(resource)` - state subscriptions +- `dispatchAction(action, clientId, seq)` - send client actions +- `onDidAction` / `onDidNotification` - receive server events +- `createSession(config)` - create a new backend session +- `browseDirectory(uri)` - list remote filesystem contents +- `clientId` - unique connection identifier for optimistic reconciliation + +### 3. Connection Metadata + +Each active connection exposes `IRemoteAgentHostConnectionInfo`: + +```typescript +{ + address: string; // e.g. "http://192.168.1.10:3000" + name: string; // e.g. "dev-box" (from setting) + clientId: string; // assigned during handshake + defaultDirectory?: string; // home directory on the remote machine +} +``` + +## Agent Discovery + +### Root State Subscription + +`RemoteAgentHostContribution` (`src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts`) +is the central orchestrator. For each connection, it subscribes to `ROOT_STATE_URI` +(`agenthost:/root`) to discover available agents. + +The root state (`IRootState`) contains: + +```typescript +{ + agents: IAgentInfo[]; // discovered agent backends + activeSessions?: number; // count of active sessions +} +``` + +Each `IAgentInfo` describes an agent: + +```typescript +{ + provider: string; // e.g. "copilot" + displayName: string; // e.g. "Copilot" + description: string; + models: ISessionModelInfo[]; // available language models +} +``` + +### Authority Encoding + +Remote addresses are encoded into URI-safe authority strings via +`agentHostAuthority(address)`: + +- Alphanumeric addresses pass through unchanged +- Others are url-safe base64 encoded with a `b64-` prefix + +Example: `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` + +## Agent Registration + +When `_registerAgent()` is called for a discovered copilot agent from address `X`: + +### Naming Conventions + +| Concept | Value | Example | +|---------|-------|---------| +| **Authority** | `agentHostAuthority(address)` | `b64-aHR0cA` | +| **Session type** | `remote-${authority}-${provider}` | `remote-b64-aHR0cA-copilot` | +| **Agent ID** | same as session type | `remote-b64-aHR0cA-copilot` | +| **Vendor** | same as session type | `remote-b64-aHR0cA-copilot` | +| **Display name** | `configuredName \|\| "${displayName} (${address})"` | `dev-box` | + +### Four Registrations Per Agent + +1. **Chat session contribution** - via `IChatSessionsService.registerChatSessionContribution()`: + ```typescript + { type: sessionType, name: agentId, displayName, canDelegate: true, requiresCustomModels: true } + ``` + +2. **Session list controller** - `AgentHostSessionListController` handles the + sidebar session list. Lists sessions via `connection.listSessions()`, listens + for `notify/sessionAdded` and `notify/sessionRemoved` notifications. + +3. **Session handler** - `AgentHostSessionHandler` implements + `IChatSessionContentProvider`, bridging the agent host protocol to chat UI + progress events. Also registers a _dynamic chat agent_ via + `IChatAgentService.registerDynamicAgent()`. + +4. **Language model provider** - `AgentHostLanguageModelProvider` registers + models under the vendor descriptor. Model IDs are prefixed with the session + type (e.g., `remote-b64-xxx-copilot:claude-sonnet-4-20250514`). + +## URI Conventions + +| Context | Scheme | Format | Example | +|---------|--------|--------|---------| +| New session resource | `` | `:/untitled-` | `remote-b64-xxx-copilot:/untitled-abc` | +| Existing session | `` | `:/` | `remote-b64-xxx-copilot:/abc-123` | +| Backend session state | `` | `:/` | `copilot:/abc-123` | +| Root state subscription | (string) | `agenthost:/root` | - | +| Remote filesystem | `agenthost` | `agenthost:///` | `agenthost://b64-aHR0cA/home/user/project` | +| Language model ID | - | `:` | `remote-b64-xxx-copilot:claude-sonnet-4-20250514` | + +### Key distinction: session resource vs backend session URI + +- The **session resource** URI uses the session type as its scheme + (e.g., `remote-b64-xxx-copilot:/untitled-abc`). This is the URI visible to + the chat UI and session management. +- The **backend session** URI uses the provider as its scheme + (e.g., `copilot:/abc-123`). This is sent over the agent host protocol to the + server. The `AgentSession.uri(provider, rawId)` helper creates these. + +The `AgentHostSessionHandler` translates between the two: +```typescript +private _resolveSessionUri(sessionResource: URI): URI { + const rawId = sessionResource.path.substring(1); + return AgentSession.uri(this._config.provider, rawId); +} +``` + +## Session Creation Flow + +### 1. User Selects a Remote Workspace + +In the `WorkspacePicker`, the user clicks **"Browse Remotes..."**, selects a +remote host, then picks a folder on the remote filesystem. This produces a +`SessionWorkspace` with an `agenthost://` URI: + +``` +agenthost://b64-aHR0cA/home/user/myproject + ↑ authority ↑ remote filesystem path +``` + +### 2. Session Target Resolution + +`NewChatWidget._createNewSession()` detects `project.isRemoteAgentHost` and +resolves the matching session type via `getRemoteAgentHostSessionTarget()` +(defined in `remoteAgentHost.contribution.ts`): + +```typescript +// authority "b64-aHR0cA" → find connection → "remote-b64-aHR0cA-copilot" +const target = getRemoteAgentHostSessionTarget(connections, authority); +``` + +### 3. Resource URI Generation + +`getResourceForNewChatSession()` creates the session resource: + +```typescript +URI.from({ scheme: target, path: `/untitled-${generateUuid()}` }) +// → remote-b64-aHR0cA-copilot:/untitled-abc-123 +``` + +### 4. Session Object Creation + +`SessionsManagementService.createNewSessionForTarget()` creates an +`AgentHostNewSession` (when the `agentHost` option is set). This is a +lightweight `INewSession` that supports local model and mode pickers but +skips isolation mode, branch, and cloud option groups. +The project URI is set on the session, making it available as +`activeSessionItem.repository`. + +### 5. Backend Session Creation (Deferred) + +`AgentHostSessionHandler` defers backend session creation until the first turn +(for "untitled" sessions), so the user-selected model is available: + +```typescript +const session = await connection.createSession({ + model: rawModelId, + provider: 'copilot', + workingDirectory: '/home/user/myproject', // from activeSession.repository.path +}); +``` + +### 6. Working Directory Resolution + +The `resolveWorkingDirectory` callback in `RemoteAgentHostContribution` reads +the active session's repository URI path: + +```typescript +const resolveWorkingDirectory = (resourceKey: string): string | undefined => { + const activeSessionItem = this._sessionsManagementService.getActiveSession(); + if (activeSessionItem?.repository) { + return activeSessionItem.repository.path; + // For agenthost://authority/home/user/project → "/home/user/project" + } + return undefined; +}; +``` + +## Turn Handling + +When the user sends a message, `AgentHostSessionHandler._handleTurn()`: + +1. Converts variable entries to `IAgentAttachment[]` (file, directory, selection) +2. Dispatches `session/modelChanged` if the model differs from current +3. Dispatches `session/turnStarted` with the user message + attachments +4. Listens to `SessionClientState.onDidChangeSessionState` and translates + the `activeTurn` state changes into `IChatProgress[]` events: + +| Server State | Chat Progress | +|-------------|---------------| +| `streamingText` | `markdownContent` | +| `reasoning` | `thinking` | +| `toolCalls` (new) | `ChatToolInvocation` created | +| `toolCalls` (completed) | `ChatToolInvocation` finalized | +| `pendingPermissions` | `awaitConfirmation()` prompt | + +5. On cancellation, dispatches `session/turnCancelled` + +## Filesystem Provider + +`AgentHostFileSystemProvider` is a read-only `IFileSystemProvider` registered +under the `agenthost` scheme. It proxies `stat` and `readdir` calls through +`connection.browseDirectory(uri)` RPC. + +- The URI authority identifies the remote connection (sanitized address) +- The URI path is the remote filesystem path +- Authority-to-address mappings are registered by `RemoteAgentHostContribution` + via `registerAuthority(authority, address)` + +## Data Flow Diagram + +``` +Settings (chat.remoteAgentHosts) + │ + ▼ +RemoteAgentHostService (WebSocket connections) + │ + ▼ +RemoteAgentHostContribution + │ + ├─► subscribe(ROOT_STATE_URI) → IRootState.agents + │ │ + │ ▼ + │ _registerAgent() for each copilot agent: + │ ├─► registerChatSessionContribution() + │ ├─► registerChatSessionItemController() + │ ├─► registerChatSessionContentProvider() + │ └─► registerLanguageModelProvider() + │ + └─► registerProvider(AGENT_HOST_FS_SCHEME, fsProvider) + +User picks remote workspace in WorkspacePicker + │ + ▼ +NewChatWidget._createNewSession(project) + │ target = getRemoteAgentHostSessionTarget(connections, authority) + ▼ +SessionsManagementService.createNewSessionForTarget() + │ creates AgentHostNewSession + ▼ +User sends message + │ + ▼ +AgentHostSessionHandler._handleTurn() + │ resolves working directory + │ creates backend session (if untitled) + │ dispatches session/turnStarted + ▼ +connection ← streams state changes → IChatProgress[] +``` diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts index 4b6b21adaea..97ec8078107 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts @@ -33,7 +33,8 @@ export const AGENT_HOST_FS_SCHEME = 'agenthost'; * Build an agenthost URI for a given address and path. */ export function agentHostUri(authority: string, path: string): URI { - return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: path || '/' }); + const normalizedPath = !path ? '/' : path.startsWith('/') ? path : `/${path}`; + return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: normalizedPath }); } /** diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 92941d06af8..4971236cb5a 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -15,9 +15,10 @@ import { type AgentProvider, type IAgentConnection } from '../../../../platform/ import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js'; import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; -import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; @@ -25,6 +26,9 @@ import { AgentHostSessionListController } from '../../../../workbench/contrib/ch import { ISessionsManagementService } from '../../../contrib/sessions/browser/sessionsManagementService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { AGENT_HOST_FS_SCHEME, AgentHostFileSystemProvider } from './agentHostFileSystemProvider.js'; +import * as nls from '../../../../nls.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; /** * Encode a remote address into an identifier that is safe for use in @@ -41,6 +45,24 @@ export function agentHostAuthority(address: string): string { return 'b64-' + encodeBase64(VSBuffer.fromString(address), false, true); } +/** + * Given a sanitized URI authority, resolves the corresponding agent host + * session target string by looking up the matching connection. + * + * Returns `undefined` if no connection matches the authority. + */ +export function getRemoteAgentHostSessionTarget( + connections: readonly IRemoteAgentHostConnectionInfo[], + authority: string, +): AgentSessionTarget | undefined { + for (const conn of connections) { + if (agentHostAuthority(conn.address) === authority) { + return `remote-${agentHostAuthority(conn.address)}-copilot`; + } + } + return undefined; +} + /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { readonly store = this._register(new DisposableStore()); @@ -419,3 +441,23 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + properties: { + [RemoteAgentHostsSettingId]: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, + name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, + connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, + }, + required: ['address', 'name'], + }, + description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), + default: [], + tags: ['experimental', 'advanced'], + }, + }, +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts index b4396b54d59..e22ba6e32fc 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts @@ -25,6 +25,13 @@ suite('AgentHostFileSystemProvider - URI helpers', () => { assert.strictEqual(uri.path, '/'); }); + test('agentHostUri normalizes path without leading slash', () => { + const uri = agentHostUri('localhost:8081', 'home/user/project'); + assert.strictEqual(uri.scheme, AGENT_HOST_FS_SCHEME); + assert.strictEqual(uri.authority, 'localhost:8081'); + assert.strictEqual(uri.path, '/home/user/project'); + }); + test('agentHostRemotePath extracts the path component', () => { const uri = URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority: 'host', path: '/some/path' }); assert.strictEqual(agentHostRemotePath(uri), '/some/path'); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 1a5a8810b33..c2b35c960f7 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -20,8 +20,8 @@ import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../. import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { INewSession, CopilotCLISession, RemoteNewSession } from '../../chat/browser/newSession.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { INewSession, CopilotCLISession, RemoteNewSession, AgentHostNewSession } from '../../chat/browser/newSession.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { isBuiltinChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; @@ -92,7 +92,7 @@ export interface ISessionsManagementService { * Create a pending session object for the given target type. * Local sessions collect options locally; remote sessions notify the extension. */ - createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise; + createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise; /** * Open a new session, apply options, and send the initial request. @@ -265,14 +265,16 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.instantiationService.invokeFunction(openSessionDefault, existingSession, openOptions); } - async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise { + async createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise { if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } let newSession: INewSession; if (target === AgentSessionProviders.Background) { - newSession = this.instantiationService.createInstance(CopilotCLISession, sessionResource, defaultRepoUri); + newSession = this.instantiationService.createInstance(CopilotCLISession, sessionResource, options?.defaultRepoUri); + } else if (options?.agentHost) { + newSession = new AgentHostNewSession(sessionResource, target); } else { newSession = this.instantiationService.createInstance(RemoteNewSession, sessionResource, target); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 7d964ee9465..66f102e9d76 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -24,7 +24,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, isAgentHostTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -124,6 +124,7 @@ export class AgenticSessionsViewPane extends ViewPane { groupResults: () => this.currentGrouping, sortResults: () => this.currentSorting, allowedProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], + overrideExclude: session => isAgentHostTarget(session.providerType) ? false : undefined, providerLabelOverrides: new Map([ [AgentSessionProviders.Background, localize('chat.session.providerLabel.background', "Copilot CLI")], ]), diff --git a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts index fe7fe1c2b41..aa5a5ba3a44 100644 --- a/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts +++ b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts @@ -8,9 +8,16 @@ import { IGitRepository } from '../../../../workbench/contrib/git/common/gitServ export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; +/** + * URI scheme for agent host remote filesystems. + * Must match {@link AGENT_HOST_FS_SCHEME} in `agentHostFileSystemProvider.ts` + * (which lives in the `browser` layer and cannot be imported here). + */ +export const AGENT_HOST_SCHEME = 'agenthost'; + /** * Represents a workspace (folder or repository) for a session. - * The workspace type (folder vs repo) is derived from the URI scheme. + * The workspace type (folder vs repo vs remote agent host) is derived from the URI scheme. */ export class SessionWorkspace { @@ -24,7 +31,7 @@ export class SessionWorkspace { /** Whether this is a local folder workspace. */ get isFolder(): boolean { - return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME; + return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME && this.uri.scheme !== AGENT_HOST_SCHEME; } /** Whether this is a remote repository workspace. */ @@ -32,6 +39,11 @@ export class SessionWorkspace { return this.uri.scheme === GITHUB_REMOTE_FILE_SCHEME; } + /** Whether this is a remote agent host workspace. */ + get isRemoteAgentHost(): boolean { + return this.uri.scheme === AGENT_HOST_SCHEME; + } + /** Returns a new SessionWorkspace with the repository updated. */ withRepository(repository: IGitRepository | undefined): SessionWorkspace { return new SessionWorkspace(this.uri, repository); diff --git a/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts new file mode 100644 index 00000000000..8b9883f1b7b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AGENT_HOST_SCHEME, GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../common/sessionWorkspace.js'; +import type { IGitRepository } from '../../../../../workbench/contrib/git/common/gitService.js'; + +suite('SessionWorkspace', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('local folder is classified as isFolder', () => { + const ws = new SessionWorkspace(URI.file('/home/user/project')); + assert.strictEqual(ws.isFolder, true); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('GitHub repo is classified as isRepo', () => { + const ws = new SessionWorkspace(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, true); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('agent host URI is classified as isRemoteAgentHost', () => { + const ws = new SessionWorkspace(URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/home/user/project' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, true); + }); + + test('withRepository preserves URI and updates repository', () => { + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/proj' }); + const ws = new SessionWorkspace(uri); + const repo = { rootUri: URI.file('/repo') } as IGitRepository; + const ws2 = ws.withRepository(repo); + assert.strictEqual(ws2.uri.toString(), uri.toString()); + assert.strictEqual(ws2.isRemoteAgentHost, true); + assert.strictEqual(ws2.repository, repo); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index cf43a98126c..095117618df 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -41,12 +41,14 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._register(this._connection.onDidNotification(n => { if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) { const rawId = AgentSession.id(n.summary.resource); + const workingDir = typeof n.summary.workingDirectory === 'string' ? n.summary.workingDirectory : undefined; const item: IChatSessionItem = { resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), label: n.summary.title ?? `Session ${rawId.substring(0, 8)}`, description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(workingDir), timing: { created: n.summary.createdAt, lastRequestStarted: n.summary.modifiedAt, @@ -89,6 +91,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(s.workingDirectory), timing: { created: s.startTime, lastRequestStarted: s.modifiedTime, @@ -100,4 +103,15 @@ export class AgentHostSessionListController extends Disposable implements IChatS } this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); } + + private _buildMetadata(workingDirectory?: string): { readonly [key: string]: unknown } | undefined { + if (!this._description) { + return undefined; + } + const result: { [key: string]: unknown } = { remoteAgentHost: this._description }; + if (workingDirectory) { + result.workingDirectoryPath = workingDirectory; + } + return result; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 0e86de722e6..b58ce96c3c3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -21,6 +21,14 @@ export enum AgentSessionProviders { AgentHostCopilot = 'agent-host-copilot', } +/** + * A session target is either a well-known {@link AgentSessionProviders} enum + * value or a dynamic string for dynamically-registered providers (e.g. remote + * agent hosts like `remote-{authority}-copilot`). + * TODO@roblourens HACK + */ +export type AgentSessionTarget = AgentSessionProviders | (string & {}); + export function isBuiltInAgentSessionProvider(provider: string): boolean { return provider === AgentSessionProviders.Local || provider === AgentSessionProviders.Background || diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 46185e866ab..369ad0e89b1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -984,6 +984,19 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou export function getRepositoryName(session: IAgentSession): string | undefined { const metadata = session.metadata; if (metadata) { + // Remote agent host sessions: group by folder + remote name (e.g. "myproject [dev-box]") + const remoteAgentHost = metadata.remoteAgentHost as string | undefined; + if (remoteAgentHost) { + const workingDir = metadata.workingDirectoryPath as string | undefined; + if (workingDir) { + const folderName = extractRepoNameFromPath(workingDir); + if (folderName) { + return `${folderName} [${remoteAgentHost}]`; + } + } + return remoteAgentHost; + } + // Cloud sessions: metadata.owner + metadata.name const owner = metadata.owner as string | undefined; const name = metadata.name as string | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 39b5e9d8b74..85c65da6d67 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,6 @@ import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; -import { RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -728,23 +727,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), default: false, - tags: ['experimental'], - included: product.quality !== 'stable', - }, - [RemoteAgentHostsSettingId]: { - type: 'array', - items: { - type: 'object', - properties: { - address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, - name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, - connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, - }, - required: ['address', 'name'], - }, - description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), - default: [], - tags: ['experimental'], + tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, [ChatConfiguration.PlanAgentDefaultModel]: { diff --git a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts index a5236c4b082..90bfdf715b5 100644 --- a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts +++ b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts @@ -130,6 +130,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private badPath: string | undefined; private remoteAgentEnvironment: IRemoteAgentEnvironment | null | undefined; private separator: string = '/'; + + /** + * When set, the dialog is scoped to a specific URI authority (e.g. + * for browsing an `agenthost://{authority}/...` filesystem that + * uses per-connection authorities rather than the global + * {@link remoteAuthority}). + */ + private scopedAuthority: string | undefined; private readonly onBusyChangeEmitter = this._register(new Emitter()); private updatingPromise: CancelablePromise | undefined; @@ -191,6 +199,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showOpenDialog(options: IOpenDialogOptions = {}): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); const newOptions = this.getOptions(options); @@ -207,6 +216,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showSaveDialog(options: ISaveDialogOptions): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); this.requiresTrailing = true; @@ -251,6 +261,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { if (!path.startsWith('\\\\')) { path = path.replace(/\\/g, '/'); } + // When scoped to a specific authority (e.g. agenthost://host/...), + // construct the URI directly with the authority to avoid + // toLocalResource stripping or replacing it. + if (this.scopedAuthority) { + return URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path, query: hintUri?.query, fragment: hintUri?.fragment }); + } const uri: URI = this.scheme === Schemas.file ? URI.file(path) : URI.from({ scheme: this.scheme, path, query: hintUri?.query, fragment: hintUri?.fragment }); // If the default scheme is file, then we don't care about the remote authority or the hint authority const authority = (uri.scheme === Schemas.file) ? undefined : (this.remoteAuthority ?? hintUri?.authority); @@ -272,6 +288,24 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { return Schemas.file; } + /** + * Returns the per-URI authority from {@link defaultUri} if the dialog + * should be scoped to a specific authority (e.g. `agenthost://host/...`). + * + * Returns `undefined` when the authority matches the global + * {@link remoteAuthority} (standard SSH remotes), since that path is + * already handled by the existing logic. + */ + private getScopedAuthority(defaultUri: URI | undefined): string | undefined { + if (defaultUri + && defaultUri.scheme === this.scheme + && defaultUri.authority + && defaultUri.authority !== this.remoteAuthority) { + return defaultUri.authority; + } + return undefined; + } + private async getRemoteAgentEnvironment(): Promise { if (this.remoteAgentEnvironment === undefined) { this.remoteAgentEnvironment = await this.remoteAgentService.getEnvironment(); @@ -280,6 +314,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } protected getUserHome(trueHome = false): Promise { + // When scoped to a custom authority, the platform userHome is not + // meaningful (it would return a local file:// path). Use the root + // of the scoped filesystem as the home directory instead. + if (this.scopedAuthority) { + return Promise.resolve(URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path: '/' })); + } return trueHome ? this.pathService.userHome({ preferLocal: this.scheme === Schemas.file }) : this.fileDialogService.preferredHome(this.scheme); @@ -295,9 +335,9 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private async pickResource(isSave: boolean = false): Promise { this.allowFolderSelection = !!this.options.canSelectFolders; this.allowFileSelection = !!this.options.canSelectFiles; - this.separator = this.labelService.getSeparator(this.scheme, this.remoteAuthority); + this.separator = this.scopedAuthority ? '/' : this.labelService.getSeparator(this.scheme, this.remoteAuthority); this.hidden = false; - this.isWindows = await this.checkIsWindowsOS(); + this.isWindows = this.scopedAuthority ? false : await this.checkIsWindowsOS(); let homedir: URI = this.options.defaultUri ? this.options.defaultUri : this.workspaceContextService.getWorkspace().folders[0].uri; let stat: IFileStatWithPartialMetadata | undefined; const ext: string = resources.extname(homedir); @@ -983,7 +1023,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private pathFromUri(uri: URI, endWithSeparator: boolean = false): string { - let result: string = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + // For authority-scoped schemes, use the raw path component instead + // of fsPath, which would prepend the authority as a UNC prefix. + let result: string; + if (this.scopedAuthority) { + result = uri.path.replace(/\n/g, ''); + } else { + result = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + } if (this.separator === '/') { result = result.replace(/\\/g, this.separator); } else { @@ -1024,7 +1071,11 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private async createBackItem(currFolder: URI): Promise { - const fileRepresentationCurr = this.currentFolder.with({ scheme: Schemas.file, authority: '' }); + // For authority-scoped URIs, compare within the original scheme so + // that the authority is preserved and the root is detected correctly. + const compareScheme = this.scopedAuthority ? this.scheme : Schemas.file; + const compareAuthority = this.scopedAuthority ?? ''; + const fileRepresentationCurr = this.currentFolder.with({ scheme: compareScheme, authority: compareAuthority }); const fileRepresentationParent = resources.dirname(fileRepresentationCurr); if (!resources.isEqual(fileRepresentationCurr, fileRepresentationParent)) { const parentFolder = resources.dirname(currFolder); From 4b8a157d86f5cf07bac10e1e929968763bb59d71 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:19:49 -0700 Subject: [PATCH 164/183] Replace gear menu with direct Customizations editor action (#303641) When chat.customizationsMenu.enabled is true (default): - Gear icon directly opens the AI Customizations editor - Tool Sets, Chat Settings, and Show Agent Debug Logs move to the '...' context menu When the setting is false, the original gear dropdown behavior is preserved. --- .../chat/browser/actions/chatActions.ts | 27 ++++++++++++++++++- .../actions/chatOpenAgentDebugPanelAction.ts | 6 +++++ .../contrib/chat/browser/chat.contribution.ts | 2 +- .../browser/tools/toolSetsContribution.ts | 11 ++++++-- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 19984144472..f33a0d8cba9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -56,6 +56,7 @@ import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableE import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { AICustomizationManagementCommands } from '../aiCustomization/aiCustomizationManagement.js'; import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; @@ -1435,6 +1436,12 @@ export function registerChatActions() { id: MenuId.ChatWelcomeContext, group: '2_settings', order: 1 + }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 15, + group: '3_configure' }] }); } @@ -1445,11 +1452,29 @@ export function registerChatActions() { } }); + // When customizations menu is enabled, show a direct gear action to open the Customizations editor + MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + command: { + id: AICustomizationManagementCommands.OpenEditor, + title: localize2('openChatCustomizations', "Open Customizations"), + category: CHAT_CATEGORY, + icon: Codicon.gear + }, + group: 'navigation', + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.equals('view', ChatViewId), + ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`) + ), + order: 6 + }); + + // When customizations menu is disabled, show the legacy gear submenu MenuRegistry.appendMenuItem(MenuId.ViewTitle, { submenu: CHAT_CONFIG_MENU_ID, title: localize2('config.label', "Configure Chat"), group: 'navigation', - when: ContextKeyExpr.equals('view', ChatViewId), + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`).negate()), icon: Codicon.gear, order: 6 }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 0f48de3cae2..d6522e25c2a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -23,6 +23,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; +import { ChatConfiguration } from '../../common/constants.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; @@ -73,6 +74,11 @@ export function registerChatOpenAgentDebugPanelAction() { group: '2_settings', order: 0, when: ChatContextKeys.inChatEditor.negate() + }, { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 0, + group: '4_logs' }] }); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 85c65da6d67..6ab781a848d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1320,7 +1320,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.ChatCustomizationMenuEnabled]: { type: 'boolean', tags: ['preview'], - description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is available in the Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), + description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is enabled. When enabled, the gear icon in the Chat view opens the Customizations editor directly and additional actions are moved to the overflow menu. When disabled, the gear icon shows the legacy configuration dropdown."), default: true, }, [ChatConfiguration.ChatCustomizationHarnessSelectorEnabled]: { diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts index 1360fbcba5c..96618b9afc4 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts @@ -14,7 +14,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { assertType, isObject } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { Action2 } from '../../../../../platform/actions/common/actions.js'; +import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -37,6 +37,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatViewId } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../common/constants.js'; const toolEnumValues: string[] = []; @@ -323,12 +324,18 @@ export class ConfigureToolSets extends Action2 { category: CHAT_CATEGORY, f1: true, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Tools.toolsCount.greater(0)), - menu: { + menu: [{ id: CHAT_CONFIG_MENU_ID, when: ContextKeyExpr.equals('view', ChatViewId), order: 11, group: '2_level' }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 11, + group: '2_level' + }], }); } From 1519062c0f498b06d6c60831b9c0b30a71e111dc Mon Sep 17 00:00:00 2001 From: Paul Date: Fri, 20 Mar 2026 17:31:33 -0700 Subject: [PATCH 165/183] Add missing skills discovery info (#303646) --- .../chat/common/promptSyntax/service/promptsServiceImpl.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 2bf10c2f97c..3a216261ded 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -1392,7 +1392,8 @@ export class PromptsService extends Disposable implements IPromptsService { } const { files } = await this.computeSkillDiscoveryInfo(token); - return { type: PromptsType.skill, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill); + return { type: PromptsType.skill, files, sourceFolders }; } /** From 28aaabc31e486ed1f0a510a61887719e10d8af13 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:40:55 -0700 Subject: [PATCH 166/183] Option to allow-list command when offering to run outside of sandbox (#303637) * Handle unsandboxed terminal confirmation actions * test: fix unsandboxed terminal confirmation actions --- .../tools/commandLineAnalyzer/commandLineAnalyzer.ts | 1 + .../commandLineSandboxAnalyzer.ts | 2 +- .../browser/tools/runInTerminalTool.ts | 5 +++-- .../test/electron-browser/runInTerminalTool.test.ts | 12 ++++++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts index 599f0f93b8c..b4fab0650d3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts @@ -45,6 +45,7 @@ export interface ICommandLineAnalyzerOptions { treeSitterLanguage: TreeSitterCommandParserLanguage; terminalToolSessionId: string; chatSessionResource: URI | undefined; + requiresUnsandboxConfirmation?: boolean; } export interface ICommandLineAnalyzerResult { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts index 39ed6639c52..7d75a0d38cb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts @@ -22,7 +22,7 @@ export class CommandLineSandboxAnalyzer extends Disposable implements ICommandLi } return { isAutoApproveAllowed: true, - forceAutoApproval: true, + forceAutoApproval: _options.requiresUnsandboxConfirmation ? false : true, }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index c9a623cc009..a6d7dce7384 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -610,6 +610,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { treeSitterLanguage: isPowerShell(shell, os) ? TreeSitterCommandParserLanguage.PowerShell : TreeSitterCommandParserLanguage.Bash, terminalToolSessionId, chatSessionResource, + requiresUnsandboxConfirmation, }; const commandLineAnalyzerResults = await Promise.all(this._commandLineAnalyzers.map(e => e.analyze(commandLineAnalyzerOptions))); @@ -625,7 +626,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const analyzersIsAutoApproveAllowed = commandLineAnalyzerResults.every(e => e.isAutoApproveAllowed); - const customActions = !requiresUnsandboxConfirmation && isEligibleForAutoApproval() && analyzersIsAutoApproveAllowed ? commandLineAnalyzerResults.map(e => e.customActions ?? []).flat() : undefined; + const customActions = isEligibleForAutoApproval() && analyzersIsAutoApproveAllowed ? commandLineAnalyzerResults.map(e => e.customActions ?? []).flat() : undefined; let shellType = basename(shell, '.exe'); if (shellType === 'powershell') { @@ -766,7 +767,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { title: confirmationTitle, message: confirmationMessage, disclaimer, - allowAutoConfirm: requiresUnsandboxConfirmation ? false : undefined, + allowAutoConfirm: undefined, terminalCustomActions: customActions, } : undefined; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index db841082d00..2f08f195d6e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -467,7 +467,7 @@ suite('RunInTerminalTool', () => { }); assertConfirmationRequired(result, 'Run `bash` command outside the sandbox?'); - strictEqual(result?.confirmationMessages?.allowAutoConfirm, false); + strictEqual(result?.confirmationMessages?.allowAutoConfirm, undefined); const terminalData = result?.toolSpecificData as IChatTerminalToolInvocationData; strictEqual(terminalData.requestUnsandboxedExecution, true); strictEqual(terminalData.requestUnsandboxedExecutionReason, 'Needs network access outside the sandbox'); @@ -481,7 +481,15 @@ suite('RunInTerminalTool', () => { ok(confirmationMessage.value.includes('Reason for leaving the sandbox: Needs network access outside the sandbox')); strictEqual(result?.confirmationMessages?.disclaimer, undefined); - strictEqual(result?.confirmationMessages?.terminalCustomActions, undefined); + const actions = result?.confirmationMessages?.terminalCustomActions; + ok(actions, 'Expected custom actions to be defined'); + strictEqual(actions.length, 11); + ok(!isSeparator(actions[0])); + strictEqual(actions[0].label, 'Allow `echo …` in this Session'); + ok(!isSeparator(actions[4])); + strictEqual(actions[4].label, 'Allow Exact Command Line in this Session'); + ok(!isSeparator(actions[10])); + strictEqual(actions[10].label, 'Configure Auto Approve...'); }); }); From 7c45bc769a9b493861de65245d27e71ab88bff4d Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:53:04 -0700 Subject: [PATCH 167/183] Fix browser focus stealing (#303647) --- .../electron-browser/browserEditor.ts | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ec89c291587..c4f7bb4bb27 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -479,10 +479,16 @@ export class BrowserEditor extends EditorPane { // When the browser container gets focus, make sure the browser view also gets focused. // But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view). if (event.relatedTarget && this._model && this.shouldShowView) { - void this._model.focus(); + this.requestFocus(); } })); + this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => { + // If the container becomes blurred, cancel any scheduled focus call. + // This can happen when e.g. a menu closes and focus shifts back to the browser, then immediately focuses another element. + this.cancelFocus(); + })); + // Register external focus checker so that cross-window focus logic knows when // this browser view has focus (since it's outside the normal DOM tree). // Include window info so that UI like dialogs appear in the correct window. @@ -494,12 +500,33 @@ export class BrowserEditor extends EditorPane { override focus(): void { if (this._model?.url && !this._model.error) { - void this._model.focus(); + this.requestFocus(); } else { this.focusUrlInput(); } } + private _focusTimeout: ReturnType | undefined; + private requestFocus(): void { + this.ensureBrowserFocus(); + if (this._focusTimeout) { + return; + } + this._focusTimeout = setTimeout(() => { + this._focusTimeout = undefined; + if (this._model) { + void this._model.focus(); + } + }, 0); + } + + private cancelFocus(): void { + if (this._focusTimeout) { + clearTimeout(this._focusTimeout); + this._focusTimeout = undefined; + } + } + override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); if (token.isCancellationRequested) { @@ -643,7 +670,7 @@ export class BrowserEditor extends EditorPane { this._browserContainer.ownerDocument.activeElement === this._browserContainer ) { // If the editor is focused, ensure the browser view also gets focus - void this._model.focus(); + this.requestFocus(); } } else { this.doScreenshot(); @@ -1031,8 +1058,9 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); - // Cancel any scheduled screenshots + // Cancel any scheduled timers this.cancelScheduledScreenshot(); + this.cancelFocus(); void this._model?.setVisible(false); this._model = undefined; From 7503e59fc3f14f74f9efcd36584125c899c4e8e0 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 20 Mar 2026 18:19:54 -0700 Subject: [PATCH 168/183] Don't localize markdown icon syntax (#303655) * Don't localize markdown icon syntax Co-authored-by: Copilot * Add eslint rule for localized markdown icons --------- Co-authored-by: Copilot --- .../code-no-icons-in-localized-strings.ts | 99 +++++++++++++++++++ eslint.config.js | 1 + .../api/browser/statusBarExtensionPoint.ts | 2 +- .../chatSessions/chatSessions.contribution.ts | 2 +- .../browser/chatStatus/chatStatusDashboard.ts | 2 +- .../chatContext.contribution.ts | 2 +- .../chatTerminalToolProgressPart.ts | 4 +- .../tools/languageModelToolsContribution.ts | 2 +- .../files/browser/files.contribution.ts | 2 +- .../contrib/scm/browser/scmHistoryViewPane.ts | 2 +- .../contrib/tasks/browser/taskQuickPick.ts | 2 +- .../browser/tools/runInTerminalTool.ts | 4 +- .../update/browser/updateStatusBarEntry.ts | 18 ++-- 13 files changed, 121 insertions(+), 21 deletions(-) create mode 100644 .eslint-plugin-local/code-no-icons-in-localized-strings.ts diff --git a/.eslint-plugin-local/code-no-icons-in-localized-strings.ts b/.eslint-plugin-local/code-no-icons-in-localized-strings.ts new file mode 100644 index 00000000000..8f4251dfd41 --- /dev/null +++ b/.eslint-plugin-local/code-no-icons-in-localized-strings.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import { TSESTree } from '@typescript-eslint/utils'; + +/** + * Prevents theme icon syntax `$(iconName)` from appearing inside localized + * string arguments. Localizers may translate or corrupt the icon syntax, + * breaking rendering. Icon references should be kept outside the localized + * string - either prepended via concatenation or passed as a placeholder + * argument. + * + * Examples: + * ❌ localize('key', "$(gear) Settings") + * ✅ '$(gear) ' + localize('key', "Settings") + * ✅ localize('key', "Like {0}", '$(gear)') + * + * ❌ nls.localize('key', "$(loading~spin) Loading...") + * ✅ '$(loading~spin) ' + nls.localize('key', "Loading...") + */ +export default new class NoIconsInLocalizedStrings implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noIconInLocalizedString: 'Theme icon syntax $(…) should not appear inside localized strings. Move it outside the localize call or pass it as a placeholder argument.' + }, + docs: { + description: 'Prevents $(icon) theme icon syntax inside localize() string arguments', + }, + type: 'problem', + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + // Matches $(iconName) or $(iconName~modifier) but not escaped \$(...) + const iconPattern = /(? checkCallExpression(node as TSESTree.CallExpression) + }; + } +}; diff --git a/eslint.config.js b/eslint.config.js index 06dc23e6980..187dcd85864 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -92,6 +92,7 @@ export default tseslint.config( 'local/code-no-localized-model-description': 'warn', 'local/code-policy-localization-key-match': 'warn', 'local/code-no-localization-template-literals': 'error', + 'local/code-no-icons-in-localized-strings': 'warn', 'local/code-no-http-import': ['warn', { target: 'src/vs/**' }], 'local/code-no-deep-import-of-internal': ['error', { '.*Internal': true, 'searchExtTypesInternal': false }], 'local/code-layering': [ diff --git a/src/vs/workbench/api/browser/statusBarExtensionPoint.ts b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts index 4da1f68eeb1..c8d1765a7ab 100644 --- a/src/vs/workbench/api/browser/statusBarExtensionPoint.ts +++ b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts @@ -199,7 +199,7 @@ const statusBarItemSchema = { }, text: { type: 'string', - description: localize('text', 'The text to show for the entry. You can embed icons in the text by leveraging the `$()`-syntax, like \'Hello $(globe)!\'') + description: localize('text', 'The text to show for the entry. You can embed icons in the text by leveraging the `$()`-syntax, like \'Hello {0}!\'', '$(globe)') }, tooltip: { type: 'string', diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 2880e10c211..b4ae5208966 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -83,7 +83,7 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint 0) { const displayName = this.getDisplayNameForChatSessionType(chatSessionType); if (displayName) { - const text = localize('inProgressChatSession', "$(loading~spin) {0} in progress", displayName); + const text = '$(loading~spin) ' + localize('inProgressChatSession', "{0} in progress", displayName); const chatSessionsElement = this.element.appendChild($('div.description')); const parts = renderLabelWithIcons(text); chatSessionsElement.append(...parts); diff --git a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts index 8109c362c3b..b33c3fe5278 100644 --- a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts @@ -67,7 +67,7 @@ export class ChatContextContribution extends Disposable implements IWorkbenchCon for (const contribution of ext.value) { const icon = contribution.icon ? ThemeIcon.fromString(contribution.icon) : undefined; if (!icon && contribution.icon) { - ext.collector.error(localize('chatContextExtPoint.invalidIcon', "Invalid icon format for chat context contribution '{0}'. Icon must be in the format '$(iconId)' or '$(iconId~spin)', e.g. '$(copilot)'.", contribution.id)); + ext.collector.error(localize('chatContextExtPoint.invalidIcon', "Invalid icon format for chat context contribution '{0}'. Icon must be in the format '{1}' or '{2}', e.g. '{3}'.", contribution.id, '$(iconId)', '$(iconId~spin)', '$(copilot)')); continue; } if (!icon) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 5d7b9dd0543..1e724674931 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -1668,8 +1668,8 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart labelElement.textContent = ''; if (this._isSandboxWrapped) { dom.reset(labelElement, ...renderLabelWithIcons(this._isComplete - ? localize('chat.terminal.ranInSandbox', "$(lock) Ran `{0}` in sandbox", this._commandText) - : localize('chat.terminal.runningInSandbox', "$(lock) Running `{0}` in sandbox", this._commandText))); + ? '$(lock) ' + localize('chat.terminal.ranInSandbox', "Ran `{0}` in sandbox", this._commandText) + : '$(lock) ' + localize('chat.terminal.runningInSandbox', "Running `{0}` in sandbox", this._commandText))); return; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index a17eb174f38..0728a528512 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -176,7 +176,7 @@ const languageModelToolSetsExtensionPoint = extensionsRegistry.ExtensionsRegistr type: 'string' }, icon: { - markdownDescription: localize('toolSetIcon', "An icon that represents this tool set, like `$(zap)`"), + markdownDescription: localize('toolSetIcon', "An icon that represents this tool set, like {0}", '`$(zap)`'), type: 'string' }, tools: { diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index 53918c80b78..8f36c834a3a 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -459,7 +459,7 @@ configurationRegistry.registerConfiguration({ type: 'string', // expression ({ "**/*.js": { "when": "$(basename).js" } }) pattern: '\\w*\\$\\(basename\\)\\w*', default: '$(basename).ext', - description: nls.localize('explorer.autoRevealExclude.when', 'Additional check on the siblings of a matching file. Use $(basename) as variable for the matching file name.') + description: nls.localize('explorer.autoRevealExclude.when', 'Additional check on the siblings of a matching file. Use {0} as variable for the matching file name.', '$(basename)') } } } diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index ab1900fc1db..88c5c132598 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -1706,7 +1706,7 @@ export class SCMHistoryViewPane extends ViewPane { compact: true, showPointer: true }, - content: new MarkdownString(localize('scmGraphViewOutdated', "Please refresh the graph using the refresh action ($(refresh))."), { supportThemeIcons: true }), + content: new MarkdownString(localize('scmGraphViewOutdated', "Please refresh the graph using the refresh action ({0}).", '$(refresh)'), { supportThemeIcons: true }), position: { hoverPosition: HoverPosition.BELOW } diff --git a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts index b10f117bc6e..15cd622c7cd 100644 --- a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts +++ b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts @@ -360,7 +360,7 @@ export class TaskQuickPick extends Disposable { public static getSettingEntry(configurationService: IConfigurationService, type: string): (ITaskTwoLevelQuickPickEntry & { settingType: string }) | undefined { if (configurationService.getValue(`${type}.autoDetect`) === 'off') { return { - label: nls.localize('TaskQuickPick.changeSettingsOptions', "$(gear) {0} task detection is turned off. Enable {1} task detection...", + label: '$(gear) ' + nls.localize('TaskQuickPick.changeSettingsOptions', "{0} task detection is turned off. Enable {1} task detection...", type[0].toUpperCase() + type.slice(1), type), task: null, settingType: type, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index a6d7dce7384..066f4836caa 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -778,8 +778,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const escapedDisplayCommand = escapeMarkdownSyntaxTokens(displayCommand); const invocationMessage = toolSpecificData.commandLine.isSandboxWrapped ? args.isBackground - ? new MarkdownString(localize('runInTerminal.invocation.sandbox.background', "$(lock) Running `{0}` in sandbox in background", escapedDisplayCommand), { supportThemeIcons: true }) - : new MarkdownString(localize('runInTerminal.invocation.sandbox', "$(lock) Running `{0}` in sandbox", escapedDisplayCommand), { supportThemeIcons: true }) + ? new MarkdownString('$(lock) ' + localize('runInTerminal.invocation.sandbox.background', "Running `{0}` in sandbox in background", escapedDisplayCommand), { supportThemeIcons: true }) + : new MarkdownString('$(lock) ' + localize('runInTerminal.invocation.sandbox', "Running `{0}` in sandbox", escapedDisplayCommand), { supportThemeIcons: true }) : args.isBackground ? new MarkdownString(localize('runInTerminal.invocation.background', "Running `{0}` in background", escapedDisplayCommand)) : new MarkdownString(localize('runInTerminal.invocation', "Running `{0}`", escapedDisplayCommand)); diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 6a9ea130312..9051baf1c59 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -71,7 +71,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc switch (state.type) { case StateType.CheckingForUpdates: this.updateEntry( - localize('updateStatus.checkingForUpdates', "$(loading~spin) Checking for updates..."), + '$(loading~spin) ' + localize('updateStatus.checkingForUpdates', "Checking for updates..."), localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), ShowTooltipCommand, ); @@ -79,7 +79,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.AvailableForDownload: this.updateEntry( - localize('updateStatus.updateAvailableStatus', "$(circle-filled) Update available, click to download."), + '$(circle-filled) ' + localize('updateStatus.updateAvailableStatus', "Update available, click to download."), localize('updateStatus.updateAvailableAria', "Update available, click to download."), 'update.downloadNow' ); @@ -95,7 +95,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Downloaded: this.updateEntry( - localize('updateStatus.updateReadyStatus', "$(circle-filled) Update downloaded, click to install."), + '$(circle-filled) ' + localize('updateStatus.updateReadyStatus', "Update downloaded, click to install."), localize('updateStatus.updateReadyAria', "Update downloaded, click to install."), 'update.install' ); @@ -111,7 +111,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Ready: this.updateEntry( - localize('updateStatus.restartToUpdateStatus', "$(circle-filled) Update is ready, click to restart."), + '$(circle-filled) ' + localize('updateStatus.restartToUpdateStatus', "Update is ready, click to restart."), localize('updateStatus.restartToUpdateAria', "Update is ready, click to restart."), 'update.restart' ); @@ -119,7 +119,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Overwriting: this.updateEntry( - localize('updateStatus.downloadingNewerUpdateStatus', "$(loading~spin) Downloading update..."), + '$(loading~spin) ' + localize('updateStatus.downloadingNewerUpdateStatus', "Downloading update..."), localize('updateStatus.downloadingNewerUpdateAria', "Downloading a newer update"), ShowTooltipCommand ); @@ -155,21 +155,21 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc private getDownloadingText({ downloadedBytes, totalBytes }: Downloading): string { if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { const percent = computeProgressPercent(downloadedBytes, totalBytes) ?? 0; - return localize('updateStatus.downloadUpdateProgressStatus', "$(loading~spin) Downloading update: {0} / {1} • {2}%", + return '$(loading~spin) ' + localize('updateStatus.downloadUpdateProgressStatus', "Downloading update: {0} / {1} • {2}%", formatBytes(downloadedBytes), formatBytes(totalBytes), percent); } else { - return localize('updateStatus.downloadUpdateStatus', "$(loading~spin) Downloading update..."); + return '$(loading~spin) ' + localize('updateStatus.downloadUpdateStatus', "Downloading update..."); } } private getUpdatingText({ currentProgress, maxProgress }: Updating): string { const percentage = computeProgressPercent(currentProgress, maxProgress); if (percentage !== undefined) { - return localize('updateStatus.installingUpdateProgressStatus', "$(loading~spin) Installing update: {0}%", percentage); + return '$(loading~spin) ' + localize('updateStatus.installingUpdateProgressStatus', "Installing update: {0}%", percentage); } else { - return localize('updateStatus.installingUpdateStatus', "$(loading~spin) Installing update..."); + return '$(loading~spin) ' + localize('updateStatus.installingUpdateStatus', "Installing update..."); } } } From 36ca95ebaa56822d3f2d26091e607accfca48d72 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:41:15 -0700 Subject: [PATCH 169/183] Show allow-list actions for unsandboxed terminal confirmations (#303660) * Handle unsandboxed terminal confirmation actions * test: fix unsandboxed terminal confirmation actions * Fixing confirmation window issues outside sandbox --- .../chatAgentTools/browser/tools/runInTerminalTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 066f4836caa..302f83153a6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -753,7 +753,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } // If forceConfirmationReason is set, always show confirmation regardless of auto-approval - const shouldShowConfirmation = requiresUnsandboxConfirmation || (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; + const shouldShowConfirmation = (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; const confirmationMessage = requiresUnsandboxConfirmation ? new MarkdownString(localize( 'runInTerminal.unsandboxed.confirmationMessage', From 161ff4266e1157cc6a83b7898f1e8df24bae385e Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Fri, 20 Mar 2026 19:43:51 -0700 Subject: [PATCH 170/183] carousel: improve image loading perf (#303662) * carousel: improve image loading perf * resolve comments --- .../browser/imageCarousel.contribution.ts | 34 ++--- .../browser/imageCarouselEditor.ts | 127 ++++++++++++++---- .../browser/imageCarouselTypes.ts | 3 +- .../imageCarousel.contribution.test.ts | 17 ++- 4 files changed, 125 insertions(+), 56 deletions(-) diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts index 1e76ad4ed43..5c7fbd966fb 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -29,7 +29,6 @@ import { ResourceSet } from '../../../../base/common/map.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { Limiter } from '../../../../base/common/async.js'; // --- Configuration --- @@ -181,26 +180,13 @@ async function collectImageFilesFromFolder(fileService: IFileService, folderUri: return imageUris; } -async function readImageFiles(fileService: IFileService, uris: URI[]): Promise { - const limiter = new Limiter(10); - const results = await Promise.all( - uris.map(uri => limiter.queue(async () => { - try { - const content = await fileService.readFile(uri); - const mimeType = getMediaMime(uri.path) ?? 'image/png'; - return { - id: generateUuid(), - name: basename(uri), - mimeType, - data: content.value, - uri, - }; - } catch { - return undefined; - } - })) - ); - return results.filter((r): r is ICarouselImage => r !== undefined); +function createImageEntries(uris: URI[]): ICarouselImage[] { + return uris.map(uri => ({ + id: generateUuid(), + name: basename(uri), + mimeType: getMediaMime(uri.path) ?? 'image/png', + uri, + })); } class OpenImagesInCarouselFromExplorerAction extends Action2 { @@ -298,11 +284,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { return; } - const images = await readImageFiles(fileService, imageUris); - if (images.length === 0) { - notificationService.error(localize('imageReadError', "Could not read the selected images.")); - return; - } + const images = createImageEntries(imageUris); let startIndex = 0; if (startUri) { diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts index 0b9aac33655..854f5deca26 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts @@ -12,6 +12,7 @@ import { clamp } from '../../../../base/common/numbers.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; @@ -49,6 +50,7 @@ export class ImageCarouselEditor extends EditorPane { private _flatImages: IFlatImageEntry[] = []; private readonly _contentDisposables = this._register(new DisposableStore()); private readonly _imageDisposables = this._register(new DisposableStore()); + private readonly _blobUrlCache = new Map(); private _elements: { root: HTMLElement; @@ -68,7 +70,8 @@ export class ImageCarouselEditor extends EditorPane { group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService + @IStorageService storageService: IStorageService, + @IFileService private readonly _fileService: IFileService ) { super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService); } @@ -95,6 +98,7 @@ export class ImageCarouselEditor extends EditorPane { override clearInput(): void { this._contentDisposables.clear(); this._imageDisposables.clear(); + this._revokeCachedBlobUrls(); this._zoomScale = 'fit'; if (this._container) { clearNode(this._container); @@ -114,6 +118,7 @@ export class ImageCarouselEditor extends EditorPane { this._contentDisposables.clear(); this._imageDisposables.clear(); + this._revokeCachedBlobUrls(); clearNode(this._container); if (this._flatImages.length === 0) { @@ -263,11 +268,8 @@ export class ImageCarouselEditor extends EditorPane { btn.ariaLabel = localize('imageCarousel.thumbnailLabel', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length); const img = thumbnail.img as HTMLImageElement; - const blob = new Blob([image.data.buffer.slice(0)], { type: image.mimeType }); - const url = URL.createObjectURL(blob); - img.src = url; + this._loadBlobUrl(image).then(url => { img.src = url; }); img.alt = image.name; - this._contentDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); this._contentDisposables.add(addDisposableListener(btn, 'click', () => { this._currentIndex = currentFlatIndex; @@ -290,20 +292,42 @@ export class ImageCarouselEditor extends EditorPane { * Update only the changing parts: main image src, caption, button states, thumbnail selection. * No DOM teardown/rebuild — eliminates the blank flash. */ - private updateCurrentImage(): void { + private async updateCurrentImage(): Promise { if (!this._elements) { return; } - // Swap main image blob URL - this._imageDisposables.clear(); - const entry = this._flatImages[this._currentIndex]; + // Capture the navigation index before starting async work so that + // we can discard stale results if the user navigates while loading/decoding. + const navigationIndex = this._currentIndex; + + // Swap main image using cached/lazy-loaded blob URL. + // Pre-decode via decode() before assigning to so the browser + // decodes on a worker thread, avoiding main-thread stalls during commit. + const entry = this._flatImages[navigationIndex]; const currentImage = entry.image; - const blob = new Blob([currentImage.data.buffer.slice(0)], { type: currentImage.mimeType }); - const url = URL.createObjectURL(blob); - this._elements.mainImage.src = url; - this._elements.mainImage.alt = currentImage.name; - this._imageDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); + const url = await this._loadBlobUrl(currentImage); + + // If the user navigated while loading the blob URL, discard this result. + if (this._currentIndex !== navigationIndex) { + return; + } + + const tmp = new Image(); + tmp.src = url; + tmp.decode().then(() => { + // Only apply if user hasn't navigated away during decode + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }, () => { + // Decode failed (invalid image) — still show src for browser fallback + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }); // Reset zoom when switching images this._applyZoom('fit'); @@ -324,32 +348,80 @@ export class ImageCarouselEditor extends EditorPane { this._elements.prevBtn.disabled = this._currentIndex === 0; this._elements.nextBtn.disabled = this._currentIndex === this._flatImages.length - 1; - // Update thumbnail selection + // Update thumbnail selection — only toggle active class and + // call getBoundingClientRect on the active thumbnail to avoid + // layout thrashing across all thumbnails on every navigation. for (let i = 0; i < this._thumbnailElements.length; i++) { const isActive = i === this._currentIndex; const thumbnail = this._thumbnailElements[i]; thumbnail.classList.toggle('active', isActive); if (isActive) { thumbnail.setAttribute('aria-current', 'page'); - // Scroll only the thumbnail strip, not the entire editor - const container = this._elements.sectionsContainer; - const containerRect = container.getBoundingClientRect(); - const thumbRect = thumbnail.getBoundingClientRect(); - if (thumbRect.left < containerRect.left) { - container.scrollLeft += thumbRect.left - containerRect.left; - } else if (thumbRect.right > containerRect.right) { - container.scrollLeft += thumbRect.right - containerRect.right; - } } else { thumbnail.removeAttribute('aria-current'); } } + // Scroll the active thumbnail into view without blocking the main thread. + // Using scrollIntoView with 'nearest' avoids forced layout from + // getBoundingClientRect + scrollLeft and is handled efficiently by + // the browser's scroll machinery. + const activeThumbnail = this._thumbnailElements[this._currentIndex]; + if (activeThumbnail) { + activeThumbnail.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + // Update editor title to reflect current section if (this.input instanceof ImageCarouselEditorInput) { const currentSection = this._sections[entry.sectionIndex]; this.input.setName(currentSection.title || this.input.collection.title); } + + // Preload adjacent images for smoother navigation + this._preloadAdjacentImages(); + } + + private async _loadBlobUrl(image: ICarouselImage): Promise { + const cached = this._blobUrlCache.get(image.id); + if (cached) { + return cached; + } + + let buffer: Uint8Array; + if (image.data) { + buffer = image.data.buffer; + } else if (image.uri) { + const content = await this._fileService.readFile(image.uri); + buffer = content.value.buffer; + } else { + return ''; + } + + const blob = new Blob([buffer as Uint8Array], { type: image.mimeType }); + const url = URL.createObjectURL(blob); + this._blobUrlCache.set(image.id, url); + return url; + } + + private _revokeCachedBlobUrls(): void { + for (const url of this._blobUrlCache.values()) { + URL.revokeObjectURL(url); + } + this._blobUrlCache.clear(); + } + + private _preloadAdjacentImages(): void { + for (const idx of [this._currentIndex - 1, this._currentIndex + 1]) { + if (idx >= 0 && idx < this._flatImages.length) { + this._loadBlobUrl(this._flatImages[idx].image).then(url => { + // Pre-decode via decode() so the compositor doesn't block + // the main thread decoding this image during commit. + const img = new Image(); + img.src = url; + img.decode().catch(() => { /* invalid image */ }); + }); + } + } } previous(): void { @@ -431,9 +503,14 @@ export class ImageCarouselEditor extends EditorPane { img.classList.add('scale-to-fit'); img.classList.remove('pixelated'); img.style.zoom = ''; + // Remove zoomed/overflow before scrollTo to avoid an expensive + // synchronous ScrollLayer that blocks the main thread. + const wasZoomed = container.classList.contains('zoomed'); container.classList.remove('zoomed'); container.classList.remove('zoom-out'); - container.scrollTo(0, 0); + if (wasZoomed) { + container.scrollTo(0, 0); + } } else { const scale = clamp(newScale, MIN_SCALE, MAX_SCALE); this._zoomScale = scale; diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts index cda9d2b23d8..c0aaa014ffd 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts @@ -10,7 +10,8 @@ export interface ICarouselImage { readonly id: string; readonly name: string; readonly mimeType: string; - readonly data: VSBuffer; + /** In-memory image data. Omit when the image can be loaded lazily from `uri`. */ + readonly data?: VSBuffer; readonly uri?: URI; readonly source?: string; readonly caption?: string; diff --git a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts index fc95cb68395..c78c14c5df0 100644 --- a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts +++ b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts @@ -361,7 +361,7 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { assert.strictEqual(infoMessages.length, 0, 'Should not show info notification'); }); - test('all image reads failing shows error notification', async () => { + test('images with URIs are passed lazily without reading file contents', async () => { const folderUri = URI.file('/workspace/broken'); const resolveMap = new Map(); @@ -372,8 +372,13 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { ] )); - // No file contents → all readFile calls will fail + // No file contents — with lazy loading, no readFile should be called at action time + let readFileCallCount = 0; stubFileService(resolveMap, new Map()); + instantiationService.stub(IFileService, 'readFile', async () => { + readFileCallCount++; + throw new Error('readFile should not be called'); + }); stubExplorerService([]); stubEditorService(); stubNotificationService(); @@ -384,7 +389,11 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { await instantiationService.invokeFunction(command.handler, folderUri); - assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when all reads fail'); - assert.strictEqual(errorMessages.length, 1, 'Should show error notification for read failures'); + assert.strictEqual(readFileCallCount, 0, 'readFile should not be called during action'); + assert.strictEqual(openedInputs.length, 1, 'Should open carousel with lazy image entries'); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include 2 lazy image entries'); + assert.strictEqual(images[0].data, undefined, 'Image data should not be loaded eagerly'); + assert.ok(images[0].uri, 'Image should have a URI for lazy loading'); }); }); From 55969564bb5637a0fe1bdf5a665c5c5f21723407 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 20 Mar 2026 21:45:05 -0700 Subject: [PATCH 171/183] Enable setting caseInsensitive through search API for agent tools (#303679) * Enable setting caseInsensitive through search API for agent tools Fix #303673 * Avoid per-call allocation in isFilePatternMatch for ignoreCase option (#303681) * Initial plan * Extract filePatternIgnoreCaseOptions as module-level constant to avoid per-call allocations Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/a131b260-e6c0-47f6-aa2a-95ac0f24fe10 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: roblourens <323878+roblourens@users.noreply.github.com> --- .../workbench/api/common/extHostWorkspace.ts | 2 + .../api/test/browser/extHostWorkspace.test.ts | 37 +++++++++++++++++++ .../services/search/common/search.ts | 6 ++- .../services/search/node/fileSearch.ts | 2 +- .../search/test/common/search.test.ts | 24 +++++++++++- .../vscode.proposed.findFiles2.d.ts | 6 +++ .../vscode.proposed.findTextInFiles2.d.ts | 6 +++ 7 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 3b01c6c8ac6..24bb32c2327 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -524,6 +524,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac disregardSearchExcludeSettings: options.useExcludeSettings !== undefined && (options.useExcludeSettings !== ExcludeSettingOptions.SearchAndFilesExclude), maxResults: options.maxResults, excludePattern: excludePatterns.length > 0 ? excludePatterns : undefined, + ignoreGlobCase: options.caseInsensitive, _reason: 'startFileSearch', shouldGlobSearch: query.type === 'include' ? undefined : true, }; @@ -597,6 +598,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac disregardSearchExcludeSettings: options.useExcludeSettings !== undefined && (options.useExcludeSettings !== ExcludeSettingOptions.SearchAndFilesExclude), fileEncoding: options.encoding, maxResults: options.maxResults, + ignoreGlobCase: options.caseInsensitive, previewOptions: options.previewOptions ? { matchLines: options.previewOptions?.numMatchLines ?? 100, charsPerLine: options.previewOptions?.charsPerLine ?? 10000, diff --git a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts index 09190276e41..fd22da56829 100644 --- a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts @@ -883,6 +883,25 @@ suite('ExtHostWorkspace', function () { }); }); + test('caseInsensitive', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.ignoreGlobCase, true); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2([''], { caseInsensitive: true }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); + // todo: add tests with multiple filePatterns and excludes }); @@ -1096,6 +1115,24 @@ suite('ExtHostWorkspace', function () { assert(mainThreadCalled, 'mainThreadCalled'); }); + test('caseInsensitive', async () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override async $startTextSearch(query: IPatternInfo, folder: UriComponents | null, options: ITextQueryBuilderOptions, requestId: number, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.ignoreGlobCase, true); + return null; + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + await (ws.findTextInFiles2({ pattern: 'foo' }, { caseInsensitive: true }, new ExtensionIdentifier('test'))).complete; + assert(mainThreadCalled, 'mainThreadCalled'); + }); + // TODO: test multiple includes/excludess }); }); diff --git a/src/vs/workbench/services/search/common/search.ts b/src/vs/workbench/services/search/common/search.ts index fd1743ceb68..b425f20be3e 100644 --- a/src/vs/workbench/services/search/common/search.ts +++ b/src/vs/workbench/services/search/common/search.ts @@ -647,11 +647,13 @@ export function isSerializedFileMatch(arg: ISerializedSearchProgressItem): arg i return !!(arg).path; } -export function isFilePatternMatch(candidate: IRawFileMatch, filePatternToUse: string, fuzzy = true): boolean { +const filePatternIgnoreCaseOptions = { ignoreCase: true }; + +export function isFilePatternMatch(candidate: IRawFileMatch, filePatternToUse: string, fuzzy = true, ignoreCase?: boolean): boolean { const pathToMatch = candidate.searchPath ? candidate.searchPath : candidate.relativePath; return fuzzy ? fuzzyContains(pathToMatch, filePatternToUse) : - glob.match(filePatternToUse, pathToMatch); + glob.match(filePatternToUse, pathToMatch, ignoreCase ? filePatternIgnoreCaseOptions : undefined); } export interface ISerializedFileMatch { diff --git a/src/vs/workbench/services/search/node/fileSearch.ts b/src/vs/workbench/services/search/node/fileSearch.ts index d850308c70c..cfb2819810b 100644 --- a/src/vs/workbench/services/search/node/fileSearch.ts +++ b/src/vs/workbench/services/search/node/fileSearch.ts @@ -591,7 +591,7 @@ export class FileWalker { if (this.normalizedFilePatternLowercase) { return isFilePatternMatch(candidate, this.normalizedFilePatternLowercase); } else if (this.filePattern) { - return isFilePatternMatch(candidate, this.filePattern, false); + return isFilePatternMatch(candidate, this.filePattern, false, this.config.ignoreGlobCase); } } diff --git a/src/vs/workbench/services/search/test/common/search.test.ts b/src/vs/workbench/services/search/test/common/search.test.ts index d2ccd94f59c..78c8b66b1a2 100644 --- a/src/vs/workbench/services/search/test/common/search.test.ts +++ b/src/vs/workbench/services/search/test/common/search.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ITextSearchPreviewOptions, OneLineRange, TextSearchMatch, SearchRange } from '../../common/search.js'; +import { ITextSearchPreviewOptions, OneLineRange, TextSearchMatch, SearchRange, isFilePatternMatch } from '../../common/search.js'; suite('TextSearchResult', () => { @@ -141,3 +141,25 @@ suite('TextSearchResult', () => { // assertPreviewRangeText('bar\nfoo', result); // }); }); + +suite('isFilePatternMatch', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('glob match is case-sensitive by default', () => { + const candidate = { relativePath: 'src/Foo.ts', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.ts', false), true); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.TS', false), false); + }); + + test('glob match is case-insensitive when ignoreCase is true', () => { + const candidate = { relativePath: 'src/Foo.ts', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.TS', false, true), true); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.Ts', false, true), true); + }); + + test('glob match with mixed case pattern', () => { + const candidate = { relativePath: 'src/MyComponent.TSX', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.tsx', false), false); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.tsx', false, true), true); + }); +}); diff --git a/src/vscode-dts/vscode.proposed.findFiles2.d.ts b/src/vscode-dts/vscode.proposed.findFiles2.d.ts index 8e7c11874b4..af324e90719 100644 --- a/src/vscode-dts/vscode.proposed.findFiles2.d.ts +++ b/src/vscode-dts/vscode.proposed.findFiles2.d.ts @@ -71,6 +71,12 @@ declare module 'vscode' { * For more info, see the setting description for `search.followSymlinks`. */ followSymlinks?: boolean; + + /** + * Whether glob patterns should be matched case-insensitively. + * Defaults to `false`. + */ + caseInsensitive?: boolean; } /** diff --git a/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts b/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts index c7c7c9507a5..4575ba17d0c 100644 --- a/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts +++ b/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts @@ -91,6 +91,12 @@ declare module 'vscode' { */ followSymlinks?: boolean; + /** + * Whether glob patterns should be matched case-insensitively. + * Defaults to `false`. + */ + caseInsensitive?: boolean; + /** * Interpret files using this encoding. * See the vscode setting `"files.encoding"` From 9d7d0363de8a854c19c2df3e520d67d2389db4a3 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 20 Mar 2026 22:23:12 -0700 Subject: [PATCH 172/183] When archiving active session, clear it (#303684) --- .../chat/browser/widgetHosts/viewPane/chatViewPane.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 2adfac1bc56..bdc1c632cb0 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -13,6 +13,7 @@ import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { autorun, IReader } from '../../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; @@ -628,6 +629,16 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } })); + // When the currently displayed session is archived, start a new session + this._register(this.agentSessionsService.model.onDidChangeSessionArchivedState(e => { + if (e.isArchived()) { + const currentSessionResource = chatWidget.viewModel?.sessionResource; + if (currentSessionResource && isEqual(currentSessionResource, e.resource)) { + this.clear(); + } + } + })); + // When showing sessions stacked, adjust the height of the sessions list to make room for chat input this._register(autorun(reader => { chatWidget.inputPart.height.read(reader); From 54be29cea73c391fb160d96f8a166999beefde60 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:14:24 -0700 Subject: [PATCH 173/183] Merge pull request #303618 from microsoft/DileepY/303505 Include analyzer messages in background terminal output --- .../browser/tools/runInTerminalTool.ts | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 302f83153a6..97cb4971faf 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1006,12 +1006,23 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ? `Note: The tool simplified the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` : `Command is running in terminal with ID=${termId}` ); + const backgroundOutput = pollingResult?.modelOutputEvalResponse ?? pollingResult?.output; + const outputAnalyzerMessage = backgroundOutput + ? await this._getOutputAnalyzerMessage(undefined, backgroundOutput, command, didSandboxWrapCommand) + : undefined; if (pollingResult && pollingResult.modelOutputEvalResponse) { - resultText += `\n\ The command became idle with output:\n${pollingResult.modelOutputEvalResponse}`; + resultText += `\n\ The command became idle with output:\n`; + if (outputAnalyzerMessage) { + resultText += `${outputAnalyzerMessage}\n`; + } + resultText += pollingResult.modelOutputEvalResponse; } else if (pollingResult) { - resultText += `\n\ The command is still running, with output:\n${pollingResult.output}`; + resultText += `\n\ The command is still running, with output:\n`; + if (outputAnalyzerMessage) { + resultText += `${outputAnalyzerMessage}\n`; + } + resultText += pollingResult.output; } - const endCwd = await toolTerminal.instance.getCwdResource(); return { toolMetadata: { @@ -1206,14 +1217,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { resultText.push(`Note: Command timed out after ${timeoutValue}ms. Output collected so far is shown below and the command may still be running in terminal ID ${termId}.\n\n`); } - let outputAnalyzerMessage: string | undefined; - for (const analyzer of this._outputAnalyzers) { - const message = await analyzer.analyze({ exitCode, exitResult: terminalResult, commandLine: command, isSandboxWrapped: didSandboxWrapCommand }); - if (message) { - outputAnalyzerMessage = message; - break; - } - } + const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { resultText.push(`${outputAnalyzerMessage}\n`); } @@ -1248,6 +1252,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + private async _getOutputAnalyzerMessage(exitCode: number | undefined, exitResult: string, commandLine: string, isSandboxWrapped: boolean): Promise { + for (const analyzer of this._outputAnalyzers) { + const message = await analyzer.analyze({ exitCode, exitResult, commandLine, isSandboxWrapped }); + if (message) { + return message; + } + } + + return undefined; + } + private static readonly _maxImageFileSize = 5 * 1024 * 1024; /** From a1254fd4c266b86ea1a605a8e8d3f08b7a4b24a4 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Sat, 21 Mar 2026 19:18:30 +1100 Subject: [PATCH 174/183] update external tool invocations so terminal output renders (#303394) --- .../contrib/chat/common/model/chatModel.ts | 12 ++-- .../chat/test/common/model/chatModel.test.ts | 61 ++++++++++++++++++- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 4124516612e..e5ce38c5fea 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -862,6 +862,9 @@ export class Response extends AbstractResponse implements IDisposable { ); if (existingInvocation) { + if (progress.toolSpecificData !== undefined) { + existingInvocation.toolSpecificData = progress.toolSpecificData; + } if (progress.isComplete) { existingInvocation.didExecuteTool({ content: [], @@ -870,9 +873,6 @@ export class Response extends AbstractResponse implements IDisposable { toolResultDetails: progress.resultDetails }); } - if (progress.toolSpecificData !== undefined) { - existingInvocation.toolSpecificData = progress.toolSpecificData; - } return; } @@ -900,15 +900,15 @@ export class Response extends AbstractResponse implements IDisposable { if (progress.isComplete) { // Already completed on first push + if (progress.toolSpecificData !== undefined) { + invocation.toolSpecificData = progress.toolSpecificData; + } invocation.didExecuteTool({ content: [], toolResultMessage: progress.pastTenseMessage, toolResultError: progress.errorMessage, toolResultDetails: progress.resultDetails }); - if (progress.toolSpecificData !== undefined) { - invocation.toolSpecificData = progress.toolSpecificData; - } } this._responseParts.push(invocation); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index d30ca039c5a..8a3a7439207 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -27,7 +27,7 @@ import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, ICh import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatModel, ChatRequestModel, ChatResponseResource, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; -import { ChatRequestQueueKind, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatService, IChatTerminalToolInvocationData, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { MockChatService } from '../chatService/mockChatService.js'; @@ -553,6 +553,65 @@ suite('Response', () => { assert.strictEqual(textEditGroups.length, 0, 'Should not have textEditGroup for cell edits'); assert.strictEqual(notebookEditGroups.length, 1, 'Should have notebookEditGroup for cell edits'); }); + + test('external terminal tool updates preserve toolSpecificData when completing an existing invocation', () => { + const response = store.add(new Response([])); + const toolSpecificData: IChatTerminalToolInvocationData = { + kind: 'terminal', + language: 'bash', + commandLine: { original: 'npm test' }, + terminalCommandOutput: { text: 'all green' }, + terminalCommandState: { exitCode: 0 }, + }; + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-1', + toolName: 'run_in_terminal', + isComplete: false, + invocationMessage: 'Running npm test', + }); + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-1', + toolName: 'run_in_terminal', + isComplete: true, + pastTenseMessage: 'Ran npm test', + toolSpecificData, + }); + + assert.strictEqual(response.value.length, 1); + assert.strictEqual(response.value[0].kind, 'toolInvocation'); + assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData); + assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true); + }); + + test('external terminal tool updates preserve toolSpecificData when first pushed as complete', () => { + const response = store.add(new Response([])); + const toolSpecificData: IChatTerminalToolInvocationData = { + kind: 'terminal', + language: 'bash', + commandLine: { original: 'npm test' }, + terminalCommandOutput: { text: 'all green' }, + terminalCommandState: { exitCode: 0 }, + }; + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-2', + toolName: 'run_in_terminal', + isComplete: true, + invocationMessage: 'Running npm test', + pastTenseMessage: 'Ran npm test', + toolSpecificData, + }); + + assert.strictEqual(response.value.length, 1); + assert.strictEqual(response.value[0].kind, 'toolInvocation'); + assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData); + assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true); + }); }); suite('normalizeSerializableChatData', () => { From b978bf74b283c3be8c1003c85b0d1acd71514515 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:20:44 -0700 Subject: [PATCH 175/183] tmp directory should not be used by sandboxed commands (#303699) * Fix terminal sandbox tmp handling and upgrade sandbox runtime Fixes #299224 Fixes #303568 * fixing test * merging changes --- package-lock.json | 10 ++-- package.json | 2 +- remote/package-lock.json | 10 ++-- remote/package.json | 2 +- .../browser/tools/runInTerminalTool.ts | 2 + .../common/terminalSandboxService.ts | 53 ++++++++++++++++--- .../browser/terminalSandboxService.test.ts | 50 ++++++++++++++++- .../runInTerminalTool.test.ts | 11 +++- 8 files changed, 118 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03b6d34bf96..7ef401451f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -419,15 +419,15 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.23.tgz", - "integrity": "sha512-Np0VRH6D71cGoJZvd8hCz1LMfwg9ERJovrOJSCz5aSQSQJPWPNIFPV1wfc8oAhJpStOuYkot+EmXOkRRxuGMCQ==", + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", + "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, diff --git a/package.json b/package.json index b7ad7635f91..212143c69c6 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/vite && npm install @vscode/component-explorer-vite-plugin@next && npm install @vscode/component-explorer@next" }, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", diff --git a/remote/package-lock.json b/remote/package-lock.json index c6cfdf09904..e58902bd620 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,7 +8,7 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -52,15 +52,15 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.23.tgz", - "integrity": "sha512-Np0VRH6D71cGoJZvd8hCz1LMfwg9ERJovrOJSCz5aSQSQJPWPNIFPV1wfc8oAhJpStOuYkot+EmXOkRRxuGMCQ==", + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", + "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, diff --git a/remote/package.json b/remote/package.json index 526d813cc36..0fbecc3c5a4 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 97cb4971faf..7ea0d6a0b2d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -151,6 +151,8 @@ Background Processes: parts.push(` Sandboxing: - ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default +- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided +- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox - When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true and prompt the user to bypass the sandbox - Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. 'Operation not permitted' errors, network failures, or file access errors, etc - When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason; the user will be prompted before it runs unsandboxed`); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 77efbd88b4f..ac6a8445c2b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -11,6 +11,7 @@ import { dirname, posix, win32 } from '../../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; +import { localize } from '../../../../../nls.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -22,6 +23,8 @@ import { TerminalChatAgentToolsSettingId } from './terminalChatAgentToolsConfigu import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; import { ITrustedDomainService } from '../../../url/common/trustedDomainService.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ILifecycleService, WillShutdownJoinerOrder } from '../../../../services/lifecycle/common/lifecycle.js'; export const ITerminalSandboxService = createDecorator('terminalSandboxService'); @@ -50,6 +53,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private _appRoot: string; private _os: OperatingSystem = OS; private _defaultWritePaths: string[] = ['~/.npm']; + private static readonly _sandboxTempDirName = 'tmp'; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -59,6 +63,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IProductService private readonly _productService: IProductService, + @ILifecycleService private readonly _lifecycleService: ILifecycleService, ) { super(); this._appRoot = dirname(FileAccess.asFileUri('').path); @@ -87,6 +93,17 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => { this.setNeedsForceUpdateConfigFile(); })); + + this._register(this._lifecycleService.onWillShutdown(e => { + if (!this._tempDir) { + return; + } + e.join(this._cleanupSandboxTempDir(), { + id: 'join.deleteFilesInSandboxTempDir', + label: localize('deleteFilesInSandboxTempDir', "Delete Files in Sandbox Temp Dir"), + order: WillShutdownJoinerOrder.Default + }); + })); } public async isEnabled(): Promise { @@ -119,9 +136,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // Use ELECTRON_RUN_AS_NODE=1 to make Electron executable behave as Node.js // TMPDIR must be set as environment variable before the command // Quote shell arguments so the wrapped command cannot break out of the outer shell. - const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; + const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; if (this._remoteEnvDetails) { - return `${wrappedCommand}`; + return `"${this._execPath}" ${wrappedCommand}`; } return `ELECTRON_RUN_AS_NODE=1 ${wrappedCommand}`; } @@ -212,13 +229,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb if (await this.isEnabled()) { this._needsForceUpdateConfigFile = true; const remoteEnv = this._remoteEnvDetails || await this._remoteEnvDetailsPromise; - if (remoteEnv) { - this._tempDir = remoteEnv.tmpDir; - } else { - const environmentService = this._environmentService as IEnvironmentService & { tmpDir?: URI }; - this._tempDir = environmentService.tmpDir; - } + this._tempDir = this._getSandboxTempDirPath(remoteEnv); if (this._tempDir) { + await this._fileService.createFolder(this._tempDir); this._defaultWritePaths.push(this._tempDir.path); } if (!this._tempDir) { @@ -227,6 +240,30 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } } + private async _cleanupSandboxTempDir(): Promise { + if (!this._tempDir) { + return; + } + try { + await this._fileService.del(this._tempDir, { recursive: true, useTrash: false }); + } catch (error) { + this._logService.warn('TerminalSandboxService: Failed to delete sandbox temp dir', error); + } + } + + private _getSandboxTempDirPath(remoteEnv: IRemoteAgentEnvironment | null): URI | undefined { + if (remoteEnv?.userHome) { + return URI.joinPath(remoteEnv.userHome, this._productService.serverDataFolderName ?? this._productService.dataFolderName, TerminalSandboxService._sandboxTempDirName); + } + + const nativeEnv = this._environmentService as IEnvironmentService & { userHome?: URI }; + if (nativeEnv.userHome) { + return URI.joinPath(nativeEnv.userHome, this._productService.dataFolderName, TerminalSandboxService._sandboxTempDirName); + } + + return undefined; + } + private _addTrustedDomainsToAllowedDomains(allowedDomains: string[]): string[] { const allowedDomainsSet = new Set(allowedDomains); for (const domain of this._trustedDomainService.trustedDomains) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 68c0165879a..596c35e9b1d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -6,12 +6,14 @@ import { strictEqual, ok } from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestProductService } from '../../../../../test/common/workbenchTestServices.js'; import { TerminalSandboxService } from '../../common/terminalSandboxService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -23,6 +25,7 @@ import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/common/remoteAgentEnvironment.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; suite('TerminalSandboxService - allowTrustedDomains', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -31,8 +34,12 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { let configurationService: TestConfigurationService; let trustedDomainService: MockTrustedDomainService; let fileService: MockFileService; + let lifecycleService: TestLifecycleService; let workspaceContextService: MockWorkspaceContextService; + let productService: IProductService; let createdFiles: Map; + let createdFolders: string[]; + let deletedFolders: string[]; class MockTrustedDomainService implements ITrustedDomainService { _serviceBrand: undefined; @@ -50,6 +57,15 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { createdFiles.set(uri.path, contentString); return {}; } + + async createFolder(uri: URI): Promise { + createdFolders.push(uri.path); + return {}; + } + + async del(uri: URI): Promise { + deletedFolders.push(uri.path); + } } class MockRemoteAgentService { @@ -132,11 +148,19 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { setup(() => { createdFiles = new Map(); + createdFolders = []; + deletedFolders = []; instantiationService = workbenchInstantiationService({}, store); configurationService = new TestConfigurationService(); trustedDomainService = new MockTrustedDomainService(); fileService = new MockFileService(); + lifecycleService = store.add(new TestLifecycleService()); workspaceContextService = new MockWorkspaceContextService(); + productService = { + ...TestProductService, + dataFolderName: '.test-data', + serverDataFolderName: '.test-server-data' + }; workspaceContextService.setWorkspaceFolders([URI.file('/workspace-one')]); // Setup default configuration @@ -155,9 +179,11 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { execPath: '/usr/bin/node' }); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IProductService, productService); instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); instantiationService.stub(ITrustedDomainService, trustedDomainService); instantiationService.stub(IWorkspaceContextService, workspaceContextService); + instantiationService.stub(ILifecycleService, lifecycleService); }); test('should filter out sole wildcard (*) from trusted domains', async () => { @@ -341,6 +367,28 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { ok(refreshedConfig.filesystem.allowWrite.includes('/configured/path'), 'Refreshed config should preserve configured allowWrite paths'); }); + test('should create sandbox temp dir under the server data folder', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + const expectedTempDir = URI.joinPath(URI.file('/home/user'), productService.serverDataFolderName ?? productService.dataFolderName, 'tmp'); + + strictEqual(sandboxService.getTempDir()?.path, expectedTempDir.path, 'Sandbox temp dir should live under the server data folder'); + strictEqual(createdFolders[0], expectedTempDir.path, 'Sandbox temp dir should be created before writing the config'); + ok(configPath?.startsWith(expectedTempDir.path), 'Sandbox config file should be written inside the sandbox temp dir'); + }); + + test('should delete sandbox temp dir on shutdown', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + const expectedTempDir = URI.joinPath(URI.file('/home/user'), productService.serverDataFolderName ?? productService.dataFolderName, 'tmp'); + + lifecycleService.fireShutdown(); + await Promise.all(lifecycleService.shutdownJoiners); + + strictEqual(lifecycleService.shutdownJoiners.length, 1, 'Shutdown should register a temp-dir cleanup joiner'); + strictEqual(deletedFolders[0], expectedTempDir.path, 'Shutdown should delete the sandbox temp dir'); + }); + test('should add ripgrep bin directory to PATH when wrapping command', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 2f08f195d6e..cc94726c09b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -36,7 +36,7 @@ import { ITerminalSandboxService } from '../../common/terminalSandboxService.js' import { ILanguageModelToolsService, IPreparedToolInvocation, IToolInvocationPreparationContext, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; -import { RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; +import { createRunInTerminalToolData, RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; import { ShellIntegrationQuality } from '../../browser/toolTerminalCreator.js'; import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; import { TerminalChatService } from '../../../chat/browser/terminalChatService.js'; @@ -204,6 +204,15 @@ suite('RunInTerminalTool', () => { } suite('sandbox invocation messaging', () => { + test('should instruct models to use $TMPDIR instead of /tmp when sandboxed', async () => { + sandboxEnabled = true; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('must utilize the $TMPDIR environment variable'), 'Expected sandboxed tool description to require $TMPDIR usage'); + ok(toolData.modelDescription?.includes('The /tmp directory is not guaranteed to be accessible or writable and must be avoided'), 'Expected sandboxed tool description to discourage /tmp usage'); + }); + test('should use sandbox labels when command is sandbox wrapped', async () => { terminalSandboxService.isEnabled = async () => true; terminalSandboxService.getSandboxConfigPath = async () => '/tmp/vscode-sandbox-settings.json'; From ac2da2ad982b341103387a7cc1ca82a2f05b8829 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:30:56 -0700 Subject: [PATCH 176/183] Fixing incorrect rendering of invocation message in sandbox mode. (#303711) rendering in markdown string --- .../chatTerminalToolProgressPart.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 1e724674931..a0deb925c99 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { h } from '../../../../../../../base/browser/dom.js'; -import { renderLabelWithIcons } from '../../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ActionBar } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; -import { isMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { escapeMarkdownSyntaxTokens, isMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ChatConfiguration } from '../../../../common/constants.js'; @@ -1641,11 +1640,11 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart @IHoverService hoverService: IHoverService, @IConfigurationService configurationService: IConfigurationService, ) { - const title = isComplete ? `Ran \`${commandText}\`` : `Running \`${commandText}\``; + const title = isComplete ? `Ran \`${escapeMarkdownSyntaxTokens(commandText)}\`` : `Running \`${escapeMarkdownSyntaxTokens(commandText)}\``; super(title, context, undefined, hoverService, configurationService); this._terminalContentElement = contentElement; - this._commandText = commandText; + this._commandText = escapeMarkdownSyntaxTokens(commandText); this._isSandboxWrapped = isSandboxWrapped; this._isComplete = isComplete; @@ -1664,15 +1663,16 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart return; } - const labelElement = this._collapseButton.labelElement; - labelElement.textContent = ''; if (this._isSandboxWrapped) { - dom.reset(labelElement, ...renderLabelWithIcons(this._isComplete + this._collapseButton.label = new MarkdownString(this._isComplete ? '$(lock) ' + localize('chat.terminal.ranInSandbox', "Ran `{0}` in sandbox", this._commandText) - : '$(lock) ' + localize('chat.terminal.runningInSandbox', "Running `{0}` in sandbox", this._commandText))); + : '$(lock) ' + localize('chat.terminal.runningInSandbox', "Running `{0}` in sandbox", this._commandText), { supportThemeIcons: true }); return; } + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + const prefixText = this._isComplete ? localize('chat.terminal.ran.prefix', "Ran ") : localize('chat.terminal.running.prefix', "Running "); From 0c86a6f765a0ba860a1878cd5b09d640a44d351d Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 10:41:55 +0100 Subject: [PATCH 177/183] Clarify sandbox retry prompts to instruct LLM to set requestUnsandboxedExecution=true directly (#303436) --- .../chatAgentTools/browser/tools/runInTerminalTool.ts | 4 ++-- .../chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 7ea0d6a0b2d..a8aa5e22e0e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -153,9 +153,9 @@ Sandboxing: - ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default - When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided - Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox -- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true and prompt the user to bypass the sandbox +- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user - Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. 'Operation not permitted' errors, network failures, or file access errors, etc -- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason; the user will be prompted before it runs unsandboxed`); +- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access`); } parts.push(` diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index 9c1799d9476..658c64ad05a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -30,7 +30,7 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer : TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem; return `Command failed while running in sandboxed mode. If the command failed due to sandboxing: - If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetwork}.allowedDomains. -- You can also rerun requestUnsandboxedExecution=true and prompt the user to bypass the sandbox. +- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. Here is the output of the command:\n`; } From 2ed5b851ebca47c8b6d1097c2ef3b17a9df6cd0e Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 10:59:54 +0100 Subject: [PATCH 178/183] Include allowed/blocked network domains in run_in_terminal tool instructions (#303582) --- .../browser/tools/runInTerminalTool.ts | 53 ++++++++++++------- .../common/terminalSandboxService.ts | 18 +++++++ .../runInTerminalTool.test.ts | 1 + 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index a8aa5e22e0e..ab6b17b6c30 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -72,7 +72,7 @@ import { clamp } from '../../../../../../base/common/numbers.js'; import { IOutputAnalyzer } from './outputAnalyzer.js'; import { SandboxOutputAnalyzer } from './sandboxOutputAnalyzer.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; -import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; +import { ITerminalSandboxService, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js'; // #region Tool data @@ -124,7 +124,7 @@ function createPowerShellModelDescription(shell: string): string { ].join('\n'); } -function createGenericDescription(isSandboxEnabled: boolean): string { +function createGenericDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const parts = [` Command Execution: - Use && to chain simple commands on one line @@ -148,14 +148,27 @@ Background Processes: - Returns a terminal ID for checking status and runtime later`]; if (isSandboxEnabled) { - parts.push(` -Sandboxing: -- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default -- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided -- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox -- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user -- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. 'Operation not permitted' errors, network failures, or file access errors, etc -- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access`); + const sandboxLines = [ + '', + 'Sandboxing:', + '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default', + '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', + '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', + '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', + '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc', + '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access', + ]; + if (networkDomains) { + if (networkDomains.allowedDomains.length === 0) { + sandboxLines.push('- All network access is blocked in the sandbox'); + } else { + sandboxLines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${networkDomains.allowedDomains.join(', ')}`); + } + if (networkDomains.deniedDomains.length > 0) { + sandboxLines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`); + } + } + parts.push(sandboxLines.join('\n')); } parts.push(` @@ -175,20 +188,20 @@ Best Practices: return parts.join(''); } -function createBashModelDescription(isSandboxEnabled: boolean): string { +function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled), + createGenericDescription(isSandboxEnabled, networkDomains), '- Use [[ ]] for conditional tests instead of [ ]', '- Prefer $() over backticks for command substitution', '- Use set -e at start of complex commands to exit on errors' ].join('\n'); } -function createZshModelDescription(isSandboxEnabled: boolean): string { +function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled), + createGenericDescription(isSandboxEnabled, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use [[ ]] for conditional tests instead of [ ]', @@ -198,10 +211,10 @@ function createZshModelDescription(isSandboxEnabled: boolean): string { ].join('\n'); } -function createFishModelDescription(isSandboxEnabled: boolean): string { +function createFishModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent fish terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled), + createGenericDescription(isSandboxEnabled, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use test expressions for conditionals (no [[ ]] syntax)', @@ -225,15 +238,17 @@ export async function createRunInTerminalToolData( terminalSandboxService.isEnabled(), ]); + const networkDomains = isSandboxEnabled ? terminalSandboxService.getResolvedNetworkDomains() : undefined; + let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { modelDescription = createPowerShellModelDescription(shell); } else if (shell && os && isZsh(shell, os)) { - modelDescription = createZshModelDescription(isSandboxEnabled); + modelDescription = createZshModelDescription(isSandboxEnabled, networkDomains); } else if (shell && os && isFish(shell, os)) { - modelDescription = createFishModelDescription(isSandboxEnabled); + modelDescription = createFishModelDescription(isSandboxEnabled, networkDomains); } else { - modelDescription = createBashModelDescription(isSandboxEnabled); + modelDescription = createBashModelDescription(isSandboxEnabled, networkDomains); } return { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index ac6a8445c2b..9b0409c7123 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -28,6 +28,11 @@ import { ILifecycleService, WillShutdownJoinerOrder } from '../../../../services export const ITerminalSandboxService = createDecorator('terminalSandboxService'); +export interface ITerminalSandboxResolvedNetworkDomains { + allowedDomains: string[]; + deniedDomains: string[]; +} + export interface ITerminalSandboxService { readonly _serviceBrand: undefined; isEnabled(): Promise; @@ -36,6 +41,7 @@ export interface ITerminalSandboxService { getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; + getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains; } export class TerminalSandboxService extends Disposable implements ITerminalSandboxService { @@ -264,6 +270,18 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return undefined; } + public getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { + const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; + let allowedDomains = networkSetting.allowedDomains ?? []; + if (networkSetting.allowTrustedDomains) { + allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); + } + return { + allowedDomains, + deniedDomains: networkSetting.deniedDomains ?? [] + }; + } + private _addTrustedDomainsToAllowedDomains(allowedDomains: string[]): string[] { const allowedDomainsSet = new Set(allowedDomains); for (const domain of this._trustedDomainService.trustedDomains) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index cc94726c09b..facc4a81edc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -116,6 +116,7 @@ suite('RunInTerminalTool', () => { getTempDir: () => undefined, setNeedsForceUpdateConfigFile: () => { }, getOS: async () => OperatingSystem.Linux, + getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }), }; instantiationService.stub(ITerminalSandboxService, terminalSandboxService); From babe1c71b0632a0c231cbcb1ba773b6b43cf9505 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Mar 2026 11:49:56 +0100 Subject: [PATCH 179/183] Address review feedback for terminal sandbox instructions - Extract sandbox line generation into shared createSandboxLines() helper - Compute effective allowed domains by filtering out denied domains - Add sandbox/network domain instructions to PowerShell description - Add tests for network domain inclusion and denied domain filtering --- .../browser/tools/runInTerminalTool.ts | 65 ++++++++++++------- .../runInTerminalTool.test.ts | 26 ++++++++ 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index ab6b17b6c30..0fea4461bab 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -79,9 +79,9 @@ import { ITerminalSandboxService, type ITerminalSandboxResolvedNetworkDomains } const TOOL_REFERENCE_NAME = 'runInTerminal'; const LEGACY_TOOL_REFERENCE_FULL_NAMES = ['runCommands/runInTerminal']; -function createPowerShellModelDescription(shell: string): string { +function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const isWinPwsh = isWindowsPowerShell(shell); - return [ + const parts = [ `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, '', 'Command Execution:', @@ -106,6 +106,13 @@ function createPowerShellModelDescription(shell: string): string { '- For long-running tasks (e.g., servers), set isBackground=true', '- Returns a terminal ID for checking status and runtime later', '- Use Start-Job for background PowerShell jobs', + ]; + + if (isSandboxEnabled) { + parts.push(...createSandboxLines(networkDomains)); + } + + parts.push( '', 'Output Management:', '- Output is automatically truncated if longer than 60KB to prevent context overflow', @@ -121,7 +128,35 @@ function createPowerShellModelDescription(shell: string): string { '- Use Test-Path to check file/directory existence', '- Be specific with Select-Object properties to avoid excessive output', '- Avoid printing credentials unless absolutely required', - ].join('\n'); + ); + + return parts.join('\n'); +} + +function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { + const lines = [ + '', + 'Sandboxing:', + '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default', + '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', + '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', + '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', + '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc', + '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access', + ]; + if (networkDomains) { + const deniedSet = new Set(networkDomains.deniedDomains); + const effectiveAllowed = networkDomains.allowedDomains.filter(d => !deniedSet.has(d)); + if (effectiveAllowed.length === 0) { + lines.push('- All network access is blocked in the sandbox'); + } else { + lines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${effectiveAllowed.join(', ')}`); + } + if (networkDomains.deniedDomains.length > 0) { + lines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`); + } + } + return lines; } function createGenericDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { @@ -148,27 +183,7 @@ Background Processes: - Returns a terminal ID for checking status and runtime later`]; if (isSandboxEnabled) { - const sandboxLines = [ - '', - 'Sandboxing:', - '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default', - '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', - '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', - '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', - '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc', - '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access', - ]; - if (networkDomains) { - if (networkDomains.allowedDomains.length === 0) { - sandboxLines.push('- All network access is blocked in the sandbox'); - } else { - sandboxLines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${networkDomains.allowedDomains.join(', ')}`); - } - if (networkDomains.deniedDomains.length > 0) { - sandboxLines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`); - } - } - parts.push(sandboxLines.join('\n')); + parts.push(createSandboxLines(networkDomains).join('\n')); } parts.push(` @@ -242,7 +257,7 @@ export async function createRunInTerminalToolData( let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { - modelDescription = createPowerShellModelDescription(shell); + modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, networkDomains); } else if (shell && os && isZsh(shell, os)) { modelDescription = createZshModelDescription(isSandboxEnabled, networkDomains); } else if (shell && os && isFish(shell, os)) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index facc4a81edc..94439c82525 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -214,6 +214,32 @@ suite('RunInTerminalTool', () => { ok(toolData.modelDescription?.includes('The /tmp directory is not guaranteed to be accessible or writable and must be avoided'), 'Expected sandboxed tool description to discourage /tmp usage'); }); + test('should include allowed and denied network domains in model description', async () => { + sandboxEnabled = true; + terminalSandboxService.getResolvedNetworkDomains = () => ({ + allowedDomains: ['github.com', 'npmjs.org'], + deniedDomains: ['evil.com'], + }); + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('github.com, npmjs.org'), 'Expected allowed domains in description'); + ok(toolData.modelDescription?.includes('evil.com'), 'Expected denied domains in description'); + }); + + test('should exclude denied domains from effective allowed list', async () => { + sandboxEnabled = true; + terminalSandboxService.getResolvedNetworkDomains = () => ({ + allowedDomains: ['github.com', 'evil.com', 'npmjs.org'], + deniedDomains: ['evil.com'], + }); + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('github.com, npmjs.org'), 'Expected effective allowed list without denied domain'); + ok(!toolData.modelDescription?.includes('accessible in the sandbox (all other network access is blocked): github.com, evil.com'), 'Expected denied domain removed from allowed list'); + }); + test('should use sandbox labels when command is sandbox wrapped', async () => { terminalSandboxService.isEnabled = async () => true; terminalSandboxService.getSandboxConfigPath = async () => '/tmp/vscode-sandbox-settings.json'; From 5bd41fae6081d53ba1c446466501d6331f139cdc Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sat, 21 Mar 2026 18:42:19 +0100 Subject: [PATCH 180/183] Re-register run_in_terminal tool when sandbox settings change (#303748) * Re-register run_in_terminal tool when sandbox settings change When the terminal sandbox setting is toggled at runtime, the run_in_terminal tool's schema and description were not updated because the tool data was only computed once at startup. This meant the model never learned about requestUnsandboxedExecution when sandbox was enabled after startup. Fix by using a MutableDisposable to manage the tool registration and re-registering whenever sandbox-related settings, network domains, or trusted domains change. Fixes #303714 * Fix race condition in run_in_terminal tool re-registration and add refresh tests Guard _registerRunInTerminalTool against stale async resolutions using a monotonically increasing version counter. Export ChatAgentToolsContribution for testability. Add integration tests verifying tool data refreshes on config and trusted domain changes. --- .../terminal.chatAgentTools.contribution.ts | 109 +++++--- .../runInTerminalTool.test.ts | 238 +++++++++++++++++- 2 files changed, 310 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index d7e2dea9c4e..5d1e90193ab 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { isNumber } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -32,6 +32,7 @@ import { CreateAndRunTaskTool, CreateAndRunTaskToolData } from './tools/task/cre import { GetTaskOutputTool, GetTaskOutputToolData } from './tools/task/getTaskOutputTool.js'; import { RunTaskTool, RunTaskToolData } from './tools/task/runTaskTool.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { ITrustedDomainService } from '../../../url/common/trustedDomainService.js'; import { ITerminalSandboxService, TerminalSandboxService } from '../common/terminalSandboxService.js'; // #region Services @@ -75,64 +76,102 @@ class OutputLocationMigrationContribution extends Disposable implements IWorkben } registerWorkbenchContribution2(OutputLocationMigrationContribution.ID, OutputLocationMigrationContribution, WorkbenchPhase.Eventually); -class ChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { +export class ChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'terminal.chatAgentTools'; + private readonly _runInTerminalToolRegistration = this._register(new MutableDisposable()); + private _runInTerminalToolRegistrationVersion = 0; + constructor( - @IInstantiationService instantiationService: IInstantiationService, - @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, ) { super(); // #region Terminal - const confirmTerminalCommandTool = instantiationService.createInstance(ConfirmTerminalCommandTool); - this._register(toolsService.registerTool(ConfirmTerminalCommandToolData, confirmTerminalCommandTool)); - const getTerminalOutputTool = instantiationService.createInstance(GetTerminalOutputTool); - this._register(toolsService.registerTool(GetTerminalOutputToolData, getTerminalOutputTool)); - this._register(toolsService.executeToolSet.addTool(GetTerminalOutputToolData)); + const confirmTerminalCommandTool = _instantiationService.createInstance(ConfirmTerminalCommandTool); + this._register(_toolsService.registerTool(ConfirmTerminalCommandToolData, confirmTerminalCommandTool)); + const getTerminalOutputTool = _instantiationService.createInstance(GetTerminalOutputTool); + this._register(_toolsService.registerTool(GetTerminalOutputToolData, getTerminalOutputTool)); + this._register(_toolsService.executeToolSet.addTool(GetTerminalOutputToolData)); - const awaitTerminalTool = instantiationService.createInstance(AwaitTerminalTool); - this._register(toolsService.registerTool(AwaitTerminalToolData, awaitTerminalTool)); - this._register(toolsService.executeToolSet.addTool(AwaitTerminalToolData)); + const awaitTerminalTool = _instantiationService.createInstance(AwaitTerminalTool); + this._register(_toolsService.registerTool(AwaitTerminalToolData, awaitTerminalTool)); + this._register(_toolsService.executeToolSet.addTool(AwaitTerminalToolData)); - const killTerminalTool = instantiationService.createInstance(KillTerminalTool); - this._register(toolsService.registerTool(KillTerminalToolData, killTerminalTool)); - this._register(toolsService.executeToolSet.addTool(KillTerminalToolData)); + const killTerminalTool = _instantiationService.createInstance(KillTerminalTool); + this._register(_toolsService.registerTool(KillTerminalToolData, killTerminalTool)); + this._register(_toolsService.executeToolSet.addTool(KillTerminalToolData)); - instantiationService.invokeFunction(createRunInTerminalToolData).then(runInTerminalToolData => { - const runInTerminalTool = instantiationService.createInstance(RunInTerminalTool); - this._register(toolsService.registerTool(runInTerminalToolData, runInTerminalTool)); - this._register(toolsService.executeToolSet.addTool(runInTerminalToolData)); - }); + this._registerRunInTerminalTool(); - const getTerminalSelectionTool = instantiationService.createInstance(GetTerminalSelectionTool); - this._register(toolsService.registerTool(GetTerminalSelectionToolData, getTerminalSelectionTool)); + const getTerminalSelectionTool = _instantiationService.createInstance(GetTerminalSelectionTool); + this._register(_toolsService.registerTool(GetTerminalSelectionToolData, getTerminalSelectionTool)); - const getTerminalLastCommandTool = instantiationService.createInstance(GetTerminalLastCommandTool); - this._register(toolsService.registerTool(GetTerminalLastCommandToolData, getTerminalLastCommandTool)); + const getTerminalLastCommandTool = _instantiationService.createInstance(GetTerminalLastCommandTool); + this._register(_toolsService.registerTool(GetTerminalLastCommandToolData, getTerminalLastCommandTool)); - this._register(toolsService.readToolSet.addTool(GetTerminalSelectionToolData)); - this._register(toolsService.readToolSet.addTool(GetTerminalLastCommandToolData)); + this._register(_toolsService.readToolSet.addTool(GetTerminalSelectionToolData)); + this._register(_toolsService.readToolSet.addTool(GetTerminalLastCommandToolData)); // #endregion // #region Tasks - const runTaskTool = instantiationService.createInstance(RunTaskTool); - this._register(toolsService.registerTool(RunTaskToolData, runTaskTool)); + const runTaskTool = _instantiationService.createInstance(RunTaskTool); + this._register(_toolsService.registerTool(RunTaskToolData, runTaskTool)); - const getTaskOutputTool = instantiationService.createInstance(GetTaskOutputTool); - this._register(toolsService.registerTool(GetTaskOutputToolData, getTaskOutputTool)); + const getTaskOutputTool = _instantiationService.createInstance(GetTaskOutputTool); + this._register(_toolsService.registerTool(GetTaskOutputToolData, getTaskOutputTool)); - const createAndRunTaskTool = instantiationService.createInstance(CreateAndRunTaskTool); - this._register(toolsService.registerTool(CreateAndRunTaskToolData, createAndRunTaskTool)); - this._register(toolsService.executeToolSet.addTool(RunTaskToolData)); - this._register(toolsService.executeToolSet.addTool(CreateAndRunTaskToolData)); - this._register(toolsService.readToolSet.addTool(GetTaskOutputToolData)); + const createAndRunTaskTool = _instantiationService.createInstance(CreateAndRunTaskTool); + this._register(_toolsService.registerTool(CreateAndRunTaskToolData, createAndRunTaskTool)); + this._register(_toolsService.executeToolSet.addTool(RunTaskToolData)); + this._register(_toolsService.executeToolSet.addTool(CreateAndRunTaskToolData)); + this._register(_toolsService.readToolSet.addTool(GetTaskOutputToolData)); // #endregion + + // Re-register run_in_terminal tool when sandbox-related settings change, + // so the tool description and input schema stay in sync with the current + // sandbox state. + this._register(this._configurationService.onDidChangeConfiguration(e => { + if ( + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) + ) { + this._registerRunInTerminalTool(); + } + })); + this._register(this._trustedDomainService.onDidChangeTrustedDomains(() => { + this._registerRunInTerminalTool(); + })); + } + + private _runInTerminalTool: RunInTerminalTool | undefined; + + private _registerRunInTerminalTool(): void { + const version = ++this._runInTerminalToolRegistrationVersion; + this._instantiationService.invokeFunction(createRunInTerminalToolData).then(runInTerminalToolData => { + if (this._store.isDisposed || version !== this._runInTerminalToolRegistrationVersion) { + return; + } + if (!this._runInTerminalTool) { + this._runInTerminalTool = this._register(this._instantiationService.createInstance(RunInTerminalTool)); + } + // Dispose old registration first so registerToolData doesn't throw + // "already registered" for the same tool ID. + this._runInTerminalToolRegistration.value = undefined; + const store = new DisposableStore(); + store.add(this._toolsService.registerToolData(runInTerminalToolData)); + store.add(this._toolsService.registerToolImplementation(runInTerminalToolData.id, this._runInTerminalTool)); + store.add(this._toolsService.executeToolSet.addTool(runInTerminalToolData)); + this._runInTerminalToolRegistration.value = store; + }); } } registerWorkbenchContribution2(ChatAgentToolsContribution.ID, ChatAgentToolsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 94439c82525..7887858cbcc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -6,7 +6,7 @@ import { ok, strictEqual } from 'assert'; import { Separator } from '../../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isLinux, isWindows, OperatingSystem } from '../../../../../../base/common/platform.js'; import { count } from '../../../../../../base/common/strings.js'; @@ -33,7 +33,7 @@ import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/wi import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; -import { ILanguageModelToolsService, IPreparedToolInvocation, IToolInvocationPreparationContext, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocationPreparationContext, ToolDataSource, ToolSet, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; import { createRunInTerminalToolData, RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; @@ -43,6 +43,12 @@ import { TerminalChatService } from '../../../chat/browser/terminalChatService.j import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; import { IAgentSession } from '../../../../chat/browser/agentSessions/agentSessionsModel.js'; +import { isDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; +import { ChatAgentToolsContribution } from '../../browser/terminal.chatAgentTools.contribution.js'; +import { TerminalToolId } from '../../browser/tools/toolIds.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; class TestRunInTerminalTool extends RunInTerminalTool { protected override _osBackend: Promise = Promise.resolve(OperatingSystem.Windows); @@ -214,6 +220,41 @@ suite('RunInTerminalTool', () => { ok(toolData.modelDescription?.includes('The /tmp directory is not guaranteed to be accessible or writable and must be avoided'), 'Expected sandboxed tool description to discourage /tmp usage'); }); + test('should include requestUnsandboxedExecution in schema when sandbox is enabled', async () => { + sandboxEnabled = true; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + const properties = toolData.inputSchema?.properties as Record | undefined; + + ok(properties?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution in schema when sandbox is enabled'); + ok(properties?.['requestUnsandboxedExecutionReason'], 'Expected requestUnsandboxedExecutionReason in schema when sandbox is enabled'); + }); + + test('should not include requestUnsandboxedExecution in schema when sandbox is disabled', async () => { + sandboxEnabled = false; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + const properties = toolData.inputSchema?.properties as Record | undefined; + + ok(!properties?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution in schema when sandbox is disabled'); + ok(!properties?.['requestUnsandboxedExecutionReason'], 'Expected no requestUnsandboxedExecutionReason in schema when sandbox is disabled'); + }); + + test('should reflect sandbox setting changes in tool data', async () => { + sandboxEnabled = false; + + const toolDataBefore = await instantiationService.invokeFunction(createRunInTerminalToolData); + const propertiesBefore = toolDataBefore.inputSchema?.properties as Record | undefined; + ok(!propertiesBefore?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution before enabling sandbox'); + + sandboxEnabled = true; + + const toolDataAfter = await instantiationService.invokeFunction(createRunInTerminalToolData); + const propertiesAfter = toolDataAfter.inputSchema?.properties as Record | undefined; + ok(propertiesAfter?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution after enabling sandbox'); + ok(toolDataAfter.modelDescription?.includes('Sandboxing:'), 'Expected sandbox instructions in description after enabling sandbox'); + }); + test('should include allowed and denied network domains in model description', async () => { sandboxEnabled = true; terminalSandboxService.getResolvedNetworkDomains = () => ({ @@ -1716,3 +1757,196 @@ suite('RunInTerminalTool', () => { }); }); }); + +suite('ChatAgentToolsContribution - tool registration refresh', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let registeredToolData: Map; + let trustedDomainsEmitter: Emitter; + let sandboxEnabled: boolean; + + setup(() => { + configurationService = new TestConfigurationService(); + registeredToolData = new Map(); + trustedDomainsEmitter = store.add(new Emitter()); + sandboxEnabled = false; + + const logService = new NullLogService(); + const fileService = store.add(new FileService(logService)); + const fileSystemProvider = new TestIPCFileSystemProvider(); + store.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); + + const terminalServiceDisposeEmitter = store.add(new Emitter()); + const chatServiceDisposeEmitter = store.add(new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>()); + const chatSessionArchivedEmitter = store.add(new Emitter()); + + instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService, + fileService: () => fileService, + }, store); + + instantiationService.stub(IChatService, { + onDidDisposeSession: chatServiceDisposeEmitter.event, + getSession: () => undefined, + }); + instantiationService.stub(IAgentSessionsService, { + onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, + model: { + onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, + } as IAgentSessionsService['model'] + }); + instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService))); + instantiationService.stub(IHistoryService, { + getLastActiveWorkspaceRoot: () => undefined + }); + + const terminalSandboxService: ITerminalSandboxService = { + _serviceBrand: undefined, + isEnabled: async () => sandboxEnabled, + wrapCommand: (command: string) => `sandbox:${command}`, + getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined, + getTempDir: () => undefined, + setNeedsForceUpdateConfigFile: () => { }, + getOS: async () => OperatingSystem.Linux, + getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }), + }; + instantiationService.stub(ITerminalSandboxService, terminalSandboxService); + + const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService)); + treeSitterLibraryService.isTest = true; + instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService); + + instantiationService.stub(ITerminalService, { + onDidDisposeInstance: terminalServiceDisposeEmitter.event, + setNextCommandId: async () => { } + }); + instantiationService.stub(ITerminalProfileResolverService, { + getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile) + }); + + instantiationService.stub(ITrustedDomainService, { + _serviceBrand: undefined, + onDidChangeTrustedDomains: trustedDomainsEmitter.event, + isValid: () => true, + trustedDomains: [], + }); + + const contextKeyService = instantiationService.get(IContextKeyService); + const registeredToolImpls = new Map(); + const mockToolsService: Partial = { + _serviceBrand: undefined, + onDidChangeTools: Event.None, + registerToolData(toolData: IToolData) { + registeredToolData.set(toolData.id, toolData); + return toDisposable(() => registeredToolData.delete(toolData.id)); + }, + registerToolImplementation(id: string, tool: IToolImpl) { + registeredToolImpls.set(id, tool); + return toDisposable(() => registeredToolImpls.delete(id)); + }, + registerTool(toolData: IToolData, tool: IToolImpl) { + registeredToolData.set(toolData.id, toolData); + registeredToolImpls.set(toolData.id, tool); + return toDisposable(() => { + registeredToolData.delete(toolData.id); + registeredToolImpls.delete(toolData.id); + if (isDisposable(tool)) { + tool.dispose(); + } + }); + }, + getTools() { + return registeredToolData.values(); + }, + executeToolSet: new ToolSet('execute', 'execute', Codicon.play, ToolDataSource.Internal, undefined, undefined, contextKeyService), + readToolSet: new ToolSet('read', 'read', Codicon.book, ToolDataSource.Internal, undefined, undefined, contextKeyService), + }; + instantiationService.stub(ILanguageModelToolsService, mockToolsService as ILanguageModelToolsService); + }); + + async function flushAsync(): Promise { + // Multiple microtask cycles to let async _registerRunInTerminalTool complete + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + async function createContribution(): Promise { + const contribution = store.add(instantiationService.createInstance(ChatAgentToolsContribution)); + await flushAsync(); + return contribution; + } + + test('should register run_in_terminal tool on construction', async () => { + await createContribution(); + ok(registeredToolData.has(TerminalToolId.RunInTerminal), 'Expected run_in_terminal tool to be registered'); + }); + + test('should refresh run_in_terminal tool data when sandbox setting changes', async () => { + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + const propertiesBefore = toolDataBefore.inputSchema?.properties as Record | undefined; + ok(!propertiesBefore?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution before enabling sandbox'); + + // Enable sandbox and fire config change + sandboxEnabled = true; + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxEnabled]), + source: ConfigurationTarget.USER, + change: null!, + }); + + // Wait for async registration + await flushAsync(); + + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered'); + const propertiesAfter = toolDataAfter.inputSchema?.properties as Record | undefined; + ok(propertiesAfter?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution after enabling sandbox'); + }); + + test('should refresh run_in_terminal tool data when trusted domains change', async () => { + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + + // Fire trusted domains change + trustedDomainsEmitter.fire(); + + // Wait for async registration + await flushAsync(); + + // Tool should still be registered (re-registered with fresh data) + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered after trusted domains change'); + }); + + test('should refresh run_in_terminal tool data when sandbox network setting changes', async () => { + sandboxEnabled = true; + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + + // Fire network config change + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetwork]), + source: ConfigurationTarget.USER, + change: null!, + }); + + // Wait for async registration + await flushAsync(); + + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered after network setting change'); + }); +}); From 6ae7d0c59297c78d6cd054931a0121637e7a97a0 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 21 Mar 2026 11:13:36 -0700 Subject: [PATCH 181/183] Fix workbench contrib warning (#303760) --- src/vs/sessions/sessions.desktop.main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 15a27157211..d0e992b60a8 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -221,6 +221,7 @@ import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js'; // Remote Agent Host +import '../platform/agentHost/electron-browser/agentHostService.js'; import '../platform/agentHost/electron-browser/remoteAgentHostService.js'; import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; From 146a2ea7e751e1923045b0161b153aafef3910a8 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 21 Mar 2026 11:14:33 -0700 Subject: [PATCH 182/183] Human-readable remote agent host address (#303758) --- .../remoteAgentHost.instructions.md | 35 ++++++++++++++++++ .../contrib/remoteAgentHost/ARCHITECTURE.md | 36 ++++++++++--------- .../browser/remoteAgentHost.contribution.ts | 13 +++++-- .../agentHostFileSystemProvider.test.ts | 21 ++++++++--- 4 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 .github/instructions/remoteAgentHost.instructions.md diff --git a/.github/instructions/remoteAgentHost.instructions.md b/.github/instructions/remoteAgentHost.instructions.md new file mode 100644 index 00000000000..aa3f24b290e --- /dev/null +++ b/.github/instructions/remoteAgentHost.instructions.md @@ -0,0 +1,35 @@ +--- +description: Architecture documentation for remote agent host connections. Use when working in `src/vs/sessions/contrib/remoteAgentHost` +applyTo: src/vs/sessions/contrib/remoteAgentHost/** +--- + +# Remote Agent Host + +The remote agent host feature connects the sessions app to agent host processes running on other machines over WebSocket. + +## Key Files + +- `ARCHITECTURE.md` - full architecture documentation (URI conventions, registration flow, data flow diagram) +- `REMOTE_AGENT_HOST_RECONNECTION.md` - reconnection lifecycle spec (15 numbered requirements) +- `browser/remoteAgentHost.contribution.ts` - central orchestrator +- `browser/agentHostFileSystemProvider.ts` - read-only FS provider for remote browsing + +## Architecture Documentation + +When making changes to this feature area, **review and update `ARCHITECTURE.md`** if your changes affect: + +- Connection lifecycle (connect, disconnect, reconnect) +- Agent registration flow +- URI conventions or naming +- Session creation flow +- The data flow diagram + +The doc lives at `src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md`. + +## Related Code Outside This Folder + +- `src/vs/platform/agentHost/common/remoteAgentHostService.ts` - service interface (`IRemoteAgentHostService`) +- `src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` - Electron implementation +- `src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` - WebSocket protocol client +- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` - session list sidebar +- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` - session content provider diff --git a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md index aae3798a801..b45c0aaf863 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md +++ b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md @@ -98,9 +98,13 @@ Remote addresses are encoded into URI-safe authority strings via `agentHostAuthority(address)`: - Alphanumeric addresses pass through unchanged -- Others are url-safe base64 encoded with a `b64-` prefix +- "Normal" addresses (`[a-zA-Z0-9.:-]`) get colons replaced with `__` +- Everything else is url-safe base64 encoded with a `b64-` prefix -Example: `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` +Examples: +- `localhost:8081` → `localhost__8081` +- `192.168.1.1:8080` → `192.168.1.1__8080` +- `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` ## Agent Registration @@ -110,10 +114,10 @@ When `_registerAgent()` is called for a discovered copilot agent from address `X | Concept | Value | Example | |---------|-------|---------| -| **Authority** | `agentHostAuthority(address)` | `b64-aHR0cA` | -| **Session type** | `remote-${authority}-${provider}` | `remote-b64-aHR0cA-copilot` | -| **Agent ID** | same as session type | `remote-b64-aHR0cA-copilot` | -| **Vendor** | same as session type | `remote-b64-aHR0cA-copilot` | +| **Authority** | `agentHostAuthority(address)` | `localhost__8081` | +| **Session type** | `remote-${authority}-${provider}` | `remote-localhost__8081-copilot` | +| **Agent ID** | same as session type | `remote-localhost__8081-copilot` | +| **Vendor** | same as session type | `remote-localhost__8081-copilot` | | **Display name** | `configuredName \|\| "${displayName} (${address})"` | `dev-box` | ### Four Registrations Per Agent @@ -134,23 +138,23 @@ When `_registerAgent()` is called for a discovered copilot agent from address `X 4. **Language model provider** - `AgentHostLanguageModelProvider` registers models under the vendor descriptor. Model IDs are prefixed with the session - type (e.g., `remote-b64-xxx-copilot:claude-sonnet-4-20250514`). + type (e.g., `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). ## URI Conventions | Context | Scheme | Format | Example | |---------|--------|--------|---------| -| New session resource | `` | `:/untitled-` | `remote-b64-xxx-copilot:/untitled-abc` | -| Existing session | `` | `:/` | `remote-b64-xxx-copilot:/abc-123` | +| New session resource | `` | `:/untitled-` | `remote-localhost__8081-copilot:/untitled-abc` | +| Existing session | `` | `:/` | `remote-localhost__8081-copilot:/abc-123` | | Backend session state | `` | `:/` | `copilot:/abc-123` | | Root state subscription | (string) | `agenthost:/root` | - | -| Remote filesystem | `agenthost` | `agenthost:///` | `agenthost://b64-aHR0cA/home/user/project` | -| Language model ID | - | `:` | `remote-b64-xxx-copilot:claude-sonnet-4-20250514` | +| Remote filesystem | `agenthost` | `agenthost:///` | `agenthost://localhost__8081/home/user/project` | +| Language model ID | - | `:` | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | ### Key distinction: session resource vs backend session URI - The **session resource** URI uses the session type as its scheme - (e.g., `remote-b64-xxx-copilot:/untitled-abc`). This is the URI visible to + (e.g., `remote-localhost__8081-copilot:/untitled-abc`). This is the URI visible to the chat UI and session management. - The **backend session** URI uses the provider as its scheme (e.g., `copilot:/abc-123`). This is sent over the agent host protocol to the @@ -173,8 +177,8 @@ remote host, then picks a folder on the remote filesystem. This produces a `SessionWorkspace` with an `agenthost://` URI: ``` -agenthost://b64-aHR0cA/home/user/myproject - ↑ authority ↑ remote filesystem path +agenthost://localhost__8081/home/user/myproject + ↑ authority ↑ remote filesystem path ``` ### 2. Session Target Resolution @@ -184,7 +188,7 @@ resolves the matching session type via `getRemoteAgentHostSessionTarget()` (defined in `remoteAgentHost.contribution.ts`): ```typescript -// authority "b64-aHR0cA" → find connection → "remote-b64-aHR0cA-copilot" +// authority "localhost__8081" → find connection → "remote-localhost__8081-copilot" const target = getRemoteAgentHostSessionTarget(connections, authority); ``` @@ -194,7 +198,7 @@ const target = getRemoteAgentHostSessionTarget(connections, authority); ```typescript URI.from({ scheme: target, path: `/untitled-${generateUuid()}` }) -// → remote-b64-aHR0cA-copilot:/untitled-abc-123 +// → remote-localhost__8081-copilot:/untitled-abc-123 ``` ### 4. Session Object Creation diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 4971236cb5a..545dbb83d16 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -34,14 +34,21 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; * Encode a remote address into an identifier that is safe for use in * both URI schemes and URI authorities, and is collision-free. * - * If the address contains only alphanumeric characters it is returned as-is. - * Otherwise it is url-safe base64-encoded (no padding) to guarantee the - * result contains only `[A-Za-z0-9_-]`. + * Three tiers: + * 1. Purely alphanumeric addresses are returned as-is. + * 2. "Normal" addresses containing only `[a-zA-Z0-9.:-]` get colons + * replaced with `__` (double underscore) for human readability. + * Addresses containing `_` skip this tier to keep the encoding + * collision-free (`__` can only appear from colon replacement). + * 3. Everything else is url-safe base64-encoded with a `b64-` prefix. */ export function agentHostAuthority(address: string): string { if (/^[a-zA-Z0-9]+$/.test(address)) { return address; } + if (/^[a-zA-Z0-9.:\-]+$/.test(address)) { + return address.replaceAll(':', '__'); + } return 'b64-' + encodeBase64(VSBuffer.fromString(address), false, true); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts index e22ba6e32fc..6761d7455c3 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts @@ -52,13 +52,26 @@ suite('AgentHostAuthority - encoding', () => { assert.strictEqual(agentHostAuthority('localhost'), 'localhost'); }); - test('address with special characters is base64-encoded', () => { - const authority = agentHostAuthority('localhost:8081'); - assert.ok(authority.startsWith('b64-')); + test('normal host:port address uses human-readable encoding', () => { + assert.strictEqual(agentHostAuthority('localhost:8081'), 'localhost__8081'); + assert.strictEqual(agentHostAuthority('192.168.1.1:8080'), '192.168.1.1__8080'); + assert.strictEqual(agentHostAuthority('my-host:9090'), 'my-host__9090'); + assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80'); + }); + + test('address with underscore falls through to base64', () => { + const authority = agentHostAuthority('host_name:8080'); + assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`); + }); + + test('address with exotic characters is base64-encoded', () => { + assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-')); + assert.ok(agentHostAuthority('host with spaces').startsWith('b64-')); + assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-')); }); test('different addresses produce different authorities', () => { - const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80']; + const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080']; const results = cases.map(agentHostAuthority); const unique = new Set(results); assert.strictEqual(unique.size, cases.length, 'all authorities must be unique'); From 896c13e4a85d21f5dea5ae6766af60d815385ae3 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 21 Mar 2026 12:10:48 -0700 Subject: [PATCH 183/183] Add unit test skill for vscode (#303766) --- .github/skills/unit-tests/SKILL.md | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/skills/unit-tests/SKILL.md diff --git a/.github/skills/unit-tests/SKILL.md b/.github/skills/unit-tests/SKILL.md new file mode 100644 index 00000000000..f2a8b66c5a3 --- /dev/null +++ b/.github/skills/unit-tests/SKILL.md @@ -0,0 +1,87 @@ +--- +name: unit-tests +description: Use when running unit tests in the VS Code repo. Covers the runTests tool, scripts/test.sh (macOS/Linux) and scripts/test.bat (Windows), and their supported arguments for filtering, globbing, and debugging tests. +--- + +# Running Unit Tests + +## Preferred: Use the `runTests` tool + +If the `runTests` tool is available, **prefer it** over running shell commands. It provides structured output with detailed pass/fail information and supports filtering by file and test name. + +- Pass absolute paths to test files via the `files` parameter. +- Pass test names via the `testNames` parameter to filter which tests run. +- Set `mode="coverage"` to collect coverage. + +Example (conceptual): run tests in `src/vs/editor/test/common/model.test.ts` with test name filter `"should split lines"`. + +## Fallback: Shell scripts + +When the `runTests` tool is not available (e.g. in CLI environments), use the platform-appropriate script from the repo root: + +- **macOS / Linux:** `./scripts/test.sh [options]` +- **Windows:** `.\scripts\test.bat [options]` + +These scripts download Electron if needed and launch the Mocha test runner. + +### Commonly used options + +#### `--run ` - Run tests from a specific file + +Accepts a **source file path** (starting with `src/`). The runner strips the `src/` prefix and the `.ts`/`.js` extension automatically to resolve the compiled module. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts +``` + +Multiple files can be specified by repeating `--run`: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --run src/vs/editor/test/common/range.test.ts +``` + +#### `--grep ` (aliases: `-g`, `-f`) - Filter tests by name + +Runs only tests whose full title matches the pattern (passed to Mocha's `--grep`). + +```bash +./scripts/test.sh --grep "should split lines" +``` + +Combine with `--run` to filter tests within a specific file: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --grep "should split lines" +``` + +#### `--runGlob ` (aliases: `--glob`, `--runGrep`) - Run tests matching a glob + +Runs all test files matching a glob pattern against the compiled output directory. Useful for running all tests under a feature area. + +```bash +./scripts/test.sh --runGlob "**/editor/test/**/*.test.js" +``` + +Note: the glob runs against compiled `.js` files in the output directory, not source `.ts` files. + +#### `--coverage` - Generate a coverage report + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --coverage +``` + +#### `--timeout ` - Set test timeout + +Override the default Mocha timeout for long-running tests. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --timeout 10000 +``` + +### Integration tests + +Integration tests (files ending in `.integrationTest.ts` or located in `extensions/`) are **not run** by `scripts/test.sh`. Use `scripts/test-integration.sh` (or `scripts/test-integration.bat`) instead. + +### Compilation requirement + +Tests run against compiled JavaScript output. Ensure the `VS Code - Build` watch task is running or that compilation has completed before running tests. Test failures caused by stale output are a common pitfall.