From a9bef0440b0277b22155697baf3bac810f8a6ee4 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 27 Feb 2026 14:15:36 -0800 Subject: [PATCH 01/11] chat: fix dropdown action order to match default - Reorders dropdown actions to show queue/steer (the default actions) before stop and send (destructive action) - Marks the configured default action as checked so it's focused when the dropdown opens with keyboard - This ensures the dropdown pre-selection matches the action that would be triggered by pressing Enter Fixes #297559 (Commit message generated by Copilot) --- .../chat/browser/widget/input/chatQueuePickerActionItem.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index fac726fe312a2..e47d4746bb78d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -159,11 +159,14 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { } private _getDropdownActions(): IActionWidgetDropdownAction[] { + const isSteerDefault = this._isSteerDefault(); + const queueAction: IActionWidgetDropdownAction = { id: ChatQueueMessageAction.ID, label: localize('chat.queueMessage', "Add to Queue"), tooltip: '', enabled: true, + checked: !isSteerDefault, icon: Codicon.add, class: undefined, hover: { @@ -179,6 +182,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { label: localize('chat.steerWithMessage', "Steer with Message"), tooltip: '', enabled: true, + checked: isSteerDefault, icon: Codicon.arrowRight, class: undefined, hover: { @@ -204,7 +208,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { } }; - return [sendAction, queueAction, steerAction]; + return [queueAction, steerAction, sendAction]; } } From a3f153b24d9aa0a73bf74657b6d82a89b551fcb7 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 27 Feb 2026 14:18:34 -0800 Subject: [PATCH 02/11] undo that --- .../chat/browser/widget/input/chatQueuePickerActionItem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index e47d4746bb78d..c03c6f69f393e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -208,7 +208,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { } }; - return [queueAction, steerAction, sendAction]; + return [sendAction, queueAction, steerAction]; } } From 1730f3e34c618aed504594cc819a01dd4ae227bb Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Sat, 28 Feb 2026 09:26:46 +1100 Subject: [PATCH 03/11] Show agent picker for Background Agents in welcome view (#298202) * Show agent picker for Background Agents in welcome view * 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/chat/browser/actions/chatExecuteActions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 292cf32d80d7a..b2995f02d6b68 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -477,9 +477,10 @@ export class OpenModePickerAction extends Action2 { ContextKeyExpr.or( ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.chatSessionHasCustomAgentTarget), - // Hide in welcome view when session type is not local + // Show in welcome view for local sessions or sessions with custom agent target ContextKeyExpr.or( ChatContextKeys.inAgentSessionsWelcome.negate(), + ChatContextKeys.chatSessionHasCustomAgentTarget, ChatContextKeys.agentSessionType.isEqualTo(AgentSessionProviders.Local))), group: 'navigation', }, From 6eee7730a51000f869b3c3271dd244a8adf291bf Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 27 Feb 2026 16:35:20 -0600 Subject: [PATCH 04/11] fix chat question carousel with title row and styling (#298366) --- .../chatQuestionCarouselPart.ts | 7 ++- .../media/chatQuestionCarousel.css | 43 ++++++++++--------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index ddd912f7c0c55..eba7c5ea597be 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -488,6 +488,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Render question header row with title and close button const headerRow = dom.$('.chat-question-header-row'); + const titleRow = dom.$('.chat-question-title-row'); // Render question title (short header) in the header bar as plain text if (question.title) { @@ -518,14 +519,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent title.textContent = messageContent; } } - headerRow.appendChild(title); + titleRow.appendChild(title); } // Add close button to header row (if allowSkip is enabled) if (this._closeButtonContainer) { - headerRow.appendChild(this._closeButtonContainer); + titleRow.appendChild(this._closeButtonContainer); } + headerRow.appendChild(titleRow); + this._questionContainer.appendChild(headerRow); // Render full question text below the header row (supports multi-line and markdown) 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 9407257b4789c..9ac7c1ca7e2da 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 @@ -48,9 +48,22 @@ padding: 0 16px 10px 16px; overflow: hidden; + .chat-question-title-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + min-width: 0; + } + .chat-question-title { flex: 1; min-width: 0; + word-wrap: break-word; + overflow-wrap: break-word; + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + margin: 0; padding-top: 4px; padding-bottom: 4px; margin-left: -16px; @@ -59,28 +72,18 @@ padding-right: 16px; border-bottom: 1px solid var(--vscode-chat-requestBorder); - .chat-question-title { - flex: 1; - min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; - - .rendered-markdown { - a { - color: var(--vscode-textLink-foreground); - } + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); + } - a:hover, - a:active { - color: var(--vscode-textLink-activeForeground); - } + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); + } - p { - margin: 0; - } + p { + margin: 0; } } From 4be5209b80ba8da1df081e4ee7be83b1b4505bfc Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 27 Feb 2026 23:36:23 +0100 Subject: [PATCH 05/11] Enhance PromptFileParser to ignore links and variables inside inline code and fenced code blocks (#298344) --- .../common/promptSyntax/promptFileParser.ts | 55 ++++++ .../service/promptFileParser.test.ts | 177 ++++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index f6b160c7ee276..0de7e30f98220 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -416,14 +416,66 @@ export class PromptBody { const fileReferences: IBodyFileReference[] = []; const variableReferences: IBodyVariableReference[] = []; const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0); + let inFencedCodeBlock = false; + let fencedCodeBlockFenceChar: string | undefined; + let fencedCodeBlockFenceLength = 0; for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) { const line = this.linesWithEOL[i]; + const trimmedLine = line.trimStart(); + + // Detect fenced code block lines (``` or ~~~, 3 or more chars) + const fenceMatch = /^(?(`{3,}|~{3,}))/u.exec(trimmedLine); + if (fenceMatch) { + const fence = fenceMatch.groups!.fence; + const fenceChar = fence[0]; + const fenceLength = fence.length; + const restOfLine = trimmedLine.slice(fence.length); + + if (!inFencedCodeBlock) { + // Opening fence: record fence char/length and enter fenced code block + inFencedCodeBlock = true; + fencedCodeBlockFenceChar = fenceChar; + fencedCodeBlockFenceLength = fenceLength; + lineStartOffset += line.length; + continue; + } + + // Potential closing fence: must match fence char and have at least the same length, + // and only whitespace is allowed after the fence. + if (fencedCodeBlockFenceChar === fenceChar && fenceLength >= fencedCodeBlockFenceLength && /^\s*$/.test(restOfLine)) { + inFencedCodeBlock = false; + fencedCodeBlockFenceChar = undefined; + fencedCodeBlockFenceLength = 0; + lineStartOffset += line.length; + continue; + } + } + + // Skip all lines inside fenced code blocks + if (inFencedCodeBlock) { + lineStartOffset += line.length; + continue; + } + + // Collect inline code spans (backtick-delimited) to exclude from matching + const inlineCodeRanges: { start: number; end: number }[] = []; + for (const inlineMatch of line.matchAll(/`[^`]+`/g)) { + inlineCodeRanges.push({ start: inlineMatch.index, end: inlineMatch.index + inlineMatch[0].length }); + } + + const isInsideInlineCode = (offset: number) => { + return inlineCodeRanges.some(r => offset >= r.start && offset < r.end); + }; + // Match markdown links: [text](link) const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g); for (const match of linkMatch) { if (match.index > 0 && line[match.index - 1] === '!') { continue; // skip image links } + if (isInsideInlineCode(match.index)) { + continue; // skip matches inside inline code + } const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis const linkStartOffset = match.index + match[0].length - match[2].length - 1; const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); @@ -440,6 +492,9 @@ export class PromptBody { if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) { continue; } + if (isInsideInlineCode(match.index)) { + continue; // skip matches inside inline code + } const contentMatch = match.groups?.['filePath'] || match.groups?.['toolName']; if (!contentMatch) { continue; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index 136ed9195ccac..d50fa8b4fe2fa 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -239,6 +239,183 @@ suite('PromptFileParser', () => { assert.deepEqual(result.header.tools, ['search', 'terminal']); }); + test('ignores links and variables inside inline code and fenced code blocks', async () => { + const uri = URI.parse('file:///test/prompt3.md'); + const content = [ + '---', + `description: "Prompt with markdown code"`, + '---', + 'Outside #tool:outside and [outside](./outside.md).', + 'Inline code: `#tool:inline and [inline](./inline.md)` should be ignored.', + '```ts', + '#tool:block and #file:./inside-block.md and [block](./block.md)', + '```', + 'After block #file:./after.md and [after](./after-link.md).', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(reference => ({ content: reference.content, isMarkdownLink: reference.isMarkdownLink })), [ + { content: './outside.md', isMarkdownLink: true }, + { content: './after.md', isMarkdownLink: false }, + { content: './after-link.md', isMarkdownLink: true } + ]); + assert.deepEqual(result.body.variableReferences.map(reference => reference.name), ['outside']); + }); + + test('ignores references in multiple inline code spans on the same line', async () => { + const uri = URI.parse('file:///test/prompt-inline.md'); + const content = [ + '---', + 'description: "test"', + '---', + 'Before `#tool:ignored1` middle #tool:visible `[link](./ignored.md)` after [real](./real.md).', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './real.md', isMarkdownLink: true }, + ]); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + }); + + test('handles fenced code block without language specifier', async () => { + const uri = URI.parse('file:///test/prompt-fence.md'); + const content = [ + '---', + 'description: "test"', + '---', + '```', + '#file:./ignored.md', + '[link](./ignored-link.md)', + '```', + '#file:./visible.md', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './visible.md', isMarkdownLink: false }, + ]); + assert.deepEqual(result.body.variableReferences, []); + }); + + test('handles multiple fenced code blocks', async () => { + const uri = URI.parse('file:///test/prompt-multi-fence.md'); + const content = [ + '---', + 'description: "test"', + '---', + '#tool:before', + '```js', + '#tool:ignored1', + '```', + '#tool:between', + '```python', + '#tool:ignored2', + '```', + '#tool:after', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['before', 'between', 'after']); + }); + + test('unclosed fenced code block ignores all remaining lines', async () => { + const uri = URI.parse('file:///test/prompt-unclosed.md'); + const content = [ + '---', + 'description: "test"', + '---', + '#tool:visible', + '```', + '#tool:ignored', + '#file:./ignored.md', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + assert.deepEqual(result.body.fileReferences, []); + }); + + test('adjacent inline code does not suppress outside references', async () => { + const uri = URI.parse('file:///test/prompt-adjacent.md'); + const content = [ + '---', + 'description: "test"', + '---', + '`code`#tool:attached `more`[link](./file.md)', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + // #tool:attached starts right after the closing backtick, so it's outside inline code + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['attached']); + // [link](./file.md) starts after the second inline code span + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './file.md', isMarkdownLink: true }, + ]); + }); + + test('indented fenced code block is still detected', async () => { + const uri = URI.parse('file:///test/prompt-indent.md'); + const content = [ + '---', + 'description: "test"', + '---', + ' ```ts', + ' #tool:ignored', + ' ```', + '#tool:visible', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + }); + + test('fenced code block with 4 backticks', async () => { + const uri = URI.parse('file:///test/prompt-4tick.md'); + const content = [ + '---', + 'description: "test"', + '---', + '````', + '#tool:ignored and [link](./ignored.md)', + '````', + '#tool:visible', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.variableReferences.map(r => r.name), ['visible']); + assert.deepEqual(result.body.fileReferences, []); + }); + + test('fenced code block with tilde fence (~~~)', async () => { + const uri = URI.parse('file:///test/prompt-tilde.md'); + const content = [ + '---', + 'description: "test"', + '---', + '~~~', + '#file:./ignored.md and [link](./ignored-link.md)', + '#tool:ignored', + '~~~', + '[real](./real.md)', + ].join('\n'); + + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.body); + assert.deepEqual(result.body.fileReferences.map(r => ({ content: r.content, isMarkdownLink: r.isMarkdownLink })), [ + { content: './real.md', isMarkdownLink: true }, + ]); + assert.deepEqual(result.body.variableReferences, []); + }); + test('agent with agents', async () => { const uri = URI.parse('file:///test/test.agent.md'); From 3bc832a220f0b2fb6375a197a46f0131d36f8767 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 27 Feb 2026 14:37:21 -0800 Subject: [PATCH 06/11] plugins: store installed plugins in storage rather than paths (#298352) * plugins: store installed plugins in storage rather than paths This simplifies some things and sets the groundwork for more special things (like updating and disk cleanup) that we'll do with marketplace plugins. * fix --- .../contrib/chat/browser/agentPluginsView.ts | 3 +- .../contrib/chat/browser/chat.contribution.ts | 3 +- .../chat/browser/pluginInstallService.ts | 44 +-- .../chat/common/plugins/agentPluginService.ts | 2 + .../common/plugins/agentPluginServiceImpl.ts | 353 ++++++++++++------ .../common/plugins/pluginInstallService.ts | 7 +- .../plugins/pluginMarketplaceService.ts | 125 ++++--- .../plugins/pluginMarketplaceService.test.ts | 197 ++-------- .../service/promptsService.test.ts | 1 + 9 files changed, 343 insertions(+), 392 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 9d2bc74d70922..3ca175477f688 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -157,13 +157,12 @@ class UninstallPluginAction extends Action { constructor( private readonly plugin: IAgentPlugin, - @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, ) { super(UninstallPluginAction.ID, localize('uninstall', "Uninstall")); } override async run(): Promise { - this.pluginInstallService.uninstallPlugin(this.plugin.uri); + this.plugin.remove(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index aad03e3583ae4..355094d8011ec 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -143,7 +143,7 @@ import './widget/input/editor/chatInputEditorContrib.js'; import './widget/input/editor/chatInputEditorHover.js'; import { LanguageModelToolsConfirmationService } from './tools/languageModelToolsConfirmationService.js'; import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; -import { AgentPluginService, ConfiguredAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; +import { AgentPluginService, ConfiguredAgentPluginDiscovery, MarketplaceAgentPluginDiscovery } from '../common/plugins/agentPluginServiceImpl.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; import { IPluginMarketplaceService, PluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; @@ -1701,6 +1701,7 @@ registerAction2(ConfigureToolSets); registerEditorFeature(ChatPasteProvidersFeature); agentPluginDiscoveryRegistry.register(new SyncDescriptor(ConfiguredAgentPluginDiscovery)); +agentPluginDiscoveryRegistry.register(new SyncDescriptor(MarketplaceAgentPluginDiscovery)); registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 740442742c4c4..fe24a8fda9b9c 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -5,20 +5,18 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { ChatConfiguration } from '../common/constants.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { IMarketplacePlugin } from '../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; export class PluginInstallService implements IPluginInstallService { declare readonly _serviceBrand: undefined; constructor( @IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService, - @IConfigurationService private readonly _configurationService: IConfigurationService, + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IFileService private readonly _fileService: IFileService, @INotificationService private readonly _notificationService: INotificationService, ) { } @@ -54,7 +52,7 @@ export class PluginInstallService implements IPluginInstallService { return; } - this._addPluginPath(pluginDir.fsPath); + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); } async updatePlugin(plugin: IMarketplacePlugin): Promise { @@ -65,43 +63,7 @@ export class PluginInstallService implements IPluginInstallService { }); } - async uninstallPlugin(pluginUri: URI): Promise { - await this._removePluginPath(pluginUri.fsPath); - } - getPluginInstallUri(plugin: IMarketplacePlugin): URI { return this._pluginRepositoryService.getPluginInstallUri(plugin); } - - /** - * Adds the given file-system path to `chat.plugins.paths` in user-local config. - */ - private _addPluginPath(fsPath: string): void { - const current = this._configurationService.getValue>(ChatConfiguration.PluginPaths) ?? {}; - if (Object.prototype.hasOwnProperty.call(current, fsPath)) { - return; - } - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...current, [fsPath]: true }, - ConfigurationTarget.USER_LOCAL, - ); - } - - /** - * Removes the given file-system path from `chat.plugins.paths` in user-local config. - */ - private _removePluginPath(fsPath: string) { - const current = this._configurationService.getValue>(ChatConfiguration.PluginPaths) ?? {}; - if (!Object.prototype.hasOwnProperty.call(current, fsPath)) { - return; - } - const updated = { ...current }; - delete updated[fsPath]; - return this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - updated, - ConfigurationTarget.USER_LOCAL, - ); - } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index c22a9c0166b51..81ba54b263fa5 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -45,6 +45,8 @@ export interface IAgentPlugin { readonly uri: URI; readonly enabled: IObservable; setEnabled(enabled: boolean): void; + /** Removes this plugin from its discovery source (config or installed storage). */ + remove(): void; readonly hooks: IObservable; readonly commands: IObservable; readonly skills: IObservable; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 8a2b48a7cf595..fa2ed06dbc456 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -253,41 +253,51 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic type PluginEntry = IAgentPlugin & { enabled: ISettableObservable }; -export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgentPluginDiscovery { +/** + * Describes a single discovered plugin source, before the shared + * infrastructure builds the full {@link IAgentPlugin} from it. + */ +interface IPluginSource { + readonly uri: URI; + readonly enabled: boolean; + readonly fromMarketplace: IMarketplacePlugin | undefined; + /** Called when setEnabled is invoked on the plugin */ + setEnabled(value: boolean): void; + /** Called when remove is invoked on the plugin */ + remove(): void; +} + +/** + * Shared base class for plugin discovery implementations. Contains the common + * logic for reading plugin contents (commands, skills, agents, hooks, MCP server + * definitions) from the filesystem and watching for live updates. + * + * Subclasses implement {@link _discoverPluginSources} to determine *which* + * plugins exist, while this class handles the rest. + */ +export abstract class AbstractAgentPluginDiscovery extends Disposable implements IAgentPluginDiscovery { - private readonly _pluginPathsConfig: IObservable>; private readonly _pluginEntries = new Map(); private readonly _plugins = observableValue('discoveredAgentPlugins', []); public readonly plugins: IObservable = this._plugins; - private _discoverVersion = 0; + protected _discoverVersion = 0; constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IFileService private readonly _fileService: IFileService, - @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @IPathService private readonly _pathService: IPathService, - @ILogService private readonly _logService: ILogService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, + protected readonly _fileService: IFileService, + protected readonly _pathService: IPathService, + protected readonly _logService: ILogService, + protected readonly _instantiationService: IInstantiationService, ) { super(); - this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); } - public start(): void { - const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); - this._register(autorun(reader => { - this._pluginPathsConfig.read(reader); - scheduler.schedule(); - })); - scheduler.schedule(); - } + public abstract start(): void; - private async _refreshPlugins(): Promise { + protected async _refreshPlugins(): Promise { const version = ++this._discoverVersion; - const plugins = await this._discoverPlugins(); + const plugins = await this._discoverAndBuildPlugins(); if (version !== this._discoverVersion || this._store.isDisposed) { return; } @@ -295,38 +305,20 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent this._plugins.set(plugins, undefined); } - private async _discoverPlugins(): Promise { + /** Subclasses return plugin sources to discover. */ + protected abstract _discoverPluginSources(): Promise; + + private async _discoverAndBuildPlugins(): Promise { + const sources = await this._discoverPluginSources(); const plugins: IAgentPlugin[] = []; const seenPluginUris = new Set(); - const config = this._pluginPathsConfig.get(); - - for (const [path, enabled] of Object.entries(config)) { - if (!path.trim()) { - continue; - } - - const resources = this._resolvePluginPath(path.trim()); - for (const resource of resources) { - let stat; - try { - stat = await this._fileService.resolve(resource); - } catch { - this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve plugin path: ${resource.toString()}`); - continue; - } - - if (!stat.isDirectory) { - this._logService.debug(`[ConfiguredAgentPluginDiscovery] Plugin path is not a directory: ${resource.toString()}`); - continue; - } - const key = stat.resource.toString(); - if (!seenPluginUris.has(key)) { - const adapter = await this._detectPluginFormatAdapter(stat.resource); - const fromMarketplace = await this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource); - seenPluginUris.add(key); - plugins.push(this._toPlugin(stat.resource, path, enabled, adapter, fromMarketplace)); - } + for (const source of sources) { + const key = source.uri.toString(); + if (!seenPluginUris.has(key)) { + seenPluginUris.add(key); + const adapter = await this._detectPluginFormatAdapter(source.uri); + plugins.push(this._toPlugin(source.uri, source.enabled, adapter, source.fromMarketplace, value => source.setEnabled(value), () => source.remove())); } } @@ -336,58 +328,6 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent return plugins; } - /** - * Resolves a plugin path to one or more resource URIs. Absolute paths are - * used directly; relative paths are resolved against each workspace folder. - */ - private _resolvePluginPath(path: string): URI[] { - if (win32.isAbsolute(path) || posix.isAbsolute(path)) { - return [URI.file(path)]; - } - - return this._workspaceContextService.getWorkspace().folders.map( - folder => joinPath(folder.uri, path) - ); - } - - /** - * Updates the enabled state of a plugin path in the configuration, - * writing to the most specific config target where the key is defined. - */ - private _updatePluginPathEnabled(configKey: string, value: boolean): void { - const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); - - // Walk from most specific to least specific to find where this key is defined - const targets = [ - ConfigurationTarget.WORKSPACE_FOLDER, - ConfigurationTarget.WORKSPACE, - ConfigurationTarget.USER_LOCAL, - ConfigurationTarget.USER_REMOTE, - ConfigurationTarget.USER, - ConfigurationTarget.APPLICATION, - ]; - - for (const target of targets) { - const mapping = getConfigValueInTarget(inspected, target); - if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...mapping, [configKey]: value }, - target, - ); - return; - } - } - - // Key not found in any target; write to USER_LOCAL as default - const current = getConfigValueInTarget(inspected, ConfigurationTarget.USER_LOCAL) ?? {}; - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...current, [configKey]: value }, - ConfigurationTarget.USER_LOCAL, - ); - } - private async _detectPluginFormatAdapter(pluginUri: URI): Promise { const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude'); if (isInClaudeDirectory || await this._pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'))) { @@ -397,7 +337,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent return this._instantiationService.createInstance(CopilotPluginFormatAdapter); } - private async _pathExists(resource: URI): Promise { + protected async _pathExists(resource: URI): Promise { try { await this._fileService.resolve(resource); return true; @@ -406,7 +346,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent } } - private _toPlugin(uri: URI, configKey: string, initialEnabled: boolean, adapter: IAgentPluginFormatAdapter, fromMarketplace: IMarketplacePlugin | undefined): IAgentPlugin { + private _toPlugin(uri: URI, initialEnabled: boolean, adapter: IAgentPluginFormatAdapter, fromMarketplace: IMarketplacePlugin | undefined, setEnabledCallback: (value: boolean) => void, removeCallback: () => void): IAgentPlugin { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { @@ -476,9 +416,8 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent const plugin: PluginEntry = { uri, enabled, - setEnabled: (value: boolean) => { - this._updatePluginPathEnabled(configKey, value); - }, + setEnabled: setEnabledCallback, + remove: removeCallback, hooks, commands, skills, @@ -758,3 +697,203 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent } } +export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery { + + private readonly _pluginPathsConfig: IObservable>; + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IFileService fileService: IFileService, + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IPathService pathService: IPathService, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(fileService, pathService, logService, instantiationService); + this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); + } + + public override start(): void { + const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + this._register(autorun(reader => { + this._pluginPathsConfig.read(reader); + scheduler.schedule(); + })); + scheduler.schedule(); + } + + protected override async _discoverPluginSources(): Promise { + const sources: IPluginSource[] = []; + const config = this._pluginPathsConfig.get(); + + for (const [path, enabled] of Object.entries(config)) { + if (!path.trim()) { + continue; + } + + const resources = this._resolvePluginPath(path.trim()); + for (const resource of resources) { + let stat; + try { + stat = await this._fileService.resolve(resource); + } catch { + this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve plugin path: ${resource.toString()}`); + continue; + } + + if (!stat.isDirectory) { + this._logService.debug(`[ConfiguredAgentPluginDiscovery] Plugin path is not a directory: ${resource.toString()}`); + continue; + } + + const fromMarketplace = this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource); + const configKey = path; + sources.push({ + uri: stat.resource, + enabled, + fromMarketplace, + setEnabled: (value: boolean) => this._updatePluginPathEnabled(configKey, value), + remove: () => this._removePluginPath(configKey), + }); + } + } + + return sources; + } + + /** + * Resolves a plugin path to one or more resource URIs. Absolute paths are + * used directly; relative paths are resolved against each workspace folder. + */ + private _resolvePluginPath(path: string): URI[] { + if (win32.isAbsolute(path) || posix.isAbsolute(path)) { + return [URI.file(path)]; + } + + return this._workspaceContextService.getWorkspace().folders.map( + folder => joinPath(folder.uri, path) + ); + } + + /** + * Updates the enabled state of a plugin path in the configuration, + * writing to the most specific config target where the key is defined. + */ + private _updatePluginPathEnabled(configKey: string, value: boolean): void { + const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); + + // Walk from most specific to least specific to find where this key is defined + const targets = [ + ConfigurationTarget.WORKSPACE_FOLDER, + ConfigurationTarget.WORKSPACE, + ConfigurationTarget.USER_LOCAL, + ConfigurationTarget.USER_REMOTE, + ConfigurationTarget.USER, + ConfigurationTarget.APPLICATION, + ]; + + for (const target of targets) { + const mapping = getConfigValueInTarget(inspected, target); + if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { + this._configurationService.updateValue( + ChatConfiguration.PluginPaths, + { ...mapping, [configKey]: value }, + target, + ); + return; + } + } + + // Key not found in any target; write to USER_LOCAL as default + const current = getConfigValueInTarget(inspected, ConfigurationTarget.USER_LOCAL) ?? {}; + this._configurationService.updateValue( + ChatConfiguration.PluginPaths, + { ...current, [configKey]: value }, + ConfigurationTarget.USER_LOCAL, + ); + } + + /** + * Removes a plugin path from `chat.plugins.paths` in the most specific + * config target where the key is defined. + */ + private _removePluginPath(configKey: string): void { + const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); + + const targets = [ + ConfigurationTarget.WORKSPACE_FOLDER, + ConfigurationTarget.WORKSPACE, + ConfigurationTarget.USER_LOCAL, + ConfigurationTarget.USER_REMOTE, + ConfigurationTarget.USER, + ConfigurationTarget.APPLICATION, + ]; + + for (const target of targets) { + const mapping = getConfigValueInTarget(inspected, target); + if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { + const updated = { ...mapping }; + delete updated[configKey]; + this._configurationService.updateValue( + ChatConfiguration.PluginPaths, + updated, + target, + ); + return; + } + } + } +} + +export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscovery { + + constructor( + @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, + @IFileService fileService: IFileService, + @IPathService pathService: IPathService, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(fileService, pathService, logService, instantiationService); + } + + public override start(): void { + const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); + this._register(autorun(reader => { + this._pluginMarketplaceService.installedPlugins.read(reader); + scheduler.schedule(); + })); + scheduler.schedule(); + } + + protected override async _discoverPluginSources(): Promise { + const installed = this._pluginMarketplaceService.installedPlugins.get(); + const sources: IPluginSource[] = []; + + for (const entry of installed) { + let stat; + try { + stat = await this._fileService.resolve(entry.pluginUri); + } catch { + this._logService.debug(`[MarketplaceAgentPluginDiscovery] Could not resolve installed plugin: ${entry.pluginUri.toString()}`); + continue; + } + + if (!stat.isDirectory) { + this._logService.debug(`[MarketplaceAgentPluginDiscovery] Installed plugin path is not a directory: ${entry.pluginUri.toString()}`); + continue; + } + + sources.push({ + uri: stat.resource, + enabled: entry.enabled, + fromMarketplace: entry.plugin, + setEnabled: (value: boolean) => this._pluginMarketplaceService.setInstalledPluginEnabled(entry.pluginUri, value), + remove: () => this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri), + }); + } + + return sources; + } +} diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index abdf81f730311..10a89c2e4b18d 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -14,15 +14,10 @@ export interface IPluginInstallService { /** * Clones the marketplace repository (if not already cached) and registers - * the plugin's source directory in the user's `chat.plugins.paths` config. + * the plugin in the marketplace service's installed plugins storage. */ installPlugin(plugin: IMarketplacePlugin): Promise; - /** - * Removes the plugin from `chat.plugins.paths` config. - */ - uninstallPlugin(pluginUri: URI): Promise; - /** * Pulls the latest changes for an already-cloned marketplace repository. */ diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index e536a09d199e2..11a8db5482303 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -7,13 +7,16 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Lazy } from '../../../../../base/common/lazy.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../base/common/marshalling.js'; -import { isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; -import { URI } from '../../../../../base/common/uri.js'; +import { IObservable } from '../../../../../base/common/observable.js'; +import { isEqual, isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; +import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ObservableMemento, observableMemento } from '../../../../../platform/observable/common/observableMemento.js'; import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; @@ -69,13 +72,24 @@ interface IMarketplaceJson { }[]; } +export interface IMarketplaceInstalledPlugin { + readonly pluginUri: URI; + readonly plugin: IMarketplacePlugin; + readonly enabled: boolean; +} + export const IPluginMarketplaceService = createDecorator('pluginMarketplaceService'); export interface IPluginMarketplaceService { readonly _serviceBrand: undefined; readonly onDidChangeMarketplaces: Event; + /** Installed marketplace plugins, backed by storage. */ + readonly installedPlugins: IObservable; fetchMarketplacePlugins(token: CancellationToken): Promise; - getMarketplacePluginMetadata(pluginUri: URI): Promise; + getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined; + addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void; + removeInstalledPlugin(pluginUri: URI): void; + setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void; } /** @@ -98,12 +112,31 @@ interface IGitHubMarketplaceCacheEntry { type IStoredGitHubMarketplaceCache = Dto>; -export class PluginMarketplaceService implements IPluginMarketplaceService { +interface IStoredInstalledPlugin { + readonly pluginUri: UriComponents; + readonly plugin: IMarketplacePlugin; + readonly enabled: boolean; +} + +const installedPluginsMemento = observableMemento({ + defaultValue: [], + key: 'chat.plugins.installed.v1', + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + }, +}); + +export class PluginMarketplaceService extends Disposable implements IPluginMarketplaceService { declare readonly _serviceBrand: undefined; private readonly _gitHubMarketplaceCache = new Lazy>(() => this._loadPersistedGitHubMarketplaceCache()); + private readonly _installedPluginsStore: ObservableMemento; readonly onDidChangeMarketplaces: Event; + readonly installedPlugins: IObservable; + constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IRequestService private readonly _requestService: IRequestService, @@ -112,6 +145,14 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { @ILogService private readonly _logService: ILogService, @IStorageService private readonly _storageService: IStorageService, ) { + super(); + + this._installedPluginsStore = this._register( + installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) + ); + + this.installedPlugins = this._installedPluginsStore.map(s => revive(s)); + this.onDidChangeMarketplaces = Event.filter( _configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.PluginsEnabled) || e.affectsConfiguration(ChatConfiguration.PluginMarketplaces), @@ -295,64 +336,30 @@ export class PluginMarketplaceService implements IPluginMarketplaceService { ); } - async getMarketplacePluginMetadata(pluginUri: URI): Promise { - const configuredRefs = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; - const refs = parseMarketplaceReferences(configuredRefs); - - for (const ref of refs) { - let repoDir: URI; - try { - repoDir = this._pluginRepositoryService.getRepositoryUri(ref); - } catch { - continue; - } - - if (!isEqualOrParent(pluginUri, repoDir)) { - continue; - } - - for (const def of MARKETPLACE_DEFINITIONS) { - const definitionUri = joinPath(repoDir, def.path); - let json: IMarketplaceJson | undefined; - try { - const contents = await this._fileService.readFile(definitionUri); - json = parseJSONC(contents.value.toString()) as IMarketplaceJson | undefined; - } catch { - continue; - } - - if (!json?.plugins || !Array.isArray(json.plugins)) { - continue; - } - - for (const p of json.plugins) { - if (typeof p.name !== 'string' || !p.name) { - continue; - } - - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - continue; - } + getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined { + const installed = this.installedPlugins.get(); + return installed.find(e => isEqualOrParent(pluginUri, e.pluginUri))?.plugin; + } - const pluginSourceUri = normalizePath(joinPath(repoDir, source)); - if (isEqualOrParent(pluginUri, pluginSourceUri)) { - return { - name: p.name, - description: p.description ?? '', - version: p.version ?? '', - source, - marketplace: ref.displayLabel, - marketplaceReference: ref, - marketplaceType: def.type, - readmeUri: getMarketplaceReadmeFileUri(repoDir, source), - }; - } - } - } + addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void { + const current = this.installedPlugins.get(); + if (current.some(e => isEqual(e.pluginUri, pluginUri))) { + return; } + this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); + } - return undefined; + removeInstalledPlugin(pluginUri: URI): void { + const current = this.installedPlugins.get(); + this._installedPluginsStore.set(current.filter(e => !isEqual(e.pluginUri, pluginUri)), undefined); + } + + setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void { + const current = this.installedPlugins.get(); + this._installedPluginsStore.set( + current.map(e => isEqual(e.pluginUri, pluginUri) ? { ...e, enabled } : e), + undefined, + ); } private async _fetchFromClonedRepo(reference: IMarketplaceReference, token: CancellationToken): Promise { 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 4b42ab0ea040c..46bb4fbcb3f25 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 @@ -4,8 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { VSBuffer } from '../../../../../../base/common/buffer.js'; -import { joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -118,205 +116,52 @@ suite('PluginMarketplaceService', () => { suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - const repoDir = URI.file('/cache/agentPlugins/github.com/microsoft/plugins'); const marketplaceRef = parseMarketplaceReference('microsoft/plugins')!; - function createMarketplaceJson(plugins: object[], metadata?: object): string { - return JSON.stringify({ metadata, plugins }); - } - - function createService(fileContents: Map): PluginMarketplaceService { + function createService(): PluginMarketplaceService { const instantiationService = store.add(new TestInstantiationService()); - const configService = new TestConfigurationService({ + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.PluginMarketplaces]: ['microsoft/plugins'], [ChatConfiguration.PluginsEnabled]: true, - }); - - const fileService = { - readFile: async (uri: URI) => { - const content = fileContents.get(uri.path); - if (content !== undefined) { - return { value: VSBuffer.fromString(content) }; - } - throw new Error('File not found'); - }, - } as unknown as IFileService; - - const repositoryService = { - getRepositoryUri: () => repoDir, - getPluginInstallUri: (plugin: { source: string }) => joinPath(repoDir, plugin.source), - } as unknown as IAgentPluginRepositoryService; - - instantiationService.stub(IConfigurationService, configService); - instantiationService.stub(IFileService, fileService); - instantiationService.stub(IAgentPluginRepositoryService, repositoryService); + })); + instantiationService.stub(IFileService, {} as unknown as IFileService); + instantiationService.stub(IAgentPluginRepositoryService, {} as unknown as IAgentPluginRepositoryService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IRequestService, {} as unknown as IRequestService); instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); - return instantiationService.createInstance(PluginMarketplaceService); + return store.add(instantiationService.createInstance(PluginMarketplaceService)); } - test('returns metadata for a plugin that matches by source', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin' }, - ]), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'plugins/my-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - - assert.deepStrictEqual(result && { - name: result.name, - description: result.description, - version: result.version, - source: result.source, - marketplace: result.marketplace, - marketplaceType: result.marketplaceType, - }, { + test('returns metadata for an installed plugin', () => { + const service = createService(); + const pluginUri = URI.file('/cache/agentPlugins/my-plugin'); + const plugin = { name: 'my-plugin', description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin', marketplace: marketplaceRef.displayLabel, + marketplaceReference: marketplaceRef, marketplaceType: MarketplaceType.Copilot, - }); - }); - - test('returns undefined for a URI outside all marketplace repos', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, - ]), - ); + }; - const service = createService(files); - const unrelatedUri = URI.file('/some/other/path'); + service.addInstalledPlugin(pluginUri, plugin); + const result = service.getMarketplacePluginMetadata(pluginUri); - const result = await service.getMarketplacePluginMetadata(unrelatedUri); - assert.strictEqual(result, undefined); + assert.deepStrictEqual(result, plugin); }); - test('returns undefined when plugin URI is in repo but no source matches', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, - ]), - ); - - const service = createService(files); - const noMatchUri = joinPath(repoDir, 'plugins/other-plugin'); - - const result = await service.getMarketplacePluginMetadata(noMatchUri); + test('returns undefined for a URI that is not installed', () => { + const service = createService(); + const result = service.getMarketplacePluginMetadata(URI.file('/some/other/path')); assert.strictEqual(result, undefined); }); - test('returns undefined when no marketplace.json files exist', async () => { - const service = createService(new Map()); - const pluginUri = joinPath(repoDir, 'plugins/my-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); + test('returns undefined when no plugins are installed', () => { + const service = createService(); + const result = service.getMarketplacePluginMetadata(URI.file('/any/path')); assert.strictEqual(result, undefined); }); - - test('falls back to Claude marketplace.json when Copilot one is missing', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.claude-plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'claude-plugin', version: '3.0.0', source: 'src/claude-plugin' }, - ]), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'src/claude-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - assert.ok(result); - assert.strictEqual(result!.name, 'claude-plugin'); - assert.strictEqual(result!.marketplaceType, MarketplaceType.Claude); - }); - - test('resolves source relative to pluginRoot metadata', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson( - [{ name: 'nested', version: '1.0.0', source: 'my-plugin' }], - { pluginRoot: 'packages' }, - ), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'packages/my-plugin'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - assert.ok(result); - assert.strictEqual(result!.name, 'nested'); - assert.strictEqual(result!.source, 'packages/my-plugin'); - }); - - test('selects the correct plugin among multiple entries', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'alpha', version: '1.0.0', source: 'plugins/alpha' }, - { name: 'beta', version: '2.0.0', source: 'plugins/beta' }, - { name: 'gamma', version: '3.0.0', source: 'plugins/gamma' }, - ]), - ); - - const service = createService(files); - const pluginUri = joinPath(repoDir, 'plugins/beta'); - - const result = await service.getMarketplacePluginMetadata(pluginUri); - assert.ok(result); - assert.strictEqual(result!.name, 'beta'); - assert.strictEqual(result!.version, '2.0.0'); - }); - - test('returns undefined when no marketplaces are configured', async () => { - const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ - [ChatConfiguration.PluginMarketplaces]: [], - [ChatConfiguration.PluginsEnabled]: true, - })); - instantiationService.stub(IFileService, {} as unknown as IFileService); - instantiationService.stub(IAgentPluginRepositoryService, {} as unknown as IAgentPluginRepositoryService); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IRequestService, {} as unknown as IRequestService); - instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); - - const service = instantiationService.createInstance(PluginMarketplaceService); - const result = await service.getMarketplacePluginMetadata(URI.file('/any/path')); - assert.strictEqual(result, undefined); - }); - - test('matches when pluginUri is a subdirectory inside a plugin source', async () => { - const files = new Map(); - files.set( - joinPath(repoDir, '.github/plugin/marketplace.json').path, - createMarketplaceJson([ - { name: 'my-plugin', version: '1.0.0', source: 'plugins/my-plugin' }, - ]), - ); - - const service = createService(files); - const nestedUri = joinPath(repoDir, 'plugins/my-plugin/src/tool.ts'); - - const result = await service.getMarketplacePluginMetadata(nestedUri); - assert.ok(result); - assert.strictEqual(result!.name, 'my-plugin'); - }); }); 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 59673c29689fd..90e6605a00dd3 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 @@ -3506,6 +3506,7 @@ suite('PromptsService', () => { uri: URI.file(path), enabled, setEnabled: () => { }, + remove: () => { }, hooks, commands, skills, From 278880aeb30de8d2b16d0d1ee65e5f82a3d869fe Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 27 Feb 2026 14:47:41 -0800 Subject: [PATCH 07/11] mcp: surface task statusMessage notifications in chat progress (#298369) - When a tool call returns a CreateTaskResult, the MCP server now forwards task statusMessage from notifications/tasks/status into the chat progress stream via the ToolProgress callback - Adds onStatusMessage optional callback to McpTask constructor to report status messages as they come in from server notifications - Updates callTool method signature to accept onStatusMessage callback, passed from _callWithProgress in mcpServer.ts - This allows task-mode MCP tools to communicate progress through the established progress channel without requiring duplicate progress notifications Fixes #298013 (Commit message generated by Copilot) --- src/vs/workbench/contrib/mcp/common/mcpServer.ts | 2 +- .../contrib/mcp/common/mcpServerRequestHandler.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 49743c1f10a06..27cd47200856f 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -1087,7 +1087,7 @@ export class McpTool implements IMcpTool { arguments: params, task: shouldUseTask ? {} : undefined, _meta: meta, - }, token); + }, token, progress ? (message) => progress.report({ message }) : undefined); // Wait for tools to refresh for dynamic servers (#261611) await this._server.awaitToolRefresh(); diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts index 218f3ab72c888..55c0519c291e5 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -514,11 +514,11 @@ export class McpServerRequestHandler extends Disposable { /** * Call a specific tool. Supports tasks automatically if `task` is set on the request. */ - async callTool(params: MCP.CallToolRequest['params'] & MCP.Request['params'], token?: CancellationToken): Promise { + async callTool(params: MCP.CallToolRequest['params'] & MCP.Request['params'], token?: CancellationToken, onStatusMessage?: (message: string) => void): Promise { const response = await this.sendRequest({ method: 'tools/call', params }, token); if (isTaskResult(response)) { - const task = new McpTask(response.task, token); + const task = new McpTask(response.task, token, onStatusMessage); this._taskManager.adoptClientTask(task); task.setHandler(this); return task.result.finally(() => { @@ -603,7 +603,8 @@ export class McpTask extends Disposable implements IMcpTas constructor( private readonly _task: MCP.Task, - _token: CancellationToken = CancellationToken.None + _token: CancellationToken = CancellationToken.None, + private readonly _onStatusMessage?: (message: string) => void, ) { super(); @@ -735,6 +736,9 @@ export class McpTask extends Disposable implements IMcpTas onDidUpdateState(task: MCP.Task) { this._lastTaskState.set(task, undefined); + if (task.statusMessage && this._onStatusMessage) { + this._onStatusMessage(task.statusMessage); + } } setHandler(handler: McpServerRequestHandler | undefined): void { From d59ed4683446077a38bfee701aa147cc87859d39 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 27 Feb 2026 16:54:10 -0600 Subject: [PATCH 08/11] improve question carousel submit button flow (#298360) --- .../chatQuestionCarouselPart.ts | 64 ++++++++++++------- .../media/chatQuestionCarousel.css | 8 +-- .../chatQuestionCarouselPart.test.ts | 19 +++++- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index eba7c5ea597be..fbdfccb2402f4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -55,6 +55,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _prevButton: Button | undefined; private _nextButton: Button | undefined; private readonly _nextButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); + private _submitButton: Button | undefined; + private readonly _submitButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); private _skipAllButton: Button | undefined; private _isSkipped = false; @@ -164,6 +166,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent nextButton.label = `$(${Codicon.chevronRight.id})`; this._nextButton = nextButton; + const submitButton = interactiveStore.add(new Button(this._navigationButtons, { ...defaultButtonStyles })); + submitButton.element.classList.add('chat-question-submit-button'); + submitButton.label = localize('submit', 'Submit'); + this._submitButton = submitButton; + this._navigationButtons.appendChild(arrowsContainer); this._footerRow.appendChild(this._navigationButtons); this.domNode.append(this._footerRow); @@ -171,7 +178,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Register event listeners interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1))); - interactiveStore.add(nextButton.onDidClick(() => this.handleNext())); + interactiveStore.add(nextButton.onDidClick(() => this.navigate(1))); + interactiveStore.add(submitButton.onDidClick(() => this.submit())); if (this._skipAllButton) { interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore())); } @@ -192,7 +200,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (isTextInput || isFreeformTextarea) { e.preventDefault(); e.stopPropagation(); - this.handleNext(); + this.handleNextOrSubmit(); } } else if ((event.ctrlKey || event.metaKey) && (event.keyCode === KeyCode.Backspace || event.keyCode === KeyCode.Delete)) { e.stopPropagation(); @@ -228,10 +236,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } /** - * Handles the next/submit button action. - * Either advances to the next question or submits. + * Handles the next/submit behavior for keyboard and option selection flows. + * Either advances to the next question or submits when on the last question. */ - private handleNext(): void { + private handleNextOrSubmit(): void { this.saveCurrentAnswer(); if (this._currentIndex < this.carousel.questions.length - 1) { @@ -245,6 +253,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + /** + * Handles explicit submit action from the dedicated submit button. + */ + private submit(): void { + this.saveCurrentAnswer(); + this._options.onSubmit(this._answers); + this.hideAndShowSummary(); + } + /** * Focuses the container element and announces the question for screen reader users. */ @@ -291,10 +308,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._singleSelectItems.clear(); this._multiSelectCheckboxes.clear(); this._freeformTextareas.clear(); + this._nextButtonHover.value = undefined; + this._submitButtonHover.value = undefined; // Clear references to disposed elements this._prevButton = undefined; this._nextButton = undefined; + this._submitButton = undefined; this._skipAllButton = undefined; this._questionContainer = undefined; this._navigationButtons = undefined; @@ -464,7 +484,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } private renderCurrentQuestion(focusContainerForScreenReader: boolean = false): void { - if (!this._questionContainer || !this._prevButton || !this._nextButton) { + if (!this._questionContainer || !this._prevButton || !this._nextButton || !this._submitButton) { return; } @@ -559,24 +579,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._prevButton!.enabled = this._currentIndex > 0; this._prevButton!.element.style.display = isSingleQuestion ? 'none' : ''; - // Update next button icon/label for last question + // Keep navigation arrows stable and disable next on the last question const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1; const submitLabel = localize('submit', 'Submit'); const nextLabel = localize('next', 'Next'); const nextLabelWithKeybinding = this.getLabelWithKeybinding(nextLabel, NEXT_QUESTION_ACTION_ID); - if (isLastQuestion) { - this._nextButton!.label = submitLabel; - this._nextButton!.element.setAttribute('aria-label', submitLabel); - // Switch to primary style for submit - this._nextButton!.element.classList.add('chat-question-nav-submit'); - this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: submitLabel }); - } else { - this._nextButton!.label = `$(${Codicon.chevronRight.id})`; - this._nextButton!.element.setAttribute('aria-label', nextLabelWithKeybinding); - // Keep secondary style for next - this._nextButton!.element.classList.remove('chat-question-nav-submit'); - this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabelWithKeybinding }); - } + this._nextButton!.label = `$(${Codicon.chevronRight.id})`; + this._nextButton!.enabled = !isLastQuestion; + this._nextButton!.element.setAttribute('aria-label', nextLabelWithKeybinding); + this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabelWithKeybinding }); + + this._submitButton!.enabled = isLastQuestion; + this._submitButton!.element.style.display = isLastQuestion ? '' : 'none'; + this._submitButton!.element.setAttribute('aria-label', submitLabel); + this._submitButtonHover.value = isLastQuestion + ? this._hoverService.setupDelayedHover(this._submitButton!.element, { content: submitLabel }) + : undefined; // Update aria-label to reflect the current question this._updateAriaLabel(); @@ -750,7 +768,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (freeform) { freeform.value = ''; } - this.handleNext(); + this.handleNextOrSubmit(); })); this._inputBoxes.add(this._hoverService.setupDelayedHover(listItem, { @@ -818,7 +836,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Enter confirms current selection and advances to next question e.preventDefault(); e.stopPropagation(); - this.handleNext(); + this.handleNextOrSubmit(); return; } else if (event.keyCode >= KeyCode.Digit1 && event.keyCode <= KeyCode.Digit9) { // Number keys 1-9 select the corresponding option, or focus freeform for next number @@ -1014,7 +1032,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } else if (event.keyCode === KeyCode.Enter) { e.preventDefault(); e.stopPropagation(); - this.handleNext(); + this.handleNextOrSubmit(); } else if (event.keyCode === KeyCode.Space) { e.preventDefault(); // Toggle the currently focused checkbox using click() to trigger onChange 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 9ac7c1ca7e2da..28f9603b6699b 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 @@ -374,16 +374,16 @@ background: var(--vscode-button-secondaryHoverBackground) !important; } - /* Submit button (next on last question) uses primary background */ - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { + /* Dedicated submit button uses primary background */ + .chat-question-carousel-nav .monaco-button.chat-question-submit-button { background: var(--vscode-button-background) !important; color: var(--vscode-button-foreground) !important; - width: auto; + height: 22px; min-width: auto; padding: 0 8px; } - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { + .chat-question-carousel-nav .monaco-button.chat-question-submit-button:hover:not(.disabled) { background: var(--vscode-button-hoverBackground) !important; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 7fbf788530a3b..66d10c9b487c3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -283,16 +283,29 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(prevButton.classList.contains('disabled') || prevButton.disabled, 'Previous button should be disabled on first question'); }); - test('next button shows submit icon on last question', () => { + test('next button stays as arrow and is disabled on last question', () => { const carousel = createMockCarousel([ { id: 'q1', type: 'text', title: 'Only Question' } ]); createWidget(carousel); // Use dedicated class selector for stability - const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLElement; + const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLButtonElement; assert.ok(nextButton, 'Next button should exist'); - assert.strictEqual(nextButton.getAttribute('aria-label'), 'Submit', 'Next button should have Submit aria-label on last question'); + assert.strictEqual(nextButton.getAttribute('aria-label'), 'Next', 'Next button should preserve Next aria-label on last question'); + assert.ok(nextButton.classList.contains('disabled') || nextButton.disabled, 'Next button should be disabled on last question'); + }); + + test('submit button is shown on last question', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Only Question' } + ]); + createWidget(carousel); + + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; + assert.ok(submitButton, 'Submit button should exist'); + assert.strictEqual(submitButton.getAttribute('aria-label'), 'Submit'); + assert.notStrictEqual(submitButton.style.display, 'none', 'Submit button should be visible on last question'); }); }); From 536950ddf3180381bc3ec8a1a94176f4b40e9131 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:24:39 -0800 Subject: [PATCH 09/11] Fix session resource aliasing in chat sessions (#298374) first fix --- .../api/browser/mainThreadChatAgents2.ts | 16 ++++++++++++++ .../chatSessions/chatSessions.contribution.ts | 22 +++++++++++++++---- .../chat/common/chatSessionsService.ts | 6 +++++ .../test/common/mockChatSessionsService.ts | 4 ++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a8089d43ed381..4fdf590d55372 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -207,6 +207,22 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA if (newItem) { chatSessionResource = newItem.resource; isUntitled = false; + + // Update the model's contributed session with the resolved resource + // so subsequent requests don't re-invoke newChatSessionItemHandler + // and getChatSessionFromInternalUri returns the real resource. + chatSession?.setContributedChatSession({ + chatSessionType: contributedSession.chatSessionType, + chatSessionResource, + isUntitled: false, + initialSessionOptions: contributedSession.initialSessionOptions, + }); + + // Register alias so session-option lookups work with the new resource + this._chatSessionService.registerSessionResourceAlias( + contributedSession.chatSessionResource, + chatSessionResource + ); } } 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 f0161aec65010..4d426fcaa17be 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -300,6 +300,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _sessionTypeInputPlaceholders: Map = new Map(); private readonly _sessions = new ResourceMap(); + private readonly _resourceAliases = new ResourceMap(); // real resource -> untitled resource private readonly _hasCanDelegateProvidersKey: IContextKey; @@ -1078,20 +1079,33 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public hasAnySessionOptions(sessionResource: URI): boolean { - const session = this._sessions.get(sessionResource); + const session = this._sessions.get(this._resolveResource(sessionResource)); return !!session && !!session.options && Object.keys(session.options).length > 0; } public getSessionOption(sessionResource: URI, optionId: string): string | IChatSessionProviderOptionItem | undefined { - const session = this._sessions.get(sessionResource); + const session = this._sessions.get(this._resolveResource(sessionResource)); return session?.getOption(optionId); } public setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean { - const session = this._sessions.get(sessionResource); + const session = this._sessions.get(this._resolveResource(sessionResource)); return !!session?.setOption(optionId, value); } + /** + * Resolve a resource through the alias map. If the resource is a real + * resource that has been aliased to an untitled resource, return the + * untitled resource (the canonical key in {@link _sessions}). + */ + private _resolveResource(resource: URI): URI { + return this._resourceAliases.get(resource) ?? resource; + } + + public registerSessionResourceAlias(untitledResource: URI, realResource: URI): void { + this._resourceAliases.set(realResource, untitledResource); + } + /** * Store option groups for a session type */ @@ -1134,7 +1148,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ for (const u of updates) { this.setSessionOption(sessionResource, u.optionId, u.value); } - this._onDidChangeSessionOptions.fire(sessionResource); + this._onDidChangeSessionOptions.fire(this._resolveResource(sessionResource)); this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: finished for ${sessionResource}`); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 858254564bead..c609524139d5f 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -306,6 +306,12 @@ export interface IChatSessionsService { * Returns undefined if the controller doesn't have a handler or if no controller is registered. */ createNewChatSessionItem(chatSessionType: string, request: IChatAgentRequest, token: CancellationToken): Promise; + + /** + * Registers an alias so that session-option lookups by the real resource + * are redirected to the canonical (untitled) resource in the internal session map. + */ + registerSessionResourceAlias(untitledResource: URI, realResource: URI): void; } export function isSessionInProgressStatus(state: ChatSessionStatus): boolean { diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 46cdb2f5e0968..0136bfb939361 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -229,6 +229,10 @@ export class MockChatSessionsService implements IChatSessionsService { return undefined; } + registerSessionResourceAlias(_untitledResource: URI, _realResource: URI): void { + // noop + } + registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable { // Store the emitter so tests can trigger it this.onChange = onChange; From 3612f8842e28d96c7f170f3eca083301b01b0731 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:41:27 -0800 Subject: [PATCH 10/11] Terminal sandboxing errors out when trusted domains are urls instead of domains. (#298372) * fixing when trusted domains are url's instead of just domains * changes * changes --- .../common/terminalSandboxService.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 54d5a7899a809..8b1bcca004799 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -160,17 +160,10 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb : {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); - const allowedDomainsSet = new Set(networkSetting.allowedDomains ?? []); + let allowedDomains = networkSetting.allowedDomains ?? []; if (networkSetting.allowTrustedDomains) { - for (const domain of this._trustedDomainService.trustedDomains) { - // Filter out sole wildcard '*' as sandbox runtime doesn't allow it - // Wildcards like '*.github.com' are OK - if (domain !== '*') { - allowedDomainsSet.add(domain); - } - } + allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); } - const allowedDomains = Array.from(allowedDomainsSet); const sandboxSettings = { network: { @@ -211,4 +204,19 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } } } + + private _addTrustedDomainsToAllowedDomains(allowedDomains: string[]): string[] { + const allowedDomainsSet = new Set(allowedDomains); + for (const domain of this._trustedDomainService.trustedDomains) { + try { + const uri = new URL(domain); + allowedDomainsSet.add(uri.hostname); + } catch { + if (domain !== '*') { + allowedDomainsSet.add(domain); + } + } + } + return Array.from(allowedDomainsSet); + } } From 90127b75e1aff3a290352637a70d69b9124a612d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 28 Feb 2026 01:51:14 +0100 Subject: [PATCH 11/11] fix setting repo (#298387) * set remote repo uri * fix setting repo * fix setting repo uri --- .../contrib/chat/browser/newChatViewPane.ts | 14 ++++++++++---- src/vs/sessions/contrib/chat/browser/newSession.ts | 3 ++- src/vs/sessions/contrib/chat/browser/repoPicker.ts | 11 +++++++++-- .../workspace/browser/workspaceFolderManagement.ts | 11 +++++++++-- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index a0d367ea964d6..9928b50f61611 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -309,10 +309,16 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._newSession.value = session; // Wire pickers to the new session - this._folderPicker.setNewSession(session); - this._repoPicker.setNewSession(session); - this._isolationModePicker.setNewSession(session); - this._branchPicker.setNewSession(session); + const target = this._targetPicker.selectedTarget; + if (target === AgentSessionProviders.Background) { + this._folderPicker.setNewSession(session); + this._isolationModePicker.setNewSession(session); + this._branchPicker.setNewSession(session); + } + + if (target === AgentSessionProviders.Cloud) { + this._repoPicker.setNewSession(session); + } // Set the current model on the session (for local sessions) const currentModel = this._currentLanguageModel.get(); diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index c0879b5875d7a..f8211f04300cf 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -214,7 +214,8 @@ export class RemoteNewSession extends Disposable implements INewSession { this._repoUri = uri; this._onDidChange.fire('repoUri'); this._onDidChange.fire('disabled'); - this.setOption('repository', uri.fsPath); + const id = uri.path.substring(1); + this.setOption('repositories', { id, name: id }); } setIsolationMode(_mode: IsolationMode): void { diff --git a/src/vs/sessions/contrib/chat/browser/repoPicker.ts b/src/vs/sessions/contrib/chat/browser/repoPicker.ts index ca45f083cd2e8..bc263c8234a1d 100644 --- a/src/vs/sessions/contrib/chat/browser/repoPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/repoPicker.ts @@ -14,6 +14,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { INewSession } from './newSession.js'; +import { URI } from '../../../../base/common/uri.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_REPO = 'agentSessions.lastPickedRepo'; @@ -82,7 +84,7 @@ export class RepoPicker extends Disposable { this._newSession = session; this._browseGeneration++; if (session && this._selectedRepo) { - session.setOption('repositories', this._selectedRepo); + this._setRepo(this._selectedRepo); } } @@ -178,7 +180,7 @@ export class RepoPicker extends Disposable { this._addToRecentlyPicked(item); this.storageService.store(STORAGE_KEY_LAST_REPO, JSON.stringify(item), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); - this._newSession?.setOption('repositories', item); + this._setRepo(item); this._onDidSelectRepo.fire(item.id); } @@ -267,4 +269,9 @@ export class RepoPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); } + + private _setRepo(repo: IRepoItem): void { + this._newSession?.setRepoUri(URI.parse(`${GITHUB_REMOTE_FILE_SCHEME}://github/${repo.id}`)); + } + } diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 09b03f5a160f9..4e310aecaf869 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -58,7 +58,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements } private getActiveSessionFolderData(session: IActiveSessionItem | undefined): IWorkspaceFolderCreationData | undefined { - if (session?.providerType !== AgentSessionProviders.Background) { + if (!session) { return undefined; } @@ -70,7 +70,14 @@ export class WorkspaceFolderManagementContribution extends Disposable implements } if (session.repository) { - return { uri: session.repository }; + if (session.providerType === AgentSessionProviders.Background) { + return { uri: session.repository }; + } + // if (session.providerType === AgentSessionProviders.Cloud) { + // return { + // uri: session.repository + // }; + // } } return undefined;