From 469ad3d4333cce0bbdcc11998dc7dc2db39c51f3 Mon Sep 17 00:00:00 2001 From: Giuseppe Cianci Date: Fri, 19 Jun 2026 11:20:58 +0200 Subject: [PATCH 01/20] sessions: match new agent-host session by type, not just novelty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local agent host runs a single sessions provider whose session cache holds every agent-host session type (codex, claude, copilot). When a new session is sent, the provider identifies the freshly-committed backend session in `_waitForNewSession` purely by novelty — the first cached session whose raw id was not present before the send. The session's type/scheme was never checked. That is wrong when a session of a *different* type appears in the cache during the send. A slow Codex session (which cold-starts a native app-server before its first /responses turn) sent while a Claude session from an earlier run is present would latch onto the Claude session and return it as the Codex commit. `sendRequest` then reports an "active session replaced: codex -> claude", the workbench swaps the active slot to the wrong session, removes the Codex one, and the Codex reply renders nowhere. A claude harness that is suppressed in the current window (preferAgentHost=false) additionally throws "Agent host targets must have an item provider" when activated. This is exactly why the Codex smoke test timed out on the Windows CI runner (where the cold-start race is widest), failing build 449022. Filter `_waitForNewSession` by the `chatResource` scheme so a send only ever commits a session of its own type, in both the immediate cache scan and the onDidChangeSessions wait. Re-enable the Codex smoke test that was disabled in #321983 to keep the build green. Verified: a new unit test reproduces the cross-type latch (fails before, passes after); live launch with stale claude sessions present shows the Codex session stays active with no wrong swap; and the (re-enabled) Codex smoke test passes locally end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/baseAgentHostSessionsProvider.ts | 19 ++++++++--- .../localAgentHostSessionsProvider.test.ts | 34 +++++++++++++++++++ .../areas/agentsWindow/agentsWindow.test.ts | 2 +- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 32729fb6f977c..70efb251ddca8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -2482,7 +2482,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessions.fire({ added: [skeleton], removed: [], changed: [] }); try { - const committedSession = await this._waitForNewSession(existingKeys); + const committedSession = await this._waitForNewSession(existingKeys, chatResource.scheme); if (committedSession) { this._preserveNewSessionConfig(newSession, committedSession.sessionId); // Session graduated: release the eager subscription without @@ -2880,10 +2880,21 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } - private async _waitForNewSession(existingKeys: Set): Promise { + /** + * Resolve the freshly-committed backend session for an in-flight send. + * + * The local agent host runs a single provider whose session cache holds + * **every** agent-host session type (codex, claude, copilot, …). A send + * therefore has to identify *its own* new session by both novelty (a raw id + * not present before the send) **and** type: `expectedScheme` is the + * `chatResource` scheme (e.g. `agent-host-codex`), so a session of another + * type that happens to appear mid-send — a slow codex send racing against a + * restored claude session, say — is never mistaken for this send's commit. + */ + private async _waitForNewSession(existingKeys: Set, expectedScheme: string): Promise { await this._refreshSessions(); for (const [key, cached] of this._sessionCache) { - if (!existingKeys.has(key)) { + if (!existingKeys.has(key) && cached.resource.scheme === expectedScheme) { return cached; } } @@ -2894,7 +2905,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement waitDisposables.add(this._onDidChangeSessions.event(e => { const newSession = e.added.find(s => { const rawId = s.resource.path.substring(1); - return !existingKeys.has(rawId); + return !existingKeys.has(rawId) && s.resource.scheme === expectedScheme; }); if (newSession) { resolve(newSession); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 917c11f82c26a..b3eac872025e9 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2123,6 +2123,40 @@ suite('LocalAgentHostSessionsProvider', () => { ); }); + test('sendRequest only commits a session of the same type, ignoring a foreign-type session that appears mid-send', async () => { + // Regression test: the local agent host runs a single provider whose + // session cache holds every agent-host session type (codex, claude, + // copilot). When a slow session (e.g. codex cold start) is sent while a + // session of a DIFFERENT type appears in the cache, `_waitForNewSession` + // must not latch onto that foreign session and return it as the codex + // commit — otherwise the active session is swapped to the wrong type. + const codexAndClaude = [ + { type: 'agent-host-codex', name: 'codex', displayName: 'Codex', description: 'test', icon: undefined }, + { type: 'agent-host-claude', name: 'claude', displayName: 'Claude', description: 'test', icon: undefined }, + ]; + agentHost.setAgents([ + { provider: 'codex', displayName: 'Codex', description: '', models: [] } as AgentInfo, + { provider: 'claude', displayName: 'Claude', description: '', models: [] } as AgentInfo, + ]); + const provider = createProvider(disposables, agentHost, codexAndClaude, { + openSession: true, + sendRequest: async (): Promise => { + // While the codex send is in flight, a foreign-type (claude) + // session shows up in the host's list (e.g. restored from an + // earlier run), and the real codex session also commits. + agentHost.addSession(createSession('foreign-claude', { provider: 'claude', summary: 'Foreign Claude' })); + agentHost.addSession(createSession('real-codex', { provider: 'codex', summary: 'Real Codex' })); + return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }; + }, + }); + + const session = provider.createNewSession(URI.parse('file:///home/user/project'), 'codex'); + const chat = await provider.createNewChat(session.sessionId); + const committed = await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); + + assert.strictEqual(committed.resource.scheme, 'agent-host-codex', `expected the committed session to be the codex session, got ${committed.resource.toString()}`); + }); + test('sendRequest forwards resolved session config to chat service', async () => { const sendOptions: IChatSendRequestOptions[] = []; const provider = createProvider(disposables, agentHost, undefined, { diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index f035be815b0f7..a9aa87a33c123 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -557,7 +557,7 @@ export function setup(logger: Logger) { }); }); - describe.skip('Agents Window (Codex)', () => { + describe('Agents Window (Codex)', () => { const codex = setupAgentHostSuite(logger, { serverLabel: 'Codex', From b955ea3bd58d7dc3fd771531e85cdcb3f39259d0 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 19 Jun 2026 12:36:43 +0100 Subject: [PATCH 02/20] fix Custom Instructions agent log title (#321739) --- .../node/automaticInstructionsCollector.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts b/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts index bb169a0a41c07..b50f361e0b512 100644 --- a/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts +++ b/extensions/copilot/src/platform/promptFiles/node/automaticInstructionsCollector.ts @@ -857,14 +857,14 @@ export class CustomInstructionsReferenceLogger { const customInstructionsDebugInfo = await this.toCustomInstructionsDebugInfo(references); const span = this._otelService.startSpan('collect_automatic_instructions', { attributes: { - [GenAiAttr.OPERATION_NAME]: GenAiOperationName.CHAT, + [GenAiAttr.OPERATION_NAME]: GenAiOperationName.CONTENT_EVENT, ...(sessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: sessionId } : {}), }, }); span.setAttributes({ - [CopilotChatAttr.DEBUG_NAME]: `Agent Instructions`, + [CopilotChatAttr.DEBUG_NAME]: `Custom Instructions`, 'copilot_chat.event_category': 'discovery', - 'copilot_chat.event_details': ICustomInstructionsDebugInfo.formatCompact(customInstructionsDebugInfo) + (collectInstructionsInExtension ? '\n(collected in extension)' : '\n(collected in core)'), + 'copilot_chat.event_details': ICustomInstructionsDebugInfo.formatAgentDebugLog(customInstructionsDebugInfo) + (collectInstructionsInExtension ? '\n(collected in extension)' : '\n(collected in core)'), }); span.end(); } @@ -937,18 +937,14 @@ namespace ICustomInstructionsDebugInfo { function formatSkillNames(a: string[]): string { return a.map(i => posix.basename(posix.dirname(i))).join(', '); } - export function formatCompact(a: ICustomInstructionsDebugInfo): string { + export function formatAgentDebugLog(a: ICustomInstructionsDebugInfo): string { const result = []; - result.push(`instructions: [${a.instructions.length} entries]`); - if (a.instructions.length > 0) { - result.push(`(${a.instructions.map(i => posix.basename(i)).join(', ')})`); - } + result.push(`context included: [${a.instructions.length}] ${formatFileNames(a.instructions)}`); if (a.index) { - result.push(`index: {`); - result.push(`agents: [${a.index.agents.length}] ${formatFileNames(a.index.agents)}`); - result.push(`instructions: [${a.index.instructions.length}] ${formatFileNames(a.index.instructions)}`); - result.push(`skills: [${a.index.skills.length}] ${formatSkillNames(a.index.skills)}`); - result.push(`}`); + result.push(`on-demand loading:`); + result.push(` instructions: [${a.index.instructions.length}] ${formatFileNames(a.index.instructions)}`); + result.push(` skills: [${a.index.skills.length}] ${formatSkillNames(a.index.skills)}`); + result.push(` agents: [${a.index.agents.length}] ${formatFileNames(a.index.agents)}`); } return result.join('\n'); } From edc6fba317e8aad3262783c84fdfa57d43af71c4 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 19 Jun 2026 13:47:51 +0100 Subject: [PATCH 03/20] Remove letter-spacing from agent sessions title style (#322088) chat: remove letter-spacing from agent sessions title style Co-authored-by: mrleemurray --- .../chat/browser/widgetHosts/viewPane/media/chatViewPane.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index 9c2cf73fb3b0d..edc68225a6a47 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -67,7 +67,6 @@ font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.5px; .agent-sessions-title { cursor: pointer; From 9bfd3c1528ad396f80bac163964cef94868dd4ae Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 19 Jun 2026 13:48:05 +0100 Subject: [PATCH 04/20] AH skill completions: qualify skill with plugin (#321929) * AH skill completions: qualify skill with plugin * fix tests --- .../node/agentHostSkillCompletionProvider.ts | 40 ++++++++++--- .../agentHostSkillCompletionProvider.test.ts | 59 ++++++++++++++----- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts index f5c5219618cf0..e85de8374f994 100644 --- a/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostSkillCompletionProvider.ts @@ -13,6 +13,7 @@ import { CustomizationType, SkillCustomization } from '../common/state/sessionSt import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; import { extractWhitespaceDelimitedSlashToken } from './agentHostSlashCompletion.js'; + /** * Generic completion provider that contributes slash completions for skills * exposed through an agent's global and session-effective customizations. @@ -49,46 +50,67 @@ export class AgentHostSkillCompletionProvider extends Disposable implements IAge const skillsSeen = new Set(); return candidates .filter(skill => { - const uri = skill.uri.toString(); - if ((!typed.length || skill.name.startsWith(typed)) && !skillsSeen.has(uri)) { + const uri = skill.uri; + if ((!typed.length || skill.slashCommandName.startsWith(typed)) && !skillsSeen.has(uri)) { skillsSeen.add(uri); return true; } return false; }) .map(skill => ({ - insertText: '/' + skill.name + ' ', + insertText: '/' + skill.slashCommandName + ' ', rangeStart: leading.rangeStart, rangeEnd: leading.rangeEnd, attachment: { type: MessageAttachmentKind.Simple, - label: '/' + skill.name, + label: '/' + skill.slashCommandName, _meta: { - uri: skill.uri.toString(), + uri: skill.uri, name: skill.name, - displayName: skill.name, + displayName: skill.slashCommandName, ...(skill.description !== undefined ? { description: skill.description } : {}), }, }, })); } - private async _getCandidates(agent: IAgent, session: URI): Promise { + private async _getCandidates(agent: IAgent, session: URI): Promise { if (!agent.getSessionCustomizations) { return []; } const customizations = await agent.getSessionCustomizations(session); - const result: SkillCustomization[] = []; + const result: SlashCommmandCandidate[] = []; for (const c of customizations) { if (c.type === CustomizationType.McpServer || !c.enabled || !c.children) { continue; } for (const child of c.children) { if (child.type === CustomizationType.Skill) { - result.push(child); + result.push(this._toSlashCommandCandidate(c.type === CustomizationType.Plugin ? c.name : undefined, child)); } } } return result; } + + private _toSlashCommandCandidate(pluginId: string | undefined, skill: SkillCustomization): SlashCommmandCandidate { + // see getCanonicalPluginCommandId + let slashCommandName = skill.name; + if (pluginId && skill.name !== pluginId) { + slashCommandName = `${pluginId}:${skill.name}`; + } + return { + slashCommandName: slashCommandName, + name: skill.name, + description: skill.description, + uri: skill.uri, + }; + } +} + +interface SlashCommmandCandidate { + readonly slashCommandName: string; + readonly name: string; + readonly description: string | undefined; + readonly uri: string; } diff --git a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts index 8eff59dc59d8d..86cce7880d606 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSkillCompletionProvider.test.ts @@ -63,32 +63,58 @@ suite('AgentHostSkillCompletionProvider', () => { assert.deepStrictEqual([...completions.triggerCharacters], [CompletionTriggerCharacter.Slash]); }); - test('returns session-effective skills with attachment metadata and trailing space', async () => { + test('complete skills from a plugin', async () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [ - plugin('skills', [skill('agent-host-docs', 'Use this skill when working on Agent Host code')]), + plugin('my-skill', [skill('agent-host-docs', 'Use this skill when working on Agent Host code')]), ]; const provider = createProvider(agent); const result = await run(provider, '/'); assert.deepStrictEqual(result, [{ - insertText: '/agent-host-docs ', + insertText: '/my-skill:agent-host-docs ', rangeStart: 0, rangeEnd: 1, attachment: { type: MessageAttachmentKind.Simple, - label: '/agent-host-docs', + label: '/my-skill:agent-host-docs', _meta: { uri: 'file:///skills/agent-host-docs/SKILL.md', name: 'agent-host-docs', - displayName: 'agent-host-docs', + displayName: 'my-skill:agent-host-docs', description: 'Use this skill when working on Agent Host code', }, }, }]); }); + test('complete skills from a plugin with the same name as the skill', async () => { + const agent = new MockAgent('mock'); + agent.getSessionCustomizations = async () => [ + plugin('monitor-pr', [skill('monitor-pr', 'Use this skill when working with PRs')]), + ]; + const provider = createProvider(agent); + + const result = await run(provider, '/'); + + assert.deepStrictEqual(result, [{ + insertText: '/monitor-pr ', + rangeStart: 0, + rangeEnd: 1, + attachment: { + type: MessageAttachmentKind.Simple, + label: '/monitor-pr', + _meta: { + uri: 'file:///skills/monitor-pr/SKILL.md', + name: 'monitor-pr', + displayName: 'monitor-pr', + description: 'Use this skill when working with PRs', + }, + }, + }]); + }); + test('flattens skill children in session-effective order and ignores non-skill children', async () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [ @@ -99,7 +125,7 @@ suite('AgentHostSkillCompletionProvider', () => { const result = await run(provider, '/'); - assert.deepStrictEqual(result.map(item => item.insertText), ['/session-skill ', '/global-skill ']); + assert.deepStrictEqual(result.map(item => item.insertText), ['/first:session-skill ', '/second:global-skill ']); }); test('ignores disabled customization containers', async () => { @@ -112,7 +138,7 @@ suite('AgentHostSkillCompletionProvider', () => { const result = await run(provider, '/'); - assert.deepStrictEqual(result.map(item => item.insertText), ['/visible-skill ']); + assert.deepStrictEqual(result.map(item => item.insertText), ['/enabled:visible-skill ']); }); test('returns an empty list when the agent has no session customizations hook', async () => { @@ -129,10 +155,10 @@ suite('AgentHostSkillCompletionProvider', () => { agent.getSessionCustomizations = async () => [plugin('skills', [skill('alpha'), skill('beta')])]; const provider = createProvider(agent); - const result = await run(provider, '/b extra', 2); + const result = await run(provider, '/skills:b extra', '/skills:b'.length); assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ - { insertText: '/beta ', rangeStart: 0, rangeEnd: 2 }, + { insertText: '/skills:beta ', rangeStart: 0, rangeEnd: 9 }, ]); }); @@ -140,12 +166,12 @@ suite('AgentHostSkillCompletionProvider', () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [plugin('skills', [skill('alpha'), skill('beta')])]; const provider = createProvider(agent); - const text = 'use /b extra'; + const text = 'use /skills:b extra'; - const result = await run(provider, text, text.indexOf('/b') + '/b'.length); + const result = await run(provider, text, text.indexOf('/skills:b') + '/skills:b'.length); assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ - { insertText: '/beta ', rangeStart: 4, rangeEnd: 6 }, + { insertText: '/skills:beta ', rangeStart: 4, rangeEnd: 13 }, ]); }); @@ -158,8 +184,8 @@ suite('AgentHostSkillCompletionProvider', () => { const result = await run(provider, text); assert.deepStrictEqual(result.map(item => ({ insertText: item.insertText, rangeStart: item.rangeStart, rangeEnd: item.rangeEnd })), [ - { insertText: '/alpha ', rangeStart: 4, rangeEnd: 5 }, - { insertText: '/beta ', rangeStart: 4, rangeEnd: 5 }, + { insertText: '/skills:alpha ', rangeStart: 4, rangeEnd: 5 }, + { insertText: '/skills:beta ', rangeStart: 4, rangeEnd: 5 }, ]); }); @@ -177,7 +203,7 @@ suite('AgentHostSkillCompletionProvider', () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [plugin('skills', [skill('cached-skill')])]; const provider = createProvider(agent); - const text = 'use /cached-skill trailing'; + const text = 'use /skills:cached-skill trailing'; const result = await run(provider, text, text.indexOf('trailing')); @@ -188,8 +214,9 @@ suite('AgentHostSkillCompletionProvider', () => { const agent = new MockAgent('mock'); agent.getSessionCustomizations = async () => [plugin('skills', [skill('cached-skill')])]; const provider = createProvider(agent); + const text = '/skills:cached-skill trailing'; - const result = await run(provider, '/cached-skill trailing', 14); + const result = await run(provider, text, text.indexOf('trailing')); assert.deepStrictEqual(result, []); }); From 52869e8a772f8cab2c7171192eccef555180abf4 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 19 Jun 2026 13:48:20 +0100 Subject: [PATCH 05/20] Update scrollbar and minimap slider colors for improved visibility (#322071) update scrollbar and minimap slider colors for improved visibility in light and dark themes Co-authored-by: mrleemurray --- extensions/theme-defaults/themes/2026-dark.json | 12 ++++++------ extensions/theme-defaults/themes/2026-light.json | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index f88f582c9c12e..da580e40bce47 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -48,9 +48,9 @@ "inputValidation.errorBorder": "#BE1100", "inputValidation.errorForeground": "#bfbfbf", "scrollbar.shadow": "#191B1D4D", - "scrollbarSlider.background": "#83848533", - "scrollbarSlider.hoverBackground": "#83848566", - "scrollbarSlider.activeBackground": "#83848599", + "scrollbarSlider.background": "#A8A9AA85", + "scrollbarSlider.hoverBackground": "#A8A9AA90", + "scrollbarSlider.activeBackground": "#A8A9AA9C", "badge.background": "#3994BCF0", "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", @@ -276,9 +276,9 @@ "charts.green": "#86CF86", "charts.purple": "#AD80D7", "inlineChat.border": "#00000000", - "minimapSlider.background": "#83848533", - "minimapSlider.hoverBackground": "#83848566", - "minimapSlider.activeBackground": "#83848599", + "minimapSlider.background": "#A8A9AA85", + "minimapSlider.hoverBackground": "#A8A9AA90", + "minimapSlider.activeBackground": "#A8A9AA9C", "agents.background": "#121314", "agentsPanel.background": "#191A1B", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index abfcdf33f7e0f..13af896372f76 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -58,9 +58,9 @@ "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", - "scrollbarSlider.background": "#99999926", - "scrollbarSlider.hoverBackground": "#99999940", - "scrollbarSlider.activeBackground": "#99999955", + "scrollbarSlider.background": "#64646480", + "scrollbarSlider.hoverBackground": "#64646490", + "scrollbarSlider.activeBackground": "#646464A0", "badge.background": "#0069CC", "badge.foreground": "#FFFFFF", "progressBar.background": "#0069CC", @@ -282,9 +282,9 @@ "charts.purple": "#652D90", "agentStatusIndicator.background": "#FFFFFF", "inlineChat.border": "#00000000", - "minimapSlider.background": "#99999926", - "minimapSlider.hoverBackground": "#99999940", - "minimapSlider.activeBackground": "#99999955", + "minimapSlider.background": "#64646480", + "minimapSlider.hoverBackground": "#64646490", + "minimapSlider.activeBackground": "#646464A0", "agents.background": "#FAFAFD", "agentsPanel.background": "#FFFFFF", From 2ec8fa9156ca0690fcbca73c2f4400fb32662328 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 19 Jun 2026 13:48:35 +0100 Subject: [PATCH 06/20] Remove unnecessary scroll shadows for Monaco list and embedded editors (#322080) * fix: suppress edge fades for embedded single-line editors in scroll shadows * fix: remove unnecessary scroll shadow gradient for monaco list --------- Co-authored-by: mrleemurray --- .../browser/media/scrollShadows.css | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css index 27b9b8cdee82e..88cef7ec9de45 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css @@ -63,11 +63,6 @@ z-index: 10; } -.style-override-scrollShadows .monaco-list > .monaco-scrollable-element::after { - bottom: 0; - background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); -} - /* ============================================================================= * Editor area. The main editor viewport scrolls inside `.editor-scrollable`; * pin the fades to that scrollable element so they share the scrollbar's @@ -97,3 +92,14 @@ bottom: 0; background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); } + +/* ============================================================================= + * Embedded single-line editors used as text inputs (settings / keybindings + * search boxes, etc.) live inside the editor part but should read as plain + * inputs, not as scrolling editor surfaces. Suppress the edge fades there so the + * input keeps a flat background and never shows the editor-coloured rectangle. + * ========================================================================== */ +.style-override-scrollShadows .suggest-input-container .monaco-editor .editor-scrollable::before, +.style-override-scrollShadows .suggest-input-container .monaco-editor .editor-scrollable::after { + display: none; +} From e421d2536d82cb52eb7e55fed15581c5a8bd9ba2 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 19 Jun 2026 13:48:54 +0100 Subject: [PATCH 07/20] Adjust tab sizing and enable overflow scrolling (#322087) style: adjust tab sizing to fit content and enable overflow scrolling Co-authored-by: mrleemurray --- src/vs/sessions/browser/media/style.css | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 3b5eb26ae5b69..30f62a68fd1d8 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -331,15 +331,17 @@ --tab-border-top-color: transparent !important; } -/* Allow tabs to shrink when space is constrained so the close button stays - * reachable, while still letting them grow to fit their label when there is - * space. The default `sizing-fit` rule sets `width: 120px; min-width: - * fit-content; flex-shrink: 0;` — clearing min-width alone leaves the tab - * pinned at 120px and prevents it from expanding to its full content width. */ +/* Size tabs to their content and never shrink them, so that when more tabs + * exist than fit the container they overflow and the horizontal scrollbar + * appears (matching the default editor behaviour). The default `sizing-fit` + * rule sets `width: 120px; min-width: fit-content; flex-shrink: 0;` — pinning + * tabs at 120px. We instead let the width follow the label (`width: auto`) + * while keeping `flex-shrink: 0` so tabs keep their content width and the tab + * strip scrolls instead of squashing the tabs. */ .agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit { width: auto !important; min-width: 0 !important; - flex: 0 1 auto !important; + flex: 0 0 auto !important; } .agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label, From 446f2ce00ac341fc89398ed96875f4ba2e2d45f6 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 19 Jun 2026 16:17:25 +0200 Subject: [PATCH 08/20] Use prod endpoints for testing telemetry (#322065) --- .../log/vscode-node/loggingActions.ts | 87 +++++++++++++++---- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts index acb97f05e93a9..0c654168a5202 100644 --- a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +++ b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts @@ -19,7 +19,7 @@ import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/ import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { collectErrorMessages, collectSingleLineErrorMessage, ILogService, sanitizeNetworkErrorForTelemetry } from '../../../platform/log/common/logService'; import { outputChannel } from '../../../platform/log/vscode/outputChannelLogTarget'; -import { FetchEvent, IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { FetchEvent, IFetcherService, Response } from '../../../platform/networking/common/fetcherService'; import { IFetcher, userAgentLibraryHeader } from '../../../platform/networking/common/networking'; import { NodeFetcher } from '../../../platform/networking/node/nodeFetcher'; import { NodeFetchFetcher } from '../../../platform/networking/node/nodeFetchFetcher'; @@ -210,24 +210,50 @@ User Settings: const useVSCodeTelemetryLibForGH = this.configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.UseVSCodeTelemetryLibForGH, this.experimentationService); const githubTelemetryFetcher = useVSCodeTelemetryLibForGH ? currentFetcher : nodeFetcher; const microsoftAIKey = (this._context.extension.packageJSON as { ariaKey?: string }).ariaKey ?? ''; - const microsoftTelemetryUrl = !microsoftAIKey || isOneDataSystemKey(microsoftAIKey) - ? 'https://mobile.events.data.microsoft.com' // Microsoft 1DS/OneCollector telemetry endpoint (newer). - : 'https://dc.services.visualstudio.com'; // Azure Application Insights telemetry endpoint (older). - const secondaryUrls = [ - { url: microsoftTelemetryUrl, fetcher: currentFetcher }, - // GitHub Copilot telemetry endpoint (configured/effective host). - { url: vscode.Uri.parse(this.capiClientService.copilotTelemetryURL).with({ path: '/_ping' }).toString(), fetcher: githubTelemetryFetcher }, - // Experimentation (ExP/TAS) service endpoint. - { url: 'https://default.exp-tas.com', fetcher: nodeFetchFetcher }, + const microsoftIsOneDS = !microsoftAIKey || isOneDataSystemKey(microsoftAIKey); + const secondaryUrls: { + url: string; + fetcher: { name: string; fetcher: IFetcher }; + connect: (fetcher: IFetcher, url: string) => Promise; + }[] = [ + microsoftIsOneDS + ? { + // Microsoft 1DS/OneCollector telemetry endpoint (newer). + url: oneCollectorTelemetryUrl, + fetcher: currentFetcher, + connect: (fetcher, url) => vscode.env.isTelemetryEnabled + ? sendRawTelemetry(fetcher, this.envService, url, this._context, 'GitHub.copilot-chat/diagnosticsTelemetryProbe', {}) + : Promise.resolve('Telemetry is not enabled'), + } + : { + // Azure Application Insights telemetry endpoint (older). + url: 'https://dc.services.visualstudio.com/v2.1/track', + fetcher: currentFetcher, + connect: (fetcher, url) => sendRawAITelemetry(fetcher, this.envService, url), + }, + { + // GitHub Copilot telemetry endpoint (App Insights-compatible ingestion; the URL already + // ends in `/telemetry`, which is the path production posts telemetry to). + url: this.capiClientService.copilotTelemetryURL, + fetcher: githubTelemetryFetcher, + connect: (fetcher, url) => sendRawAITelemetry(fetcher, this.envService, url), + }, + { + // Experimentation (ExP/TAS) service endpoint (path used by vscode-tas-client). + url: 'https://default.exp-tas.com/vscode/ab', + fetcher: nodeFetchFetcher, + connect: (fetcher, url) => fetcher.fetch(url, { callSite: 'diagnostics-secondary-probe' }), + }, ]; await appendText(editor, `\n`); - for (const { url, fetcher } of secondaryUrls) { - const authHeaders = await this.getAuthHeaders(isGHEnterprise, url); + for (const { url, fetcher, connect } of secondaryUrls) { await appendText(editor, `Connecting to ${url} (${fetcher.name}): `); const start = Date.now(); try { - const response = await Promise.race([fetcher.fetcher.fetch(url, { headers: authHeaders, callSite: 'diagnostics-secondary-probe' }), timeout(timeoutSeconds * 1000)]); - if (response) { + const response = await Promise.race([connect(fetcher.fetcher, url), timeout(timeoutSeconds * 1000)]); + if (typeof response === 'string') { + await appendText(editor, `${response}\n`); + } else if (response) { await appendText(editor, `HTTP ${response.status} (${Date.now() - start} ms)\n`); } else { await appendText(editor, `timed out after ${timeoutSeconds} seconds\n`); @@ -504,7 +530,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { const key = library.replace(/-/g, ''); const requestStartTime = Date.now(); try { - const response = await sendRawTelemetry(fetcher, envService, extensionContext, 'GitHub.copilot-chat/fetcherTelemetryProbe', {}); + const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetryProbe', {}); probeResults[key] = `Status: ${response.status}`; logService.debug(`Fetcher telemetry probe: ${library} ${probeResults[key]} (${Date.now() - requestStartTime}ms)`); } catch (e) { @@ -539,7 +565,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { remoteName: vscode.env.remoteName ?? 'none', ...probeResults, }; - const response = await sendRawTelemetry(fetcher, envService, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties); + const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties); logService.debug(`Fetcher telemetry: Succeeded in ${Date.now() - requestStartTime}ms using ${fetcher.getUserAgentLibrary()} with status ${response.status} (${response.statusText}).`); } catch (e) { @@ -551,8 +577,9 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { }); } -async function sendRawTelemetry(fetcher: IFetcher, envService: IEnvService, extensionContext: IVSCodeExtensionContext, eventName: string, properties: Record) { - const url = 'https://mobile.events.data.microsoft.com/OneCollector/1.0?cors=true&content-type=application/x-json-stream'; +const oneCollectorTelemetryUrl = 'https://mobile.events.data.microsoft.com/OneCollector/1.0?cors=true&content-type=application/x-json-stream'; + +async function sendRawTelemetry(fetcher: IFetcher, envService: IEnvService, url: string, extensionContext: IVSCodeExtensionContext, eventName: string, properties: Record) { const product = require(path.join(vscode.env.appRoot, 'product.json')); const vscodeCommitHash: string = product.commit || ''; const ariaKey = (extensionContext.extension.packageJSON as { ariaKey?: string }).ariaKey ?? ''; @@ -621,6 +648,30 @@ async function sendRawTelemetry(fetcher: IFetcher, envService: IEnvService, exte return response; } +/** + * Sends a raw Application Insights telemetry request with an empty batch (zero events) to probe + * connectivity to an Application Insights ingestion endpoint. + */ +async function sendRawAITelemetry(fetcher: IFetcher, envService: IEnvService, url: string) { + const body = '[]'; + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': `GitHubCopilotChat/${envService.getVersion()}`, + [userAgentLibraryHeader]: fetcher.getUserAgentLibrary(), + }; + if (fetcher.getUserAgentLibrary() === NodeFetcher.ID) { + headers['content-length'] = String(Buffer.byteLength(body)); + } + const response = await fetcher.fetch(url, { + method: 'POST', + headers, + body, + callSite: 'diagnostics-telemetry-probe', + }); + await response.text(); + return response; +} + const ids_paths = /(^|\b)[\p{L}\p{Nd}]+((=""?[^"]+""?)|(([.:=/"_-]+[\p{L}\p{Nd}]+)+))(\b|$)/giu; export function sanitizeValue(input: string | undefined): string { return (input || '').replace(ids_paths, (m) => maskByClass(m)); From 554b344d749954eb0e14d3916577993e9bc18a20 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Sat, 20 Jun 2026 00:34:03 +1000 Subject: [PATCH 09/20] Enhance logging for model refresh and update CopilotClient integration to pass GitHub token during model retrieval (#322045) * Enhance logging for model refresh and update CopilotClient integration to pass GitHub token during model retrieval * Remove token mention from CopilotClient startup log message --- .../agentHost/node/claude/claudeAgent.ts | 2 +- .../agentHost/node/copilot/copilotAgent.ts | 47 +++--- .../agentHost/test/node/copilotAgent.test.ts | 138 +++++++++++++++--- 3 files changed, 137 insertions(+), 50 deletions(-) diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index cd61f749aa049..2639977dd1d3e 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -333,7 +333,7 @@ export class ClaudeAgent extends Disposable implements IAgent { .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default)) .map(m => toAgentModelInfo(m, this.id)); - this._logService.info(`[Claude] Models refreshed. Count: ${filtered.length}`); + this._logService.info(`[Claude] Models refreshed. Count: ${filtered.length}, ${filtered.map(m => m.name).join(', ')}`); this._models.set(filtered, undefined); } catch (err) { this._logService.error(err, '[Claude] Failed to refresh models'); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index df5101c9fa676..34b6ea8004610 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type ModelInfo } from '@github/copilot-sdk'; +import { CopilotClient, RuntimeConnection, type CopilotClientOptions } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; import * as os from 'os'; import { CancelablePromise, createCancelablePromise, Delayer, Limiter, SequencerByKey } from '../../../../base/common/async.js'; @@ -42,7 +42,6 @@ import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDa import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { ActiveClientState } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -123,6 +122,8 @@ interface IProvisionalSession { export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './copilotSessionLauncher.js'; +type ModelInfo = Awaited>['models'][number]; + interface ISerializedModelSelection { id?: unknown; config?: unknown; @@ -475,10 +476,6 @@ export class CopilotAgent extends Disposable implements IAgent { this._githubToken = token; this._updateRestrictedTelemetry(token); this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); - if (tokenChanged && this._client && this._sessions.size === 0) { - this._logService.info('[Copilot] Restarting CopilotClient with new token'); - await this._stopClient(); - } if (tokenChanged) { void this._refreshModels(); } @@ -501,7 +498,7 @@ export class CopilotAgent extends Disposable implements IAgent { return; } try { - const models = await this._listModels(); + const models = await this._listModels(tokenAtRefreshStart); if (this._githubToken === tokenAtRefreshStart) { this._models.set(models, undefined); } @@ -561,10 +558,6 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- client lifecycle --------------------------------------------------- private async _ensureClient(): Promise { - const tokenAtStartup = this._githubToken; - if (!tokenAtStartup) { - throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot', this.getProtectedResources()); - } if (this._client) { return this._client; } @@ -577,7 +570,7 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionSyncAtStartup = this._isSessionSyncEnabled(); const rubberDuckAtStartup = this._isRubberDuckEnabled(); const clientStarting = (async () => { - this._logService.info('[Copilot] Starting CopilotClient... (with token)'); + this._logService.info('[Copilot] Starting CopilotClient...'); // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars // that can interfere with the Node.js process the SDK spawns. @@ -654,7 +647,6 @@ export class CopilotAgent extends Disposable implements IAgent { const telemetry = await this._otelService.getSdkTelemetryConfig(); const clientOptions: CopilotClientOptions = { - gitHubToken: tokenAtStartup, useLoggedInUser: false, connection: RuntimeConnection.forStdio({ path: cliPath }), env, @@ -664,10 +656,6 @@ export class CopilotAgent extends Disposable implements IAgent { }; const client = this._createCopilotClient(clientOptions); await client.start(); - if (this._githubToken !== tokenAtStartup) { - await client.stop(); - throw new Error('Copilot authentication changed while the client was starting'); - } if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup) { await client.stop(); throw new Error('Copilot startup config changed while the client was starting'); @@ -712,7 +700,7 @@ export class CopilotAgent extends Disposable implements IAgent { * `ModelBilling` type — narrow through {@link ICopilotModelBilling} until the SDK catches up. */ private _createContextTierConfigSchemaProperty(billing: ModelInfo['billing'] | undefined): ConfigPropertySchema | undefined { - const tokenPrices = (billing as ICopilotModelBilling | undefined)?.tokenPrices; + const tokenPrices = billing?.tokenPrices; const defaultMax = tokenPrices?.contextMax; const longContextMax = tokenPrices?.longContext?.contextMax; if (!defaultMax || !longContextMax || defaultMax >= longContextMax) { @@ -748,25 +736,25 @@ export class CopilotAgent extends Disposable implements IAgent { * `billing.tokenPrices` / `billing.priceCategory` are present on the runtime CAPI `/models` payload but not yet * declared on the published SDK `ModelBilling` type — narrow through {@link ICopilotModelBilling}. */ - private _createModelPricingMeta(billing: ModelInfo['billing'] | undefined): Record | undefined { - const typedBilling = billing as ICopilotModelBilling | undefined; - const tokenPrices = typedBilling?.tokenPrices; + private _createModelPricingMeta(modelInfo: ModelInfo | undefined): Record | undefined { + const billing = modelInfo?.billing; + const tokenPrices = billing?.tokenPrices; const longContext = tokenPrices?.longContext; const differsFromDefault = (longValue: number | undefined, defaultValue: number | undefined): number | undefined => longValue !== undefined && longValue !== defaultValue ? longValue : undefined; return createAgentModelPricingMeta({ - multiplierNumeric: typeof typedBilling?.multiplier === 'number' ? typedBilling.multiplier : undefined, + multiplierNumeric: typeof billing?.multiplier === 'number' ? billing.multiplier : undefined, inputCost: tokenPrices?.inputPrice, cacheCost: tokenPrices?.cachePrice, - cacheWriteCost: tokenPrices?.cacheWritePrice, + cacheWriteCost: tokenPrices?.cachePrice, outputCost: tokenPrices?.outputPrice, longContextInputCost: differsFromDefault(longContext?.inputPrice, tokenPrices?.inputPrice), longContextCacheCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), - longContextCacheWriteCost: differsFromDefault(longContext?.cacheWritePrice, tokenPrices?.cacheWritePrice), + longContextCacheWriteCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), longContextOutputCost: differsFromDefault(longContext?.outputPrice, tokenPrices?.outputPrice), - priceCategory: typeof typedBilling?.priceCategory === 'string' ? typedBilling.priceCategory : undefined, + priceCategory: typeof modelInfo?.modelPickerPriceCategory === 'string' ? modelInfo.modelPickerPriceCategory : undefined, }); } @@ -929,10 +917,10 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - private async _listModels(): Promise { + private async _listModels(gitHubToken: string): Promise { this._logService.info('[Copilot] Listing models...'); const client = await this._ensureClient(); - const models = await client.listModels(); + const { models } = await client.rpc.models.list({ gitHubToken }); const result = models.map((m): IAgentModelInfo => ({ provider: this.id, id: m.id, @@ -943,9 +931,9 @@ export class CopilotAgent extends Disposable implements IAgent { supportsVision: !!m.capabilities?.supports?.vision, configSchema: this._createModelConfigSchema(m), policyState: m.policy?.state as PolicyState | undefined, - _meta: this._createModelPricingMeta(m.billing), + _meta: this._createModelPricingMeta(m), })); - this._logService.info(`[Copilot] Found ${result.length} models`); + this._logService.info(`[Copilot] Found ${result.length} models: ${result.map(m => m.name).join(', ')}`); return result; } @@ -972,7 +960,6 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId); const client = await this._ensureClient(); - // When forking, use the SDK's sessions.fork RPC. Forking from a source // session that has no turns is equivalent to creating a fresh session; // in that case the agent service drops `config.fork` before calling us, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 2ee65166854dd..5462bfa3e39bb 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -32,7 +32,6 @@ import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; import { CustomizationType, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; @@ -187,6 +186,7 @@ class TestSessionDataService extends Disposable implements ISessionDataService { cleanupOrphanedData(): Promise { return Promise.resolve(); } whenIdle(): Promise { return Promise.resolve(); } } +type CopilotModelsList = CopilotClient['rpc']['models']['list']; interface ITestCopilotModelInfo { readonly id: string; @@ -207,12 +207,16 @@ interface ITestCopilotModelInfo { readonly longContext?: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number }; }; }; + readonly modelPickerPriceCategory?: string; readonly supportedReasoningEfforts?: ModelInfo['supportedReasoningEfforts']; readonly defaultReasoningEffort?: ModelInfo['defaultReasoningEffort']; } interface ITestCopilotClient extends Pick { - readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } }; + readonly rpc: { + readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] }; + readonly models: { readonly list: CopilotModelsList }; + }; } function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { @@ -230,14 +234,31 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): ModelInfo { }, ...(model.policy ? { policy: { state: model.policy.state ?? 'enabled', terms: '' } } : {}), ...(model.billing ? { billing: model.billing } : {}), + ...(model.modelPickerPriceCategory ? { modelPickerPriceCategory: model.modelPickerPriceCategory } : {}), ...(model.supportedReasoningEfforts ? { supportedReasoningEfforts: model.supportedReasoningEfforts } : {}), ...(model.defaultReasoningEffort ? { defaultReasoningEffort: model.defaultReasoningEffort } : {}), }; } class TestCopilotClient implements ITestCopilotClient { - readonly rpc: ITestCopilotClient['rpc'] = { sessions: { fork: async () => ({ sessionId: 'forked-session' }) } }; + readonly rpc: ITestCopilotClient['rpc'] = { + sessions: { fork: async () => ({ sessionId: 'forked-session' }) }, + models: { + list: async params => { + this.modelListRequests.push(params); + const error = this.modelListErrors.shift(); + if (error) { + throw error; + } + return { models: this._models.map(toSdkModelInfo) }; + } + }, + }; + startCallCount = 0; + stopCallCount = 0; listSessionCallCount = 0; + readonly modelListRequests: Parameters[0][] = []; + readonly modelListErrors: Error[] = []; readonly getSessionMetadataCalls: string[] = []; readonly deletedSessionIds: string[] = []; @@ -246,8 +267,13 @@ class TestCopilotClient implements ITestCopilotClient { private readonly _models: readonly ITestCopilotModelInfo[] = [], ) { } - async start(): Promise { } - async stop(): ReturnType { return []; } + async start(): Promise { + this.startCallCount++; + } + async stop(): ReturnType { + this.stopCallCount++; + return []; + } async listSessions(): ReturnType { this.listSessionCallCount++; return this._sessions; @@ -601,26 +627,99 @@ suite('CopilotAgent', () => { ); }); - test('returns empty models and throws AuthRequired for sessions before authentication', async () => { - const agent = createTestAgent(disposables); + test('returns empty models and lists sessions before authentication', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const ownedSession = AgentSession.uri('copilotcli', 'owned-before-auth'); + const ownedDb = sessionDataService.openDatabase(ownedSession); + ownedDb.dispose(); + const client = new TestCopilotClient([sdkSession('owned-before-auth')]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); try { - assert.deepStrictEqual(agent.models.get(), []); - await assert.rejects( - () => agent.listSessions(), - (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, - ); + const sessions = await agent.listSessions(); + assert.deepStrictEqual({ + models: agent.models.get(), + sessions: sessions.map(session => AgentSession.id(session.session)), + starts: client.startCallCount, + listCalls: client.listSessionCallCount, + }, { + models: [], + sessions: ['owned-before-auth'], + starts: 1, + listCalls: 1, + }); } finally { await disposeAgent(agent); } }); - test('requires authentication before creating a session', async () => { - const agent = createTestAgent(disposables); + test('starts the client and creates a provisional session before authentication', async () => { + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { copilotClient: client }); + const session = AgentSession.uri('copilotcli', 'unauth-create'); + const workingDirectory = URI.file('/workspace'); try { - await assert.rejects( - () => agent.createSession({ workingDirectory: URI.file('/workspace') }), - (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED, - ); + const result = await agent.createSession({ session, workingDirectory }); + assert.ok(result.workingDirectory); + assert.deepStrictEqual({ + session: result.session.toString(), + workingDirectory: result.workingDirectory.toString(), + provisional: result.provisional, + starts: client.startCallCount, + stops: client.stopCallCount, + }, { + session: session.toString(), + workingDirectory: workingDirectory.toString(), + provisional: true, + starts: 1, + stops: 0, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('passes the GitHub token when refreshing models', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'model-token'); + await waitForState(agent.models, models => models.length > 0); + + assert.deepStrictEqual(client.modelListRequests, [{ gitHubToken: 'model-token' }]); + } finally { + await disposeAgent(agent); + } + }); + + test('does not stop the client when the auth token changes', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.listSessions(); + await agent.authenticate('https://api.github.com', 'model-token-a'); + for (let i = 0; i < 200 && client.modelListRequests.length < 1; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + await agent.authenticate('https://api.github.com', 'model-token-b'); + for (let i = 0; i < 200 && client.modelListRequests.length < 2; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + assert.deepStrictEqual({ + starts: client.startCallCount, + stops: client.stopCallCount, + requests: client.modelListRequests, + }, { + starts: 1, + stops: 0, + requests: [{ gitHubToken: 'model-token-a' }, { gitHubToken: 'model-token-b' }], + }); } finally { await disposeAgent(agent); } @@ -751,7 +850,6 @@ suite('CopilotAgent', () => { capabilities: { limits: { max_context_window_tokens: 200_000 } }, billing: { multiplier: 1, - priceCategory: 'medium', tokenPrices: { contextMax: 200_000, inputPrice: 3, @@ -760,6 +858,7 @@ suite('CopilotAgent', () => { longContext: { contextMax: 1_000_000, inputPrice: 6, cachePrice: 1, outputPrice: 22.5 }, }, }, + modelPickerPriceCategory: 'medium', }]), }); try { @@ -770,6 +869,7 @@ suite('CopilotAgent', () => { multiplierNumeric: 1, inputCost: 3, cacheCost: 1, + cacheWriteCost: 1, outputCost: 15, longContextInputCost: 6, longContextOutputCost: 22.5, From a0c54ca2e9e56b1b857d359a9248474f0a817d31 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Sat, 20 Jun 2026 00:35:16 +1000 Subject: [PATCH 10/20] Update GitHub Copilot and SDK versions to 1.0.64-0 in package and package-lock files (#322020) * Update GitHub Copilot and SDK versions to 1.0.64-0 in package and package-lock files * Increase timeout for CopilotAgent tests to 30 seconds --- extensions/copilot/package-lock.json | 72 +++++++------- extensions/copilot/package.json | 2 +- package-lock.json | 94 +++++++++++-------- package.json | 4 +- remote/package-lock.json | 82 ++++++++-------- remote/package.json | 4 +- .../agentHost/test/node/copilotAgent.test.ts | 2 +- 7 files changed, 136 insertions(+), 124 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 8aba310e900d0..fe491f3040f95 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -13,7 +13,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.63", + "@github/copilot": "^1.0.64-0", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -2948,9 +2948,9 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", - "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", + "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2", @@ -2960,20 +2960,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.63", - "@github/copilot-darwin-x64": "1.0.63", - "@github/copilot-linux-arm64": "1.0.63", - "@github/copilot-linux-x64": "1.0.63", - "@github/copilot-linuxmusl-arm64": "1.0.63", - "@github/copilot-linuxmusl-x64": "1.0.63", - "@github/copilot-win32-arm64": "1.0.63", - "@github/copilot-win32-x64": "1.0.63" + "@github/copilot-darwin-arm64": "1.0.64-0", + "@github/copilot-darwin-x64": "1.0.64-0", + "@github/copilot-linux-arm64": "1.0.64-0", + "@github/copilot-linux-x64": "1.0.64-0", + "@github/copilot-linuxmusl-arm64": "1.0.64-0", + "@github/copilot-linuxmusl-x64": "1.0.64-0", + "@github/copilot-win32-arm64": "1.0.64-0", + "@github/copilot-win32-x64": "1.0.64-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", - "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", + "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", "cpu": [ "arm64" ], @@ -2987,9 +2987,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", - "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", + "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", "cpu": [ "x64" ], @@ -3003,9 +3003,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", - "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", + "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", "cpu": [ "arm64" ], @@ -3022,9 +3022,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", - "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", + "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", "cpu": [ "x64" ], @@ -3041,9 +3041,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", - "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", + "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", "cpu": [ "arm64" ], @@ -3060,9 +3060,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", - "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", + "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", "cpu": [ "x64" ], @@ -3079,9 +3079,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", - "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", + "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", "cpu": [ "arm64" ], @@ -3095,9 +3095,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", - "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", + "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", "cpu": [ "x64" ], diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5e832bbc0961b..d843396cb9d90 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -7127,7 +7127,7 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.63", + "@github/copilot": "^1.0.64-0", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/package-lock.json b/package-lock.json index c1ebbfa6c7eb0..91303890d143b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -1084,9 +1084,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", - "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", + "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2", @@ -1096,20 +1096,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.63", - "@github/copilot-darwin-x64": "1.0.63", - "@github/copilot-linux-arm64": "1.0.63", - "@github/copilot-linux-x64": "1.0.63", - "@github/copilot-linuxmusl-arm64": "1.0.63", - "@github/copilot-linuxmusl-x64": "1.0.63", - "@github/copilot-win32-arm64": "1.0.63", - "@github/copilot-win32-x64": "1.0.63" + "@github/copilot-darwin-arm64": "1.0.64-0", + "@github/copilot-darwin-x64": "1.0.64-0", + "@github/copilot-linux-arm64": "1.0.64-0", + "@github/copilot-linux-x64": "1.0.64-0", + "@github/copilot-linuxmusl-arm64": "1.0.64-0", + "@github/copilot-linuxmusl-x64": "1.0.64-0", + "@github/copilot-win32-arm64": "1.0.64-0", + "@github/copilot-win32-x64": "1.0.64-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", - "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", + "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", "cpu": [ "arm64" ], @@ -1123,9 +1123,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", - "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", + "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", "cpu": [ "x64" ], @@ -1139,12 +1139,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", - "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", + "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1155,12 +1158,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", - "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", + "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1171,12 +1177,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", - "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", + "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1187,12 +1196,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", - "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", + "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1203,12 +1215,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", - "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", + "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.61", + "@github/copilot": "^1.0.64-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1226,9 +1238,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", - "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", + "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", "cpu": [ "arm64" ], @@ -1242,9 +1254,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", - "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", + "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 882bc400d0cec..c726770761a07 100644 --- a/package.json +++ b/package.json @@ -95,8 +95,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", diff --git a/remote/package-lock.json b/remote/package-lock.json index eb4eaf7bc83b0..55824d04ad9f6 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,8 +8,8 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.0", @@ -59,9 +59,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", - "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.64-0.tgz", + "integrity": "sha512-PlH7ByBHjmPLqLXS4CE2q8hN6CFEfkCMV6ScBEzW/u73+KYQB4fGNouo8Lr8okL6D5CW5rzPJbsXyISyJqVOZg==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2", @@ -71,20 +71,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.63", - "@github/copilot-darwin-x64": "1.0.63", - "@github/copilot-linux-arm64": "1.0.63", - "@github/copilot-linux-x64": "1.0.63", - "@github/copilot-linuxmusl-arm64": "1.0.63", - "@github/copilot-linuxmusl-x64": "1.0.63", - "@github/copilot-win32-arm64": "1.0.63", - "@github/copilot-win32-x64": "1.0.63" + "@github/copilot-darwin-arm64": "1.0.64-0", + "@github/copilot-darwin-x64": "1.0.64-0", + "@github/copilot-linux-arm64": "1.0.64-0", + "@github/copilot-linux-x64": "1.0.64-0", + "@github/copilot-linuxmusl-arm64": "1.0.64-0", + "@github/copilot-linuxmusl-x64": "1.0.64-0", + "@github/copilot-win32-arm64": "1.0.64-0", + "@github/copilot-win32-x64": "1.0.64-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", - "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.64-0.tgz", + "integrity": "sha512-97DUGiuYrkCYOlSSLWMmr+K0uGzAxz1JOL/GyO/7mNl6V/1xgs6Van1Jj+Dpj4ly96iHE8lUIW8cQNCG66644g==", "cpu": [ "arm64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", - "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.64-0.tgz", + "integrity": "sha512-2PXY4mSFtIjFdRaAt8PakegRgGtf6Sz9z6U/dIgVygNfctVNzaL5FH65PNPm8Y80jaDvEcz1/XY5MiQtxnlzZQ==", "cpu": [ "x64" ], @@ -114,9 +114,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", - "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.64-0.tgz", + "integrity": "sha512-PLP+vR508fOTlCr9CSZiXi9geicHKXuX9jLGdwNqK2TMZO5TqCLz8wP7dBEmkdkeXcFKovMb8nQVB1Toc6xutw==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", - "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.64-0.tgz", + "integrity": "sha512-NvVjQ69zr390ijzo2f75+v0DHm6xnvPbi67ugnKDk7ZPbx8P3vSxVdAnrzrrL4T3T8ng3pJANcC4p+eGbx+UDw==", "cpu": [ "x64" ], @@ -152,9 +152,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", - "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.64-0.tgz", + "integrity": "sha512-qCnVF5vIcTO74CukAENZo8e5nqXm4QUshuKN69aiZb5GOhVvyyIKsf5Jo7ikZt54jJBHycAMUKlTA8L3/nK+KA==", "cpu": [ "arm64" ], @@ -171,9 +171,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", - "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.64-0.tgz", + "integrity": "sha512-WDBEmkBk1RulTfdLK5IuttNBadjLOBpvQyonGQ/aLeaetRNNdapoygrSjFU7q1QBSenmCyanXH6D+TS7tP3Qsw==", "cpu": [ "x64" ], @@ -190,12 +190,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", - "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.2.tgz", + "integrity": "sha512-JJDsGM/bA1LGy1Ro/8iC8RLpKsLmuiFdQ67oFAVfi0Hfxyx289teHwmM70ehK76DXBkrPqqcxcliJi56k1ggFA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.61", + "@github/copilot": "^1.0.64-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -213,9 +213,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", - "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.64-0.tgz", + "integrity": "sha512-PC7yuUKcVbhli4bpzWFVT3juxj+v/iONazetNe3tMpHWza3W7MeFRifzAseSErKQCt2fHJth3m8bQAwFN2jfrA==", "cpu": [ "arm64" ], @@ -229,9 +229,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.63", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", - "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", + "version": "1.0.64-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.64-0.tgz", + "integrity": "sha512-d2fnUTIlqNxCqS2PuV+FD99ZOYBaX72OLtAmphbKyz36KyZ6D4ssiu8M4vHVTKWWdyc3TWiLsnIB+ryWdv1gGw==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index ebb945beef7cc..86894ffa3302a 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "^1.0.63", - "@github/copilot-sdk": "^1.0.1", + "@github/copilot": "^1.0.64-0", + "@github/copilot-sdk": "^1.0.2", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.0", diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 5462bfa3e39bb..e705a90d54f09 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -747,7 +747,7 @@ suite('CopilotAgent', () => { } await disposeAgent(agent); } - }); + }).timeout(30_000); suite('restart on startup config change', () => { From 894554b93a9e4d118c147cf9cca58c49ca66fdfe Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:35:35 +0200 Subject: [PATCH 11/20] sessions: restore trailing separator in panel part configuration (#322099) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/parts/panelPart.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index a314fb87a6e5a..8a38fcc14e725 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -88,7 +88,7 @@ export class PanelPart extends AbstractPaneCompositePart { ) { super( Parts.PANEL_PART, - { hasTitle: true, trailingSeparator: false }, + { hasTitle: true, trailingSeparator: true }, PanelPart.activePanelSettingsKey, ActivePanelContext.bindTo(contextKeyService), PanelFocusContext.bindTo(contextKeyService), From bf46ff6aa6087791540c8224ce056d1bb245ab13 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 19 Jun 2026 15:35:40 +0100 Subject: [PATCH 12/20] Centralize Modern UI toggle (#322086) * refactor: rename floatingPanels setting to modernUI and update related configurations Co-authored-by: Copilot * style: clarify comments regarding layout affecting modules and floating panels presentation Co-authored-by: Copilot * fix: add deferred re-clamp for header size changes in Pane layout * feat: migrate legacy experimental settings to modernUI toggle * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: mrleemurray Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/splitview/paneview.ts | 25 ++- src/vs/workbench/browser/layout.ts | 6 +- .../browser/media/floatingPanels.css | 2 +- .../parts/activitybar/activitybarPart.ts | 2 +- .../browser/parts/statusbar/statusbarPart.ts | 2 +- .../browser/workbench.contribution.ts | 27 +++- .../browser/media/commandCenter.css | 2 +- .../styleOverrides/browser/media/fontRamp.css | 2 +- .../browser/media/keyboardFocusOnly.css | 2 +- .../styleOverrides/browser/media/padding.css | 2 +- .../browser/media/paneHeaders.css | 2 +- .../browser/media/roundedCorners.css | 2 +- .../browser/media/scrollShadows.css | 2 +- .../styleOverrides/browser/media/tabs.css | 4 +- .../browser/styleOverrides.contribution.ts | 152 +++++------------- .../services/layout/browser/layoutService.ts | 20 +-- .../parts/activitybar/activitybarPart.test.ts | 6 +- 17 files changed, 114 insertions(+), 146 deletions(-) diff --git a/src/vs/base/browser/ui/splitview/paneview.ts b/src/vs/base/browser/ui/splitview/paneview.ts index 42e000a5dcddb..024b21479868f 100644 --- a/src/vs/base/browser/ui/splitview/paneview.ts +++ b/src/vs/base/browser/ui/splitview/paneview.ts @@ -5,7 +5,7 @@ import { isFirefox } from '../../browser.js'; import { DataTransfers } from '../../dnd.js'; -import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, trackFocus } from '../../dom.js'; +import { $, addDisposableListener, append, clearNode, EventHelper, EventType, getWindow, isHTMLElement, scheduleAtNextAnimationFrame, trackFocus } from '../../dom.js'; import { DomEmitter } from '../../event.js'; import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { Gesture, EventType as TouchEventType } from '../../touch.js'; @@ -13,7 +13,7 @@ import { IBoundarySashes, Orientation } from '../sash/sash.js'; import { Color, RGBA } from '../../../common/color.js'; import { Emitter, Event } from '../../../common/event.js'; import { KeyCode } from '../../../common/keyCodes.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../common/lifecycle.js'; import { ScrollEvent } from '../../../common/scrollable.js'; import './paneview.css'; import { localize } from '../../../../nls.js'; @@ -85,6 +85,15 @@ export abstract class Pane extends Disposable implements IView { */ private _headerSize: number | undefined = undefined; + /** + * Deferred re-clamp scheduled when {@link Pane.layout} detects that the header + * size changed between passes (e.g. the `--pane-header-size` CSS variable was + * overridden at runtime). Fires {@link Pane.onDidChange} on the next frame so + * the split view re-clamps the size it reserves for this pane without + * reentering the current layout pass. + */ + private readonly _headerSizeRelayout = this._register(new MutableDisposable()); + private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; @@ -327,9 +336,21 @@ export abstract class Pane extends Disposable implements IView { layout(size: number): void { // Re-read the header size from CSS once per layout pass; subsequent // `minimumSize` / `maximumSize` reads within the pass reuse the cache. + const previousHeaderSize = this._headerSize; this._headerSize = undefined; const headerSize = this.headerSize; + // If the header size changed since the previous pass — e.g. the + // `--pane-header-size` CSS variable was overridden at runtime (a style + // override toggled) — the containing split view still reserves the old + // size for this pane, most visibly when it is collapsed. Refresh the + // header and, on the next frame, fire `onDidChange` so the split view + // re-clamps the reservation. Deferring avoids reentering this layout pass. + if (previousHeaderSize !== undefined && previousHeaderSize !== headerSize) { + this.updateHeader(); + this._headerSizeRelayout.value = scheduleAtNextAnimationFrame(getWindow(this.element), () => this._onDidChange.fire(undefined)); + } + const width = this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size; const height = this._orientation === Orientation.VERTICAL ? size - headerSize : this.orthogonalSize - headerSize; diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 89d7fd156d11b..f7701c3972b78 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -438,8 +438,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.updateShadows(); } - // Floating Panels - if (e.affectsConfiguration(LayoutSettings.FLOATING_PANELS)) { + // Modern UI Update (floating panels presentation) + if (e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this.updateFloatingPanels(); this.layout(); // re-layout so parts pick up the new floating margins } @@ -619,7 +619,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } isFloatingPanelsEnabled(): boolean { - return this.configurationService.getValue(LayoutSettings.FLOATING_PANELS) === true; + return this.configurationService.getValue(LayoutSettings.MODERN_UI) === true; } private updateFloatingPanels(): void { diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index cc391c7855db8..16a28090628eb 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -6,7 +6,7 @@ /* ---- Floating Panels (experimental) ---- */ /* - * When `workbench.experimental.floatingPanels` is set, the primary side + * When `workbench.experimental.modernUI` (Modern UI Update) is set, the primary side * bar, secondary side bar and bottom panel are rendered as floating cards with a * margin, border and rounded corners — echoing the agents window design. The * matching content dimensions are reduced in code (AbstractPaneCompositePart) so diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index b287cf081caeb..ee371523d8318 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -105,7 +105,7 @@ export class ActivitybarPart extends Part { // Floating panels changes the reserved left/bottom gutter (and therefore // the fixed part width): signal the grid that the size constraint changed. - if (e.affectsConfiguration(LayoutSettings.FLOATING_PANELS)) { + if (e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this._onDidChange.fire(undefined); } })); diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index 870daad10df02..05bec26f27aa7 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -223,7 +223,7 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { // part height) for the main status bar only: signal the grid that the size // constraint changed. this._register(this.configurationService.onDidChangeConfiguration(e => { - if (this.getId() === Parts.STATUSBAR_PART && e.affectsConfiguration(LayoutSettings.FLOATING_PANELS)) { + if (this.getId() === Parts.STATUSBAR_PART && e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this._onDidChange.fire(undefined); } })); diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 9e631577682ed..f2a3b35e6eac4 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -807,11 +807,11 @@ const registry = Registry.as(ConfigurationExtensions.Con 'default': true, 'description': localize('shadows', "Controls whether shadow effects are shown around the side panels and other workbench elements.") }, - [LayoutSettings.FLOATING_PANELS]: { + [LayoutSettings.MODERN_UI]: { 'type': 'boolean', 'default': false, 'tags': ['experimental'], - 'description': localize('floatingPanels', "Controls whether the side bars and bottom panel are shown as floating cards with rounded corners and gaps, similar to the agents window design."), + 'description': localize('modernUI', "Controls whether the experimental Modern UI Update is enabled. When on, the side bars and bottom panel are shown as floating cards with rounded corners and gaps, and a set of refreshed workbench styles is applied, matching the Agents window design."), }, } }); @@ -1033,3 +1033,26 @@ Registry.as(Extensions.ConfigurationMigration) return result; } }]); + +// Migrate the previous standalone experiments (`workbench.experimental.floatingPanels` +// and `workbench.experimental.styleOverrides`) onto the consolidated +// `workbench.experimental.modernUI` toggle. Either of the old settings being on +// enables the unified Modern UI Update experiment. +Registry.as(Extensions.ConfigurationMigration) + .registerConfigurationMigrations([{ + key: 'workbench.experimental.floatingPanels', migrateFn: (value: unknown, valueAccessor) => { + const result: ConfigurationKeyValuePairs = [['workbench.experimental.floatingPanels', { value: undefined }]]; + if (value === true && valueAccessor(LayoutSettings.MODERN_UI) === undefined) { + result.push([LayoutSettings.MODERN_UI, { value: true }]); + } + return result; + } + }, { + key: 'workbench.experimental.styleOverrides', migrateFn: (value: unknown, valueAccessor) => { + const result: ConfigurationKeyValuePairs = [['workbench.experimental.styleOverrides', { value: undefined }]]; + if (Array.isArray(value) && value.length > 0 && valueAccessor(LayoutSettings.MODERN_UI) === undefined) { + result.push([LayoutSettings.MODERN_UI, { value: true }]); + } + return result; + } + }]); diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css index 9be0e34850e45..8695ade49d1da 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css @@ -15,7 +15,7 @@ * * All rules are gated behind the `.style-override-commandCenter` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when - * the `workbench.experimental.styleOverrides` setting includes "commandCenter". + * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ /* diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css index fb6f0fa9dc03b..208e73835ed0b 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css @@ -25,7 +25,7 @@ * * All rules are gated behind the `.style-override-fontRamp` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "fontRamp". Without + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. Without * that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css index 880dbd6fa2624..ff78c1cc2dd38 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css @@ -15,7 +15,7 @@ * * All rules are gated behind the `.style-override-keyboardFocusOnly` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "keyboardFocusOnly". + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * * Scoped to the panel surfaces (side bar, bottom panel, auxiliary bar, status * bar) so editor and global widget focus rings are unaffected. diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css index 6207f026add76..2e1ef09a34291 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css @@ -11,7 +11,7 @@ * * All rules are gated behind the `.style-override-padding` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "padding". + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css index 4f3e1a3b3c56d..da82c6d158d1e 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css @@ -17,7 +17,7 @@ * * All rules are gated behind the `.style-override-paneHeaders` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "paneHeaders". + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css index 37589163ed649..47e756af0f834 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css @@ -19,7 +19,7 @@ * * All rules are gated behind the `.style-override-roundedCorners` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when - * the `workbench.experimental.styleOverrides` setting includes "roundedCorners". + * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css index 88cef7ec9de45..0a96b9dabb0b2 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css @@ -22,7 +22,7 @@ * * All rules are gated behind the `.style-override-scrollShadows` class, which * the StyleOverridesContribution toggles onto the workbench container(s) when - * the `workbench.experimental.styleOverrides` setting includes "scrollShadows". + * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ /* diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 18d6122c6aac1..711927464d02d 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -11,8 +11,8 @@ * * All rules are gated behind the `.style-override-tabs` class, which the * StyleOverridesContribution toggles onto the workbench container(s) when the - * `workbench.experimental.styleOverrides` setting includes "tabs". Without that - * class these rules never apply. Mirrors the Agents window styling defined in + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. Mirrors + * the Agents window styling defined in * src/vs/sessions/browser/media/style.css. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts index c1b736c16ad14..f26669cd628f9 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts +++ b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts @@ -3,15 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../nls.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, LayoutSettings } from '../../../services/layout/browser/layoutService.js'; import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; // Bundle the CSS for every style-override module. Each file gates all of its // rules behind a `.style-override-` ancestor class, so the styles are inert @@ -27,12 +24,8 @@ import './media/scrollShadows.css'; import './media/statusBar.css'; import './media/tabs.css'; -const SETTING_ID = 'workbench.experimental.styleOverrides'; - interface IStyleOverrideModule { readonly id: string; - readonly label: string; - readonly description: string; /** * Whether this module changes layout-affecting CSS variables (e.g. the pane * header size). Toggling such a module requires a workbench relayout so the @@ -42,63 +35,22 @@ interface IStyleOverrideModule { } /** - * The fixed catalog of available style-override experiments. The CSS for each - * module ships with the product (imported above) and is gated behind the - * `.style-override-` class. Users can only enable modules from this list; - * arbitrary CSS cannot be injected. + * The fixed catalog of built-in style-override modules. The CSS for each module + * ships with the product (imported above) and is gated behind the + * `.style-override-` class. All modules are enabled together as part of the + * Modern UI Update experiment (`LayoutSettings.MODERN_UI`). */ const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ - { - id: 'activityBar', - label: localize('styleOverrides.activityBar', "Activity Bar"), - description: localize('styleOverrides.activityBar.description', "Replaces the active activity bar item's left highlight border with a rounded background behind the icon, and forces item foregrounds to the editor foreground so icons stay legible.") - }, - { - id: 'commandCenter', - label: localize('styleOverrides.commandCenter', "Agents Window Command Center"), - description: localize('styleOverrides.commandCenter.description', "Makes the command center and agent status input transparent at rest, revealing their background on hover to match the Agents window.") - }, - { - id: 'fontRamp', - label: localize('styleOverrides.fontRamp', "Font Ramp"), - description: localize('styleOverrides.fontRamp.description', "Applies a unified typographic ramp across the workbench: headings at 26/18px, 13px body, 12px section titles and tabs, 11px metadata and 10px badges.") - }, - { - id: 'keyboardFocusOnly', - label: localize('styleOverrides.keyboardFocusOnly', "Keyboard-Only Focus Borders"), - description: localize('styleOverrides.keyboardFocusOnly.description', "Hides focus borders that appear when panels are focused with the mouse, while keeping them for keyboard navigation.") - }, - { - id: 'padding', - label: localize('styleOverrides.padding', "Padding"), - description: localize('styleOverrides.padding.description', "Adds extra padding to view pane headers and bodies for more breathing room around content.") - }, - { - id: 'paneHeaders', - label: localize('styleOverrides.paneHeaders', "Pane Headers"), - description: localize('styleOverrides.paneHeaders.description', "Insets the view pane header separators, rounds their corners and adds a background tint on hover."), - layoutAffecting: true - }, - { - id: 'roundedCorners', - label: localize('styleOverrides.roundedCorners', "Rounded Corners"), - description: localize('styleOverrides.roundedCorners.description', "Applies a three-tier corner radius system: 8px for overlays (quick input, hovers, menus, dialogs), 6px for non-control containers and 4px for interactable controls (inputs, lists).") - }, - { - id: 'scrollShadows', - label: localize('styleOverrides.scrollShadows', "Scroll Shadows"), - description: localize('styleOverrides.scrollShadows.description', "Adds soft inset shadows to the top and bottom of lists, trees and the editor so content reads as scrolling under a fixed frame.") - }, - { - id: 'statusBar', - label: localize('styleOverrides.statusBar', "Status Bar"), - description: localize('styleOverrides.statusBar.description', "Forces the status bar foreground to the general editor foreground so its text and icons stay legible when the status bar background is neutralized.") - }, - { - id: 'tabs', - label: localize('styleOverrides.tabs', "Agents Window Tabs"), - description: localize('styleOverrides.tabs.description', "Styles editor tabs as transparent, rounded pills to match the Agents window.") - } + { id: 'activityBar' }, + { id: 'commandCenter' }, + { id: 'fontRamp' }, + { id: 'keyboardFocusOnly' }, + { id: 'padding' }, + { id: 'paneHeaders', layoutAffecting: true }, + { id: 'roundedCorners' }, + { id: 'scrollShadows' }, + { id: 'statusBar' }, + { id: 'tabs' } ]; function classNameFor(moduleId: string): string { @@ -106,20 +58,17 @@ function classNameFor(moduleId: string): string { } /** - * A development-oriented contribution that toggles built-in CSS style-override - * modules on or off based on the `workbench.experimental.styleOverrides` setting. - * - * Unlike a free-form CSS loader, the available styles are fixed and shipped with - * the product. The setting merely selects which of the predefined modules are - * active, so the styling itself cannot be changed by end users. + * A contribution that toggles the built-in CSS style-override modules on or off + * as a group, based on the `workbench.experimental.modernUI` setting. When the + * Modern UI Update experiment is enabled, all modules are applied together; + * otherwise none are. */ export class StyleOverridesContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.styleOverrides'; - private readonly knownModuleIds = new Set(STYLE_OVERRIDE_MODULES.map(m => m.id)); private readonly knownClassNames = STYLE_OVERRIDE_MODULES.map(m => classNameFor(m.id)); - private readonly layoutAffectingClassNames = new Set(STYLE_OVERRIDE_MODULES.filter(m => m.layoutAffecting).map(m => classNameFor(m.id))); + private readonly hasLayoutAffectingModule = STYLE_OVERRIDE_MODULES.some(m => m.layoutAffecting); /** Whether a layout-affecting module was active at the last applied selection. */ private layoutAffectingActive = false; @@ -135,11 +84,17 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench // A config change re-applies to every container (the global `update()` // covers all windows, including auxiliary ones). this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(SETTING_ID)) { + if (e.affectsConfiguration(LayoutSettings.MODERN_UI)) { this.update(); // Some modules drive layout-affecting CSS variables (e.g. the - // `paneHeaders` header size). Only relayout when one of those is - // toggled, to avoid an unnecessary full workbench relayout. + // `paneHeaders` header size) that the JS layout reads back, so a + // relayout is required once the classes are toggled. The base layout + // (`Layout`) also relayouts for this same setting, but its listener + // runs earlier (startup) than this contribution's (Restored phase), + // so that pass happens *before* these classes are applied and reads + // stale values. This relayout therefore runs last and is the + // authoritative one — do not remove it as "redundant". Guarded so it + // only fires when the enabled state actually flips. const layoutAffectingActive = this.hasActiveLayoutAffectingModule(); if (layoutAffectingActive !== this.layoutAffectingActive) { this.layoutAffectingActive = layoutAffectingActive; @@ -151,45 +106,30 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench // Apply the current selection to windows opened after startup (e.g. // auxiliary windows). Subsequent config changes are handled by `update()`. this._register(this.layoutService.onDidAddContainer(({ container }) => { - this.applyTo(container, this.activeClassNames()); + this.applyTo(container, this.isEnabled()); })); this.update(); } - private activeClassNames(): Set { - const selection = this.configurationService.getValue(SETTING_ID); - const active = new Set(); - if (Array.isArray(selection)) { - for (const id of selection) { - if (this.knownModuleIds.has(id)) { - active.add(classNameFor(id)); - } - } - } - return active; + private isEnabled(): boolean { + return this.configurationService.getValue(LayoutSettings.MODERN_UI) === true; } private hasActiveLayoutAffectingModule(): boolean { - const active = this.activeClassNames(); - for (const className of this.layoutAffectingClassNames) { - if (active.has(className)) { - return true; - } - } - return false; + return this.isEnabled() && this.hasLayoutAffectingModule; } private update(): void { - const active = this.activeClassNames(); + const enabled = this.isEnabled(); for (const container of this.layoutService.containers) { - this.applyTo(container, active); + this.applyTo(container, enabled); } } - private applyTo(container: HTMLElement, active: Set): void { + private applyTo(container: HTMLElement, enabled: boolean): void { for (const className of this.knownClassNames) { - container.classList.toggle(className, active.has(className)); + container.classList.toggle(className, enabled); } } @@ -204,23 +144,5 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench } } -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - ...workbenchConfigurationNodeBase, - properties: { - [SETTING_ID]: { - type: 'array', - items: { - type: 'string', - enum: STYLE_OVERRIDE_MODULES.map(m => m.id), - enumDescriptions: STYLE_OVERRIDE_MODULES.map(m => `${m.label}: ${m.description}`) - }, - uniqueItems: true, - default: [], - tags: ['experimental'], - markdownDescription: localize('styleOverrides', "Enables one or more built-in style-override modules that adjust the appearance of the workbench. Each module is a predefined set of styles that ships with the product; only modules from this list can be enabled and the styles themselves cannot be customized. Leave empty to disable all overrides. This is an experimental setting intended for trying out style ideas.") - } - } -}); - Registry.as(WorkbenchExtensions.Workbench) .registerWorkbenchContribution(StyleOverridesContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 395c0573026cc..c8b014a5e6c0f 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -50,15 +50,15 @@ export const enum LayoutSettings { COMMAND_CENTER = 'window.commandCenter', LAYOUT_ACTIONS = 'workbench.layoutControl.enabled', SHADOWS = 'workbench.shadows', - FLOATING_PANELS = 'workbench.experimental.floatingPanels' + MODERN_UI = 'workbench.experimental.modernUI' } /** - * The margin (in pixels) reserved on each side of a part when the floating - * panels experiment (`LayoutSettings.FLOATING_PANELS`) is enabled. Parts grow - * or shrink their content by this amount to leave room for the card margin and - * border applied in CSS (`part.css`, `.floating-panels`). This value must be - * kept in sync with the `--floating-panel-margin` custom property defined there. + * The margin (in pixels) reserved on each side of a part when the Modern UI Update + * experiment (`LayoutSettings.MODERN_UI`) is enabled. Parts grow or shrink their + * content by this amount to leave room for the margin/border applied in CSS + * (`src/vs/workbench/browser/media/floatingPanels.css`, `.floating-panels`). + * Keep in sync with the `--vscode-spacing-size60` (6px) token used there. */ export const FLOATING_PANEL_MARGIN = 6; @@ -225,9 +225,11 @@ export interface IWorkbenchLayoutService extends ILayoutService { hasFocus(part: Parts): boolean; /** - * Returns whether the floating panels experiment is enabled for this - * workbench. Always `false` for the agents window, which has its own floating - * card design and must not apply the experiment's content insets. + * Returns whether the floating panels presentation is enabled for this + * workbench, i.e. whether the Modern UI Update experiment + * (`LayoutSettings.MODERN_UI`) is on. Always `false` for the agents window, + * which has its own floating card design and must not apply the experiment's + * content insets. */ isFloatingPanelsEnabled(): boolean; diff --git a/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts index ff6ae3e61be9b..d20d907c18e4a 100644 --- a/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts +++ b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts @@ -74,7 +74,7 @@ suite('ActivitybarPart', () => { function createActivitybarPart(compact: boolean, floatingPanelsEnabled = false): { part: ActivitybarPart; configService: TestConfigurationService; layoutService: TestFloatingPanelsLayoutService } { const configService = new TestConfigurationService({ [LayoutSettings.ACTIVITY_BAR_COMPACT]: compact, - [LayoutSettings.FLOATING_PANELS]: floatingPanelsEnabled, + [LayoutSettings.MODERN_UI]: floatingPanelsEnabled, }); const storageService = disposables.add(new TestStorageService()); const themeService = new TestThemeService(); @@ -244,8 +244,8 @@ suite('ActivitybarPart', () => { disposables.add(part.onDidChange(e => events.push(e))); layoutService.floatingPanelsEnabled = true; - configService.setUserConfiguration(LayoutSettings.FLOATING_PANELS, true); - fireConfigChange(configService, LayoutSettings.FLOATING_PANELS); + configService.setUserConfiguration(LayoutSettings.MODERN_UI, true); + fireConfigChange(configService, LayoutSettings.MODERN_UI); assert.deepStrictEqual(events, [undefined]); assert.strictEqual(part.minimumWidth, ActivitybarPart.ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN); From 0c4d517850c203b0f8baad97af07ed90fb2a0f36 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:36:34 +0200 Subject: [PATCH 13/20] Agents - avoid flickering of the changes list when switching between changesets (#322094) --- .../browser/agentHostSessionChangesets.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index 35132c6bc365b..f03e8b9298e83 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -159,8 +159,18 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { private readonly _options: IAgentHostAdapterOptions, private readonly _dialogService: IDialogService, ) { + // Throttle only the changes path; `isLoadingChanges` and `operations` + // keep reading `this.changesetStateObs` directly so spinners/buttons stay + // responsive. Throttle (not debounce) so a continuous stream keeps + // updating instead of starving until edits stop. The `derived` wrapper + // defers reading the abstract `changesetStateObs` until the observable is + // actually observed (it isn't assigned yet during base construction). + const throttledChangesetStateObs = throttledObservable( + derived(reader => this.changesetStateObs.read(reader).read(reader)), + CHANGESET_UPDATE_THROTTLE_MS); + this.isLoadingChanges = derived(reader => { - const changesetState = this.changesetStateObs.read(reader).read(reader); + const changesetState = throttledChangesetStateObs.read(reader); // If the changeset state is `undefined`, it means that the first snapshot // has not yet arrived, so in order to avoid any flickering in the Changes @@ -182,19 +192,11 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { const mapDiffUri = this._options.mapDiffUri; - // Throttle only the changes path; `isLoadingChanges` and `operations` - // keep reading `this.changesetStateObs` directly so spinners/buttons stay - // responsive. Throttle (not debounce) so a continuous stream keeps - // updating instead of starving until edits stop. The `derived` wrapper - // defers reading the abstract `changesetStateObs` until the observable is - // actually observed (it isn't assigned yet during base construction). - const throttledChangesetValueObs = throttledObservable(derived(reader => this.changesetStateObs.read(reader).read(reader)), CHANGESET_UPDATE_THROTTLE_MS); - // Hold the raw `ChangesetFile[]` (with last-value semantics) so unchanged // files keep their reference across reducer updates, enabling the // per-file cache below to skip rebuilding them. const changesetFilesObs = derivedObservableWithCache(this, (reader, lastValue) => { - const changesetState = throttledChangesetValueObs.read(reader); + const changesetState = throttledChangesetStateObs.read(reader); if (changesetState === null || changesetState instanceof Error) { return []; } @@ -216,13 +218,11 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { // Build one change per file, reusing the cached result for files whose // `ChangesetFile` reference is unchanged so only changed files are // re-parsed and re-mapped. - const mappedChangesObs = mapObservableArrayCached(this, changesetFilesObs.map(files => files ?? []), file => changesetFileToChange(file, mapDiffUri)); + const mappedChangesObs = mapObservableArrayCached(this, + changesetFilesObs.map(files => files ?? []), + file => changesetFileToChange(file, mapDiffUri)); const changesObs = derived(this, reader => { - const files = changesetFilesObs.read(reader); - if (files === undefined) { - return undefined; - } return mappedChangesObs.read(reader).filter(isDefined); }); @@ -231,7 +231,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { }); const operationsObs = derivedObservableWithCache(this, (reader, lastValue) => { - const changesetState = this.changesetStateObs.read(reader).read(reader); + const changesetState = throttledChangesetStateObs.read(reader); if (changesetState === null || changesetState instanceof Error) { return []; } From ada77775af7085d99242fb08d0631ffd3bb010d2 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:38:03 +0000 Subject: [PATCH 14/20] fix: don't report failed web openTunnel as a bug assertion (fixes #321637) (#321644) Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/web.main.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index d2beac1382d64..ee42b29aad34c 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -5,7 +5,6 @@ import { mark } from '../../base/common/performance.js'; import { domContentLoaded, detectFullscreen, getCookieValue, getWindow } from '../../base/browser/dom.js'; -import { assertReturnsDefined } from '../../base/common/types.js'; import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; import { ILogService, ConsoleLogger, getLogLevel, ILoggerService, ILogger } from '../../platform/log/common/log.js'; import { ConsoleLogInAutomationLogger } from '../../platform/log/browser/log.js'; @@ -25,7 +24,7 @@ import { FileService } from '../../platform/files/common/fileService.js'; import { Schemas, connectionTokenCookieName } from '../../base/common/network.js'; import { IAnyWorkspaceIdentifier, IWorkspaceContextService, UNKNOWN_EMPTY_WINDOW_WORKSPACE, isTemporaryWorkspace, isWorkspaceIdentifier } from '../../platform/workspace/common/workspace.js'; import { IWorkbenchConfigurationService } from '../services/configuration/common/configuration.js'; -import { onUnexpectedError } from '../../base/common/errors.js'; +import { onUnexpectedError, ErrorNoTelemetry } from '../../base/common/errors.js'; import { setFullscreen } from '../../base/browser/browser.js'; import { URI, UriComponents } from '../../base/common/uri.js'; import { WorkspaceService } from '../services/configuration/browser/configurationService.js'; @@ -219,7 +218,7 @@ export class BrowserMain extends Disposable { await remoteAuthorityResolverService.resolveAuthority(this.configuration.remoteAuthority); }, openTunnel: async tunnelOptions => { - const tunnel = assertReturnsDefined(await remoteExplorerService.forward({ + const tunnel = await remoteExplorerService.forward({ remote: tunnelOptions.remoteAddress, local: tunnelOptions.localAddressPort, name: tunnelOptions.label, @@ -235,7 +234,11 @@ export class BrowserMain extends Disposable { onAutoForward: undefined, requireLocalPort: undefined, protocol: tunnelOptions.protocol === TunnelProtocol.Https ? tunnelOptions.protocol : TunnelProtocol.Http - })); + }); + + if (tunnel === undefined) { + throw new ErrorNoTelemetry(`Could not open tunnel to ${tunnelOptions.remoteAddress.host}:${tunnelOptions.remoteAddress.port}.`); + } if (typeof tunnel === 'string') { throw new Error(tunnel); From 85217f735f0e9132234b86461f1d30622fcac826 Mon Sep 17 00:00:00 2001 From: Justin Emery <102086785+cavalloJustinEmery@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:42:35 -0400 Subject: [PATCH 15/20] fix: plugin skill files not accessible when connected to remote (#309465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: plugin skill files not accessible when connected to remote When connected to an SSH/WSL/dev container remote, local file:// URIs for agent plugin skill files were being passed as native filesystem paths to the remote extension host, which can't read them. Fix getFilePath() to emit vscode-local:// URIs for local file:// paths when isRemote is true — the remote extension host can resolve these via the built-in local file bridge. Also fix _listExtensionPromptFiles to include plugin-storage files so they pass the trust check without triggering a confirmation dialog on every read. Fixes #305168 * fix: correct vscode-local URI scheme format in comments and test names * test: fix test failures on Windows CI and missing getConnection stub --------- Co-authored-by: Martin Aeschlimann --- .../chatPromptFilesContribution.ts | 7 ++++- .../computeAutomaticInstructions.ts | 12 +++++++-- .../computeAutomaticInstructions.test.ts | 27 +++++++++++++++++++ .../service/promptsService.test.ts | 1 + 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index dcf3d709148d4..47492f7b69fdc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -181,11 +181,16 @@ CommandsRegistry.registerCommand('_listExtensionPromptFiles', async (accessor): promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None), ]); - // Combine all files and collect extension-contributed ones + // Combine all files and collect extension- and plugin-contributed ones. + // Plugin files are included so the copilot extension can trust them and + // serve them to the LLM without a confirmation dialog when connected to a + // remote (where they are emitted as vscode-local:/... URIs). const result: IExtensionPromptFileResult[] = []; for (const file of [...agents, ...instructions, ...prompts, ...skills, ...hooks]) { if (file.storage === PromptsStorage.extension) { result.push({ uri: file.uri.toJSON(), type: file.type, extensionId: file.extension.identifier.value }); + } else if (file.storage === PromptsStorage.plugin) { + result.push({ uri: file.uri.toJSON(), type: file.type, extensionId: file.pluginUri.toString() }); } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 89a4d7ec81aac..73ad16f3249af 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -349,7 +349,8 @@ export class ComputeAutomaticInstructions { const remoteEnv = await this._remoteAgentService.getEnvironment(); const remoteOS = remoteEnv?.os; - const filePath = (uri: URI) => getFilePath(uri, remoteOS); + const isRemote = this._remoteAgentService.getConnection() !== null; + const filePath = (uri: URI) => getFilePath(uri, remoteOS, isRemote); const entries: string[] = []; if (fileReadTool) { @@ -632,7 +633,14 @@ export class ComputeAutomaticInstructions { } -export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined): string { +export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined, isRemote = false): string { + // When connected to a remote, local file:// URIs must be represented using + // the vscode-local scheme so the remote extension host can read them via the + // local file bridge. This works for WSL, SSH, and dev containers without + // any cache migration. + if (isRemote && uri.scheme === Schemas.file) { + return uri.with({ scheme: 'vscode-local' }).toString(); + } if (uri.scheme === Schemas.file || uri.scheme === Schemas.vscodeRemote) { const fsPath = uri.fsPath; // uri.fsPath uses the local OS's path separators, but the path diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 964cc7c15339b..8aba1c4e01eef 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -190,6 +190,7 @@ suite('ComputeAutomaticInstructions', () => { instaService.stub(IRemoteAgentService, { getEnvironment: () => Promise.resolve(null), + getConnection: () => null, }); instaService.stub(IContextKeyService, new MockContextKeyService()); @@ -2798,4 +2799,30 @@ suite('getFilePath', () => { const result = getFilePath(uri, undefined); assert.strictEqual(result, uri.fsPath); }); + + test('should return vscode-local:/ URI string for file:// URIs when connected to a remote', () => { + const uri = URI.file('/C:/Users/user/AppData/Roaming/agent-plugins/my-skill/SKILL.md'); + const result = getFilePath(uri, OperatingSystem.Linux, /* isRemote */ true); + assert.strictEqual(result, uri.with({ scheme: 'vscode-local' }).toString()); + }); + + test('should return vscode-local:/ URI string for file:// URIs when connected to a Windows remote', () => { + const uri = URI.file('/C:/Users/user/AppData/Roaming/agent-plugins/my-skill/SKILL.md'); + const result = getFilePath(uri, OperatingSystem.Windows, /* isRemote */ true); + assert.strictEqual(result, uri.with({ scheme: 'vscode-local' }).toString()); + }); + + test('should not convert file:// URIs to vscode-local:/ when not connected to a remote', () => { + const uri = URI.file('/home/user/.copilot/agent-plugins/my-skill/SKILL.md'); + const result = getFilePath(uri, undefined, /* isRemote */ false); + assert.strictEqual(result, uri.fsPath); + }); + + test('should not convert vscode-remote:// URIs when connected to a remote', () => { + const uri = URI.from({ scheme: Schemas.vscodeRemote, authority: 'wsl+ubuntu', path: '/home/user/project/file.ts' }); + const result = getFilePath(uri, OperatingSystem.Linux, /* isRemote */ true); + // Do not use uri.fsPath here — it is host-OS-dependent and returns + // backslashes on Windows CI, but the function normalizes to the remote OS. + assert.strictEqual(result, '/home/user/project/file.ts'); + }); }); 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 03be925a35d76..5c5ccd5800e75 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 @@ -194,6 +194,7 @@ suite('PromptsService', () => { instaService.stub(IRemoteAgentService, { getEnvironment: () => Promise.resolve(null), + getConnection: () => null, }); instaService.stub(IContextKeyService, new MockContextKeyService()); From f736b7d66de354b772ede41ae611a5fe91c926a9 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:52:30 -0700 Subject: [PATCH 16/20] Update strictKnownMarketplaces managed setting to support rich types (#322025) * Support rich strictKnwonMarketPlaces managed setting * config update * Address PR review: validate allowlist entries, honor url ref, fix docs * add test and update policy * fix test * update ux --- build/lib/policies/objectPolicy.ts | 6 +- build/lib/policies/policyData.jsonc | 39 +++-- build/lib/test/policyConversion.test.ts | 16 +- src/vs/base/common/managedSettings.ts | 19 +++ .../configuration/common/configurations.ts | 14 +- .../policy/common/copilotManagedSettings.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 28 ++- .../chat/browser/pluginInstallService.ts | 17 ++ .../common/plugins/agentPluginServiceImpl.ts | 62 +------ .../plugins/pluginMarketplaceService.ts | 30 ++-- .../common/plugins/strictKnownMarketplaces.ts | 160 ++++++++++++++++++ .../plugins/pluginInstallService.test.ts | 4 + .../plugins/strictKnownMarketplaces.test.ts | 106 ++++++++++++ .../accounts/browser/managedSettings.ts | 27 +-- .../test/browser/managedSettings.test.ts | 25 ++- .../browser/multiplexPolicyService.test.ts | 59 +++++++ 16 files changed, 501 insertions(+), 113 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/plugins/strictKnownMarketplaces.ts create mode 100644 src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts diff --git a/build/lib/policies/objectPolicy.ts b/build/lib/policies/objectPolicy.ts index b565b06e8bb3c..27638a68f4bbf 100644 --- a/build/lib/policies/objectPolicy.ts +++ b/build/lib/policies/objectPolicy.ts @@ -13,7 +13,11 @@ export class ObjectPolicy extends BasePolicy { static from(category: CategoryDto, policy: PolicyDto): ObjectPolicy | undefined { const { type, name, minimumVersion, localization } = policy; - if (type !== 'object' && type !== 'array') { + // `type` may be a single type or a union (e.g. `['array', 'null']`, where `null` + // models the unset default). Object- and array-typed settings are carried as JSON + // and render as a multiText element. + const types = Array.isArray(type) ? type : [type]; + if (!types.includes('object') && !types.includes('array')) { return undefined; } diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 764e3e8966585..e1bf0baae55dd 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -128,6 +128,21 @@ "default": "*", "included": true }, + { + "key": "chat.agentHost.enabled", + "name": "ChatAgentHostEnabled", + "category": "InteractiveSession", + "minimumVersion": "1.126", + "localization": { + "description": { + "key": "chat.agentHost.enabled", + "value": "When enabled, some agents run in a separate agent host process." + } + }, + "type": "boolean", + "default": true, + "included": true + }, { "key": "chat.agentHost.codexAgent.enabled", "name": "Codex3PIntegration", @@ -237,21 +252,6 @@ "default": true, "included": true }, - { - "key": "chat.agentHost.enabled", - "name": "ChatAgentHostEnabled", - "category": "InteractiveSession", - "minimumVersion": "1.126", - "localization": { - "description": { - "key": "chat.agentHost.enabled", - "value": "When enabled, some agents run in a separate agent host process." - } - }, - "type": "boolean", - "default": true, - "included": true - }, { "key": "chat.plugins.enabled", "name": "ChatPluginsEnabled", @@ -290,11 +290,14 @@ "localization": { "description": { "key": "chat.plugins.strictMarketplaces.policy", - "value": "Only trust marketplaces supplied via enterprise policy; plugins from any other marketplace will not load." + "value": "Allowlist of plugin marketplace sources. When set, only marketplaces matching an entry are trusted; an empty array blocks all marketplaces." } }, - "type": "boolean", - "default": false, + "type": [ + "array", + "null" + ], + "default": null, "included": true }, { diff --git a/build/lib/test/policyConversion.test.ts b/build/lib/test/policyConversion.test.ts index 5c3f75e958655..434d038e0d6d3 100644 --- a/build/lib/test/policyConversion.test.ts +++ b/build/lib/test/policyConversion.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { suite, test } from 'node:test'; import { promises as fs } from 'fs'; import path from 'path'; -import type { ExportedPolicyDataDto, CategoryDto } from '../policies/policyDto.ts'; +import type { ExportedPolicyDataDto, CategoryDto, PolicyDto } from '../policies/policyDto.ts'; import { BooleanPolicy } from '../policies/booleanPolicy.ts'; import { NumberPolicy } from '../policies/numberPolicy.ts'; import { ObjectPolicy } from '../policies/objectPolicy.ts'; @@ -520,4 +520,18 @@ suite('Policy E2E conversion', () => { assert.ok(parsed.length > 0, 'Should parse at least one policy from policyData.jsonc'); }); + test('ObjectPolicy.from accepts a union type (e.g. array | null)', () => { + const category: CategoryDto = { key: 'Extensions', name: { key: 'Extensions', value: 'Extensions' } }; + const policy: PolicyDto = { + key: 'chat.plugins.strictMarketplaces', + name: 'ChatStrictMarketplaces', + category: 'Extensions', + minimumVersion: '1.0', + localization: { description: { key: 'desc', value: 'desc' } }, + type: ['array', 'null'], + default: null, + }; + assert.ok(ObjectPolicy.from(category, policy), 'A union array|null type should be classified as an object policy'); + }); + }); diff --git a/src/vs/base/common/managedSettings.ts b/src/vs/base/common/managedSettings.ts index 53edd6b2cd5b6..56e99e0ef0b7f 100644 --- a/src/vs/base/common/managedSettings.ts +++ b/src/vs/base/common/managedSettings.ts @@ -11,6 +11,25 @@ export type IExtraKnownMarketplaceEntry = | { readonly name: string; readonly source: { readonly source: 'github'; readonly repo: string; readonly ref?: string } } | { readonly name: string; readonly source: { readonly source: 'git'; readonly url: string; readonly ref?: string } }; +/** + * A single entry in the enterprise-managed `strictKnownMarketplaces` allowlist + * (the `chat.plugins.strictMarketplaces` setting), a discriminated union on + * `source`. Delivered as JSON via managed settings (the server endpoint or + * native MDM) and validated at match time, so the optional fields are only + * meaningful for their corresponding `source`. + */ +export interface IStrictMarketplaceSource { + readonly source: 'github' | 'git' | 'url' | 'npm' | 'file' | 'directory' | 'hostPattern' | 'pathPattern'; + readonly repo?: string; + readonly url?: string; + readonly ref?: string; + readonly path?: string; + readonly package?: string; + readonly hostPattern?: string; + readonly pathPattern?: string; + readonly headers?: Readonly>; +} + /** * Converts an {@link IExtraKnownMarketplaceEntry} array into the * `{ [name]: url-or-shorthand }` dict stored on the `chat.plugins.extraMarketplaces` diff --git a/src/vs/platform/configuration/common/configurations.ts b/src/vs/platform/configuration/common/configurations.ts index b43fdcb443eb1..0a2e5ca0e6f35 100644 --- a/src/vs/platform/configuration/common/configurations.ts +++ b/src/vs/platform/configuration/common/configurations.ts @@ -130,11 +130,15 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat } private toPolicyDefinitionType(configType: unknown, policyName: PolicyName): 'string' | 'number' | 'boolean' | undefined { - if (configType !== 'string' && configType !== 'number' && configType !== 'array' && configType !== 'object' && configType !== 'boolean') { + // `configType` may be a single type or a union (e.g. `['array', 'null']`). + // Normalize to an array and keep only the types we can represent as policies. + const configTypes = Array.isArray(configType) ? configType : [configType]; + const supportedTypes = configTypes.filter(type => type === 'string' || type === 'number' || type === 'array' || type === 'object' || type === 'boolean'); + if (supportedTypes.length === 0) { this.logService.warn(`PolicyConfiguration#updatePolicyDefinitions - policy '${policyName}' has unsupported type '${configType}'`); return undefined; } - return configType === 'number' ? 'number' : configType === 'boolean' ? 'boolean' : 'string'; + return supportedTypes.includes('number') ? 'number' : supportedTypes.includes('boolean') ? 'boolean' : 'string'; } private async updatePolicyDefinitions(properties: string[]): Promise { @@ -240,7 +244,11 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat const policyName = property?.policy?.name ?? property?.policyReference?.name; if (policyName) { let policyValue: PolicyValue | ParsedType | undefined = this.policyService.getPolicyValue(policyName); - if (isString(policyValue) && property.type !== 'string') { + // `property.type` may be a single type or a union (e.g. `['array', 'null']`). + // A string policy value carries a JSON payload that must be parsed unless the + // setting itself is (or can be) a plain string. + const acceptsStringType = Array.isArray(property.type) ? property.type.includes('string') : property.type === 'string'; + if (isString(policyValue) && !acceptsStringType) { try { policyValue = this.parse(policyValue); } catch (e) { diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 47a2c71db6d7d..700589f1ef192 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -29,7 +29,7 @@ export const COPILOT_ENABLED_PLUGINS_KEY = 'enabledPlugins'; /** Managed-settings key for enterprise marketplaces (carried as a JSON-encoded `{ [name]: url-or-shorthand }`). */ export const COPILOT_EXTRA_MARKETPLACES_KEY = 'extraKnownMarketplaces'; -/** Managed-settings key for the strict-marketplace flag (boolean). */ +/** Managed-settings key for the strict-marketplace allowlist (carried as a JSON-encoded array of source entries; absent = no restrictions, `[]` = lockdown). */ export const COPILOT_STRICT_MARKETPLACES_KEY = 'strictKnownMarketplaces'; export const ICopilotManagedSettingsService = createDecorator('copilotManagedSettingsService'); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 963845986e859..e1449fc6d4193 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1051,9 +1051,27 @@ configurationRegistry.registerConfiguration({ }, }, [ChatConfiguration.StrictMarketplaces]: { - type: 'boolean', - markdownDescription: nls.localize('chat.plugins.strictMarketplaces', "When enabled, only marketplaces supplied via enterprise policy are trusted. Plugins from any other marketplace will not load."), - default: false, + type: ['array', 'null'], + items: { + type: 'object', + properties: { + source: { + type: 'string', + enum: ['github', 'git', 'url', 'npm', 'file', 'directory', 'hostPattern', 'pathPattern'], + }, + repo: { type: 'string' }, + url: { type: 'string' }, + ref: { type: 'string' }, + path: { type: 'string' }, + package: { type: 'string' }, + hostPattern: { type: 'string' }, + pathPattern: { type: 'string' }, + headers: { type: 'object', additionalProperties: { type: 'string' } }, + }, + required: ['source'], + }, + markdownDescription: nls.localize('chat.plugins.strictMarketplaces', "Enterprise-managed allowlist of plugin marketplace sources. When set, only marketplaces matching one of these entries can be installed; an empty array blocks all marketplaces. This does not retroactively disable already-installed plugins. Each entry is an object with a `source` discriminator (`github`, `git`, `url`, `npm`, `file`, `directory`, `hostPattern`, or `pathPattern`) and the corresponding fields. Typically delivered via enterprise policy."), + default: null, restricted: true, scope: ConfigurationScope.APPLICATION, tags: ['experimental'], @@ -1063,12 +1081,12 @@ configurationRegistry.registerConfiguration({ minimumVersion: '1.122', value: (policyData) => policyData.managedSettings?.[COPILOT_STRICT_MARKETPLACES_KEY], managedSettings: { - [COPILOT_STRICT_MARKETPLACES_KEY]: { type: 'boolean' }, + [COPILOT_STRICT_MARKETPLACES_KEY]: { type: 'string' }, }, localization: { description: { key: 'chat.plugins.strictMarketplaces.policy', - value: nls.localize('chat.plugins.strictMarketplaces.policy', "Only trust marketplaces supplied via enterprise policy; plugins from any other marketplace will not load."), + value: nls.localize('chat.plugins.strictMarketplaces.policy', "Allowlist of plugin marketplace sources. When set, only marketplaces matching an entry are trusted; an empty array blocks all marketplaces."), } }, }, diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index ec8c55f1ad625..46cc7a297abad 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -423,6 +423,23 @@ export class PluginInstallService implements IPluginInstallService { return true; } + // Under the strict-marketplace enterprise policy, a marketplace that is not + // on the allowlist is blocked outright — the user cannot grant trust to + // bypass it. Surface a non-actionable enterprise-policy notification that + // points at the managed setting (shown as "Managed by organization"). + if (this._pluginMarketplaceService.isStrictMarketplacePolicyActive()) { + this._notificationService.notify({ + severity: Severity.Warning, + message: localize('strictMarketplaceBlockedInstall', "Plugins from '{0}' are blocked by your organization's policy.", plugin.marketplaceReference.displayLabel), + actions: { + primary: [new Action('chat.plugins.viewMarketplacePolicy', localize('viewPolicySettings', "View Policy Settings"), undefined, true, () => { + return this._commandService.executeCommand('workbench.action.openSettings', ChatConfiguration.StrictMarketplaces); + })], + }, + }); + return false; + } + const { confirmed } = await this._dialogService.confirm({ type: 'question', message: localize('trustMarketplace', "Trust Plugins from '{0}'?", plugin.marketplaceReference.displayLabel), diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index c114aba6974c8..050d6678edb62 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -11,7 +11,7 @@ import { untildify } from '../../../../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { equals } from '../../../../../base/common/objects.js'; -import { autorun, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, observableSignalFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, ISettableObservable, ITransaction, observableFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; import { posix, win32 @@ -54,7 +54,7 @@ import { HookType } from '../promptSyntax/hookTypes.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService } from './agentPluginService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; -import { type IMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; +import { type IMarketplaceReference } from './marketplaceReference.js'; // Re-export shared helpers so existing consumers (including tests) continue to work. export { shellQuotePluginRootInCommand, resolveMcpServersMap, convertBareEnvVarsToVsCodeSyntax } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -98,7 +98,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IStorageService storageService: IStorageService, - @IPluginMarketplaceService pluginMarketplaceService: IPluginMarketplaceService, @ILogService logService: ILogService, ) { super(); @@ -122,11 +121,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.EnabledPlugins)), () => configurationService.inspect>(ChatConfiguration.EnabledPlugins).policyValue, ); - const strictMarketplaces = observableConfigValue(ChatConfiguration.StrictMarketplaces, false, configurationService); - // Re-evaluate marketplace trust when the policy-delivered extra - // marketplaces change (consulted under strict mode). - const extraMarketplacesChanged = observableSignalFromEvent('extraMarketplaces', - Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.ExtraMarketplaces))); this.plugins = derived(read => { if (!pluginsEnabled.read(read)) { @@ -141,14 +135,9 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic this._register(autorun(reader => { const plugins = this.plugins.read(reader); const policy = enabledPluginsPolicy.read(reader); - const strict = strictMarketplaces.read(reader); - extraMarketplacesChanged.read(reader); - const trustedExtras = strict - ? parseMarketplaceReferences(readConfiguredMarketplaces(configurationService).extraValues) - : []; transaction(tx => { for (const plugin of plugins) { - setPolicyBlocked(plugin, this._isBlockedByPolicy(plugin, policy, strict, trustedExtras, pluginMarketplaceService, logService), tx); + setPolicyBlocked(plugin, this._isBlockedByPolicy(plugin, policy, logService), tx); } }); })); @@ -158,22 +147,18 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic * Determines whether a plugin is blocked by enterprise policy: * - If `chat.plugins.enabledPlugins` (policy-managed via `ChatEnabledPlugins`) * is set, only plugins whose ID appears with value `true` are allowed. - * - If `chat.plugins.strictMarketplaces` is on, only plugins from a - * marketplace listed in `chat.plugins.extraMarketplaces` are allowed. + * + * Marketplace trust (`chat.plugins.strictMarketplaces`) is enforced at + * install time only, already-installed plugins are not + * retroactively unloaded. * * Plugins without a marketplace provenance (e.g. user-configured filesystem * paths from `chat.pluginLocations`) are never blocked — they are user-side - * concerns outside the enterprise enforcement boundary. Copilot-CLI-installed - * plugins under `~/.copilot/installed-plugins///` are - * gated using their install-path identity even when they lack rich - * marketplace metadata. + * concerns outside the enterprise enforcement boundary. */ private _isBlockedByPolicy( plugin: IAgentPlugin, enabledPluginsPolicy: Record | undefined, - strictMarketplaces: boolean, - trustedExtras: readonly IMarketplaceReference[], - pluginMarketplaceService: IPluginMarketplaceService, logService: ILogService, ): boolean { const identity = getPolicyIdentity(plugin); @@ -187,15 +172,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic return true; } } - if (strictMarketplaces) { - const trusted = identity.marketplaceReference - ? pluginMarketplaceService.isMarketplaceTrusted(identity.marketplaceReference) - : isCliBucketTrusted(identity.marketplace, trustedExtras); - if (!trusted) { - logService.debug(`[AgentPluginService] Plugin '${pluginId}' blocked — marketplace not trusted under strict mode`); - return true; - } - } return false; } @@ -280,28 +256,6 @@ function getPolicyIdentity(plugin: IAgentPlugin): IPolicyIdentity | undefined { return { name, marketplace }; } -/** - * Under strict mode, decide whether a Copilot-CLI-installed plugin's bucket - * name (the `` segment of its install path) corresponds to a - * trusted marketplace listed in `chat.plugins.extraMarketplaces`. Uses - * heuristics covering common CLI bucket-naming conventions (GitHub repo name, - * shorthand `owner/repo`, raw reference value, canonical id tail). - */ -function isCliBucketTrusted(bucket: string, trustedExtras: readonly IMarketplaceReference[]): boolean { - return trustedExtras.some(ref => { - if (ref.githubRepo && ref.githubRepo.split('/').pop() === bucket) { - return true; - } - if (ref.displayLabel === bucket || ref.displayLabel.endsWith(`/${bucket}`)) { - return true; - } - if (ref.canonicalId.endsWith(`/${bucket}`) || ref.canonicalId.endsWith(`/${bucket}.git`)) { - return true; - } - return false; - }); -} - /** * Minimal shape of a parsed plugin manifest. Known fields are typed; unknown * keys (e.g. `commands`, `skills`, `hooks`, `mcpServers`) remain `unknown` and diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index d9fdab4cc3846..761c0e2b2da8b 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -29,6 +29,7 @@ import { FileBackedInstalledPluginsStore, IStoredInstalledPlugin } from './fileB import { IWorkspacePluginSettingsService } from './workspacePluginSettingsService.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { type IMarketplaceReference, deduplicateMarketplaceReferences, MarketplaceReferenceKind, parseMarketplaceObjectEntry, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; +import { getStrictKnownMarketplaces, isMarketplaceReferenceAllowed } from './strictKnownMarketplaces.js'; // Re-export marketplace reference types for downstream consumers. export { deduplicateMarketplaceReferences, extraKnownMarketplacesToConfigDict, MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences, readConfiguredMarketplaces } from './marketplaceReference.js'; @@ -171,8 +172,14 @@ export interface IPluginMarketplaceService { getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined; addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void; removeInstalledPlugin(pluginUri: URI): void; - /** Returns whether the given marketplace has been explicitly trusted by the user. */ + /** Returns whether the given marketplace is trusted — either explicitly trusted by the user, or allowed by the enterprise allowlist when strict mode is active. */ isMarketplaceTrusted(ref: IMarketplaceReference): boolean; + /** + * Returns whether the strict-marketplace enterprise policy + * (`chat.plugins.strictMarketplaces`) is active — i.e. an allowlist is + * configured. When active, blocked marketplaces cannot be trusted by the user. + */ + isStrictMarketplacePolicyActive(): boolean; /** Records that the user trusts the given marketplace, persisted permanently. */ trustMarketplace(ref: IMarketplaceReference): void; /** @@ -613,19 +620,22 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } isMarketplaceTrusted(ref: IMarketplaceReference): boolean { - // In strict mode (`chat.plugins.strictMarketplaces`, typically enabled via the - // `ChatStrictMarketplaces` enterprise policy), trust is restricted to - // marketplaces in `chat.plugins.extraMarketplaces` — the policy-only slot. - // User-configured entries in `chat.plugins.marketplaces` do NOT grant trust - // under strict mode; that's the whole point of "strict" — the enterprise - // fully controls the allowed marketplaces. - if (this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)) { - const refs = parseMarketplaceReferences(readConfiguredMarketplaces(this._configurationService).extraValues); - return refs.some(r => r.canonicalId === ref.canonicalId); + // In strict mode (`chat.plugins.strictMarketplaces`, typically delivered via the + // `ChatStrictMarketplaces` enterprise policy), trust is governed entirely by the + // allowlist: a marketplace is trusted only if it matches one of the configured + // source entries. The user-trusted store is bypassed — that's the whole point of + // "strict": the enterprise fully controls the allowed marketplaces. + const allowlist = getStrictKnownMarketplaces(this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)); + if (allowlist !== undefined) { + return isMarketplaceReferenceAllowed(allowlist, ref); } return this._trustedMarketplacesStore.get().includes(ref.canonicalId); } + isStrictMarketplacePolicyActive(): boolean { + return getStrictKnownMarketplaces(this._configurationService.getValue(ChatConfiguration.StrictMarketplaces)) !== undefined; + } + // --- Plugin metadata hydration ----------------------------------------------- /** diff --git a/src/vs/workbench/contrib/chat/common/plugins/strictKnownMarketplaces.ts b/src/vs/workbench/contrib/chat/common/plugins/strictKnownMarketplaces.ts new file mode 100644 index 0000000000000..789f0648333f8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/strictKnownMarketplaces.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStrictMarketplaceSource } from '../../../../../base/common/managedSettings.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IMarketplaceReference, MarketplaceReferenceKind, parseMarketplaceReference } from './marketplaceReference.js'; + +/** + * The allowlist entry type for `chat.plugins.strictMarketplaces`. Re-exported + * from `base/common` so chat plugin code can import it alongside the matcher it + * operates with; the same type is consumed by the managed-settings adapter. + */ +export type { IStrictMarketplaceSource }; + +/** + * The value of the `chat.plugins.strictMarketplaces` allowlist. + * - `undefined`: no restrictions — all marketplaces are allowed. + * - `[]`: complete lockdown — no marketplace is allowed. + * - non-empty array: only marketplaces matching an entry are allowed. + */ +export type StrictKnownMarketplaces = readonly IStrictMarketplaceSource[]; + +/** + * Coerces a resolved configuration value into a {@link StrictKnownMarketplaces} + * allowlist. Returns `undefined` (meaning "no restrictions") when the value is + * not an array — which is also how an unset policy surfaces (the registered + * `null` default). Malformed entries (non-objects or entries without a string + * `source`) are dropped so a bad managed-settings payload degrades to "no match" + * rather than throwing during matching. + */ +export function getStrictKnownMarketplaces(value: unknown): StrictKnownMarketplaces | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((entry): entry is IStrictMarketplaceSource => + typeof entry === 'object' && entry !== null && typeof entry.source === 'string'); +} + +/** + * Checks whether a marketplace reference is allowed by the strict allowlist. + * + * @param allowlist `undefined` allows everything, `[]` blocks everything, + * otherwise the reference must match at least one entry. + */ +export function isMarketplaceReferenceAllowed(allowlist: StrictKnownMarketplaces | undefined, ref: IMarketplaceReference): boolean { + if (allowlist === undefined) { + return true; + } + if (allowlist.length === 0) { + return false; + } + return allowlist.some(entry => matchesAllowlistEntry(entry, ref)); +} + +function matchesAllowlistEntry(entry: IStrictMarketplaceSource, ref: IMarketplaceReference): boolean { + switch (entry.source) { + case 'github': { + // VS Code marketplace references do not model an in-repo path, so an + // entry that pins a `path` can never match. + if (typeof entry.repo !== 'string' || entry.path !== undefined) { + return false; + } + const candidate = parseMarketplaceReference(appendRef(entry.repo, entry.ref)); + return !!candidate && candidate.canonicalId === ref.canonicalId; + } + case 'git': { + if (typeof entry.url !== 'string' || entry.path !== undefined) { + return false; + } + const candidate = parseMarketplaceReference(appendRef(entry.url, entry.ref)); + return !!candidate && candidate.canonicalId === ref.canonicalId; + } + case 'url': { + if (typeof entry.url !== 'string') { + return false; + } + const candidate = parseMarketplaceReference(appendRef(entry.url, entry.ref)); + return !!candidate && candidate.canonicalId === ref.canonicalId; + } + case 'npm': { + // npm is not a supported marketplace source in VS Code. + return false; + } + case 'file': + case 'directory': { + if (ref.kind !== MarketplaceReferenceKind.LocalFileUri || !ref.localRepositoryUri || typeof entry.path !== 'string') { + return false; + } + // Note: `~` is not expanded here (no home-dir resolution in the common + // layer); enterprise allowlists should use absolute paths. + return isEqual(ref.localRepositoryUri, URI.file(entry.path)); + } + case 'hostPattern': { + if (typeof entry.hostPattern !== 'string') { + return false; + } + const host = extractHost(ref); + return !!host && testPattern(entry.hostPattern, host); + } + case 'pathPattern': { + if (typeof entry.pathPattern !== 'string' || ref.kind !== MarketplaceReferenceKind.LocalFileUri || !ref.localRepositoryUri) { + return false; + } + return testPattern(entry.pathPattern, ref.localRepositoryUri.fsPath); + } + default: + return false; + } +} + +/** Appends (or replaces) a `#ref` fragment on a marketplace value. */ +function appendRef(value: string, ref: string | undefined): string { + if (!ref) { + return value; + } + const fragmentIndex = value.indexOf('#'); + const base = fragmentIndex === -1 ? value : value.slice(0, fragmentIndex); + return `${base}#${ref}`; +} + +/** Extracts the host of a marketplace reference for `hostPattern` matching. */ +function extractHost(ref: IMarketplaceReference): string | undefined { + if (ref.kind === MarketplaceReferenceKind.GitHubShorthand) { + return 'github.com'; + } + if (ref.kind !== MarketplaceReferenceKind.GitUri) { + return undefined; + } + // scp-style git URLs: `git@host:path`. + const scpMatch = /^[\w._-]+@([\w.-]+):/.exec(ref.cloneUrl); + if (scpMatch) { + return scpMatch[1].toLowerCase(); + } + try { + let authority = URI.parse(ref.cloneUrl).authority.toLowerCase(); + const at = authority.lastIndexOf('@'); + if (at !== -1) { + authority = authority.slice(at + 1); + } + const colon = authority.indexOf(':'); + if (colon !== -1) { + authority = authority.slice(0, colon); + } + return authority || undefined; + } catch { + return undefined; + } +} + +/** Tests a regex pattern against a value, treating invalid patterns as non-matching. */ +function testPattern(pattern: string, value: string): boolean { + try { + return new RegExp(pattern).test(value); + } catch { + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index ebd0ea996896e..a0b7f92a14e4d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -68,6 +68,8 @@ suite('PluginInstallService', () => { updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[]; /** Whether the marketplace is already trusted */ marketplaceTrusted: boolean; + /** Whether the strict-marketplace enterprise policy is active */ + strictMarketplacePolicyActive?: boolean; /** Canonical IDs that were trusted via trustMarketplace() */ trustedMarketplaces: string[]; /** Plugins returned by readPluginsFromDirectory */ @@ -99,6 +101,7 @@ suite('PluginInstallService', () => { pullRepositoryCalls: [], updatePluginSourceCalls: [], marketplaceTrusted: true, + strictMarketplacePolicyActive: false, trustedMarketplaces: [], readPluginsResult: [], singlePluginManifestResult: undefined, @@ -269,6 +272,7 @@ suite('PluginInstallService', () => { state.addedPlugins.push({ uri: uri.toString(), plugin }); }, isMarketplaceTrusted: () => state.marketplaceTrusted, + isStrictMarketplacePolicyActive: () => state.strictMarketplacePolicyActive ?? false, trustMarketplace: (ref: IMarketplaceReference) => { state.trustedMarketplaces.push(ref.canonicalId); }, diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts new file mode 100644 index 0000000000000..e29c4bb09aaca --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/plugins/strictKnownMarketplaces.test.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { parseMarketplaceReference } from '../../../common/plugins/marketplaceReference.js'; +import { getStrictKnownMarketplaces, isMarketplaceReferenceAllowed, IStrictMarketplaceSource } from '../../../common/plugins/strictKnownMarketplaces.js'; + +suite('strictKnownMarketplaces', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function ref(value: string) { + const parsed = parseMarketplaceReference(value); + assert.ok(parsed, `expected '${value}' to parse`); + return parsed!; + } + + test('getStrictKnownMarketplaces coerces non-arrays to undefined', () => { + assert.strictEqual(getStrictKnownMarketplaces(null), undefined); + assert.strictEqual(getStrictKnownMarketplaces(undefined), undefined); + assert.strictEqual(getStrictKnownMarketplaces(true), undefined); + assert.strictEqual(getStrictKnownMarketplaces('[]'), undefined); + assert.deepStrictEqual(getStrictKnownMarketplaces([]), []); + }); + + test('getStrictKnownMarketplaces drops malformed entries', () => { + const value = [ + { source: 'github', repo: 'owner/repo' }, + null, + 42, + 'github', + { repo: 'owner/repo' }, // missing source + ]; + assert.deepStrictEqual(getStrictKnownMarketplaces(value), [{ source: 'github', repo: 'owner/repo' }]); + }); + + test('undefined allowlist allows everything; empty allowlist blocks everything', () => { + const r = ref('microsoft/vscode'); + assert.strictEqual(isMarketplaceReferenceAllowed(undefined, r), true); + assert.strictEqual(isMarketplaceReferenceAllowed([], r), false); + }); + + test('github entry matches shorthand and equivalent URI forms', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'microsoft/vscode-team-kit' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('microsoft/vscode-team-kit')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('https://github.com/microsoft/vscode-team-kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('git@github.com:microsoft/vscode-team-kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('microsoft/other-repo')), false); + }); + + test('github entry ref must match exactly (tri-state)', () => { + const pinned: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo', ref: 'main' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo#main')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo')), false); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('owner/repo#dev')), false); + + const unpinned: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(unpinned, ref('owner/repo')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(unpinned, ref('owner/repo#main')), false); + }); + + test('github entry with a path never matches (paths are not modeled)', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'github', repo: 'owner/repo', path: 'sub' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('owner/repo')), false); + }); + + test('git entry matches a git URL reference', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'git', url: 'https://example.com/team/kit.git' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('https://example.com/team/kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('https://example.com/team/other.git')), false); + }); + + test('url entry honors a pinned ref', () => { + const pinned: IStrictMarketplaceSource[] = [{ source: 'url', url: 'https://example.com/team/kit.git', ref: 'main' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('https://example.com/team/kit.git#main')), true); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('https://example.com/team/kit.git')), false); + assert.strictEqual(isMarketplaceReferenceAllowed(pinned, ref('https://example.com/team/kit.git#dev')), false); + }); + + test('npm entries never match', () => { + const allowlist: IStrictMarketplaceSource[] = [{ source: 'npm', package: 'whatever' }]; + assert.strictEqual(isMarketplaceReferenceAllowed(allowlist, ref('owner/repo')), false); + }); + + test('file entry matches a local file URI reference', () => { + const r = ref('file:///opt/approved/marketplace'); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'file', path: r.localRepositoryUri!.fsPath }], r), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'directory', path: r.localRepositoryUri!.fsPath }], r), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'file', path: '/opt/unapproved' }], r), false); + }); + + test('hostPattern matches by host; invalid regex is treated as non-matching', () => { + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '^github\\.com$' }], ref('microsoft/vscode')), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '\\.internal\\.example\\.com$' }], ref('https://plugins.internal.example.com/team/kit.git')), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '^github\\.com$' }], ref('https://example.com/team/kit.git')), false); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'hostPattern', hostPattern: '(' }], ref('microsoft/vscode')), false); + }); + + test('pathPattern matches resolved local path only', () => { + const local = ref('file:///opt/approved/marketplace'); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'pathPattern', pathPattern: 'approved' }], local), true); + assert.strictEqual(isMarketplaceReferenceAllowed([{ source: 'pathPattern', pathPattern: 'approved' }], ref('microsoft/vscode')), false); + }); +}); diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts index e14435565662b..48131260194e5 100644 --- a/src/vs/workbench/services/accounts/browser/managedSettings.ts +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { IPolicyData } from '../../../../base/common/defaultAccount.js'; -import { IExtraKnownMarketplaceEntry, extraKnownMarketplacesToConfigDict } from '../../../../base/common/managedSettings.js'; +import { IExtraKnownMarketplaceEntry, IStrictMarketplaceSource, extraKnownMarketplacesToConfigDict } from '../../../../base/common/managedSettings.js'; import { ManagedSettingValue } from '../../../../base/common/policy.js'; import { isObject, isString } from '../../../../base/common/types.js'; -import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, flattenManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_STRICT_MARKETPLACES_KEY, flattenManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; /** * Response shape from the Copilot `/copilot_internal/managed_settings` endpoint. @@ -30,7 +30,7 @@ export interface IManagedSettingsResponse { | { readonly source: 'github'; readonly repo: string; readonly ref?: string } | { readonly source: 'git'; readonly url: string; readonly ref?: string }; }>; - readonly strictKnownMarketplaces?: boolean; + readonly strictKnownMarketplaces?: readonly IStrictMarketplaceSource[]; /** Any unknown keys in the response are accepted for forward compatibility. */ readonly [key: string]: unknown; } @@ -45,13 +45,14 @@ export interface IManagedSettingsResponse { * dropping undeclared or type-mismatched keys happens later, at the * `projectManagedSettings` step. * - * - Scalar leaves (`permissions.*`, `strictKnownMarketplaces`, and any - * forward-compatible scalar keys) are flattened into dot-separated keys. - * - Structured settings (`enabledPlugins`, `extraKnownMarketplaces`) are carried - * as canonical JSON strings under a single key each — the same shape an admin - * authors via native MDM. `PolicyConfiguration` parses the JSON back into the - * object-typed setting on read. `extraKnownMarketplaces` is normalized from the - * API's `Record` map to the `{ [name]: url-or-shorthand }` dict. + * - Scalar leaves (`permissions.*` and any forward-compatible scalar keys) are + * flattened into dot-separated keys. + * - Structured settings (`enabledPlugins`, `extraKnownMarketplaces`, + * `strictKnownMarketplaces`) are carried as canonical JSON strings under a + * single key each — the same shape an admin authors via native MDM. + * `PolicyConfiguration` parses the JSON back into the object-typed setting on + * read. `extraKnownMarketplaces` is normalized from the API's + * `Record` map to the `{ [name]: url-or-shorthand }` dict. * * Malformed marketplace entries are dropped (with an optional warning via * {@link onWarn}) rather than throwing, so a bad enterprise settings file degrades @@ -60,7 +61,7 @@ export interface IManagedSettingsResponse { * Exported for unit-testing the shape transformation independently of network I/O. */ export function adaptManagedSettings(response: IManagedSettingsResponse, onWarn?: (msg: string) => void): Partial { - const { enabledPlugins, extraKnownMarketplaces, ...rest } = response; + const { enabledPlugins, extraKnownMarketplaces, strictKnownMarketplaces, ...rest } = response; const managedSettings: Record = { ...flattenManagedSettings(rest) }; @@ -68,6 +69,10 @@ export function adaptManagedSettings(response: IManagedSettingsResponse, onWarn? managedSettings[COPILOT_ENABLED_PLUGINS_KEY] = JSON.stringify(enabledPlugins); } + if (Array.isArray(strictKnownMarketplaces)) { + managedSettings[COPILOT_STRICT_MARKETPLACES_KEY] = JSON.stringify(strictKnownMarketplaces); + } + const marketplaceDict = extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(extraKnownMarketplaces, onWarn)); if (marketplaceDict) { managedSettings[COPILOT_EXTRA_MARKETPLACES_KEY] = JSON.stringify(marketplaceDict); diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts index 27dbce49ba97d..067eda3a3392c 100644 --- a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts @@ -41,12 +41,19 @@ suite('adaptManagedSettings', () => { }); }); - test('carries strictKnownMarketplaces as a boolean managed setting', () => { - assert.deepStrictEqual(adaptManagedSettings({ strictKnownMarketplaces: true }), { - managedSettings: { strictKnownMarketplaces: true }, + test('carries strictKnownMarketplaces as a canonical JSON string under a single key', () => { + assert.deepStrictEqual(adaptManagedSettings({ + strictKnownMarketplaces: [{ source: 'github', repo: 'rwoll/markdown-review' }], + }), { + managedSettings: { + strictKnownMarketplaces: '[{"source":"github","repo":"rwoll/markdown-review"}]', + }, }); - assert.deepStrictEqual(adaptManagedSettings({ strictKnownMarketplaces: false }), { - managedSettings: { strictKnownMarketplaces: false }, + }); + + test('carries an empty strictKnownMarketplaces array (lockdown) as a JSON string', () => { + assert.deepStrictEqual(adaptManagedSettings({ strictKnownMarketplaces: [] }), { + managedSettings: { strictKnownMarketplaces: '[]' }, }); }); @@ -95,10 +102,10 @@ suite('adaptManagedSettings', () => { extraKnownMarketplaces: { 'a': { source: { source: 'github', repo: 'a/b', ref: 'r' } }, }, - strictKnownMarketplaces: true, + strictKnownMarketplaces: [{ source: 'github', repo: 'a/b' }], }), { managedSettings: { - strictKnownMarketplaces: true, + strictKnownMarketplaces: '[{"source":"github","repo":"a/b"}]', enabledPlugins: '{"p@m":true}', extraKnownMarketplaces: '{"a":"a/b#r"}', }, @@ -108,11 +115,11 @@ suite('adaptManagedSettings', () => { test('resilience: unknown scalar keys flatten into the bag alongside structured keys', () => { assert.deepStrictEqual(adaptManagedSettings({ enabledPlugins: { 'p@m': true }, - strictKnownMarketplaces: false, + strictKnownMarketplaces: [], joshsFakeSetting: true, } as IManagedSettingsResponse), { managedSettings: { - strictKnownMarketplaces: false, + strictKnownMarketplaces: '[]', joshsFakeSetting: true, enabledPlugins: '{"p@m":true}', }, diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index b2771c0c25c6e..2ccad35322e71 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -144,6 +144,28 @@ suite('MultiplexPolicyService', () => { value: policyData => policyData.cloud_session_storage_enabled === false ? false : undefined, } }, + 'setting.G': { + 'type': ['array', 'null'], + 'default': null, + policy: { + name: 'PolicySettingG', + category: PolicyCategory.Extensions, + minimumVersion: '1.0.0', + localization: { description: { key: '', value: '' } }, + value: policyData => policyData.chat_preview_features_enabled === false ? JSON.stringify(['policyValueG1', 'policyValueG2']) : undefined, + } + }, + 'setting.H': { + 'type': ['array', 'null'], + 'default': null, + policy: { + name: 'PolicySettingH', + category: PolicyCategory.Extensions, + minimumVersion: '1.0.0', + localization: { description: { key: '', value: '' } }, + value: policyData => policyData.chat_preview_features_enabled === false ? JSON.stringify([]) : undefined, + } + }, } }; @@ -369,4 +391,41 @@ suite('MultiplexPolicyService', () => { assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); assert.strictEqual(policyConfiguration.configurationModel.getValue('setting.F'), undefined); }); + + test('union-typed (array | null) policy registers and parses JSON string value', async () => { + await clear(); + + const policyData: IPolicyData = { chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); + await defaultAccountService.refresh(); + + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingG'), JSON.stringify(['policyValueG1', 'policyValueG2'])); + assert.deepStrictEqual(policyConfiguration.configurationModel.getValue('setting.G'), ['policyValueG1', 'policyValueG2']); + }); + + test('union-typed (array | null) policy preserves an empty array (lockdown) distinct from unset', async () => { + await clear(); + + // Policy set to an empty array (e.g. a lockdown allowlist): must round-trip to `[]`, not `undefined`. + const setPolicyData: IPolicyData = { chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, setPolicyData)); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingH'), JSON.stringify([])); + assert.deepStrictEqual(policyConfiguration.configurationModel.getValue('setting.H'), []); + }); + + test('union-typed (array | null) policy unset leaves the setting at its default (distinct from empty array)', async () => { + await clear(); + + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, {})); + await defaultAccountService.refresh(); + await policyConfiguration.initialize(); + + assert.strictEqual(policyService.getPolicyValue('PolicySettingH'), undefined); + assert.strictEqual(policyConfiguration.configurationModel.getValue('setting.H'), undefined); + }); }); From 37e7e85b10f6adb901f5f40dd5743cccd4b1cc90 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Fri, 19 Jun 2026 17:21:43 +0200 Subject: [PATCH 17/20] Optimize node_modules caching for CI & PR checks (#322074) * CI: speed up node_modules cache with zstd + shared scripts Switch the Linux/macOS node_modules cache from single-threaded gzip (tar -czf) to multi-threaded zstd. The "Create node_modules archive" step was spending ~5min of single-core gzip on a multi-GB tree on every cache miss; zstd -T0 uses all cores and decompresses much faster, so cache-hit jobs benefit too. Windows stays on 7-Zip (already threaded). Extract the archive/extract commands into shared per-platform scripts under .github/workflows/node_modules_cache/ (cache.sh / cache.ps1, each dispatching on an archive|extract argument) so the format and flags live in one place instead of being duplicated across ~8 workflows. Bump build/.cachesalt to invalidate existing gzip caches. Also remove the obsolete extensions/copilot CI workflows (copilot-setup-steps.yml, ensure-node-modules-cache.yml, pr.yml) and the unused build/listBuildCacheFiles.js, and drop their now-stale entries (plus lit-html and signals-core) from .eslint-allowed-javascript-files. * ci: seed copilot node_modules cache on main and rename cache keys Add copilot-linux and copilot-windows jobs to pr-node-modules.yml so the copilot node_modules cache is populated on main. Rename the copilot cache keys to copilot-node_modules-linux / copilot-node_modules-windows in pr.yml. * ci: extract node_modules cache into composite actions Factor the repeated node_modules cache plumbing into two local composite actions, restore-node-modules and save-node-modules, and migrate all workflows that used the cache.sh/cache.ps1 archive flow (pr, pr-node-modules, pr-{linux,darwin,win32}-test, copilot-setup-steps, component-fixtures, css-order-scan). - restore-node-modules computes the key, restores the cache, optionally extracts on a hit, and exports the resolved key via $GITHUB_ENV. - save-node-modules archives node_modules and saves it to the cache, reusing the key exported by restore so callers don't repeat the prefix. - Bespoke install steps stay in the workflows, so per-job env/secrets never cross the action boundary. - Only seed the cache on branch pushes (component-fixtures skips PRs, whose caches aren't shared). * save the node_modules cache for now to test it * ci: fix node_modules cache save dropping the archive cache.sh wrote its archive as cache.tzst, but actions/cache reserves that name for its own tarball and passes --exclude cache.tzst, so our archive was excluded and an empty (~200 B) cache was saved on Linux/macOS. Rename the archive to node-modules.tzst and bump build/.cachesalt to invalidate the broken cache entries. * empty commit * Remove again saving to the node modules cache from PR steps --- .eslint-allowed-javascript-files | 6 - .../actions/restore-node-modules/action.yml | 60 +++++ .github/actions/save-node-modules/action.yml | 21 ++ .github/workflows/component-fixtures.yml | 24 +- .github/workflows/copilot-setup-steps.yml | 21 +- .github/workflows/css-order-scan.yml | 21 +- .../workflows/node_modules_cache/README.md | 35 +++ .../workflows/node_modules_cache/cache.ps1 | 27 ++ .github/workflows/node_modules_cache/cache.sh | 27 ++ .github/workflows/pr-darwin-test.yml | 21 +- .github/workflows/pr-linux-test.yml | 21 +- .github/workflows/pr-node-modules.yml | 144 ++++++----- .github/workflows/pr-win32-test.yml | 31 +-- .github/workflows/pr.yml | 101 +++----- build/.cachesalt | 2 +- .../common/computeNodeModulesCacheKey.ts | 1 - .../.github/workflows/copilot-setup-steps.yml | 72 ------ .../workflows/ensure-node-modules-cache.yml | 83 ------- extensions/copilot/.github/workflows/pr.yml | 235 ------------------ extensions/copilot/build/.cachesalt | 1 - .../copilot/build/listBuildCacheFiles.js | 48 ---- 21 files changed, 313 insertions(+), 689 deletions(-) create mode 100644 .github/actions/restore-node-modules/action.yml create mode 100644 .github/actions/save-node-modules/action.yml create mode 100644 .github/workflows/node_modules_cache/README.md create mode 100644 .github/workflows/node_modules_cache/cache.ps1 create mode 100755 .github/workflows/node_modules_cache/cache.sh delete mode 100644 extensions/copilot/.github/workflows/copilot-setup-steps.yml delete mode 100644 extensions/copilot/.github/workflows/ensure-node-modules-cache.yml delete mode 100644 extensions/copilot/.github/workflows/pr.yml delete mode 100644 extensions/copilot/build/.cachesalt delete mode 100644 extensions/copilot/build/listBuildCacheFiles.js diff --git a/.eslint-allowed-javascript-files b/.eslint-allowed-javascript-files index a90f84180f126..7d31572290daa 100644 --- a/.eslint-allowed-javascript-files +++ b/.eslint-allowed-javascript-files @@ -20,7 +20,6 @@ extensions/copilot/.mocha-multi-reporters.js extensions/copilot/.mocharc.js extensions/copilot/.vscode-test.mjs extensions/copilot/.vscode/extensions/visualization-runner/entry.js -extensions/copilot/build/listBuildCacheFiles.js extensions/copilot/script/electron/simulationWorkbenchMain.js extensions/copilot/src/extension/completions-core/vscode-node/extension/test/run.js extensions/copilot/src/extension/test/node/fixtures/gitdiff/generate-diffs.js @@ -98,13 +97,8 @@ scripts/code-sessions-web.js scripts/code-web.js scripts/xterm-update.js src/vs/base/browser/dompurify/dompurify.js -src/vs/base/common/lit-html/directive-helpers.js -src/vs/base/common/lit-html/directive.js -src/vs/base/common/lit-html/directives/repeat.js -src/vs/base/common/lit-html/lit-html.js src/vs/base/common/marked/marked.js src/vs/base/common/semver/semver.js -src/vs/base/common/signals-core/signals-core.js src/vs/base/test/common/filters.perf.data.js src/vs/editor/test/node/diffing/fixtures/difficult-move/1.js src/vs/editor/test/node/diffing/fixtures/difficult-move/2.js diff --git a/.github/actions/restore-node-modules/action.yml b/.github/actions/restore-node-modules/action.yml new file mode 100644 index 0000000000000..1e4e483f6e9af --- /dev/null +++ b/.github/actions/restore-node-modules/action.yml @@ -0,0 +1,60 @@ +name: Restore node_modules cache +description: Computes the node_modules cache key, restores the cache, and extracts it on a hit. + +inputs: + key-prefix: + description: Prefix for the cache key. The package-lock hash is appended automatically. + required: true + key-args: + description: Arguments passed to build/azure-pipelines/common/computeNodeModulesCacheKey.ts. + required: true + extract: + description: When 'true', the restored archive is extracted into node_modules on a cache hit. + default: 'true' + +outputs: + cache-hit: + description: "'true' when the node_modules cache was restored from an exact key match." + value: ${{ steps.restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Prepare node_modules cache key + if: runner.os != 'Windows' + shell: bash + run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts ${{ inputs.key-args }} > .build/packagelockhash + + - name: Prepare node_modules cache key + if: runner.os == 'Windows' + shell: pwsh + run: | + mkdir .build -ea 0 + node build/azure-pipelines/common/computeNodeModulesCacheKey.ts ${{ inputs.key-args }} > .build/packagelockhash + + - name: Restore node_modules cache + id: restore + uses: actions/cache/restore@v5 + with: + path: .build/node_modules_cache + key: "${{ inputs.key-prefix }}-${{ hashFiles('.build/packagelockhash') }}" + + - name: Export node_modules cache key + if: runner.os != 'Windows' + shell: bash + run: echo "NODE_MODULES_CACHE_KEY=${{ steps.restore.outputs.cache-primary-key }}" >> "$GITHUB_ENV" + + - name: Export node_modules cache key + if: runner.os == 'Windows' + shell: pwsh + run: Add-Content -Path $env:GITHUB_ENV -Value "NODE_MODULES_CACHE_KEY=${{ steps.restore.outputs.cache-primary-key }}" + + - name: Extract node_modules cache + if: runner.os != 'Windows' && inputs.extract == 'true' && steps.restore.outputs.cache-hit == 'true' + shell: bash + run: ./.github/workflows/node_modules_cache/cache.sh extract + + - name: Extract node_modules cache + if: runner.os == 'Windows' && inputs.extract == 'true' && steps.restore.outputs.cache-hit == 'true' + shell: pwsh + run: ./.github/workflows/node_modules_cache/cache.ps1 extract diff --git a/.github/actions/save-node-modules/action.yml b/.github/actions/save-node-modules/action.yml new file mode 100644 index 0000000000000..16d8ce96d83a3 --- /dev/null +++ b/.github/actions/save-node-modules/action.yml @@ -0,0 +1,21 @@ +name: Save node_modules cache +description: Archives node_modules and saves the archive to the GitHub Actions cache. + +runs: + using: composite + steps: + - name: Create node_modules archive + if: runner.os != 'Windows' + shell: bash + run: ./.github/workflows/node_modules_cache/cache.sh archive + + - name: Create node_modules archive + if: runner.os == 'Windows' + shell: pwsh + run: ./.github/workflows/node_modules_cache/cache.ps1 archive + + - name: Save node_modules cache + uses: actions/cache/save@v5 + with: + path: .build/node_modules_cache + key: ${{ env.NODE_MODULES_CACHE_KEY }} diff --git a/.github/workflows/component-fixtures.yml b/.github/workflows/component-fixtures.yml index 04bac6ac7d6f5..e42f20b623d5e 100644 --- a/.github/workflows/component-fixtures.yml +++ b/.github/workflows/component-fixtures.yml @@ -38,19 +38,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-component-fixtures-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-component-fixtures + key-args: "compile $(node -p process.arch)" - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -70,13 +63,10 @@ jobs: run: npm ci working-directory: build/rspack - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + - name: Save node_modules cache + # Only seed the cache on branch pushes — PR-scoped caches are not shared with other refs. + if: github.event_name != 'pull_request' && steps.cache-node-modules.outputs.cache-hit != 'true' + uses: ./.github/actions/save-node-modules - name: Copy codicons run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index c3a390e905f59..0f1b025b10a2d 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -50,19 +50,12 @@ jobs: sudo update-rc.d xvfb defaults sudo service xvfb start - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux x64 $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-linux + key-args: "linux x64 $(node -p process.arch)" - name: Install build dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -103,14 +96,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Create .build folder run: mkdir -p .build diff --git a/.github/workflows/css-order-scan.yml b/.github/workflows/css-order-scan.yml index 4be9a52ad4d23..49f6b27e0aafb 100644 --- a/.github/workflows/css-order-scan.yml +++ b/.github/workflows/css-order-scan.yml @@ -29,19 +29,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-css-order-scan-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-css-order-scan + key-args: "compile $(node -p process.arch)" - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -61,13 +54,9 @@ jobs: run: npm ci working-directory: build/rspack - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules - name: Copy codicons run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf diff --git a/.github/workflows/node_modules_cache/README.md b/.github/workflows/node_modules_cache/README.md new file mode 100644 index 0000000000000..00ea968111928 --- /dev/null +++ b/.github/workflows/node_modules_cache/README.md @@ -0,0 +1,35 @@ +# node_modules cache scripts + +Shared helpers used by the GitHub Actions workflows to store and restore the +`node_modules` cache. They exist so the archive format and compression flags +live in a single place instead of being duplicated across every workflow. + +Each script takes an action argument: + +| Script | Platform | `archive` | `extract` | +| ------ | -------- | --------- | --------- | +| `cache.sh ` | Linux / macOS | Create `node-modules.tzst` (cache miss) | Restore `node-modules.tzst` (cache hit) | +| `cache.ps1 ` | Windows | Create `cache.7z` (cache miss) | Restore `cache.7z` (cache hit) | + +Linux/macOS use multi-threaded `zstd` (`node-modules.tzst`); Windows uses 7-Zip +(`cache.7z`). The archive must NOT be named `cache.tzst`: `actions/cache` names +its own tarball `cache.tzst` and passes `--exclude cache.tzst`, which would drop +our archive and save an empty cache. The two families are not interchangeable, +so the cache key already encodes the OS (`node_modules-linux-*`, +`node_modules-windows-*`, …). + +Example usage from a workflow step: + +```yaml +- name: Extract node_modules cache + if: steps.cache-node-modules.outputs.cache-hit == 'true' + run: ./.github/workflows/node_modules_cache/cache.sh extract + +- name: Create node_modules archive + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: ./.github/workflows/node_modules_cache/cache.sh archive +``` + +If you change the archive format or flags, update both the `archive` and +`extract` branch of the script, and bump `build/.cachesalt` to invalidate +existing caches. diff --git a/.github/workflows/node_modules_cache/cache.ps1 b/.github/workflows/node_modules_cache/cache.ps1 new file mode 100644 index 0000000000000..a0355d6dc9b98 --- /dev/null +++ b/.github/workflows/node_modules_cache/cache.ps1 @@ -0,0 +1,27 @@ +# Store or restore the node_modules cache on Windows. +# +# Usage: +# cache.ps1 archive # create .build/node_modules_cache/cache.7z (cache miss) +# cache.ps1 extract # restore .build/node_modules_cache/cache.7z (cache hit) +# +# Uses 7-Zip. The archive and extract format must stay compatible; if you change +# the format here, bump build/.cachesalt to invalidate old caches. +param( + [Parameter(Mandatory = $true)] + [ValidateSet("archive", "extract")] + [string]$Action +) + +. build/azure-pipelines/win32/exec.ps1 +$ErrorActionPreference = "Stop" + +switch ($Action) { + "archive" { + exec { node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt } + exec { mkdir -Force .build/node_modules_cache } + exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } + } + "extract" { + exec { 7z.exe x .build/node_modules_cache/cache.7z -aoa } + } +} diff --git a/.github/workflows/node_modules_cache/cache.sh b/.github/workflows/node_modules_cache/cache.sh new file mode 100755 index 0000000000000..19d109f67091f --- /dev/null +++ b/.github/workflows/node_modules_cache/cache.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Store or restore the node_modules cache on Linux / macOS. +# +# Usage: +# cache.sh archive # create .build/node_modules_cache/node-modules.tzst (cache miss) +# cache.sh extract # restore .build/node_modules_cache/node-modules.tzst (cache hit) +# +# Uses multi-threaded zstd. The archive must NOT be named cache.tzst because +# actions/cache reserves that name and excludes it from the saved cache. The +# archive and extract flags must stay compatible; if you change the format +# here, bump build/.cachesalt to invalidate old caches. +set -e + +case "$1" in + archive) + node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt + mkdir -p .build/node_modules_cache + tar --use-compress-program='zstd -T0 -3' -cf .build/node_modules_cache/node-modules.tzst --files-from .build/node_modules_list.txt + ;; + extract) + tar --use-compress-program='zstd -d -T0' -xf .build/node_modules_cache/node-modules.tzst + ;; + *) + echo "Usage: $0 {archive|extract}" >&2 + exit 1 + ;; +esac diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c9622e23a8966..c36da018996eb 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -33,19 +33,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-macos-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-macos + key-args: "darwin ${{ env.VSCODE_ARCH }} $(node -p process.arch)" - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -75,14 +68,6 @@ jobs: # on macOS. GYP_DEFINES: "kerberos_use_rtld=false" - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Create .build folder run: mkdir -p .build diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 665bfd36550bd..452f974f802f7 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -55,19 +55,12 @@ jobs: sudo update-rc.d xvfb defaults sudo service xvfb start - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-linux + key-args: "linux ${{ env.VSCODE_ARCH }} $(node -p process.arch)" - name: Install build dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -108,14 +101,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Create .build folder run: mkdir -p .build diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index 1f83561abb101..000c17b3782e3 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -20,19 +20,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-compile-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-compile + key-args: "compile $(node -p process.arch)" - name: Install build tools if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -56,13 +49,9 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules - name: Prepare built-in extensions cache key run: | @@ -99,15 +88,13 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-linux-${{ hashFiles('.build/packagelockhash') }}" + key-prefix: node_modules-linux + key-args: "linux ${{ env.VSCODE_ARCH }} $(node -p process.arch)" + extract: 'false' - name: Install build dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -148,13 +135,9 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules macOS: name: macOS @@ -171,15 +154,13 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-macos-${{ hashFiles('.build/packagelockhash') }}" + key-prefix: node_modules-macos + key-args: "darwin ${{ env.VSCODE_ARCH }} $(node -p process.arch)" + extract: 'false' - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -209,13 +190,9 @@ jobs: # on macOS. GYP_DEFINES: "kerberos_use_rtld=false" - - name: Create node_modules archive + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt + uses: ./.github/actions/save-node-modules windows: name: Windows @@ -232,18 +209,13 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - shell: pwsh - run: | - mkdir .build -ea 0 - node build/azure-pipelines/common/computeNodeModulesCacheKey.ts win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache - uses: actions/cache@v5 id: node-modules-cache + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-windows-${{ hashFiles('.build/packagelockhash') }}" + key-prefix: node_modules-windows + key-args: "win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch)" + extract: 'false' - name: Install dependencies if: steps.node-modules-cache.outputs.cache-hit != 'true' @@ -273,12 +245,70 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} - - name: Create node_modules archive + - name: Save node_modules cache if: steps.node-modules-cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt } - exec { mkdir -Force .build/node_modules_cache } - exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } + uses: ./.github/actions/save-node-modules + + copilot-linux: + name: Copilot (Linux) + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: extensions/copilot/.nvmrc + + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules + with: + key-prefix: copilot-node_modules-linux + key-args: "$(node -p process.platform) $(node -p process.arch)" + + - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci --ignore-scripts --no-workspaces + + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: extensions/copilot + run: npm ci + + - name: Save node_modules cache + if: steps.cache-node-modules.outputs.cache-hit != 'true' + uses: ./.github/actions/save-node-modules + + copilot-windows: + name: Copilot (Windows) + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=copilot-windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + steps: + - name: Checkout microsoft/vscode + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: extensions/copilot/.nvmrc + + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules + with: + key-prefix: copilot-node_modules-windows + key-args: "$(node -p process.platform) $(node -p process.arch)" + + - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci --ignore-scripts --no-workspaces + + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: extensions/copilot + run: npm ci + + - name: Save node_modules cache + if: steps.cache-node-modules.outputs.cache-hit != 'true' + uses: ./.github/actions/save-node-modules diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index fbd1479b6afb9..a1255348f21c8 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -33,26 +33,15 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - shell: pwsh - run: | - mkdir .build -ea 0 - node build/azure-pipelines/common/computeNodeModulesCacheKey.ts win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache - uses: actions/cache/restore@v5 - id: node-modules-cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-windows-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.node-modules-cache.outputs.cache-hit == 'true' - shell: pwsh - run: 7z.exe x .build/node_modules_cache/cache.7z -aoa + key-prefix: node_modules-windows + key-args: "win32 ${{ env.VSCODE_ARCH }} $(node -p process.arch)" - name: Install dependencies - if: steps.node-modules-cache.outputs.cache-hit != 'true' + if: steps.cache-node-modules.outputs.cache-hit != 'true' shell: pwsh run: | . build/azure-pipelines/win32/exec.ps1 @@ -84,16 +73,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.node-modules-cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt } - exec { mkdir -Force .build/node_modules_cache } - exec { 7z.exe a .build/node_modules_cache/cache.7z -mx3 `@.build/node_modules_list.txt } - - name: Create .build folder shell: pwsh run: mkdir .build -ea 0 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d6e29e867ddb8..96fb9a707d150 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -31,19 +31,12 @@ jobs: with: node-version-file: .nvmrc - - name: Prepare node_modules cache key - run: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash - - name: Restore node_modules cache id: cache-node-modules - uses: actions/cache/restore@v5 + uses: ./.github/actions/restore-node-modules with: - path: .build/node_modules_cache - key: "node_modules-compile-${{ hashFiles('.build/packagelockhash') }}" - - - name: Extract node_modules cache - if: steps.cache-node-modules.outputs.cache-hit == 'true' - run: tar -xzf .build/node_modules_cache/cache.tgz + key-prefix: node_modules-compile + key-args: "compile $(node -p process.arch)" - name: Install build tools if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -67,14 +60,6 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create node_modules archive - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - set -e - node build/azure-pipelines/common/listNodeModules.ts .build/node_modules_list.txt - mkdir -p .build/node_modules_cache - tar -czf .build/node_modules_cache/cache.tgz --files-from .build/node_modules_list.txt - - name: Type check /build/ scripts run: npm run typecheck working-directory: build @@ -191,20 +176,19 @@ jobs: with: node-version-file: extensions/copilot/.nvmrc - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - key: copilot-build_cache-${{ hashFiles('extensions/copilot/build/.cachesalt', 'extensions/copilot/package-lock.json') }} - path: extensions/copilot/.build/build_cache + key-prefix: copilot-node_modules-linux + key-args: "$(node -p process.platform) $(node -p process.arch)" - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - working-directory: extensions/copilot - run: tar -xzf .build/build_cache/cache.tgz + - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci --ignore-scripts --no-workspaces - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' working-directory: extensions/copilot run: npm ci @@ -275,36 +259,22 @@ jobs: sudo apt-get update sudo apt-get install -y xvfb libgtk-3-0 libgbm1 - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - key: copilot-build_cache-${{ hashFiles('extensions/copilot/build/.cachesalt', 'extensions/copilot/package-lock.json') }} - path: extensions/copilot/.build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - working-directory: extensions/copilot - run: tar -xzf .build/build_cache/cache.tgz + key-prefix: copilot-node_modules-linux + key-args: "$(node -p process.platform) $(node -p process.arch)" - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci --ignore-scripts --no-workspaces - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' working-directory: extensions/copilot run: npm ci - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - working-directory: extensions/copilot - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - name: TypeScript type checking working-directory: extensions/copilot run: npm run typecheck @@ -378,35 +348,22 @@ jobs: - name: Install setuptools run: pip install setuptools - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache + - name: Restore node_modules cache + id: cache-node-modules + uses: ./.github/actions/restore-node-modules with: - key: copilot-windows-build_cache-${{ hashFiles('extensions/copilot/build/.cachesalt', 'extensions/copilot/package-lock.json') }} - path: extensions/copilot/.build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - working-directory: extensions/copilot - run: 7z.exe x .build/build_cache/cache.7z -aoa + key-prefix: copilot-node_modules-windows + key-args: "$(node -p process.platform) $(node -p process.arch)" - name: Install root dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci --ignore-scripts --no-workspaces - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' + - name: Install copilot dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' working-directory: extensions/copilot run: npm ci - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - working-directory: extensions/copilot - run: | - mkdir -Force .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -Force .build/build_cache - 7z.exe a .build/build_cache/cache.7z -mx3 `@.build/build_cache_list.txt - - name: TypeScript type checking working-directory: extensions/copilot run: npm run typecheck diff --git a/build/.cachesalt b/build/.cachesalt index 57519bc2df234..df1d8c82aab64 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2026-05-27T16:21:42.961Z +2026-06-19T12:30:00.000Z diff --git a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts index 0dbe8b1b4210c..e5dbc06aa946e 100644 --- a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts +++ b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts @@ -12,7 +12,6 @@ const ROOT = path.join(import.meta.dirname, '../../../'); const shasum = crypto.createHash('sha256'); shasum.update(fs.readFileSync(path.join(ROOT, 'build/.cachesalt'))); -shasum.update(fs.readFileSync(path.join(ROOT, 'extensions/copilot/build/.cachesalt'))); // TODO: remove this one when all build scripts are cleaned up shasum.update(fs.readFileSync(path.join(ROOT, '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'build', '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'remote', '.npmrc'))); diff --git a/extensions/copilot/.github/workflows/copilot-setup-steps.yml b/extensions/copilot/.github/workflows/copilot-setup-steps.yml deleted file mode 100644 index 1c99b58e53849..0000000000000 --- a/extensions/copilot/.github/workflows/copilot-setup-steps.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Copilot Setup Steps - -# Automatically run the setup steps when they are changed to allow for easy validation, and -# allow manual testing through the repository's "Actions" tab -on: - workflow_dispatch: - push: - paths: - - .github/workflows/copilot-setup-steps.yml - pull_request: - paths: - - .github/workflows/copilot-setup-steps.yml - -jobs: - copilot-setup-steps: - name: Setup Development Environment - runs-on: vscode-large-runners - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0' - - - name: Install setuptools - run: pip install setuptools - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: tar -xzf .build/build_cache/cache.tgz - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - - name: Verify installation - run: | - node --version - npm --version - python --version - dotnet --version \ No newline at end of file diff --git a/extensions/copilot/.github/workflows/ensure-node-modules-cache.yml b/extensions/copilot/.github/workflows/ensure-node-modules-cache.yml deleted file mode 100644 index a82dab5344bdf..0000000000000 --- a/extensions/copilot/.github/workflows/ensure-node-modules-cache.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Ensure node modules cache - -on: - push: - branches: - - main - -permissions: - contents: read - -jobs: - linux: - name: Linux - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-ubuntu-22.04-x64 ] - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Restore build cache - uses: actions/cache@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - windows: - name: Windows - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-windows-2022-x64 ] - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '22.21.x' - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Restore build cache - uses: actions/cache@v5 - id: build-cache - with: - key: windows-build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - mkdir -Force .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -Force .build/build_cache - 7z.exe a .build/build_cache/cache.7z -mx3 `@.build/build_cache_list.txt diff --git a/extensions/copilot/.github/workflows/pr.yml b/extensions/copilot/.github/workflows/pr.yml deleted file mode 100644 index 8bb30f5ae88a2..0000000000000 --- a/extensions/copilot/.github/workflows/pr.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: PR Checks - -on: - push: - branches: - - 'gh-readonly-queue/main/*' - pull_request: - branches: - - main - - 'release/*' - -permissions: - contents: read - pull-requests: read - -jobs: - check-test-cache: - name: Check test cache - runs-on: ubuntu-22.04 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - lfs: true - - - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: tar -xzf .build/build_cache/cache.tgz - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Ensure no duplicate cache keys - run: npx tsx test/base/cache-cli check - - - name: Ensure no untrusted cache changes - run: npx tsx build/pr-check-cache-files.ts - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPOSITORY: ${{ github.repository }} - PULL_REQUEST: ${{ github.event.pull_request.number }} - - check-telemetry: - name: Check telemetry events - runs-on: ubuntu-22.04 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - lfs: true - - - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Validate telemetry events - run: npx --package=@vscode/telemetry-extractor --yes vscode-telemetry-extractor -s . > /dev/null - - linux-tests: - name: Test (Linux) - runs-on: ubuntu-22.04 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0' - - - name: Install setuptools - run: pip install setuptools - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: tar -xzf .build/build_cache/cache.tgz - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - set -e - mkdir -p .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -p .build/build_cache - tar -czf .build/build_cache/cache.tgz --files-from .build/build_cache_list.txt - - - name: TypeScript type checking - run: npm run typecheck - - - name: Lint - run: npm run lint - - - name: Compile - run: npm run compile - - - name: Run vitest unit tests - run: npm run test:unit - - - name: Run simulation tests with cache - run: npm run simulate-ci - - - name: Run extension tests using VS Code - run: xvfb-run -a npm run test:extension - - - name: Run Completions Core prompt tests - run: npm run test:prompt - - - name: Run Completions Core lib tests using VS Code - run: xvfb-run -a npm run test:completions-core - - - name: Archive simulation output - if: always() - run: | - set -e - mkdir -p .simulation-archive - tar -czf .simulation-archive/simulation.tgz -C .simulation . - - - name: Upload simulation output - if: always() - uses: actions/upload-artifact@v7 - with: - name: simulation-output-linux-${{ github.run_attempt }} - path: .simulation-archive/simulation.tgz - - windows-tests: - name: Test (Windows) - runs-on: windows-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - lfs: true - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: '3.12' - architecture: 'x64' - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0' - - - name: Install setuptools - run: pip install setuptools - - - name: Restore build cache - uses: actions/cache/restore@v5 - id: build-cache - with: - key: windows-build_cache-${{ hashFiles('build/.cachesalt', 'package-lock.json') }} - path: .build/build_cache - - - name: Extract build cache - if: steps.build-cache.outputs.cache-hit == 'true' - run: 7z.exe x .build/build_cache/cache.7z -aoa - - - name: Install dependencies - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm ci - - - name: Create build cache archive - if: steps.build-cache.outputs.cache-hit != 'true' - run: | - mkdir -Force .build - node build/listBuildCacheFiles.js .build/build_cache_list.txt - mkdir -Force .build/build_cache - 7z.exe a .build/build_cache/cache.7z -mx3 `@.build/build_cache_list.txt - - - name: TypeScript type checking - run: npm run typecheck - - - name: Lint - run: npm run lint - - - name: Compile - run: npm run compile - - - name: Run vitest unit tests - run: npm run test:unit - - - name: Run simulation tests with cache - run: npm run simulate-ci - - - name: Run extension tests using VS Code - run: npm run test:extension - - - name: Run Completions Core prompt tests - run: npm run test:prompt - - - name: Run Completions Core lib tests using VS Code - run: npm run test:completions-core diff --git a/extensions/copilot/build/.cachesalt b/extensions/copilot/build/.cachesalt deleted file mode 100644 index 08be458a78bab..0000000000000 --- a/extensions/copilot/build/.cachesalt +++ /dev/null @@ -1 +0,0 @@ -2026-06-06T10:06:34.000Z diff --git a/extensions/copilot/build/listBuildCacheFiles.js b/extensions/copilot/build/listBuildCacheFiles.js deleted file mode 100644 index 8cb1797e583da..0000000000000 --- a/extensions/copilot/build/listBuildCacheFiles.js +++ /dev/null @@ -1,48 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -//@ts-check -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -if (process.argv.length !== 3) { - console.error('Usage: node listBuildCacheFiles.js OUTPUT_FILE'); - process.exit(-1); -} - -const ROOT = path.join(__dirname, '../'); - -/** - * @param {string} location - * @param {string[]} result - */ -function listAllFiles(location, result) { - const entries = fs.readdirSync(path.join(ROOT, location)); - for (const entry of entries) { - const entryPath = `${location}/${entry}`; - - /** @type {import('fs').Stats} */ - let stat; - try { - stat = fs.statSync(path.join(ROOT, entryPath)); - } catch (err) { - continue; - } - - if (stat.isDirectory()) { - listAllFiles(entryPath, result); - } else { - result.push(entryPath); - } - } -} - -/** @type {string[]} */ -const result = []; -listAllFiles('node_modules', result); // node modules -listAllFiles('dist', result); // contains wasm files -fs.writeFileSync(process.argv[2], result.join('\n') + '\n'); From b04eaf5dfa66ed6f19b1c64abff481d6f31ffcfb Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:38:06 +0200 Subject: [PATCH 18/20] sessions: add [PR-ICON-TRACE] logs to PR polling/fetching pipeline (#322109) * Add [PR-ICON-TRACE] trace logs to PR polling/fetching pipeline Adds trace-level logging across the GitHub PR lookup, fetch, poll and session-icon computation path so the flow can be filtered end-to-end via the shared [PR-ICON-TRACE] token while debugging missing/incorrect session PR icons. Covers githubService PR-number lookup, githubApiClient request/response, the PR/CI/review-thread models (refresh, poll, startPolling), the polling contribution per-session poller, and the agent-host session icon derivation. Injects ILogService where it was missing and updates the polling contribution test accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Pass error as separate trace arg to preserve stack formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github/browser/github.contribution.ts | 10 +++++++++ .../contrib/github/browser/githubApiClient.ts | 4 ++++ .../contrib/github/browser/githubService.ts | 21 ++++++++++++++++++- .../models/githubPullRequestCIModel.ts | 7 ++++++- .../browser/models/githubPullRequestModel.ts | 9 +++++++- .../githubPullRequestReviewThreadsModel.ts | 8 ++++++- .../test/browser/githubContribution.test.ts | 18 +++++++++------- .../browser/baseAgentHostSessionsProvider.ts | 11 +++++++++- 8 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts index 73a95b79ec0d9..69158cc2a25df 100644 --- a/src/vs/sessions/contrib/github/browser/github.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -9,6 +9,7 @@ import { structuralEquals } from '../../../../base/common/equals.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -16,6 +17,8 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { GitHubPullRequestState } from '../common/types.js'; import { GitHubService, IGitHubService } from './githubService.js'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; + export class GitHubPullRequestPollingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.contrib.githubPullRequestPolling'; @@ -27,6 +30,7 @@ export class GitHubPullRequestPollingContribution extends Disposable implements @IGitHubService private readonly _gitHubService: IGitHubService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -146,10 +150,12 @@ export class GitHubPullRequestPollingContribution extends Disposable implements const identity = pullRequestIdentityObs.read(reader); if (!identity) { // No PR number yet (or archived); this autorun re-runs once it resolves. + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} has no PR identity yet (no PR number or archived); waiting`); return; } const { owner, repo, prNumber } = identity; + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} resolved PR identity ${owner}/${repo}#${prNumber}; acquiring model and refreshing`); const modelRef = reader.store.add(this._gitHubService.createPullRequestModelReference(owner, repo, prNumber)); const model = modelRef.object; @@ -167,9 +173,11 @@ export class GitHubPullRequestPollingContribution extends Disposable implements }); reader.store.add(autorun(pollReader => { if (!shouldPollObs.read(pollReader)) { + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} PR ${owner}/${repo}#${prNumber} is merged and not active; not polling`); return; } + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting PR polling for ${owner}/${repo}#${prNumber}`); pollReader.store.add(model.startPolling()); })); @@ -182,6 +190,8 @@ export class GitHubPullRequestPollingContribution extends Disposable implements return; } + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} starting CI + review-thread polling for ${owner}/${repo}#${prNumber}@${prDetails.headSha}`); + const ciModelRef = statusReader.store.add(this._gitHubService.createPullRequestCIModelReference(owner, repo, prNumber, prDetails.headSha)); ciModelRef.object.refresh(); statusReader.store.add(ciModelRef.object.startPolling()); diff --git a/src/vs/sessions/contrib/github/browser/githubApiClient.ts b/src/vs/sessions/contrib/github/browser/githubApiClient.ts index 3f286e6e21383..115749bebd761 100644 --- a/src/vs/sessions/contrib/github/browser/githubApiClient.ts +++ b/src/vs/sessions/contrib/github/browser/githubApiClient.ts @@ -10,6 +10,7 @@ import { IRequestService, asJson } from '../../../../platform/request/common/req import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; const LOG_PREFIX = '[GitHubApiClient]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`; @@ -94,6 +95,7 @@ export class GitHubApiClient extends Disposable { const token = await this._getAuthToken(); this._logService.trace(`${LOG_PREFIX} ${method} ${pathForLogging}`); + this._logService.trace(`${TRACE_PREFIX} [GitHubApiClient] -> ${method} ${pathForLogging} (callSite ${callSite}${options?.etag !== undefined ? `, ifNoneMatch ${options.etag}` : ''})`); const response = await this._requestService.request({ type: method, @@ -117,6 +119,8 @@ export class GitHubApiClient extends Disposable { const statusCode = response.res.statusCode ?? 0; const responseETag = response.res.headers?.['etag']; + this._logService.trace(`${TRACE_PREFIX} [GitHubApiClient] <- ${method} ${pathForLogging} status ${statusCode}${responseETag ? `, etag ${responseETag}` : ''}${rateLimitRemaining !== undefined ? `, rateLimitRemaining ${rateLimitRemaining}` : ''} (callSite ${callSite})`); + if ( statusCode === 204 /* No Content */ || statusCode === 304 /* Not Modified */ diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts index 66b1797738035..c3694684d1784 100644 --- a/src/vs/sessions/contrib/github/browser/githubService.ts +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -5,6 +5,7 @@ import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IGitHubChangedFile } from '../common/types.js'; import { GitHubApiClient } from './githubApiClient.js'; import { GitHubRepositoryModel, GitHubRepositoryModelReferenceCollection } from './models/githubRepositoryModel.js'; @@ -16,6 +17,14 @@ import { getPullRequestKey } from '../common/utils.js'; import { derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; + +/** + * Shared trace prefix for the pull-request polling/fetching pipeline that feeds + * the session PR status icons. Filter the logs on this token to follow the + * whole flow end-to-end (lookup -> fetch -> poll -> icon). + */ +const TRACE_PREFIX = '[PR-ICON-TRACE]'; + export interface IGitHubService { readonly _serviceBrand: undefined; @@ -89,6 +98,7 @@ export class GitHubService extends Disposable implements IGitHubService { constructor( @IInstantiationService instantiationService: IInstantiationService, @ISessionsService sessionsService: ISessionsService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -195,6 +205,7 @@ export class GitHubService extends Disposable implements IGitHubService { const key = `${owner}/${repo}#${branch}`; let promise = this._findPRByBranchCache.get(key); if (!promise) { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch cache MISS for ${key}; starting lookup`); promise = this._fetchPullRequestNumberByHeadBranch(owner, repo, branch); this._findPRByBranchCache.set(key, promise); // Only cache successful, numeric results indefinitely; the PR number @@ -204,13 +215,19 @@ export class GitHubService extends Disposable implements IGitHubService { promise.then( value => { if (typeof value !== 'number') { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch for ${key} resolved with NO pr number; dropping cache entry so a later call retries`); this._findPRByBranchCache.delete(key); + } else { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch for ${key} resolved PR #${value}; caching indefinitely`); } }, - () => { + err => { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch for ${key} FAILED; dropping cache entry.`, err); this._findPRByBranchCache.delete(key); }, ); + } else { + this._logService.trace(`${TRACE_PREFIX} [GitHubService] findPullRequestNumberByHeadBranch cache HIT for ${key}`); } return promise.catch(() => undefined); } @@ -221,12 +238,14 @@ export class GitHubService extends Disposable implements IGitHubService { // surfaces the PR after the agent run finishes and the PR is merged. // `per_page=1` + `sort=updated` gives us the most recent match. const path = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`; + this._logService.trace(`${TRACE_PREFIX} [GitHubService] Fetching PR number for head ${owner}:${branch} via GET ${path}`); const response = await this._apiClient.request( 'GET', path, 'githubApi.findPullRequestByHeadBranch', ); const first = response.data?.[0]; + this._logService.trace(`${TRACE_PREFIX} [GitHubService] PR number lookup for ${owner}:${branch} -> ${first ? `#${first.number}` : 'no match'} (status ${response.statusCode}, ${response.data?.length ?? 0} result(s))`); return first ? first.number : undefined; } } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts index 99b8c8ddec9e9..cda9daee18c6e 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts @@ -12,6 +12,7 @@ import { GitHubApiClient } from '../githubApiClient.js'; import { computeOverallCIStatus, GitHubPRCIFetcher } from '../fetchers/githubPRCIFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestCIModel]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const DEFAULT_POLL_INTERVAL_MS = 60_000; export class GitHubPullRequestCIModelReferenceCollection extends ReferenceCollection { @@ -90,6 +91,7 @@ export class GitHubPullRequestCIModel extends Disposable { } private async _refresh(): Promise { + this._logService.trace(`${TRACE_PREFIX} [CIModel] Refreshing CI for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha} (checksEtag ${this._checksEtag ?? 'none'})`); try { const response = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headSha, this._checksEtag); if (response.statusCode === 200 && response.data) { @@ -97,8 +99,9 @@ export class GitHubPullRequestCIModel extends Disposable { this._checks.set(response.data, undefined); this._overallStatus.set(computeOverallCIStatus(response.data), undefined); } + this._logService.trace(`${TRACE_PREFIX} [CIModel] Refreshed CI for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}: status ${response.statusCode}, ${this._checks.get().length} check(s), overallStatus ${this._overallStatus.get()}`); } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}:`, err); + this._logService.error(`${TRACE_PREFIX} ${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}:`, err); } } @@ -128,6 +131,7 @@ export class GitHubPullRequestCIModel extends Disposable { */ startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): IDisposable { if (this._pollingClientCount++ === 0) { + this._logService.trace(`${TRACE_PREFIX} [CIModel] Start polling ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha} every ${intervalMs}ms`); this._pollScheduler.schedule(intervalMs); } @@ -143,6 +147,7 @@ export class GitHubPullRequestCIModel extends Disposable { } private async _poll(): Promise { + this._logService.trace(`${TRACE_PREFIX} [CIModel] Poll cycle for ${this.owner}/${this.repo}#${this.prNumber}@${this.headSha}`); await this.refresh(); // Re-schedule if not disposed (RunOnceScheduler is one-shot) if (!this._store.isDisposed && this._pollingClientCount > 0) { diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts index 3c39d0d76d441..bfad0d6446efc 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -12,6 +12,7 @@ import { computeMergeability, GitHubPRFetcher } from '../fetchers/githubPRFetche import { GitHubApiClient } from '../githubApiClient.js'; const LOG_PREFIX = '[GitHubPullRequestModel]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const DEFAULT_POLL_INTERVAL_MS = 60_000; export class GitHubPullRequestModelReferenceCollection extends ReferenceCollection { @@ -105,6 +106,7 @@ export class GitHubPullRequestModel extends Disposable { this._pollingDisposables.add(disposable); if (this._pollingDisposables.size === 1) { + this._logService.trace(`${TRACE_PREFIX} [PRModel] Start polling ${this.owner}/${this.repo}#${this.prNumber} every ${intervalMs}ms`); this._pollScheduler.schedule(intervalMs); } @@ -112,6 +114,7 @@ export class GitHubPullRequestModel extends Disposable { } private async _poll(): Promise { + this._logService.trace(`${TRACE_PREFIX} [PRModel] Poll cycle for ${this.owner}/${this.repo}#${this.prNumber}`); await this.refresh(); // Re-schedule for next poll cycle (RunOnceScheduler is one-shot) if (!this._store.isDisposed && this._pollingDisposables.size > 0) { @@ -120,6 +123,7 @@ export class GitHubPullRequestModel extends Disposable { } private async _refresh(): Promise { + this._logService.trace(`${TRACE_PREFIX} [PRModel] Refreshing ${this.owner}/${this.repo}#${this.prNumber} (prEtag ${this._pullRequestEtag ?? 'none'}, reviewsEtag ${this._reviewsEtag ?? 'none'})`); try { const [pr, reviews] = await Promise.all([ this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber, this._pullRequestEtag), @@ -150,8 +154,11 @@ export class GitHubPullRequestModel extends Disposable { } } }); + + const current = this._pullRequest.get(); + this._logService.trace(`${TRACE_PREFIX} [PRModel] Refreshed ${this.owner}/${this.repo}#${this.prNumber}: prStatus ${pr.statusCode}, reviewsStatus ${reviews.statusCode}, state ${current?.state ?? 'unknown'}, isDraft ${current?.isDraft ?? 'unknown'}, headSha ${current?.headSha ?? 'unknown'}`); } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); + this._logService.error(`${TRACE_PREFIX} ${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); } } diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts index 8d46b366339b5..5629c9c18ce8e 100644 --- a/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.ts @@ -12,6 +12,7 @@ import { GitHubApiClient } from '../githubApiClient.js'; import { GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; const LOG_PREFIX = '[GitHubPullRequestReviewThreadsModel]'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const DEFAULT_POLL_INTERVAL_MS = 60_000; export class GitHubPullRequestReviewThreadsModelReferenceCollection extends ReferenceCollection { @@ -86,11 +87,14 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { } private async _refresh(): Promise { + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Refreshing review threads for ${this.owner}/${this.repo}#${this.prNumber}`); try { const data = await this._fetcher.getReviewThreads(this.owner, this.repo, this.prNumber); this._reviewThreads.set(data, undefined); + const unresolved = data.filter(thread => !thread.isResolved).length; + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Refreshed review threads for ${this.owner}/${this.repo}#${this.prNumber}: ${data.length} thread(s), ${unresolved} unresolved`); } catch (err) { - this._logService.error(`${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); + this._logService.error(`${TRACE_PREFIX} ${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); } } @@ -116,6 +120,7 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { */ startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): IDisposable { if (this._pollingClientCount++ === 0) { + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Start polling ${this.owner}/${this.repo}#${this.prNumber} every ${intervalMs}ms`); this._pollScheduler.schedule(intervalMs); } @@ -131,6 +136,7 @@ export class GitHubPullRequestReviewThreadsModel extends Disposable { } private async _poll(): Promise { + this._logService.trace(`${TRACE_PREFIX} [ReviewThreadsModel] Poll cycle for ${this.owner}/${this.repo}#${this.prNumber}`); await this.refresh(); if (!this._store.isDisposed && this._pollingClientCount > 0) { this._pollScheduler.schedule(); diff --git a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts index 46e210019da3f..d37a9e6c9abc2 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts @@ -9,6 +9,7 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { DisposableStore, IDisposable, ImmortalReference, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; import { GitHubPullRequestCIModel } from '../../browser/models/githubPullRequestCIModel.js'; import { GitHubPullRequestReviewThreadsModel } from '../../browser/models/githubPullRequestReviewThreadsModel.js'; @@ -25,6 +26,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions suite('GitHubPullRequestPollingContribution', () => { const store = new DisposableStore(); + const logService = new NullLogService(); let sessionsManagementService: TestSessionsManagementService; let sessionsService: ISessionsService; let gitHubService: TestGitHubService; @@ -46,7 +48,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('starts polling existing and added pull request sessions', () => { const existingSession = sessionsManagementService.addSession('existing', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); const addedSession = sessionsManagementService.addSession('added', makeGitHubInfo(2)); sessionsManagementService.fireSessionsChanged({ added: [addedSession] }); @@ -60,7 +62,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('stops polling when a session is archived, then resumes when unarchived', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); sessionsManagementService.setArchived(session, true); sessionsManagementService.fireSessionsChanged({ changed: [session] }); @@ -79,7 +81,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('does not poll archived sessions until they are unarchived', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1), true); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); assert.deepStrictEqual(gitHubService.snapshot(), {}); @@ -93,7 +95,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('stops polling tracked pull requests when disposed', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); - const contribution = store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + const contribution = store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); contribution.dispose(); @@ -105,7 +107,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('polls CI checks and review threads once an open pull request resolves', () => { sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); // Until the PR details load, only the PR model is polled. assert.deepStrictEqual(gitHubService.statusModelSnapshot(), { ci: {}, reviewThreads: {} }); @@ -120,7 +122,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('does not poll CI checks or review threads for draft pull requests', () => { sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); gitHubService.setPullRequestDetails('owner', 'repo', 1, { state: GitHubPullRequestState.Open, isDraft: true, headSha: 'sha1' }); @@ -131,7 +133,7 @@ suite('GitHubPullRequestPollingContribution', () => { // Mirrors the agent-host provider, whose `gitHubInfo` initially has no PR // number (it is resolved asynchronously via findPullRequestNumberByHeadBranch). const session = sessionsManagementService.addSession('async', { owner: 'owner', repo: 'repo' }); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); // No PR number yet → nothing is polled. assert.deepStrictEqual(gitHubService.snapshot(), {}); @@ -146,7 +148,7 @@ suite('GitHubPullRequestPollingContribution', () => { test('stops polling a merged pull request unless it is the active session', () => { const session = sessionsManagementService.addSession('session', makeGitHubInfo(1)); - store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService)); + store.add(new GitHubPullRequestPollingContribution(gitHubService, sessionsManagementService, sessionsService, logService)); // Open PR → polling. gitHubService.setPullRequestDetails('owner', 'repo', 1, { state: GitHubPullRequestState.Open, isDraft: false, headSha: 'sha1' }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 70efb251ddca8..bfea62dee7590 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -53,6 +53,7 @@ import { changesetFileToChange, mapProtocolStatus } from './agentHostDiffs.js'; import { createChangesets } from './agentHostSessionChangesets.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; +const TRACE_PREFIX = '[PR-ICON-TRACE]'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); function isSafeSessionConfigKey(property: string): boolean { @@ -301,7 +302,8 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { resourceScheme: string, logicalSessionType: string, private readonly _options: IAgentHostAdapterOptions, - @ISessionsService private readonly _sessionsService: ISessionsService + @ISessionsService private readonly _sessionsService: ISessionsService, + @ILogService private readonly _logService: ILogService, ) { super(); const rawId = AgentSession.id(metadata.session); @@ -360,11 +362,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this.gitHubInfo = derived(this, reader => { const coords = gitHubCoords.read(reader); if (!coords) { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} has no GitHub coords (missing owner/repo/branch in git state); no PR icon`); return undefined; } const innerObs = pullRequestNumberObs.read(reader); const prNumber = innerObs?.read(reader)?.value; if (prNumber === undefined) { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} coords ${coords.owner}/${coords.repo}@${coords.branch}: PR number not resolved yet; emitting gitHubInfo without pullRequest`); return { owner: coords.owner, repo: coords.repo }; } const uri = URI.parse(`https://github.com/${coords.owner}/${coords.repo}/pull/${prNumber}`); @@ -383,7 +387,12 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { hasUnresolvedComments = reviewThreadsRef.object.reviewThreads.read(reader).some(thread => !thread.isResolved); } icon = computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state, { hasFailingChecks, hasUnresolvedComments }); + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} PR ${coords.owner}/${coords.repo}#${prNumber}: livePR present (state ${livePR.state}, isDraft ${livePR.isDraft}, headSha ${livePR.headSha}), hasFailingChecks ${hasFailingChecks}, hasUnresolvedComments ${hasUnresolvedComments} -> icon ${icon?.id ?? 'none'}`); + } else { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} PR ${coords.owner}/${coords.repo}#${prNumber}: livePR not loaded yet; icon undefined (waiting for PR model refresh)`); } + } else { + this._logService.trace(`${TRACE_PREFIX} [IconAdapter] Session ${this.sessionId} PR ${coords.owner}/${coords.repo}#${prNumber}: no GitHub service available; icon undefined`); } return { owner: coords.owner, From 686cd899fa5e82b0f95ffabb936a217c74422239 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 19 Jun 2026 16:42:37 +0100 Subject: [PATCH 19/20] Update icon for continue in background action in chat terminal tool progress part (#322111) fix: update icon for continue in background action in chat terminal tool progress part Co-authored-by: mrleemurray --- .../toolInvocationParts/chatTerminalToolProgressPart.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 512ad7e6da5a6..98bebe70d916c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -712,7 +712,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const action = new Action( TerminalContribCommandId.ContinueInBackground, localize('continueInBackground', 'Continue in Background'), - ThemeIcon.asClassName(Codicon.debugContinue), + ThemeIcon.asClassName(Codicon.debugContinueSmall), true, () => this.continueInBackground() ); From 53b36d652423249f8d4f550c69d054b55dab478c Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:13:47 -0700 Subject: [PATCH 20/20] Reduce duplication of browser history items on refresh etc. (#322105) --- .../browserView/common/browserHistory.ts | 6 ++- .../browserView/electron-main/browserView.ts | 45 ++++++++++++++----- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/browserView/common/browserHistory.ts b/src/vs/platform/browserView/common/browserHistory.ts index 7f0a3d185cdb8..50131aab41661 100644 --- a/src/vs/platform/browserView/common/browserHistory.ts +++ b/src/vs/platform/browserView/common/browserHistory.ts @@ -133,10 +133,12 @@ export class BrowserHistoryEntriesStore extends Disposable { const nextFaviconHash = patch.faviconHash === undefined ? existing.icon : (patch.faviconHash ?? undefined); - if (nextUrl === existing.url && nextTitle === existing.title && nextFaviconHash === existing.icon) { + // Update the time if the URL has been updated. + const nextTime = patch.url ? Date.now() : existing.time; + if (nextUrl === existing.url && nextTitle === existing.title && nextFaviconHash === existing.icon && nextTime === existing.time) { return false; } - this._items[idx] = { ...existing, url: nextUrl, title: nextTitle, icon: nextFaviconHash }; + this._items[idx] = { ...existing, url: nextUrl, title: nextTitle, icon: nextFaviconHash, time: nextTime }; this._onDidChange.fire(); return true; } diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 5fb177994d166..15d04338ca45c 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -44,6 +44,11 @@ export class BrowserView extends Disposable { private _currentHistoryHandle: IBrowserHistoryItemHandle | undefined; private _explicitNavigationPending = false; + /** + * Active index in the webContents navigation history list. + * Used to tell whether a navigation appended a new entry or replaced the current one in place. + */ + private _lastCommittedEntryIndex = -1; readonly debugger: BrowserViewDebugger; readonly emulator: BrowserViewEmulator; @@ -287,7 +292,7 @@ export class BrowserView extends Disposable { this._currentHistoryHandle?.update({ title }); }); - const fireNavigationEvent = (url: string, createNewHistoryItem: boolean) => { + const fireNavigationEvent = (url: string) => { this._onDidNavigate.fire({ url, title: webContents.getTitle(), @@ -295,11 +300,7 @@ export class BrowserView extends Disposable { canGoForward: webContents.navigationHistory.canGoForward(), certificateError: this.session.trust.getCertificateError(url) }); - if (createNewHistoryItem) { - this._trackVisit(url); - } else { - this._currentHistoryHandle?.update({ url }); - } + this._recordNavigation(url); }; const fireLoadingEvent = (loading: boolean) => { @@ -369,8 +370,14 @@ export class BrowserView extends Disposable { }); // Navigation events (when URL actually changes) - webContents.on('did-navigate', (_, url) => fireNavigationEvent(url, true)); - webContents.on('did-navigate-in-page', (_, url) => fireNavigationEvent(url, false)); + webContents.on('did-navigate', (_, url) => fireNavigationEvent(url)); + webContents.on('did-navigate-in-page', (_, url, isMainFrame) => { + // Ignore subframe (iframe) navigations: they must not rewrite the + // main frame's URL bar or its history entry. + if (isMainFrame) { + fireNavigationEvent(url); + } + }); webContents.on('did-navigate', () => { // Chromium resets the zoom factor to its per-origin default (100%) when @@ -495,19 +502,33 @@ export class BrowserView extends Disposable { } /** - * Record a successful navigation in the session's history and remember the - * resulting handle so subsequent title/favicon updates can refine it. + * Record a committed navigation in the session's history. */ - private _trackVisit(url: string): void { + private _recordNavigation(url: string): void { + const webContents = this._view.webContents; + const activeIndex = webContents.navigationHistory.getActiveIndex(); + if (!isTrackableHistoryUrl(url)) { this._currentHistoryHandle = undefined; + this._lastCommittedEntryIndex = activeIndex; + return; + } + + // A commit that leaves the active index unchanged replaced the current + // entry in place; refine the existing history item rather than appending + // a duplicate. + const handle = this._currentHistoryHandle; + if (handle && activeIndex === this._lastCommittedEntryIndex) { + handle.update({ url, title: webContents.getTitle() }); return; } + this._lastCommittedEntryIndex = activeIndex; + const userInitiated = this._explicitNavigationPending; this._explicitNavigationPending = false; this._currentHistoryHandle = this.session.history.add( url, - this._view.webContents.getTitle(), + webContents.getTitle(), this._lastFavicon, userInitiated, );