From e31bc4c60ae060b2f0b38e7ebecd940f14532e5f Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 14 Aug 2026 18:23:55 +0200 Subject: [PATCH 01/13] chat: fix: stop capitalizing localized customization group headers `text-transform: capitalize` on the shared group header re-cased strings that were already cased correctly, and got them wrong: "Included Based on Context" rendered as "Included Based On Context" and "Loaded on Demand" as "Loaded On Demand", against this repo's own rule that short prepositions stay lowercase, and "Built-in" rendered as "Built-In". Per-word capitalization is also not a transform that survives translation, so the rule was wrong for every locale rather than just awkward in English. The header is shared, so this reaches every customizations tab -- Agents, Skills, Instructions, Hooks, Prompts, Plugins and MCP Servers. Every group label on those tabs is already a correctly cased localized string, so the only rendered differences are the three the transform was getting wrong. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../aiCustomization/media/aiCustomizationManagement.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 275c364052e427..36d1c804611c0c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -479,9 +479,12 @@ min-width: 0; } +/* No text-transform: these labels are localized strings that are already cased correctly, and +capitalize re-cased them wrongly -- "Included Based on Context" became "Based On" (against this +repo's own rule that short prepositions stay lowercase) and "Built-in" became "Built-In". Per-word +casing is also not a transform that survives translation. */ .ai-customization-group-header .group-label { font-weight: var(--vscode-agents-fontWeight-semiBold); - text-transform: capitalize; color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); overflow: hidden; text-overflow: ellipsis; From b30d08ca2179337b7d6e58cf834c498751128b83 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 14 Aug 2026 18:28:03 +0200 Subject: [PATCH 02/13] mcp: fix: make Show Output clickable on a failing MCP server `updateStatus` began by clearing the row's action disposables and emptying its actions node, then rebuilt them. It runs from an autorun over the server's connection state, and an erroring server re-runs it about twice a second while producing byte-identical content: measured in a real Code OSS build, 9-10 rebuilds per 5 seconds, every one of them a no-op. A DOM node replaced between mousedown and mouseup never receives the click, so the inline `Show Output` button did nothing on precisely the rows that needed it -- the failing ones, which are the only rows that offer it at all. The row's actions are now rebuilt only when something about them changed. `getMcpStatusRenderSignature` reduces them to a comparable value covering both what they render and what they act on; leaving anything out would drop an update that matters, so it is a pure exported function with a test that fails to compile if a field is added without being covered. The list re-splices on every customizations change, so `renderElement` would otherwise undo this by clearing the actions itself. It now keys on the row's content identity rather than the entry object, which is recreated on every refresh and therefore says nothing about whether this is the same row. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../browser/aiCustomization/mcpListWidget.ts | 112 +++++++++++++++++- .../aiCustomization/mcpListWidget.test.ts | 56 +++++++++ 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 92b45cae6fbcf8..6cf8ddb7d29457 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -162,6 +162,10 @@ interface IMcpServerItemTemplateData { readonly actions: HTMLElement; readonly elementDisposables: DisposableStore; readonly actionDisposables: DisposableStore; + /** Which row the actions currently belong to, so a recycled template cannot reuse another row's. */ + renderedRowKey?: string; + /** What the actions currently show, so an unchanged status does not rebuild them. */ + renderedStatusSignature?: string; } /** @@ -206,8 +210,18 @@ class McpServerItemRenderer implements IListRenderer Promise) => this.agentHostCustomizationService.showMcpServerLog(activeSessionResource, activeSessionServer.id, beforeShow) : undefined; @@ -354,7 +391,7 @@ class McpServerItemRenderer implements IListRenderer = {}): AgentHostMcpServer { @@ -508,6 +510,60 @@ suite('mcpListWidget', () => { }); }); + suite('getMcpStatusRenderSignature', () => { + const base: IMcpStatusRenderInput = { + rowKey: 'server:mcp.config.workspace/notion:0', + label: 'notion', + state: McpServerStatus.Error, + statusLabel: 'Error', + statusClassName: 'error', + statusIconId: 'error', + activeSessionServerId: 'session-1/notion', + logOutputChannelId: 'mcp.session-1.notion', + localServerId: 'mcp.config.workspace/notion', + activeSessionResource: 'vscode-agent-session:///session-1', + }; + + // A different, and differently-typed-where-possible, value for every field. The mapped type + // is what makes this a barrier: a field added to the input fails to compile until it is + // given a value here, and the test below then proves the signature actually covers it. + const changed: { [K in keyof IMcpStatusRenderInput]-?: IMcpStatusRenderInput[K] } = { + rowKey: 'server:mcp.config.user/notion:0', + label: 'Notion', + state: McpServerStatus.Ready, + statusLabel: 'Running', + statusClassName: 'running', + statusIconId: 'check', + activeSessionServerId: 'session-1/other', + logOutputChannelId: 'mcp.session-1.other', + localServerId: 'mcp.config.user/notion', + activeSessionResource: 'vscode-agent-session:///session-2', + }; + + const fields = Object.keys(base) as (keyof IMcpStatusRenderInput)[]; + + test('the same row state produces the same signature', () => { + assert.strictEqual(getMcpStatusRenderSignature({ ...base }), getMcpStatusRenderSignature({ ...base })); + }); + + test('changing any covered value changes the signature', () => { + const baseline = getMcpStatusRenderSignature(base); + const missed = fields.filter(field => getMcpStatusRenderSignature({ ...base, [field]: changed[field] }) === baseline); + + assert.deepStrictEqual(missed, []); + }); + + test('clearing any optional value changes the signature', () => { + const baseline = getMcpStatusRenderSignature(base); + // `rowKey` and `label` are always present; everything else can legitimately go away, + // e.g. when a server loses its active-session twin. + const clearable = fields.filter(field => field !== 'rowKey' && field !== 'label'); + const missed = clearable.filter(field => getMcpStatusRenderSignature({ ...base, [field]: undefined }) === baseline); + + assert.deepStrictEqual(missed, []); + }); + }); + suite('inline actions', () => { test('authentication receives the active session and server without opening the row', () => { const sessionResource = URI.parse('vscode-agent-session:///session-1'); From 6b308e444200221545699af5ea92e0d8ae1d75b5 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 14 Aug 2026 18:32:04 +0200 Subject: [PATCH 03/13] mcp: fix: show one row per agent MCP server, not one per customization A session can carry two customizations for a single MCP server. The agent host publishes the declaration as a child of whatever declared it -- a plugin, or the .mcp.json VS Code syncs into the agent -- and separately mints a top-level customization for any server the SDK reports before that child can be resolved by name. `McpCustomizationController._applyOne` never retires the minted entry once the child becomes resolvable ("Once promoted to a top-level entry, stay top-level for the session"), so both remain in state: notion -> file:///.../vscode-synced-customization-.../.mcp.json#mcp=notion state: stopped <- the declaration notion -> mcp-top-level:copilotcli::notion state: ready, channel: mcp:// <- the live one Every consumer of getMcpServers saw both, so the servers list rendered the same server twice with contradictory status. It was worse than a repeat: the list's matcher only matches when exactly one candidate answers a key, so with two copies the server's local row could not adopt either, and both fell through as extra rows. getMcpServers now drops a child that a top-level customization already speaks for. The top-level copy wins because it is the one the host treats as live: it carries the running state and channel, and its id is what the host resolves for lifecycle and enablement. Position in the tree is the signal, not the shape of the minted id, which is the host's own business -- and not the absence of an owning plugin either, since a directory-declared child has none. Nothing else is collapsed. Two plugins that each declare a server named `search` stay two rows, because they are two servers. Only the presentation path dedupes; log, diagnostics and id lookups still walk every customization, so an id from either copy continues to resolve. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../agentHostCustomizationService.ts | 79 +++++++++++++++---- .../agentHostMcpServerCustomizations.test.ts | 75 ++++++++++++++++++ 2 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerCustomizations.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index a55b21623956e3..c0eda9e926b011 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -184,7 +184,7 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i if (!target) { return []; } - return this._flattenMcpServers(target.customizations) + return getPresentableMcpServerCustomizations(target.customizations) .map(({ server, plugin }): IAgentHostMcpServer => ({ id: this._scopedMcpServerId(sessionResource, server.id), name: server.name, @@ -208,7 +208,7 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i if (!target) { return Promise.resolve(); } - const entry = this._flattenMcpServers(target.customizations).find(({ server }) => this._scopedMcpServerId(sessionResource, server.id) === serverId); + const entry = flattenMcpServerCustomizations(target.customizations).find(({ server }) => this._scopedMcpServerId(sessionResource, server.id) === serverId); if (!entry) { return Promise.resolve(); } @@ -226,7 +226,7 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i */ private _trackMcpDiagnostics(sessionResource: URI, target: IAgentHostCustomizationTarget): void { this._mcpDiagnosticSessions.add(sessionResource); - for (const { server, plugin } of this._flattenMcpServers(target.customizations)) { + for (const { server, plugin } of flattenMcpServerCustomizations(target.customizations)) { this._mcpLogRegistry.record({ sessionResource, rawId: server.id, name: server.name, enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), state: server.state }); } } @@ -238,7 +238,7 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i if (!target) { continue; } - for (const { server, plugin } of this._flattenMcpServers(target.customizations)) { + for (const { server, plugin } of flattenMcpServerCustomizations(target.customizations)) { this._mcpLogRegistry.record({ sessionResource, rawId: server.id, name: server.name, enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), state: server.state }); } } @@ -327,17 +327,8 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i this._onDidChangeCustomizations.fire(); } - private _flattenMcpServers(customizations: readonly Customization[]): readonly { readonly server: McpServerCustomization; readonly plugin?: PluginCustomization }[] { - return customizations.flatMap(customization => customization.type === CustomizationType.McpServer - ? [{ server: customization }] - : customization.children?.filter(child => child.type === CustomizationType.McpServer).map(server => ({ - server, - plugin: customization.type === CustomizationType.Plugin ? customization : undefined, - })) ?? []); - } - private _findMcpServer(customizations: readonly Customization[], serverId: string): McpServerCustomization | undefined { - for (const { server } of this._flattenMcpServers(customizations)) { + for (const { server } of flattenMcpServerCustomizations(customizations)) { if (server.id === serverId || this._isScopedMcpServerIdForRawId(serverId, server.id)) { return server; } @@ -368,6 +359,66 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i } } +/** One MCP server customization, with the position it was published at. */ +export interface IMcpServerCustomizationEntry { + readonly server: McpServerCustomization; + /** + * The plugin that declares this server. Absent both for a server published at the top level + * and for one declared by a {@link CustomizationType.Directory} container, so it says nothing + * about where in the tree the server sits -- use {@link isTopLevel} for that. + */ + readonly plugin?: PluginCustomization; + /** Whether the agent host published this server as a customization of the session itself. */ + readonly isTopLevel: boolean; +} + +/** Every MCP server customization in a session, including duplicates of the same server. */ +export function flattenMcpServerCustomizations(customizations: readonly Customization[]): readonly IMcpServerCustomizationEntry[] { + return customizations.flatMap((customization): IMcpServerCustomizationEntry[] => customization.type === CustomizationType.McpServer + ? [{ server: customization, isTopLevel: true }] + : customization.children?.filter(child => child.type === CustomizationType.McpServer).map(server => ({ + server, + plugin: customization.type === CustomizationType.Plugin ? customization : undefined, + isTopLevel: false, + })) ?? []); +} + +/** + * The MCP servers to *show* for a session: one entry per server. + * + * A session can carry two customizations for a single server. The agent host publishes the + * declaration as a child of whatever declared it -- a plugin, or the `.mcp.json` VS Code syncs + * into the agent -- and separately mints a top-level customization for any server the SDK reports + * before that child can be resolved by name. Once minted, the top-level entry stays for the + * session, so both remain: the child holds the declaration and never leaves `stopped`, while the + * top-level entry is the one the host keeps up to date. Rendering both showed the same server + * twice, with contradictory status, and it was worse than a repeat -- the list refuses to match a + * local row to its session twin when two candidates answer to one name, so both copies fell + * through as extra rows. + * + * A child is therefore dropped when a top-level customization already speaks for that name, + * because that is the copy the agent host treats as live: it carries the running state and + * channel, and its id is what the host resolves for lifecycle and enablement. Position in the tree + * is the signal rather than the shape of the minted id, which is the host's own business. + * + * Nothing else is collapsed. Two plugins that each declare a server named `search` stay two + * entries, because they are two servers and this is not the place to decide otherwise. Lookups + * elsewhere still walk every customization, so an id from either copy continues to resolve. + */ +export function getPresentableMcpServerCustomizations(customizations: readonly Customization[]): readonly IMcpServerCustomizationEntry[] { + const entries = flattenMcpServerCustomizations(customizations); + const topLevelNames = new Set(); + for (const entry of entries) { + if (entry.isTopLevel) { + topLevelNames.add(entry.server.name); + } + } + if (topLevelNames.size === 0) { + return entries; + } + return entries.filter(entry => entry.isTopLevel || !topLevelNames.has(entry.server.name)); +} + class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizationService { private readonly _sessionStateSubscriptions = this._register(new DisposableResourceMap }>()); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerCustomizations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerCustomizations.test.ts new file mode 100644 index 00000000000000..8da0e59cbc931a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostMcpServerCustomizations.test.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CustomizationType, McpServerStatus, type Customization, type DirectoryCustomization, type McpServerCustomization, type McpServerState, type PluginCustomization } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { getPresentableMcpServerCustomizations } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; + +suite('agent host MCP server customizations', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function mcpServer(id: string, name: string, state: McpServerState = { kind: McpServerStatus.Stopped }): McpServerCustomization { + return { type: CustomizationType.McpServer, id, uri: `file:///${encodeURIComponent(id)}`, name, state }; + } + + function plugin(id: string, children: McpServerCustomization[]): PluginCustomization { + return { type: CustomizationType.Plugin, id, uri: `file:///${encodeURIComponent(id)}`, name: id, children }; + } + + function directory(id: string, children: McpServerCustomization[]): DirectoryCustomization { + return { type: CustomizationType.Directory, id, uri: `file:///${encodeURIComponent(id)}`, name: id, enabled: true, writable: true, contents: CustomizationType.McpServer, children }; + } + + function shown(customizations: readonly Customization[]): [string, string][] { + return getPresentableMcpServerCustomizations(customizations).map(({ server }) => [server.name, server.id]); + } + + test('a server a plugin declares and the host also minted is shown once, as the live copy', () => { + // The shape an agent host actually publishes: the synced `.mcp.json` declares the server + // and never leaves `stopped`, while the minted top-level entry is the one it keeps current. + const declaration = mcpServer('file:///synced/.mcp.json#mcp=notion', 'notion'); + const live = mcpServer('mcp-top-level:copilotcli:session:notion', 'notion', { kind: McpServerStatus.Ready }); + + assert.deepStrictEqual(shown([plugin('synced', [declaration]), live]), [ + ['notion', 'mcp-top-level:copilotcli:session:notion'], + ]); + }); + + test('a server a directory declares and the host also minted is shown once, as the live copy', () => { + // A directory-declared child carries no owning plugin, so "has no plugin" cannot stand in + // for "is top-level": doing so would let this child claim the name and survive shadowing. + const declaration = mcpServer('file:///.mcp.json#mcp=notion', 'notion'); + const live = mcpServer('mcp-top-level:copilotcli:session:notion', 'notion', { kind: McpServerStatus.Ready }); + + assert.deepStrictEqual(shown([directory('mcp-config', [declaration]), live]), [ + ['notion', 'mcp-top-level:copilotcli:session:notion'], + ]); + }); + + test('a server only a container declares is kept, and so is one only the host minted', () => { + const declared = mcpServer('file:///synced/.mcp.json#mcp=cleanshot', 'cleanshot'); + const inDirectory = mcpServer('file:///.mcp.json#mcp=playwright', 'playwright'); + const minted = mcpServer('mcp-top-level:copilotcli:session:notion', 'notion', { kind: McpServerStatus.Ready }); + + // Source order is preserved, exactly as it was before duplicates were collapsed. + assert.deepStrictEqual(shown([plugin('synced', [declared]), directory('mcp-config', [inDirectory]), minted]), [ + ['cleanshot', 'file:///synced/.mcp.json#mcp=cleanshot'], + ['playwright', 'file:///.mcp.json#mcp=playwright'], + ['notion', 'mcp-top-level:copilotcli:session:notion'], + ]); + }); + + test('two plugins declaring the same name stay two servers, because they are two servers', () => { + assert.deepStrictEqual(shown([ + plugin('a', [mcpServer('file:///a/.mcp.json#mcp=search', 'search')]), + plugin('b', [mcpServer('file:///b/.mcp.json#mcp=search', 'search')]), + ]), [ + ['search', 'file:///a/.mcp.json#mcp=search'], + ['search', 'file:///b/.mcp.json#mcp=search'], + ]); + }); +}); From 5ad89264d3241b083752b43f52fdb55020b31be5 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 14 Aug 2026 18:32:17 +0200 Subject: [PATCH 04/13] chat: feat: let a harness report the name of the agent behind it UI that needs to name the agent cannot derive it from the descriptor's label: that string is localized and carries a disambiguating suffix, so stripping the suffix would break in translation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostChatContribution.ts | 1 + .../contrib/chat/common/customizationHarnessService.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) 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 39be482241374c..5a2bc766fd1577 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -322,6 +322,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._customizationHarnessService.registerExternalHarness({ id: sessionType, label: localize('agentHostHarnessLabel.local', "{0} [Agent Host]", agent.displayName), + agentName: agent.displayName, icon: ThemeIcon.fromId(Codicon.server.id), // The Tools section is surfaced for the Copilot CLI agent host only. hiddenSections: agent.provider === 'copilotcli' ? [AICustomizationManagementSection.Prompts] : [AICustomizationManagementSection.Tools, AICustomizationManagementSection.Prompts], diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 10b25c470df61d..ec8132d72b2860 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -79,6 +79,16 @@ export interface IHarnessDescriptor { */ readonly id: string; readonly label: string; + /** + * The agent's own name, without any disambiguating suffix — e.g. `Copilot` + * where {@link label} is `Copilot [Agent Host]`. + * + * Exists because UI that needs to *name the agent* cannot derive it from + * `label`: that string is localized and suffixed, so stripping the suffix + * would break in translation. Undefined for harnesses that are not backed + * by a distinct agent, where callers should fall back to their own wording. + */ + readonly agentName?: string; readonly icon: ThemeIcon; /** * Management sections that should be hidden when this harness is active. From 0f3f6d337625efce9240c0ac59097ea18aa569bb Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 14 Aug 2026 18:32:19 +0200 Subject: [PATCH 05/13] chat: feat: turn a contribution on or off without moving its scope A contribution turned off for this workspace should come back on for this workspace, and one turned off everywhere should come back on everywhere. Promoting or demoting the scope behind the user's back rewrites a choice they made deliberately, and a plain on/off control shows the scope only while the row is off, so they would not even see it happen. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/common/enablement.ts | 25 ++++++++++ .../chat/test/common/enablement.test.ts | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/test/common/enablement.test.ts diff --git a/src/vs/workbench/contrib/chat/common/enablement.ts b/src/vs/workbench/contrib/chat/common/enablement.ts index 2c6b674ac22716..4d55642f4ee6a1 100644 --- a/src/vs/workbench/contrib/chat/common/enablement.ts +++ b/src/vs/workbench/contrib/chat/common/enablement.ts @@ -23,6 +23,31 @@ export function isContributionDisabled(state: ContributionEnablementState): bool return !isContributionEnabled(state); } +/** + * Turns a contribution on or off *without* moving which layer decides it. + * + * A server turned off for this workspace comes back on for this workspace, and one turned off + * everywhere comes back on everywhere. Promoting or demoting the scope behind the user's back + * would silently rewrite a choice they made deliberately -- and because a plain on/off control + * shows the scope only while the row is off, the user would not even see it happen. Changing + * scope stays an explicit act, available from the context menu. + * + * Writing the deciding layer is also what makes the control truthful: the workspace entry wins + * over the profile one in {@link EnablementModel.readEnabled}, so writing the *other* layer + * would leave the row visibly unchanged after the user asked for something. + */ +export function withContributionEnabled(state: ContributionEnablementState, enabled: boolean): ContributionEnablementState { + if (isWorkspaceScopedEnablement(state)) { + return enabled ? ContributionEnablementState.EnabledWorkspace : ContributionEnablementState.DisabledWorkspace; + } + return enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile; +} + +/** Whether the workspace layer, rather than the profile, is deciding this state. */ +export function isWorkspaceScopedEnablement(state: ContributionEnablementState): boolean { + return state === ContributionEnablementState.EnabledWorkspace || state === ContributionEnablementState.DisabledWorkspace; +} + export interface IEnablementModel { readEnabled(key: string, reader?: IReader): ContributionEnablementState; readProfileEnabled(key: string, reader?: IReader): boolean; diff --git a/src/vs/workbench/contrib/chat/test/common/enablement.test.ts b/src/vs/workbench/contrib/chat/test/common/enablement.test.ts new file mode 100644 index 00000000000000..889be4e1825ab1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/enablement.test.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ContributionEnablementState, isWorkspaceScopedEnablement, withContributionEnabled } from '../../common/enablement.js'; + +suite('enablement', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('withContributionEnabled', () => { + + test('answers a workspace choice where it was made, rather than promoting it', () => { + assert.deepStrictEqual([ + withContributionEnabled(ContributionEnablementState.EnabledWorkspace, false), + withContributionEnabled(ContributionEnablementState.DisabledWorkspace, true), + ], [ + ContributionEnablementState.DisabledWorkspace, + ContributionEnablementState.EnabledWorkspace, + ]); + }); + + test('answers a profile choice at the profile', () => { + assert.deepStrictEqual([ + withContributionEnabled(ContributionEnablementState.EnabledProfile, false), + withContributionEnabled(ContributionEnablementState.DisabledProfile, true), + ], [ + ContributionEnablementState.DisabledProfile, + ContributionEnablementState.EnabledProfile, + ]); + }); + + test('turning a workspace choice off and on again returns it to where it started', () => { + const start = ContributionEnablementState.EnabledWorkspace; + assert.strictEqual(withContributionEnabled(withContributionEnabled(start, false), true), start); + }); + }); + + test('isWorkspaceScopedEnablement names only the workspace states', () => { + assert.deepStrictEqual([ + ContributionEnablementState.EnabledWorkspace, + ContributionEnablementState.DisabledWorkspace, + ContributionEnablementState.EnabledProfile, + ContributionEnablementState.DisabledProfile, + ].map(isWorkspaceScopedEnablement), [true, true, false, false]); + }); +}); From 2301b28d779ea0295b440392b8428bca90eade6b Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Fri, 14 Aug 2026 18:33:38 +0200 Subject: [PATCH 06/13] mcp: feat: add a reusable on/off switch for customization rows Enablement used to be reachable only through a right-click menu, which meant the most common thing someone wants to do with a server was also the least discoverable. This gives a row one control in one place, so the eye learns a single target while scanning a long list. The accessible name is the subject rather than the act: role=switch announces on/off from aria-checked, so an action phrase would read "Disable Redis, switch, on" -- a label arguing with the state beside it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../aiCustomization/enablementSwitch.ts | 86 +++++++++++++++++++ .../media/aiCustomizationManagement.css | 58 +++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/enablementSwitch.ts diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/enablementSwitch.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/enablementSwitch.ts new file mode 100644 index 00000000000000..58729bb53c5565 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/enablementSwitch.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { $ } from '../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; + +/** + * A compact on/off switch for turning a customization on or off directly from a list row. + * + * Enablement used to be reachable only through a right-click menu, which meant the most common + * thing someone wants to do with a server was also the least discoverable. This gives every row + * one control in one place, so the eye learns a single target while scanning a long list. + * + * The widget is deliberately dumb: it renders state and reports intent. Callers decide what + * toggling means, and own any hover text, because the surrounding scope rules differ per list. + */ +export class EnablementSwitch extends Disposable { + + readonly element: HTMLElement; + + private readonly _onDidToggle = this._register(new Emitter()); + /** Fired when the user asks to flip the switch. The caller applies the change. */ + readonly onDidToggle: Event = this._onDidToggle.event; + + private _checked = false; + + constructor(parent: HTMLElement) { + super(); + + // A real