From 39ebcc7f34004576564c297bc89c691f564d5298 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:51:53 -0800 Subject: [PATCH 01/42] Use esbuild instead of webpack to bundle the css extension Switches from webpack to esbuild to bundle the css extension. Tested this locally in a browser and creating an official build to test the bundled extension still work correctly --- build/gulpfile.extensions.ts | 5 ++- build/lib/extensions.ts | 14 +++++++- .../css-language-features/.vscodeignore | 8 ++--- .../client/tsconfig.browser.json | 7 ++++ .../css-language-features/esbuild.browser.mts | 36 +++++++++++++++++++ extensions/css-language-features/esbuild.mts | 36 +++++++++++++++++++ .../extension-browser.webpack.config.js | 18 ---------- .../extension.webpack.config.js | 18 ---------- .../extension-browser.webpack.config.js | 20 ----------- .../server/extension.webpack.config.js | 18 ---------- .../server/tsconfig.browser.json | 7 ++++ 11 files changed, 105 insertions(+), 82 deletions(-) create mode 100644 extensions/css-language-features/client/tsconfig.browser.json create mode 100644 extensions/css-language-features/esbuild.browser.mts create mode 100644 extensions/css-language-features/esbuild.mts delete mode 100644 extensions/css-language-features/extension-browser.webpack.config.js delete mode 100644 extensions/css-language-features/extension.webpack.config.js delete mode 100644 extensions/css-language-features/server/extension-browser.webpack.config.js delete mode 100644 extensions/css-language-features/server/extension.webpack.config.js create mode 100644 extensions/css-language-features/server/tsconfig.browser.json diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index cae158ea59014..da2e52300c544 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -296,7 +296,10 @@ async function buildWebExtensions(isWatch: boolean): Promise { promises.push( ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script }))), // Also run type check on extensions - ...esbuildConfigLocations.map(script => ext.typeCheckExtension(path.dirname(script), true)) + ...esbuildConfigLocations.flatMap(script => { + const roots = ext.getBuildRootsForExtension(path.dirname(script)); + return roots.map(root => ext.typeCheckExtension(root, true)); + }) ); } diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index fac7946fc98af..51eb019f04251 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -85,7 +85,7 @@ function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolea // Unlike webpack, esbuild only does bundling so we still want to run a separate type check step input = es.merge( fromLocalEsbuild(extensionPath, esbuildConfigFileName), - typeCheckExtensionStream(extensionPath, forWeb), + ...getBuildRootsForExtension(extensionPath).map(root => typeCheckExtensionStream(root, forWeb)), ); isBundled = true; } else if (hasWebpack) { @@ -766,3 +766,15 @@ export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Prom outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined }))); } + +export function getBuildRootsForExtension(extensionPath: string): string[] { + // These extensions split their code between a client and server folder. We should treat each as build roots + if (extensionPath.endsWith('css-language-features') || extensionPath.endsWith('html-language-features') || extensionPath.endsWith('json-language-features')) { + return [ + path.join(extensionPath, 'client'), + path.join(extensionPath, 'server'), + ]; + } + + return [extensionPath]; +} diff --git a/extensions/css-language-features/.vscodeignore b/extensions/css-language-features/.vscodeignore index f6411e76fdb27..c3f4d9fe3dcf4 100644 --- a/extensions/css-language-features/.vscodeignore +++ b/extensions/css-language-features/.vscodeignore @@ -6,16 +6,12 @@ client/src/** server/src/** client/out/** server/out/** -client/tsconfig.json -server/tsconfig.json +**/tsconfig*.json server/test/** server/bin/** server/build/** server/package-lock.json server/.npmignore package-lock.json -server/extension.webpack.config.js -extension.webpack.config.js -server/extension-browser.webpack.config.js -extension-browser.webpack.config.js +**/esbuild*.mts CONTRIBUTING.md diff --git a/extensions/css-language-features/client/tsconfig.browser.json b/extensions/css-language-features/client/tsconfig.browser.json new file mode 100644 index 0000000000000..d10ec3ba37121 --- /dev/null +++ b/extensions/css-language-features/client/tsconfig.browser.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "./src/node/**", + "./src/test/**" + ] +} diff --git a/extensions/css-language-features/esbuild.browser.mts b/extensions/css-language-features/esbuild.browser.mts new file mode 100644 index 0000000000000..83ed767ec5f7e --- /dev/null +++ b/extensions/css-language-features/esbuild.browser.mts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const extensionRoot = import.meta.dirname; + +await Promise.all([ + // Build client + run({ + platform: 'browser', + entryPoints: { + 'cssClientMain': path.join(extensionRoot, 'client', 'src', 'browser', 'cssClientMain.ts'), + }, + srcDir: path.join(extensionRoot, 'client', 'src'), + outdir: path.join(extensionRoot, 'client', 'dist', 'browser'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'client', 'tsconfig.browser.json'), + }, + }, process.argv), + + // Build server + run({ + platform: 'browser', + entryPoints: { + 'cssServerMain': path.join(extensionRoot, 'server', 'src', 'browser', 'cssServerWorkerMain.ts'), + }, + srcDir: path.join(extensionRoot, 'server', 'src'), + outdir: path.join(extensionRoot, 'server', 'dist', 'browser'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'server', 'tsconfig.browser.json'), + }, + }, process.argv), +]); diff --git a/extensions/css-language-features/esbuild.mts b/extensions/css-language-features/esbuild.mts new file mode 100644 index 0000000000000..cef9c7455dce2 --- /dev/null +++ b/extensions/css-language-features/esbuild.mts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const extensionRoot = import.meta.dirname; + +await Promise.all([ + // Build client + run({ + platform: 'node', + entryPoints: { + 'cssClientMain': path.join(extensionRoot, 'client', 'src', 'node', 'cssClientMain.ts'), + }, + srcDir: path.join(extensionRoot, 'client', 'src'), + outdir: path.join(extensionRoot, 'client', 'dist', 'node'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'client', 'tsconfig.json'), + }, + }, process.argv), + + // Build server + run({ + platform: 'node', + entryPoints: { + 'cssServerMain': path.join(extensionRoot, 'server', 'src', 'node', 'cssServerNodeMain.ts'), + }, + srcDir: path.join(extensionRoot, 'server', 'src'), + outdir: path.join(extensionRoot, 'server', 'dist', 'node'), + additionalOptions: { + tsconfig: path.join(extensionRoot, 'server', 'tsconfig.json'), + }, + }, process.argv), +]); diff --git a/extensions/css-language-features/extension-browser.webpack.config.js b/extensions/css-language-features/extension-browser.webpack.config.js deleted file mode 100644 index ea4a69dd9c139..0000000000000 --- a/extensions/css-language-features/extension-browser.webpack.config.js +++ /dev/null @@ -1,18 +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 -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; -import path from 'path'; - -export default withBrowserDefaults({ - context: path.join(import.meta.dirname, 'client'), - entry: { - extension: './src/browser/cssClientMain.ts' - }, - output: { - filename: 'cssClientMain.js', - path: path.join(import.meta.dirname, 'client', 'dist', 'browser') - } -}); diff --git a/extensions/css-language-features/extension.webpack.config.js b/extensions/css-language-features/extension.webpack.config.js deleted file mode 100644 index d8a29c8797dd7..0000000000000 --- a/extensions/css-language-features/extension.webpack.config.js +++ /dev/null @@ -1,18 +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 -import withDefaults from '../shared.webpack.config.mjs'; -import path from 'path'; - -export default withDefaults({ - context: path.join(import.meta.dirname, 'client'), - entry: { - extension: './src/node/cssClientMain.ts', - }, - output: { - filename: 'cssClientMain.js', - path: path.join(import.meta.dirname, 'client', 'dist', 'node') - } -}); diff --git a/extensions/css-language-features/server/extension-browser.webpack.config.js b/extensions/css-language-features/server/extension-browser.webpack.config.js deleted file mode 100644 index 131d293a7c50c..0000000000000 --- a/extensions/css-language-features/server/extension-browser.webpack.config.js +++ /dev/null @@ -1,20 +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 -import { browser as withBrowserDefaults } from '../../shared.webpack.config.mjs'; -import path from 'path'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/browser/cssServerWorkerMain.ts', - }, - output: { - filename: 'cssServerMain.js', - path: path.join(import.meta.dirname, 'dist', 'browser'), - libraryTarget: 'var', - library: 'serverExportVar' - } -}); diff --git a/extensions/css-language-features/server/extension.webpack.config.js b/extensions/css-language-features/server/extension.webpack.config.js deleted file mode 100644 index 5f07bd8f0a1a2..0000000000000 --- a/extensions/css-language-features/server/extension.webpack.config.js +++ /dev/null @@ -1,18 +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 -import withDefaults from '../../shared.webpack.config.mjs'; -import path from 'path'; - -export default withDefaults({ - context: path.join(import.meta.dirname), - entry: { - extension: './src/node/cssServerNodeMain.ts', - }, - output: { - filename: 'cssServerMain.js', - path: path.join(import.meta.dirname, 'dist', 'node'), - } -}); diff --git a/extensions/css-language-features/server/tsconfig.browser.json b/extensions/css-language-features/server/tsconfig.browser.json new file mode 100644 index 0000000000000..d10ec3ba37121 --- /dev/null +++ b/extensions/css-language-features/server/tsconfig.browser.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "./src/node/**", + "./src/test/**" + ] +} From 2a2f6407e4266fa0dd5db1f2b62d8e11d3d0e1bd Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:05:55 -0800 Subject: [PATCH 02/42] Adopt unified js/ts setting for format settings For #292934 --- .../typescript-language-features/package.json | 236 ++++++++++++++++++ .../package.nls.json | 20 ++ .../fileConfigurationManager.ts | 40 ++- .../src/languageFeatures/formatting.ts | 24 +- 4 files changed, 294 insertions(+), 26 deletions(-) diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 70e0d21044499..b291704dcd2bf 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -1451,208 +1451,431 @@ "title": "%configuration.format%", "order": 23, "properties": { + "js/ts.format.enable": { + "type": "boolean", + "default": true, + "description": "%format.enable%", + "scope": "window", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.enable": { "type": "boolean", "default": true, "description": "%javascript.format.enable%", + "markdownDeprecationMessage": "%configuration.format.enable.unifiedDeprecationMessage%", "scope": "window" }, "typescript.format.enable": { "type": "boolean", "default": true, "description": "%typescript.format.enable%", + "markdownDeprecationMessage": "%configuration.format.enable.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.format.insertSpaceAfterCommaDelimiter": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterCommaDelimiter%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterCommaDelimiter": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterCommaDelimiter%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterCommaDelimiter": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterCommaDelimiter%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterConstructor": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterConstructor%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterConstructor": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterConstructor%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterConstructor": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterConstructor%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterSemicolonInForStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterSemicolonInForStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterSemicolonInForStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterSemicolonInForStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceBeforeAndAfterBinaryOperators": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceBeforeAndAfterBinaryOperators": { "type": "boolean", "default": true, "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeAndAfterBinaryOperators.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": { "type": "boolean", "default": true, "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeAndAfterBinaryOperators.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterKeywordsInControlFlowStatements": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterKeywordsInControlFlowStatements.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterKeywordsInControlFlowStatements.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceBeforeFunctionParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceBeforeFunctionParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeFunctionParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceBeforeFunctionParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceBeforeFunctionParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceBeforeFunctionParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { + "type": "boolean", + "default": true, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": { "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.insertSpaceAfterTypeAssertion": { + "type": "boolean", + "default": false, + "description": "%format.insertSpaceAfterTypeAssertion%", + "scope": "resource", + "tags": [ + "TypeScript" + ] + }, "typescript.format.insertSpaceAfterTypeAssertion": { "type": "boolean", "default": false, "description": "%format.insertSpaceAfterTypeAssertion%", + "markdownDeprecationMessage": "%configuration.format.insertSpaceAfterTypeAssertion.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.placeOpenBraceOnNewLineForFunctions": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.placeOpenBraceOnNewLineForFunctions": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForFunctions.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.placeOpenBraceOnNewLineForFunctions": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForFunctions%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForFunctions.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.placeOpenBraceOnNewLineForControlBlocks": { + "type": "boolean", + "default": false, + "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.placeOpenBraceOnNewLineForControlBlocks": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForControlBlocks.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.placeOpenBraceOnNewLineForControlBlocks": { "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", + "markdownDeprecationMessage": "%configuration.format.placeOpenBraceOnNewLineForControlBlocks.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.format.semicolons": { + "type": "string", + "default": "ignore", + "description": "%format.semicolons%", + "scope": "resource", + "enum": [ + "ignore", + "insert", + "remove" + ], + "enumDescriptions": [ + "%format.semicolons.ignore%", + "%format.semicolons.insert%", + "%format.semicolons.remove%" + ], + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.semicolons": { "type": "string", "default": "ignore", "description": "%format.semicolons%", + "markdownDeprecationMessage": "%configuration.format.semicolons.unifiedDeprecationMessage%", "scope": "resource", "enum": [ "ignore", @@ -1669,6 +1892,7 @@ "type": "string", "default": "ignore", "description": "%format.semicolons%", + "markdownDeprecationMessage": "%configuration.format.semicolons.unifiedDeprecationMessage%", "scope": "resource", "enum": [ "ignore", @@ -1681,16 +1905,28 @@ "%format.semicolons.remove%" ] }, + "js/ts.format.indentSwitchCase": { + "type": "boolean", + "default": true, + "description": "%format.indentSwitchCase%", + "scope": "resource", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.format.indentSwitchCase": { "type": "boolean", "default": true, "description": "%format.indentSwitchCase%", + "markdownDeprecationMessage": "%configuration.format.indentSwitchCase.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.format.indentSwitchCase": { "type": "boolean", "default": true, "description": "%format.indentSwitchCase%", + "markdownDeprecationMessage": "%configuration.format.indentSwitchCase.unifiedDeprecationMessage%", "scope": "resource" } } diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 59a9ea92331bb..cc9fc7e38cc2a 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -49,6 +49,26 @@ "format.semicolons.insert": "Insert semicolons at statement ends.", "format.semicolons.remove": "Remove unnecessary semicolons.", "format.indentSwitchCase": "Indent case clauses in switch statements. Requires using TypeScript 5.1+ in the workspace.", + "format.enable": "Enable/disable the default JavaScript and TypeScript formatter.", + "configuration.format.enable.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.enable#` instead.", + "configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterCommaDelimiter#` instead.", + "configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterConstructor#` instead.", + "configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterSemicolonInForStatements#` instead.", + "configuration.format.insertSpaceBeforeAndAfterBinaryOperators.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceBeforeAndAfterBinaryOperators#` instead.", + "configuration.format.insertSpaceAfterKeywordsInControlFlowStatements.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterKeywordsInControlFlowStatements#` instead.", + "configuration.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions#` instead.", + "configuration.format.insertSpaceBeforeFunctionParenthesis.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceBeforeFunctionParenthesis#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces#` instead.", + "configuration.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces#` instead.", + "configuration.format.insertSpaceAfterTypeAssertion.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterTypeAssertion#` instead.", + "configuration.format.placeOpenBraceOnNewLineForFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.placeOpenBraceOnNewLineForFunctions#` instead.", + "configuration.format.placeOpenBraceOnNewLineForControlBlocks.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.placeOpenBraceOnNewLineForControlBlocks#` instead.", + "configuration.format.semicolons.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.semicolons#` instead.", + "configuration.format.indentSwitchCase.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.indentSwitchCase#` instead.", "javascript.validate.enable": "Enable/disable JavaScript validation.", "javascript.goToProjectConfig.title": "Go to Project Configuration (jsconfig / tsconfig)", "typescript.goToProjectConfig.title": "Go to Project Configuration (tsconfig)", diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index 5b89c4340f329..0ee2aac227857 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -142,9 +142,7 @@ export default class FileConfigurationManager extends Disposable { document: vscode.TextDocument, options: FormattingOptions ): Proto.FormatCodeSettings { - const config = vscode.workspace.getConfiguration( - isTypeScriptDocument(document) ? 'typescript.format' : 'javascript.format', - document.uri); + const fallbackSection = isTypeScriptDocument(document) ? 'typescript' : 'javascript'; return { tabSize: options.tabSize, @@ -152,24 +150,24 @@ export default class FileConfigurationManager extends Disposable { convertTabsToSpaces: options.insertSpaces, // We can use \n here since the editor normalizes later on to its line endings. newLineCharacter: '\n', - insertSpaceAfterCommaDelimiter: config.get('insertSpaceAfterCommaDelimiter'), - insertSpaceAfterConstructor: config.get('insertSpaceAfterConstructor'), - insertSpaceAfterSemicolonInForStatements: config.get('insertSpaceAfterSemicolonInForStatements'), - insertSpaceBeforeAndAfterBinaryOperators: config.get('insertSpaceBeforeAndAfterBinaryOperators'), - insertSpaceAfterKeywordsInControlFlowStatements: config.get('insertSpaceAfterKeywordsInControlFlowStatements'), - insertSpaceAfterFunctionKeywordForAnonymousFunctions: config.get('insertSpaceAfterFunctionKeywordForAnonymousFunctions'), - insertSpaceBeforeFunctionParenthesis: config.get('insertSpaceBeforeFunctionParenthesis'), - insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: config.get('insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis'), - insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: config.get('insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets'), - insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces'), - insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingEmptyBraces'), - insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces'), - insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: config.get('insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces'), - insertSpaceAfterTypeAssertion: config.get('insertSpaceAfterTypeAssertion'), - placeOpenBraceOnNewLineForFunctions: config.get('placeOpenBraceOnNewLineForFunctions'), - placeOpenBraceOnNewLineForControlBlocks: config.get('placeOpenBraceOnNewLineForControlBlocks'), - semicolons: config.get('semicolons'), - indentSwitchCase: config.get('indentSwitchCase'), + insertSpaceAfterCommaDelimiter: readUnifiedConfig('format.insertSpaceAfterCommaDelimiter', true, { scope: document.uri, fallbackSection }), + insertSpaceAfterConstructor: readUnifiedConfig('format.insertSpaceAfterConstructor', false, { scope: document.uri, fallbackSection }), + insertSpaceAfterSemicolonInForStatements: readUnifiedConfig('format.insertSpaceAfterSemicolonInForStatements', true, { scope: document.uri, fallbackSection }), + insertSpaceBeforeAndAfterBinaryOperators: readUnifiedConfig('format.insertSpaceBeforeAndAfterBinaryOperators', true, { scope: document.uri, fallbackSection }), + insertSpaceAfterKeywordsInControlFlowStatements: readUnifiedConfig('format.insertSpaceAfterKeywordsInControlFlowStatements', true, { scope: document.uri, fallbackSection }), + insertSpaceAfterFunctionKeywordForAnonymousFunctions: readUnifiedConfig('format.insertSpaceAfterFunctionKeywordForAnonymousFunctions', true, { scope: document.uri, fallbackSection }), + insertSpaceBeforeFunctionParenthesis: readUnifiedConfig('format.insertSpaceBeforeFunctionParenthesis', false, { scope: document.uri, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis', false, { scope: document.uri, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets', false, { scope: document.uri, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces', true, { scope: document.uri, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces', true, { scope: document.uri, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces', false, { scope: document.uri, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces', false, { scope: document.uri, fallbackSection }), + insertSpaceAfterTypeAssertion: readUnifiedConfig('format.insertSpaceAfterTypeAssertion', false, { scope: document.uri, fallbackSection }), + placeOpenBraceOnNewLineForFunctions: readUnifiedConfig('format.placeOpenBraceOnNewLineForFunctions', false, { scope: document.uri, fallbackSection }), + placeOpenBraceOnNewLineForControlBlocks: readUnifiedConfig('format.placeOpenBraceOnNewLineForControlBlocks', false, { scope: document.uri, fallbackSection }), + semicolons: readUnifiedConfig('format.semicolons', 'ignore' as Proto.SemicolonPreference, { scope: document.uri, fallbackSection }), + indentSwitchCase: readUnifiedConfig('format.indentSwitchCase', true, { scope: document.uri, fallbackSection }), }; } diff --git a/extensions/typescript-language-features/src/languageFeatures/formatting.ts b/extensions/typescript-language-features/src/languageFeatures/formatting.ts index 575487b502d7c..225e0fa2e7068 100644 --- a/extensions/typescript-language-features/src/languageFeatures/formatting.ts +++ b/extensions/typescript-language-features/src/languageFeatures/formatting.ts @@ -9,12 +9,14 @@ import { LanguageDescription } from '../configuration/languageDescription'; import type * as Proto from '../tsServer/protocol/protocol'; import * as typeConverters from '../typeConverters'; import { ITypeScriptServiceClient } from '../typescriptService'; +import { readUnifiedConfig } from '../utils/configuration'; import FileConfigurationManager from './fileConfigurationManager'; -import { conditionalRegistration, requireGlobalConfiguration } from './util/dependentRegistration'; +import { conditionalRegistration, requireHasModifiedUnifiedConfig } from './util/dependentRegistration'; class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEditProvider, vscode.OnTypeFormattingEditProvider { public constructor( private readonly client: ITypeScriptServiceClient, + private readonly language: LanguageDescription, private readonly fileConfigurationManager: FileConfigurationManager ) { } @@ -24,6 +26,10 @@ class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEdit options: vscode.FormattingOptions, token: vscode.CancellationToken ): Promise { + if (!this.isEnabled(document)) { + return undefined; + } + const file = this.client.toOpenTsFilePath(document); if (!file) { return undefined; @@ -46,10 +52,14 @@ class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEdit ch: string, options: vscode.FormattingOptions, token: vscode.CancellationToken - ): Promise { + ): Promise { + if (!this.isEnabled(document)) { + return undefined; + } + const file = this.client.toOpenTsFilePath(document); if (!file) { - return []; + return undefined; } await this.fileConfigurationManager.ensureConfigurationOptions(document, options, token); @@ -83,6 +93,10 @@ class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEdit } return result; } + + private isEnabled(document: vscode.TextDocument): boolean { + return readUnifiedConfig('format.enable', true, { scope: document, fallbackSection: this.language.id }); + } } export function register( @@ -92,9 +106,9 @@ export function register( fileConfigurationManager: FileConfigurationManager ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'format.enable'), + requireHasModifiedUnifiedConfig('format.enable', language.id), ], () => { - const formattingProvider = new TypeScriptFormattingProvider(client, fileConfigurationManager); + const formattingProvider = new TypeScriptFormattingProvider(client, language, fileConfigurationManager); return vscode.Disposable.from( vscode.languages.registerOnTypeFormattingEditProvider(selector.syntax, formattingProvider, ';', '}', '\n'), vscode.languages.registerDocumentRangeFormattingEditProvider(selector.syntax, formattingProvider), From 3f8fc43e23257f3fcfbd2f9963586f36cd918b97 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:56:50 -0800 Subject: [PATCH 03/42] Use standard setting name and fix check --- .../typescript-language-features/package.json | 4 ++-- .../src/languageFeatures/formatting.ts | 20 +++---------------- .../util/dependentRegistration.ts | 14 ++++++++++++- .../src/utils/configuration.ts | 13 +++++++----- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index b291704dcd2bf..ea0c302e2fd4c 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -1451,11 +1451,11 @@ "title": "%configuration.format%", "order": 23, "properties": { - "js/ts.format.enable": { + "js/ts.format.enabled": { "type": "boolean", "default": true, "description": "%format.enable%", - "scope": "window", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" diff --git a/extensions/typescript-language-features/src/languageFeatures/formatting.ts b/extensions/typescript-language-features/src/languageFeatures/formatting.ts index 225e0fa2e7068..4b23268cd4ca7 100644 --- a/extensions/typescript-language-features/src/languageFeatures/formatting.ts +++ b/extensions/typescript-language-features/src/languageFeatures/formatting.ts @@ -9,14 +9,12 @@ import { LanguageDescription } from '../configuration/languageDescription'; import type * as Proto from '../tsServer/protocol/protocol'; import * as typeConverters from '../typeConverters'; import { ITypeScriptServiceClient } from '../typescriptService'; -import { readUnifiedConfig } from '../utils/configuration'; import FileConfigurationManager from './fileConfigurationManager'; -import { conditionalRegistration, requireHasModifiedUnifiedConfig } from './util/dependentRegistration'; +import { conditionalRegistration, requireGlobalUnifiedConfig } from './util/dependentRegistration'; class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEditProvider, vscode.OnTypeFormattingEditProvider { public constructor( private readonly client: ITypeScriptServiceClient, - private readonly language: LanguageDescription, private readonly fileConfigurationManager: FileConfigurationManager ) { } @@ -26,10 +24,6 @@ class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEdit options: vscode.FormattingOptions, token: vscode.CancellationToken ): Promise { - if (!this.isEnabled(document)) { - return undefined; - } - const file = this.client.toOpenTsFilePath(document); if (!file) { return undefined; @@ -53,10 +47,6 @@ class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEdit options: vscode.FormattingOptions, token: vscode.CancellationToken ): Promise { - if (!this.isEnabled(document)) { - return undefined; - } - const file = this.client.toOpenTsFilePath(document); if (!file) { return undefined; @@ -93,10 +83,6 @@ class TypeScriptFormattingProvider implements vscode.DocumentRangeFormattingEdit } return result; } - - private isEnabled(document: vscode.TextDocument): boolean { - return readUnifiedConfig('format.enable', true, { scope: document, fallbackSection: this.language.id }); - } } export function register( @@ -106,9 +92,9 @@ export function register( fileConfigurationManager: FileConfigurationManager ) { return conditionalRegistration([ - requireHasModifiedUnifiedConfig('format.enable', language.id), + requireGlobalUnifiedConfig('format.enabled', { fallbackSection: language.id, fallbackSubSectionNameOverride: 'format.enable' }), ], () => { - const formattingProvider = new TypeScriptFormattingProvider(client, language, fileConfigurationManager); + const formattingProvider = new TypeScriptFormattingProvider(client, fileConfigurationManager); return vscode.Disposable.from( vscode.languages.registerOnTypeFormattingEditProvider(selector.syntax, formattingProvider, ';', '}', '\n'), vscode.languages.registerDocumentRangeFormattingEditProvider(selector.syntax, formattingProvider), diff --git a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts index 916bfd8f3aecf..e390d9a29fa7b 100644 --- a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts +++ b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { API } from '../../tsServer/api'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { hasModifiedUnifiedConfig } from '../../utils/configuration'; +import { hasModifiedUnifiedConfig, readUnifiedConfig, ReadUnifiedConfigOptions } from '../../utils/configuration'; import { Disposable } from '../../utils/dispose'; export class Condition extends Disposable { @@ -118,6 +118,18 @@ export function requireHasModifiedUnifiedConfig( ); } +export function requireGlobalUnifiedConfig( + configValue: string, + options: ReadUnifiedConfigOptions +) { + return new Condition( + () => { + return !!readUnifiedConfig(configValue, undefined, options); + }, + vscode.workspace.onDidChangeConfiguration + ); +} + export function requireSomeCapability( client: ITypeScriptServiceClient, ...capabilities: readonly ClientCapability[] diff --git a/extensions/typescript-language-features/src/utils/configuration.ts b/extensions/typescript-language-features/src/utils/configuration.ts index b10a70fd27a9d..c1d85755be795 100644 --- a/extensions/typescript-language-features/src/utils/configuration.ts +++ b/extensions/typescript-language-features/src/utils/configuration.ts @@ -9,6 +9,12 @@ type ConfigurationScope = vscode.ConfigurationScope | null | undefined; export const unifiedConfigSection = 'js/ts'; +export type ReadUnifiedConfigOptions = { + readonly scope?: ConfigurationScope; + readonly fallbackSection: string; + readonly fallbackSubSectionNameOverride?: string; +}; + /** * Gets a configuration value, checking the unified `js/ts` setting first, * then falling back to the language-specific setting. @@ -16,10 +22,7 @@ export const unifiedConfigSection = 'js/ts'; export function readUnifiedConfig( subSectionName: string, defaultValue: T, - options: { - readonly scope?: ConfigurationScope; - readonly fallbackSection: string; - } + options: ReadUnifiedConfigOptions ): T { // Check unified setting first const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); @@ -30,7 +33,7 @@ export function readUnifiedConfig( // Fall back to language-specific setting const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); - return languageConfig.get(subSectionName, defaultValue); + return languageConfig.get(options.fallbackSubSectionNameOverride ?? subSectionName, defaultValue); } /** From ac2c7aab7f8dfdfe859eb56c9b13ff1c3a4866d6 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 18 Feb 2026 14:45:34 +0000 Subject: [PATCH 04/42] Adjust z-index for in-editor pane iframes to ensure proper rendering above sidebar and sashes --- extensions/theme-2026/themes/styles.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 6dfbb2888a496..de678e4e112ea 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -61,11 +61,16 @@ } /* Ensure iframe containers in pane-body render above sidebar z-index */ -.monaco-workbench > div[data-keybinding-context], .monaco-workbench > div[data-keybinding-context] { z-index: 50 !important; } +/* In-editor pane iframes don't render above sidebar z-index */ +.monaco-workbench > div[data-parent-flow-to-element-id] { + z-index: 0 !important; +} + + /* Ensure webview containers render above sidebar z-index */ .monaco-workbench .part.sidebar .webview, .monaco-workbench .part.sidebar .webview-container, From f071a3fe5610c7817dcd07979ec4e0959d11333a Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 18 Feb 2026 14:58:33 +0000 Subject: [PATCH 05/42] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/theme-2026/themes/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index de678e4e112ea..524383c8742ce 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -65,7 +65,7 @@ z-index: 50 !important; } -/* In-editor pane iframes don't render above sidebar z-index */ +/* Ensure in-editor pane iframes render below sidebar z-index */ .monaco-workbench > div[data-parent-flow-to-element-id] { z-index: 0 !important; } From cd0e3f3485d9653b9f6c9faf3e22e1b0c01bbc42 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:45:46 -0800 Subject: [PATCH 06/42] Fixing scopes --- .../src/languageFeatures/completions.ts | 16 +++--- .../fileConfigurationManager.ts | 54 +++++++++---------- .../src/utils/configuration.ts | 6 +-- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/extensions/typescript-language-features/src/languageFeatures/completions.ts b/extensions/typescript-language-features/src/languageFeatures/completions.ts index a6378deaece6d..030bb84ea0034 100644 --- a/extensions/typescript-language-features/src/languageFeatures/completions.ts +++ b/extensions/typescript-language-features/src/languageFeatures/completions.ts @@ -16,7 +16,7 @@ import * as typeConverters from '../typeConverters'; import { ClientCapability, ITypeScriptServiceClient, ServerResponse } from '../typescriptService'; import TypingsStatus from '../ui/typingsStatus'; import { nulToken } from '../utils/cancellation'; -import { readUnifiedConfig } from '../utils/configuration'; +import { readUnifiedConfig, UnifiedConfigurationScope } from '../utils/configuration'; import FileConfigurationManager from './fileConfigurationManager'; import { applyCodeAction } from './util/codeAction'; import { conditionalRegistration, requireSomeCapability } from './util/dependentRegistration'; @@ -667,14 +667,14 @@ namespace CompletionConfiguration { export function getConfigurationForResource( modeId: string, - resource: vscode.Uri + scope: UnifiedConfigurationScope ): CompletionConfiguration { - const config = vscode.workspace.getConfiguration(modeId, resource); + const config = vscode.workspace.getConfiguration(modeId, scope); return { - completeFunctionCalls: readUnifiedConfig(CompletionConfiguration.completeFunctionCalls, false, { scope: resource, fallbackSection: modeId }), - pathSuggestions: readUnifiedConfig(CompletionConfiguration.pathSuggestions, true, { scope: resource, fallbackSection: modeId }), - autoImportSuggestions: readUnifiedConfig(CompletionConfiguration.autoImportSuggestions, true, { scope: resource, fallbackSection: modeId }), - nameSuggestions: readUnifiedConfig(CompletionConfiguration.nameSuggestions, true, { scope: resource, fallbackSection: modeId }), + completeFunctionCalls: readUnifiedConfig(CompletionConfiguration.completeFunctionCalls, false, { scope: scope, fallbackSection: modeId }), + pathSuggestions: readUnifiedConfig(CompletionConfiguration.pathSuggestions, true, { scope: scope, fallbackSection: modeId }), + autoImportSuggestions: readUnifiedConfig(CompletionConfiguration.autoImportSuggestions, true, { scope: scope, fallbackSection: modeId }), + nameSuggestions: readUnifiedConfig(CompletionConfiguration.nameSuggestions, true, { scope: scope, fallbackSection: modeId }), importStatementSuggestions: config.get(CompletionConfiguration.importStatementSuggestions, true), }; } @@ -727,7 +727,7 @@ class TypeScriptCompletionItemProvider implements vscode.CompletionItemProvider< } const line = document.lineAt(position.line); - const completionConfiguration = CompletionConfiguration.getConfigurationForResource(this.language.id, document.uri); + const completionConfiguration = CompletionConfiguration.getConfigurationForResource(this.language.id, document); if (!this.shouldTrigger(context, line, position, completionConfiguration)) { return undefined; diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index 0ee2aac227857..21f48f20d632e 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -10,7 +10,7 @@ import { isTypeScriptDocument } from '../configuration/languageIds'; import { API } from '../tsServer/api'; import type * as Proto from '../tsServer/protocol/protocol'; import { ITypeScriptServiceClient } from '../typescriptService'; -import { readUnifiedConfig } from '../utils/configuration'; +import { readUnifiedConfig, UnifiedConfigurationScope } from '../utils/configuration'; import { Disposable } from '../utils/dispose'; import { equals } from '../utils/objects'; import { ResourceMap } from '../utils/resourceMap'; @@ -150,24 +150,24 @@ export default class FileConfigurationManager extends Disposable { convertTabsToSpaces: options.insertSpaces, // We can use \n here since the editor normalizes later on to its line endings. newLineCharacter: '\n', - insertSpaceAfterCommaDelimiter: readUnifiedConfig('format.insertSpaceAfterCommaDelimiter', true, { scope: document.uri, fallbackSection }), - insertSpaceAfterConstructor: readUnifiedConfig('format.insertSpaceAfterConstructor', false, { scope: document.uri, fallbackSection }), - insertSpaceAfterSemicolonInForStatements: readUnifiedConfig('format.insertSpaceAfterSemicolonInForStatements', true, { scope: document.uri, fallbackSection }), - insertSpaceBeforeAndAfterBinaryOperators: readUnifiedConfig('format.insertSpaceBeforeAndAfterBinaryOperators', true, { scope: document.uri, fallbackSection }), - insertSpaceAfterKeywordsInControlFlowStatements: readUnifiedConfig('format.insertSpaceAfterKeywordsInControlFlowStatements', true, { scope: document.uri, fallbackSection }), - insertSpaceAfterFunctionKeywordForAnonymousFunctions: readUnifiedConfig('format.insertSpaceAfterFunctionKeywordForAnonymousFunctions', true, { scope: document.uri, fallbackSection }), - insertSpaceBeforeFunctionParenthesis: readUnifiedConfig('format.insertSpaceBeforeFunctionParenthesis', false, { scope: document.uri, fallbackSection }), - insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis', false, { scope: document.uri, fallbackSection }), - insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets', false, { scope: document.uri, fallbackSection }), - insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces', true, { scope: document.uri, fallbackSection }), - insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces', true, { scope: document.uri, fallbackSection }), - insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces', false, { scope: document.uri, fallbackSection }), - insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces', false, { scope: document.uri, fallbackSection }), - insertSpaceAfterTypeAssertion: readUnifiedConfig('format.insertSpaceAfterTypeAssertion', false, { scope: document.uri, fallbackSection }), - placeOpenBraceOnNewLineForFunctions: readUnifiedConfig('format.placeOpenBraceOnNewLineForFunctions', false, { scope: document.uri, fallbackSection }), - placeOpenBraceOnNewLineForControlBlocks: readUnifiedConfig('format.placeOpenBraceOnNewLineForControlBlocks', false, { scope: document.uri, fallbackSection }), - semicolons: readUnifiedConfig('format.semicolons', 'ignore' as Proto.SemicolonPreference, { scope: document.uri, fallbackSection }), - indentSwitchCase: readUnifiedConfig('format.indentSwitchCase', true, { scope: document.uri, fallbackSection }), + insertSpaceAfterCommaDelimiter: readUnifiedConfig('format.insertSpaceAfterCommaDelimiter', true, { scope: document, fallbackSection }), + insertSpaceAfterConstructor: readUnifiedConfig('format.insertSpaceAfterConstructor', false, { scope: document, fallbackSection }), + insertSpaceAfterSemicolonInForStatements: readUnifiedConfig('format.insertSpaceAfterSemicolonInForStatements', true, { scope: document, fallbackSection }), + insertSpaceBeforeAndAfterBinaryOperators: readUnifiedConfig('format.insertSpaceBeforeAndAfterBinaryOperators', true, { scope: document, fallbackSection }), + insertSpaceAfterKeywordsInControlFlowStatements: readUnifiedConfig('format.insertSpaceAfterKeywordsInControlFlowStatements', true, { scope: document, fallbackSection }), + insertSpaceAfterFunctionKeywordForAnonymousFunctions: readUnifiedConfig('format.insertSpaceAfterFunctionKeywordForAnonymousFunctions', true, { scope: document, fallbackSection }), + insertSpaceBeforeFunctionParenthesis: readUnifiedConfig('format.insertSpaceBeforeFunctionParenthesis', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces', true, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces', true, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces', false, { scope: document, fallbackSection }), + insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: readUnifiedConfig('format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces', false, { scope: document, fallbackSection }), + insertSpaceAfterTypeAssertion: readUnifiedConfig('format.insertSpaceAfterTypeAssertion', false, { scope: document, fallbackSection }), + placeOpenBraceOnNewLineForFunctions: readUnifiedConfig('format.placeOpenBraceOnNewLineForFunctions', false, { scope: document, fallbackSection }), + placeOpenBraceOnNewLineForControlBlocks: readUnifiedConfig('format.placeOpenBraceOnNewLineForControlBlocks', false, { scope: document, fallbackSection }), + semicolons: readUnifiedConfig('format.semicolons', 'ignore' as Proto.SemicolonPreference, { scope: document, fallbackSection }), + indentSwitchCase: readUnifiedConfig('format.indentSwitchCase', true, { scope: document, fallbackSection }), }; } @@ -211,7 +211,7 @@ export default class FileConfigurationManager extends Disposable { return preferences; } - private getAutoImportFileExcludePatternsPreference(scope: vscode.ConfigurationScope, fallbackSection: string, workspaceFolder: vscode.Uri | undefined): string[] | undefined { + private getAutoImportFileExcludePatternsPreference(scope: UnifiedConfigurationScope, fallbackSection: string, workspaceFolder: vscode.Uri | undefined): string[] | undefined { const patterns = readUnifiedConfig('preferences.autoImportFileExcludePatterns', undefined, { scope, fallbackSection }); return workspaceFolder && patterns?.map(p => { // Normalization rules: https://github.com/microsoft/TypeScript/pull/49578 @@ -254,7 +254,7 @@ export const InlayHintSettingNames = Object.freeze({ enumMemberValuesEnabled: 'inlayHints.enumMemberValues.enabled', }); -export function getInlayHintsPreferences(scope: vscode.ConfigurationScope, fallbackSection: string) { +export function getInlayHintsPreferences(scope: UnifiedConfigurationScope, fallbackSection: string) { return { includeInlayParameterNameHints: getInlayParameterNameHintsPreference(scope, fallbackSection), includeInlayParameterNameHintsWhenArgumentMatchesName: !readUnifiedConfig(InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, true, { scope, fallbackSection }), @@ -267,7 +267,7 @@ export function getInlayHintsPreferences(scope: vscode.ConfigurationScope, fallb } as const; } -function getInlayParameterNameHintsPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getInlayParameterNameHintsPreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig(InlayHintSettingNames.parameterNamesEnabled, 'none', { scope, fallbackSection })) { case 'none': return 'none'; case 'literals': return 'literals'; @@ -276,7 +276,7 @@ function getInlayParameterNameHintsPreference(scope: vscode.ConfigurationScope, } } -function getQuoteStylePreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getQuoteStylePreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.quoteStyle', 'auto', { scope, fallbackSection })) { case 'single': return 'single'; case 'double': return 'double'; @@ -284,7 +284,7 @@ function getQuoteStylePreference(scope: vscode.ConfigurationScope, fallbackSecti } } -function getImportModuleSpecifierPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getImportModuleSpecifierPreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.importModuleSpecifier', 'shortest', { scope, fallbackSection })) { case 'project-relative': return 'project-relative'; case 'relative': return 'relative'; @@ -293,7 +293,7 @@ function getImportModuleSpecifierPreference(scope: vscode.ConfigurationScope, fa } } -function getImportModuleSpecifierEndingPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getImportModuleSpecifierEndingPreference(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.importModuleSpecifierEnding', 'auto', { scope, fallbackSection })) { case 'minimal': return 'minimal'; case 'index': return 'index'; @@ -302,7 +302,7 @@ function getImportModuleSpecifierEndingPreference(scope: vscode.ConfigurationSco } } -function getJsxAttributeCompletionStyle(scope: vscode.ConfigurationScope, fallbackSection: string) { +function getJsxAttributeCompletionStyle(scope: UnifiedConfigurationScope, fallbackSection: string) { switch (readUnifiedConfig('preferences.jsxAttributeCompletionStyle', 'auto', { scope, fallbackSection })) { case 'braces': return 'braces'; case 'none': return 'none'; @@ -310,7 +310,7 @@ function getJsxAttributeCompletionStyle(scope: vscode.ConfigurationScope, fallba } } -function getOrganizeImportsPreferences(scope: vscode.ConfigurationScope, fallbackSection: string): Proto.UserPreferences { +function getOrganizeImportsPreferences(scope: UnifiedConfigurationScope, fallbackSection: string): Proto.UserPreferences { const organizeImportsCollation = readUnifiedConfig<'ordinal' | 'unicode'>('preferences.organizeImports.unicodeCollation', 'ordinal', { scope, fallbackSection }); const organizeImportsCaseSensitivity = readUnifiedConfig<'auto' | 'caseInsensitive' | 'caseSensitive'>('preferences.organizeImports.caseSensitivity', 'auto', { scope, fallbackSection }); return { diff --git a/extensions/typescript-language-features/src/utils/configuration.ts b/extensions/typescript-language-features/src/utils/configuration.ts index c1d85755be795..e0038500cf4ba 100644 --- a/extensions/typescript-language-features/src/utils/configuration.ts +++ b/extensions/typescript-language-features/src/utils/configuration.ts @@ -5,12 +5,12 @@ import * as vscode from 'vscode'; -type ConfigurationScope = vscode.ConfigurationScope | null | undefined; +export type UnifiedConfigurationScope = vscode.TextDocument | null | undefined; export const unifiedConfigSection = 'js/ts'; export type ReadUnifiedConfigOptions = { - readonly scope?: ConfigurationScope; + readonly scope?: UnifiedConfigurationScope; readonly fallbackSection: string; readonly fallbackSubSectionNameOverride?: string; }; @@ -61,7 +61,7 @@ function hasModifiedValue(inspect: ReturnType Date: Wed, 18 Feb 2026 07:53:14 -0800 Subject: [PATCH 07/42] Mark more settings as language overridable --- .../typescript-language-features/package.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index ea0c302e2fd4c..baf20de579dc0 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -1479,7 +1479,7 @@ "type": "boolean", "default": true, "description": "%format.insertSpaceAfterCommaDelimiter%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1503,7 +1503,7 @@ "type": "boolean", "default": false, "description": "%format.insertSpaceAfterConstructor%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1527,7 +1527,7 @@ "type": "boolean", "default": true, "description": "%format.insertSpaceAfterSemicolonInForStatements%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1551,7 +1551,7 @@ "type": "boolean", "default": true, "description": "%format.insertSpaceBeforeAndAfterBinaryOperators%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1575,7 +1575,7 @@ "type": "boolean", "default": true, "description": "%format.insertSpaceAfterKeywordsInControlFlowStatements%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1599,7 +1599,7 @@ "type": "boolean", "default": true, "description": "%format.insertSpaceAfterFunctionKeywordForAnonymousFunctions%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1623,7 +1623,7 @@ "type": "boolean", "default": false, "description": "%format.insertSpaceBeforeFunctionParenthesis%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1647,7 +1647,7 @@ "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1671,7 +1671,7 @@ "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1695,7 +1695,7 @@ "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1719,7 +1719,7 @@ "type": "boolean", "default": true, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1743,7 +1743,7 @@ "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1767,7 +1767,7 @@ "type": "boolean", "default": false, "description": "%format.insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1791,7 +1791,7 @@ "type": "boolean", "default": false, "description": "%format.insertSpaceAfterTypeAssertion%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "TypeScript" ] @@ -1807,7 +1807,7 @@ "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForFunctions%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1831,7 +1831,7 @@ "type": "boolean", "default": false, "description": "%format.placeOpenBraceOnNewLineForControlBlocks%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" @@ -1855,7 +1855,7 @@ "type": "string", "default": "ignore", "description": "%format.semicolons%", - "scope": "resource", + "scope": "language-overridable", "enum": [ "ignore", "insert", @@ -1909,7 +1909,7 @@ "type": "boolean", "default": true, "description": "%format.indentSwitchCase%", - "scope": "resource", + "scope": "language-overridable", "tags": [ "JavaScript", "TypeScript" From 2d131b8663ad02000f79a8d3d4492dc09224df47 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 18 Feb 2026 16:41:25 +0000 Subject: [PATCH 08/42] refine quick input list separator styles and enhance settings editor background colors --- extensions/theme-2026/themes/styles.css | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 6dfbb2888a496..4cb4036a728d2 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -200,7 +200,7 @@ background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 95%, black) !important; } -.quick-input-list .quick-input-list-entry .quick-input-list-separator { +.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { height: 16px; margin-top: 2px; display: flex; @@ -208,14 +208,18 @@ font-size: 11px; padding: 0 4px 1px 4px; border-radius: var(--vscode-cornerRadius-small) !important; - background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; + background: color-mix(in srgb, var(--vscode-badge-background) 70%, transparent) !important; color: var(--vscode-badge-foreground) !important; margin-right: 8px; } -.monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, -.monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, -.monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { +.monaco-workbench.vs-dark .quick-input-list .quick-input-list-entry .quick-input-list-separator { + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; +} + +.monaco-workbench .monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, +.monaco-workbench .monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, +.monaco-workbench .monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { background: transparent !important; color: inherit !important; padding: 0; @@ -437,6 +441,15 @@ box-shadow: var(--shadow-sm); } +.monaco-workbench .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--vscode-badge-background) 70%, transparent) !important; +} + +.monaco-workbench.vs-dark .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; +} + /* Welcome Tiles */ .monaco-workbench .part.editor .welcomePageContainer .tile { box-shadow: var(--shadow-md); From 7d0d4b29c8bf1ea722542bb294b9b15e9475ef94 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:54:34 +0000 Subject: [PATCH 09/42] Add trace logging for slow regex matching in problem matchers (#295961) * Initial plan * Add trace logging for slow regex matching in problem matchers Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../tasks/browser/terminalTaskSystem.ts | 4 +-- .../contrib/tasks/common/problemCollectors.ts | 23 ++++++++---- .../contrib/tasks/common/problemMatcher.ts | 35 +++++++++++++------ 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 82bc7998af4c0..2cf3ac03c1e76 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -880,7 +880,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { let promise: Promise | undefined = undefined; if (task.configurationProperties.isBackground) { const problemMatchers = await this._resolveMatchers(resolver, task.configurationProperties.problemMatchers); - const watchingProblemMatcher = new WatchingProblemCollector(problemMatchers, this._markerService, this._modelService, this._fileService); + const watchingProblemMatcher = new WatchingProblemCollector(problemMatchers, this._markerService, this._modelService, this._fileService, this._logService); if ((problemMatchers.length > 0) && !watchingProblemMatcher.isWatching()) { this._appendOutput(nls.localize('TerminalTaskSystem.nonWatchingMatcher', 'Task {0} is a background task but uses a problem matcher without a background pattern', task._label)); this._showOutput(); @@ -1047,7 +1047,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._fireTaskEvent(TaskEvent.general(TaskEventKind.Active, task, terminal.instanceId)); const problemMatchers = await this._resolveMatchers(resolver, task.configurationProperties.problemMatchers); - const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService); + const startStopProblemMatcher = new StartStopProblemCollector(problemMatchers, this._markerService, this._modelService, ProblemHandlingStrategy.Clean, this._fileService, this._logService); this._terminalStatusManager.addTerminal(task, terminal, startStopProblemMatcher); this._taskProblemMonitor.addTerminal(terminal, startStopProblemMatcher); const problemMatcherListener = startStopProblemMatcher.onDidStateChange((event) => { diff --git a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts index 892a948cb65d9..8ede878cacaa4 100644 --- a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts +++ b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts @@ -15,6 +15,7 @@ import { IMarkerService, IMarkerData, MarkerSeverity, IMarker } from '../../../. import { generateUuid } from '../../../../base/common/uuid.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { isWindows } from '../../../../base/common/platform.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export const enum ProblemCollectorEventKind { BackgroundProcessingBegins = 'backgroundProcessingBegins', @@ -67,11 +68,11 @@ export abstract class AbstractProblemCollector extends Disposable implements IDi protected readonly _onDidRequestInvalidateLastMarker = this._register(new Emitter()); readonly onDidRequestInvalidateLastMarker = this._onDidRequestInvalidateLastMarker.event; - constructor(public readonly problemMatchers: ProblemMatcher[], protected markerService: IMarkerService, protected modelService: IModelService, fileService?: IFileService) { + constructor(public readonly problemMatchers: ProblemMatcher[], protected markerService: IMarkerService, protected modelService: IModelService, fileService?: IFileService, protected readonly logService?: ILogService) { super(); this.matchers = Object.create(null); this.bufferLength = 1; - problemMatchers.map(elem => createLineMatcher(elem, fileService)).forEach((matcher) => { + problemMatchers.map(elem => createLineMatcher(elem, fileService, logService)).forEach((matcher) => { const length = matcher.matchLength; if (length > this.bufferLength) { this.bufferLength = length; @@ -364,8 +365,8 @@ export class StartStopProblemCollector extends AbstractProblemCollector implemen private _hasStarted: boolean = false; - constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, _strategy: ProblemHandlingStrategy = ProblemHandlingStrategy.Clean, fileService?: IFileService) { - super(problemMatchers, markerService, modelService, fileService); + constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, _strategy: ProblemHandlingStrategy = ProblemHandlingStrategy.Clean, fileService?: IFileService, logService?: ILogService) { + super(problemMatchers, markerService, modelService, fileService, logService); const ownerSet: { [key: string]: boolean } = Object.create(null); problemMatchers.forEach(description => ownerSet[description.owner] = true); this.owners = Object.keys(ownerSet); @@ -422,8 +423,8 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private lines: string[] = []; public beginPatterns: RegExp[] = []; - constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, fileService?: IFileService) { - super(problemMatchers, markerService, modelService, fileService); + constructor(problemMatchers: ProblemMatcher[], markerService: IMarkerService, modelService: IModelService, fileService?: IFileService, logService?: ILogService) { + super(problemMatchers, markerService, modelService, fileService, logService); this.resetCurrentResource(); this.backgroundPatterns = []; this._activeBackgroundMatchers = new Set(); @@ -514,7 +515,12 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private async tryBegin(line: string): Promise { let result = false; for (const background of this.backgroundPatterns) { + const start = Date.now(); const matches = background.begin.regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow begin regexp took ${elapsed}ms to execute`, background.begin.regexp.source); + } if (matches) { if (this._activeBackgroundMatchers.has(background.key)) { continue; @@ -543,7 +549,12 @@ export class WatchingProblemCollector extends AbstractProblemCollector implement private tryFinish(line: string): boolean { let result = false; for (const background of this.backgroundPatterns) { + const start = Date.now(); const matches = background.end.regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow end regexp took ${elapsed}ms to execute`, background.end.regexp.source); + } if (matches) { if (this._numberOfMatches > 0) { this._onDidFindErrors.fire(this.markerService.read({ owner: background.matcher.owner })); diff --git a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts index fad04dd91bea1..479c93d6ce1da 100644 --- a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts +++ b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts @@ -24,6 +24,7 @@ import { IMarkerData, MarkerSeverity } from '../../../../platform/markers/common import { ExtensionsRegistry, ExtensionMessageCollector } from '../../../services/extensions/common/extensionsRegistry.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { FileType, IFileService, IFileStatWithPartialMetadata, IFileSystemProvider } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; export enum FileLocationKind { Default, @@ -301,12 +302,12 @@ export interface ILineMatcher { handle(lines: string[], start?: number): IHandleResult; } -export function createLineMatcher(matcher: ProblemMatcher, fileService?: IFileService): ILineMatcher { +export function createLineMatcher(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService): ILineMatcher { const pattern = matcher.pattern; if (Array.isArray(pattern)) { - return new MultiLineMatcher(matcher, fileService); + return new MultiLineMatcher(matcher, fileService, logService); } else { - return new SingleLineMatcher(matcher, fileService); + return new SingleLineMatcher(matcher, fileService, logService); } } @@ -315,10 +316,12 @@ const endOfLine: string = Platform.OS === Platform.OperatingSystem.Windows ? '\r abstract class AbstractLineMatcher implements ILineMatcher { private matcher: ProblemMatcher; private fileService?: IFileService; + private logService?: ILogService; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { this.matcher = matcher; this.fileService = fileService; + this.logService = logService; } public handle(lines: string[], start: number = 0): IHandleResult { @@ -331,6 +334,16 @@ abstract class AbstractLineMatcher implements ILineMatcher { public abstract get matchLength(): number; + protected regexpExec(regexp: RegExp, line: string): RegExpExecArray | null { + const start = Date.now(); + const result = regexp.exec(line); + const elapsed = Date.now() - start; + if (elapsed > 5) { + this.logService?.trace(`ProblemMatcher: slow regexp took ${elapsed}ms to execute`, regexp.source); + } + return result; + } + protected fillProblemData(data: IProblemData | undefined, pattern: IProblemPattern, matches: RegExpExecArray): data is IProblemData { if (data) { this.fillProperty(data, 'file', pattern, matches, true); @@ -482,8 +495,8 @@ class SingleLineMatcher extends AbstractLineMatcher { private pattern: IProblemPattern; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { - super(matcher, fileService); + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { + super(matcher, fileService, logService); this.pattern = matcher.pattern; } @@ -497,7 +510,7 @@ class SingleLineMatcher extends AbstractLineMatcher { if (this.pattern.kind !== undefined) { data.kind = this.pattern.kind; } - const matches = this.pattern.regexp.exec(lines[start]); + const matches = this.regexpExec(this.pattern.regexp, lines[start]); if (matches) { this.fillProblemData(data, this.pattern, matches); if (data.kind === ProblemLocationKind.Location && !data.location && !data.line && data.file) { @@ -521,8 +534,8 @@ class MultiLineMatcher extends AbstractLineMatcher { private patterns: IProblemPattern[]; private data: IProblemData | undefined; - constructor(matcher: ProblemMatcher, fileService?: IFileService) { - super(matcher, fileService); + constructor(matcher: ProblemMatcher, fileService?: IFileService, logService?: ILogService) { + super(matcher, fileService, logService); this.patterns = matcher.pattern; } @@ -537,7 +550,7 @@ class MultiLineMatcher extends AbstractLineMatcher { data.kind = this.patterns[0].kind; for (let i = 0; i < this.patterns.length; i++) { const pattern = this.patterns[i]; - const matches = pattern.regexp.exec(lines[i + start]); + const matches = this.regexpExec(pattern.regexp, lines[i + start]); if (!matches) { return { match: null, continue: false }; } else { @@ -559,7 +572,7 @@ class MultiLineMatcher extends AbstractLineMatcher { public override next(line: string): IProblemMatch | null { const pattern = this.patterns[this.patterns.length - 1]; Assert.ok(pattern.loop === true && this.data !== null); - const matches = pattern.regexp.exec(line); + const matches = this.regexpExec(pattern.regexp, line); if (!matches) { this.data = undefined; return null; From 9a403498a8dad78926c20589548dbcbf4b3b5c7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:05:28 -0800 Subject: [PATCH 10/42] Bump tar from 7.5.7 to 7.5.9 in /build/npm/gyp (#295930) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.9. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.9) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build/npm/gyp/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index a4ef0b2fada0c..6e28e550f4699 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { From 5101ad84a6ea9272e7d300d189139c893dfc5c5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:05:33 -0800 Subject: [PATCH 11/42] Bump fast-xml-parser and @azure/core-xml in /build (#295914) Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@azure/core-xml](https://github.com/Azure/azure-sdk-for-js). These dependencies needed to be updated together. Updates `fast-xml-parser` from 4.5.0 to 5.3.6 - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v4.5.0...v5.3.6) Updates `@azure/core-xml` from 1.4.4 to 1.5.0 - [Release notes](https://github.com/Azure/azure-sdk-for-js/releases) - [Changelog](https://github.com/Azure/azure-sdk-for-js/blob/main/documentation/Changelog-for-next-generation.md) - [Commits](https://github.com/Azure/azure-sdk-for-js/compare/@azure/core-xml_1.4.4...@azure/core-xml_1.5.0) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 5.3.6 dependency-type: indirect - dependency-name: "@azure/core-xml" dependency-version: 1.5.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build/package-lock.json | 45 ++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/build/package-lock.json b/build/package-lock.json index ffcaa1455e746..36b85902e22d6 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -307,17 +307,17 @@ } }, "node_modules/@azure/core-xml": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.4.4.tgz", - "integrity": "sha512-J4FYAqakGXcbfeZjwjMzjNcpcH4E+JtEBv+xcV1yL0Ydn/6wbQfeFKTCHh9wttAi0lmajHw7yBbHPRG+YHckZQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz", + "integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==", "dev": true, "license": "MIT", "dependencies": { - "fast-xml-parser": "^4.4.1", - "tslib": "^2.6.2" + "fast-xml-parser": "^5.0.7", + "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/cosmos": { @@ -5358,23 +5358,19 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", - "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" - }, - { - "type": "paypal", - "url": "https://paypal.me/naturalintelligence" } ], "license": "MIT", "dependencies": { - "strnum": "^1.0.5" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -8933,10 +8929,16 @@ } }, "node_modules/strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT" }, "node_modules/structured-source": { @@ -9433,10 +9435,11 @@ } }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", From 2b3ee49466964d62b63811f6930a63b5daf7d3e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:05:45 -0800 Subject: [PATCH 12/42] Bump tar from 7.5.7 to 7.5.9 (#295928) Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.9. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.9) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.9 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6adfda164d62..8524540f7d49d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -150,7 +150,7 @@ "source-map": "0.6.1", "source-map-support": "^0.3.2", "style-loader": "^3.3.2", - "tar": "^7.5.7", + "tar": "^7.5.9", "ts-loader": "^9.5.1", "tsec": "0.2.7", "tslib": "^2.6.3", @@ -16389,9 +16389,9 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/package.json b/package.json index 8735f91d979c0..4a17850e8f0fb 100644 --- a/package.json +++ b/package.json @@ -215,7 +215,7 @@ "source-map": "0.6.1", "source-map-support": "^0.3.2", "style-loader": "^3.3.2", - "tar": "^7.5.7", + "tar": "^7.5.9", "ts-loader": "^9.5.1", "tsec": "0.2.7", "tslib": "^2.6.3", From 4e34c96a2453fc358abc654e3b14b7311b00695e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 18 Feb 2026 18:15:46 +0100 Subject: [PATCH 13/42] feat: Add emergency alert banner to all qualities, with polling (#296040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enable emergency alert in all qualities * feat: Add emergency alert banner to all qualities, with polling --------- Co-authored-by: João Moreno <22350+joaomoreno@users.noreply.github.com> --- .../emergencyAlert.contribution.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts b/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts index 6ef9584b413bd..34995c7789839 100644 --- a/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts +++ b/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts @@ -11,6 +11,9 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { arch, platform } from '../../../../base/common/process.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IntervalTimer } from '../../../../base/common/async.js'; +import { mainWindow } from '../../../../base/browser/window.js'; interface IEmergencyAlert { readonly commit: string; @@ -27,19 +30,22 @@ interface IEmergencyAlerts { readonly alerts: IEmergencyAlert[]; } -export class EmergencyAlert implements IWorkbenchContribution { +const POLLING_INTERVAL = 60 * 60 * 1000; // 1 hour +const BANNER_ID = 'emergencyAlert.banner'; + +export class EmergencyAlert extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.emergencyAlert'; + private readonly pollingTimer = this._register(new IntervalTimer()); + constructor( @IBannerService private readonly bannerService: IBannerService, @IRequestService private readonly requestService: IRequestService, @IProductService private readonly productService: IProductService, @ILogService private readonly logService: ILogService ) { - if (productService.quality !== 'insider') { - return; // only enabled in insiders for now - } + super(); const emergencyAlertUrl = productService.emergencyAlertUrl; if (!emergencyAlertUrl) { @@ -47,6 +53,7 @@ export class EmergencyAlert implements IWorkbenchContribution { } this.fetchAlerts(emergencyAlertUrl); + this.pollingTimer.cancelAndSet(() => this.fetchAlerts(emergencyAlertUrl), POLLING_INTERVAL, mainWindow); } private async fetchAlerts(url: string): Promise { @@ -58,7 +65,7 @@ export class EmergencyAlert implements IWorkbenchContribution { } private async doFetchAlerts(url: string): Promise { - const requestResult = await this.requestService.request({ type: 'GET', url, disableCache: true }, CancellationToken.None); + const requestResult = await this.requestService.request({ type: 'GET', url, disableCache: true, timeout: 20000 }, CancellationToken.None); if (requestResult.res.statusCode !== 200) { throw new Error(`Failed to fetch emergency alerts: HTTP ${requestResult.res.statusCode}`); @@ -78,8 +85,9 @@ export class EmergencyAlert implements IWorkbenchContribution { return; } + this.bannerService.hide(BANNER_ID); this.bannerService.show({ - id: 'emergencyAlert.banner', + id: BANNER_ID, icon: Codicon.warning, message: emergencyAlert.message, actions: emergencyAlert.actions From b9d5c49d2663e2ecd0ff6e12a0beaf05a32220fa Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 18 Feb 2026 18:49:15 +0100 Subject: [PATCH 14/42] fixing/polishing sessions --- .../browser/media/sidebarActionButton.css | 49 +++ .../browser/parts/media/sidebarPart.css | 17 +- src/vs/sessions/browser/parts/sidebarPart.ts | 46 ++- .../browser/account.contribution.ts | 156 ++++++---- .../browser/media/accountWidget.css | 57 +--- .../media/aiCustomizationManagement.css | 8 +- .../sessions/browser/customizationCounts.ts | 61 ++++ .../customizationsToolbar.contribution.ts | 265 ++++++++++++++++ .../browser/media/customizationsToolbar.css | 133 ++++++++ .../browser/media/sessionsViewPane.css | 115 +------ .../sessionsAuxiliaryBarContribution.ts | 105 +++++-- .../sessions/browser/sessionsViewPane.ts | 284 +++++------------- src/vs/sessions/sessions.desktop.main.ts | 1 + 13 files changed, 821 insertions(+), 476 deletions(-) create mode 100644 src/vs/sessions/browser/media/sidebarActionButton.css create mode 100644 src/vs/sessions/contrib/sessions/browser/customizationCounts.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css diff --git a/src/vs/sessions/browser/media/sidebarActionButton.css b/src/vs/sessions/browser/media/sidebarActionButton.css new file mode 100644 index 0000000000000..f170a6ed4fd0f --- /dev/null +++ b/src/vs/sessions/browser/media/sidebarActionButton.css @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sidebar-action-list .actions-container { + gap: 4px; +} + +.sidebar-action > .action-label { + /* Hide the default action-label rendered by ActionViewItem */ + display: none; +} + +/* Shared styling for interactive sidebar action buttons (account widget, customization links, etc.) */ +.sidebar-action-button { + display: flex; + align-items: center; + border: none; + padding: 4px 8px; + margin: 0; + font-size: 12px; + height: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + color: var(--vscode-sideBar-foreground); + width: 100%; + text-align: left; + justify-content: flex-start; + text-decoration: none; + border-radius: 4px; + cursor: pointer; + gap: 10px; + display: flex; +} + +.sidebar-action-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.sidebar-action-button.monaco-text-button:focus { + outline-offset: -1px !important; +} + +.sidebar-action-button.monaco-text-button .codicon { + margin: 0; +} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index d8f4c72894978..0162bcb26d036 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -31,18 +31,27 @@ /* Sidebar Footer Container */ .monaco-workbench .part.sidebar > .sidebar-footer { display: flex; - align-items: center; + align-items: stretch; + gap: 4px; padding: 6px; border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); flex-shrink: 0; } -/* Make the toolbar and its action-item fill the full footer width */ +/* Make the toolbar fill the footer width and stack actions vertically */ .monaco-workbench .part.sidebar > .sidebar-footer .monaco-toolbar, .monaco-workbench .part.sidebar > .sidebar-footer .monaco-action-bar, -.monaco-workbench .part.sidebar > .sidebar-footer .actions-container, + +.monaco-workbench .part.sidebar > .sidebar-footer .actions-container { + width: 100%; + max-width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + cursor: default; +} + .monaco-workbench .part.sidebar > .sidebar-footer .action-item { - flex: 1; width: 100%; max-width: 100%; cursor: default; diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index d632124f3defb..f669616ce1780 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -53,8 +53,13 @@ export class SidebarPart extends AbstractPaneCompositePart { static readonly MARGIN_TOP = 0; static readonly MARGIN_BOTTOM = 0; static readonly MARGIN_LEFT = 0; - static readonly FOOTER_HEIGHT = 39; + private static readonly FOOTER_ITEM_HEIGHT = 26; + private static readonly FOOTER_ITEM_GAP = 4; + private static readonly FOOTER_VERTICAL_PADDING = 6; + private footerContainer: HTMLElement | undefined; + private footerToolbar: MenuWorkbenchToolBar | undefined; + private previousLayoutDimensions: { width: number; height: number; top: number; left: number } | undefined; //#region IView @@ -167,13 +172,41 @@ export class SidebarPart extends AbstractPaneCompositePart { } private createFooter(parent: HTMLElement): void { - const footer = append(parent, $('.sidebar-footer')); + const footer = append(parent, $('.sidebar-footer.sidebar-action-list')); + this.footerContainer = footer; - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { + this.footerToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { hiddenItemStrategy: HiddenItemStrategy.NoHide, toolbarOptions: { primaryGroup: () => true }, telemetrySource: 'sidebarFooter', })); + + this._register(this.footerToolbar.onDidChangeMenuItems(() => { + if (this.previousLayoutDimensions) { + const { width, height, top, left } = this.previousLayoutDimensions; + this.layout(width, height, top, left); + } + })); + } + + private getFooterHeight(): number { + const actionCount = this.footerToolbar?.getItemsLength() ?? 0; + if (actionCount === 0) { + return 0; + } + + return SidebarPart.FOOTER_VERTICAL_PADDING * 2 + + (actionCount * SidebarPart.FOOTER_ITEM_HEIGHT) + + ((actionCount - 1) * SidebarPart.FOOTER_ITEM_GAP); + } + + private updateFooterVisibility(): void { + const footer = this.footerContainer; + if (!footer) { + return; + } + + footer.style.display = this.getFooterHeight() > 0 ? '' : 'none'; } override updateStyles(): void { @@ -193,14 +226,19 @@ export class SidebarPart extends AbstractPaneCompositePart { } override layout(width: number, height: number, top: number, left: number): void { + this.previousLayoutDimensions = { width, height, top, left }; + if (!this.layoutService.isVisible(Parts.SIDEBAR_PART)) { return; } + this.updateFooterVisibility(); + const footerHeight = Math.min(height, this.getFooterHeight()); + // Layout content with reduced height to account for footer super.layout( width, - height - SidebarPart.FOOTER_HEIGHT, + height - footerHeight, top, left ); diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index fc9b4c7aedc8a..d19bc8ff3ae87 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import '../../../browser/media/sidebarActionButton.css'; import './media/accountWidget.css'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -11,7 +12,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE } from '../../../../workbench/contrib/update/browser/update.js'; import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -84,7 +85,6 @@ registerUpdateMenuItems(AccountMenu, '3_updates'); class AccountWidget extends ActionViewItem { private accountButton: Button | undefined; - private updateButton: Button | undefined; private readonly viewItemDisposables = this._register(new DisposableStore()); constructor( @@ -94,14 +94,17 @@ class AccountWidget extends ActionViewItem { @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IUpdateService private readonly updateService: IUpdateService, ) { super(undefined, action, { ...options, icon: false, label: false }); } + protected override getTooltip(): string | undefined { + return undefined; + } + override render(container: HTMLElement): void { super.render(container); - container.classList.add('account-widget'); + container.classList.add('account-widget', 'sidebar-action'); // Account button (left) const accountContainer = append(container, $('.account-widget-account')); @@ -115,7 +118,7 @@ class AccountWidget extends ActionViewItem { buttonSecondaryForeground: undefined, buttonSecondaryBorder: undefined, })); - this.accountButton.element.classList.add('account-widget-account-button'); + this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); this.updateAccountButton(); this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); @@ -125,9 +128,63 @@ class AccountWidget extends ActionViewItem { e?.stopPropagation(); this.showAccountMenu(this.accountButton!.element); })); + } + + private showAccountMenu(anchor: HTMLElement): void { + const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); + const actions: IAction[] = []; + fillInActionBarActions(menu.getActions(), actions); + menu.dispose(); + + const rect = anchor.getBoundingClientRect(); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: rect.right, y: rect.top }), + getActions: () => actions, + anchorAlignment: AnchorAlignment.LEFT, + }); + } + + private async updateAccountButton(): Promise { + if (!this.accountButton) { + return; + } + this.accountButton.label = `$(${Codicon.loading.id}~spin) ${localize('loadingAccount', "Loading account...")}`; + this.accountButton.enabled = false; + const account = await this.defaultAccountService.getDefaultAccount(); + this.accountButton.enabled = true; + this.accountButton.label = account + ? `$(${Codicon.account.id}) ${account.accountName} (${account.authenticationProvider.name})` + : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; + } + + + override onClick(): void { + // Handled by custom click handlers + } +} + +class UpdateWidget extends ActionViewItem { - // Update button (shown for progress and restart-to-update states) - const updateContainer = append(container, $('.account-widget-update')); + private updateButton: Button | undefined; + private readonly viewItemDisposables = this._register(new DisposableStore()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + @IUpdateService private readonly updateService: IUpdateService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + } + + protected override getTooltip(): string | undefined { + return undefined; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('update-widget', 'sidebar-action'); + + const updateContainer = append(container, $('.update-widget-action')); this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { ...defaultButtonStyles, secondary: true, @@ -138,79 +195,39 @@ class AccountWidget extends ActionViewItem { buttonSecondaryForeground: undefined, buttonSecondaryBorder: undefined, })); - this.updateButton.element.classList.add('account-widget-update-button'); - this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + this.updateButton.element.classList.add('update-widget-button', 'sidebar-action-button'); this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); this.updateUpdateButton(); this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); } - private isUpdateAvailable(): boolean { + private isUpdateReady(): boolean { return this.updateService.state.type === StateType.Ready; } - private isUpdateInProgress(): boolean { + private isUpdatePending(): boolean { const type = this.updateService.state.type; - return type === StateType.CheckingForUpdates + return type === StateType.AvailableForDownload + || type === StateType.CheckingForUpdates || type === StateType.Downloading || type === StateType.Downloaded || type === StateType.Updating || type === StateType.Overwriting; } - private showAccountMenu(anchor: HTMLElement): void { - const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); - const actions: IAction[] = []; - fillInActionBarActions(menu.getActions(), actions); - menu.dispose(); - - if (this.isUpdateAvailable()) { - // Update button visible: open above the button - this.contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => actions, - anchorAlignment: AnchorAlignment.LEFT, - }); - } else { - // No update button: open to the right of the button - const rect = anchor.getBoundingClientRect(); - this.contextMenuService.showContextMenu({ - getAnchor: () => ({ x: rect.right, y: rect.top }), - getActions: () => actions, - }); - } - } - - private async updateAccountButton(): Promise { - if (!this.accountButton) { - return; - } - this.accountButton.label = `$(${Codicon.loading.id}~spin) ${localize('loadingAccount', "Loading account...")}`; - this.accountButton.enabled = false; - const account = await this.defaultAccountService.getDefaultAccount(); - this.accountButton.enabled = true; - this.accountButton.label = account - ? `$(${Codicon.account.id}) ${account.accountName} (${account.authenticationProvider.name})` - : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; - } - private updateUpdateButton(): void { if (!this.updateButton) { return; } const state = this.updateService.state; - if (this.isUpdateInProgress()) { - this.updateButton.element.parentElement!.style.display = ''; + if (this.isUpdatePending() && !this.isUpdateReady()) { this.updateButton.enabled = false; this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; - } else if (this.isUpdateAvailable()) { - this.updateButton.element.parentElement!.style.display = ''; + } else { this.updateButton.enabled = true; this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; - } else { - this.updateButton.element.parentElement!.style.display = 'none'; } } @@ -257,6 +274,11 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu return instantiationService.createInstance(AccountWidget, action, options); }, undefined)); + const sessionsUpdateWidgetAction = 'sessions.action.updateWidget'; + this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsUpdateWidgetAction, (action, options) => { + return instantiationService.createInstance(UpdateWidget, action, options); + }, undefined)); + // Register the action with menu item after the view item provider // so the toolbar picks up the custom widget this._register(registerAction2(class extends Action2 { @@ -275,6 +297,32 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu // Handled by the custom view item } })); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: sessionsUpdateWidgetAction, + title: localize2('sessionsUpdateWidget', 'Sessions Update'), + menu: { + id: Menus.SidebarFooter, + group: 'navigation', + order: 0, + when: ContextKeyExpr.or( + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.CheckingForUpdates), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), + CONTEXT_UPDATE_STATE.isEqualTo(StateType.Overwriting), + ) + } + }); + } + async run(): Promise { + // Handled by the custom view item + } + })); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index ad72846d5c533..01bdd2c100b03 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -3,19 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Account Widget */ -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget > .action-label { - display: none; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 8px; -} - /* Account Button */ .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { overflow: hidden; @@ -23,49 +10,9 @@ flex: 1; } -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button { - border: none; - padding: 4px 8px; - font-size: 12px; - height: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: transparent; - color: var(--vscode-sideBar-foreground); - width: 100%; - text-align: left; - justify-content: flex-start; - border-radius: 4px; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - /* Update Button */ -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { +.monaco-workbench .part.sidebar > .sidebar-footer .update-widget-action { overflow: hidden; min-width: 0; - flex-shrink: 1; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button { - border: none; - padding: 4px 8px; - font-size: 12px; - height: auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: transparent; - color: var(--vscode-sideBar-foreground); - width: 100%; - text-align: left; - justify-content: flex-start; - border-radius: 4px; -} - -.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button:hover:not(:disabled) { - background-color: var(--vscode-toolbar-hoverBackground); + flex: 1; } diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css index a0a97bea3bf1d..4954a2a3fd979 100644 --- a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css @@ -38,11 +38,11 @@ .ai-customization-management-editor .section-list-item { display: flex; align-items: center; - padding: 8px 16px; + padding: 4px 8px; gap: 10px; cursor: pointer; - margin: 2px 6px; - border-radius: 6px; + margin: 1px 6px; + border-radius: 4px; transition: background-color 0.1s ease, opacity 0.1s ease; } @@ -79,7 +79,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; + font-size: 12px; font-weight: 400; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts new file mode 100644 index 0000000000000..dd874c3e86c71 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; + +export interface ISourceCounts { + readonly workspace: number; + readonly user: number; + readonly extension: number; +} + +export function getSourceCountsTotal(counts: ISourceCounts): number { + return counts.workspace + counts.user + counts.extension; +} + +export async function getPromptSourceCounts(promptsService: IPromptsService, promptType: PromptsType): Promise { + const [workspaceItems, userItems, extensionItems] = await Promise.all([ + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), + promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), + ]); + return { + workspace: workspaceItems.length, + user: userItems.length, + extension: extensionItems.length, + }; +} + +export async function getSkillSourceCounts(promptsService: IPromptsService): Promise { + const skills = await promptsService.findAgentSkills(CancellationToken.None); + if (!skills || skills.length === 0) { + return { workspace: 0, user: 0, extension: 0 }; + } + return { + workspace: skills.filter(s => s.storage === PromptsStorage.local).length, + user: skills.filter(s => s.storage === PromptsStorage.user).length, + extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + }; +} + +export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService): Promise { + const [agentCounts, skillCounts, instructionCounts, promptCounts, hookCounts] = await Promise.all([ + getPromptSourceCounts(promptsService, PromptsType.agent), + getSkillSourceCounts(promptsService), + getPromptSourceCounts(promptsService, PromptsType.instructions), + getPromptSourceCounts(promptsService, PromptsType.prompt), + getPromptSourceCounts(promptsService, PromptsType.hook), + ]); + + return getSourceCountsTotal(agentCounts) + + getSourceCountsTotal(skillCounts) + + getSourceCountsTotal(instructionCounts) + + getSourceCountsTotal(promptCounts) + + getSourceCountsTotal(hookCounts) + + mcpService.servers.get().length; +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts new file mode 100644 index 0000000000000..2d14ab2f63234 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../browser/media/sidebarActionButton.css'; +import './media/customizationsToolbar.css'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; +import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { Menus } from '../../../browser/menus.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { getPromptSourceCounts, getSkillSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; + +interface ICustomizationItemConfig { + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon; + readonly section: AICustomizationManagementSection; + readonly getSourceCounts?: (promptsService: IPromptsService) => Promise; + readonly getCount?: (languageModelsService: ILanguageModelsService, mcpService: IMcpService) => Promise; +} + +const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ + { + id: 'sessions.customization.agents', + label: localize('agents', "Agents"), + icon: agentIcon, + section: AICustomizationManagementSection.Agents, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.agent), + }, + { + id: 'sessions.customization.skills', + label: localize('skills', "Skills"), + icon: skillIcon, + section: AICustomizationManagementSection.Skills, + getSourceCounts: (ps) => getSkillSourceCounts(ps), + }, + { + id: 'sessions.customization.instructions', + label: localize('instructions', "Instructions"), + icon: instructionsIcon, + section: AICustomizationManagementSection.Instructions, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.instructions), + }, + { + id: 'sessions.customization.prompts', + label: localize('prompts', "Prompts"), + icon: promptIcon, + section: AICustomizationManagementSection.Prompts, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.prompt), + }, + { + id: 'sessions.customization.hooks', + label: localize('hooks', "Hooks"), + icon: hookIcon, + section: AICustomizationManagementSection.Hooks, + getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.hook), + }, + { + id: 'sessions.customization.mcpServers', + label: localize('mcpServers', "MCP Servers"), + icon: Codicon.server, + section: AICustomizationManagementSection.McpServers, + getCount: (_lm, mcp) => Promise.resolve(mcp.servers.get().length), + }, + { + id: 'sessions.customization.models', + label: localize('models', "Models"), + icon: Codicon.vm, + section: AICustomizationManagementSection.Models, + getCount: (lm) => Promise.resolve(lm.getLanguageModelIds().length), + }, +]; + +/** + * Custom ActionViewItem for each customization link in the toolbar. + * Renders icon + label + source count badges, matching the sidebar footer style. + */ +class CustomizationLinkViewItem extends ActionViewItem { + + private readonly _viewItemDisposables: DisposableStore; + private _button: Button | undefined; + private _countContainer: HTMLElement | undefined; + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly _config: ICustomizationItemConfig, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IMcpService private readonly _mcpService: IMcpService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + this._viewItemDisposables = this._register(new DisposableStore()); + } + + protected override getTooltip(): string | undefined { + return undefined; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('customization-link-widget', 'sidebar-action'); + + // Button (left) - uses supportIcons to render codicon in label + const buttonContainer = append(container, $('.customization-link-button-container')); + this._button = this._viewItemDisposables.add(new Button(buttonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this._button.element.classList.add('customization-link-button', 'sidebar-action-button'); + this._button.label = `$(${this._config.icon.id}) ${this._config.label}`; + + this._viewItemDisposables.add(this._button.onDidClick(() => { + this._action.run(); + })); + + // Count container (inside button, floating right) + this._countContainer = append(this._button.element, $('span.customization-link-counts')); + + // Subscribe to changes + this._viewItemDisposables.add(this._promptsService.onDidChangeCustomAgents(() => this._updateCounts())); + this._viewItemDisposables.add(this._promptsService.onDidChangeSlashCommands(() => this._updateCounts())); + this._viewItemDisposables.add(this._languageModelsService.onDidChangeLanguageModels(() => this._updateCounts())); + this._viewItemDisposables.add(autorun(reader => { + this._mcpService.servers.read(reader); + this._updateCounts(); + })); + this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); + this._viewItemDisposables.add(autorun(reader => { + this._activeSessionService.activeSession.read(reader); + this._updateCounts(); + })); + + // Initial count + this._updateCounts(); + } + + private async _updateCounts(): Promise { + if (!this._countContainer) { + return; + } + + if (this._config.getSourceCounts) { + const counts = await this._config.getSourceCounts(this._promptsService); + this._renderSourceCounts(this._countContainer, counts); + } else if (this._config.getCount) { + const count = await this._config.getCount(this._languageModelsService, this._mcpService); + this._renderSimpleCount(this._countContainer, count); + } + } + + private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { + container.textContent = ''; + const total = getSourceCountsTotal(counts); + container.classList.toggle('hidden', total === 0); + if (total === 0) { + return; + } + + const sources: { count: number; icon: ThemeIcon; title: string }[] = [ + { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, + { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, + { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, + ]; + + for (const source of sources) { + if (source.count === 0) { + continue; + } + const badge = append(container, $('span.source-count-badge')); + badge.title = source.title; + const icon = append(badge, $('span.source-count-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); + const num = append(badge, $('span.source-count-num')); + num.textContent = `${source.count}`; + } + } + + private _renderSimpleCount(container: HTMLElement, count: number): void { + container.textContent = ''; + container.classList.toggle('hidden', count === 0); + if (count > 0) { + const badge = append(container, $('span.source-count-badge')); + const num = append(badge, $('span.source-count-num')); + num.textContent = `${count}`; + } + } +} + +// --- Register actions and view items --- // + +class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsCustomizationsToolbar'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) { + // Register the custom ActionViewItem for this action + this._register(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => { + return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config); + }, undefined)); + + // Register the action with menu item + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: config.id, + title: localize2('customizationAction', '{0}', config.label), + menu: { + id: Menus.SidebarCustomizations, + group: 'navigation', + order: index + 1, + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(config.section); + } + } + })); + } + } +} + +registerWorkbenchContribution2(CustomizationsToolbarContribution.ID, CustomizationsToolbarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css new file mode 100644 index 0000000000000..d671775dbd57c --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-sessions-viewpane { + + /* AI Customization section - pinned to bottom */ + .ai-customization-toolbar { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-widget-border); + padding: 6px; + } + + /* Make the toolbar, action bar, and items fill full width and stack vertically */ + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { + width: 100%; + } + + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { + display: flex; + flex-direction: column; + width: 100%; + } + + .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { + width: 100%; + max-width: 100%; + } + + .ai-customization-toolbar .customization-link-widget { + width: 100%; + } + + /* Customization header - clickable for collapse */ + .ai-customization-toolbar .ai-customization-header { + display: flex; + align-items: center; + -webkit-user-select: none; + user-select: none; + } + + .ai-customization-toolbar .ai-customization-header:not(.collapsed) { + margin-bottom: 4px; + } + + .ai-customization-toolbar .ai-customization-chevron { + flex-shrink: 0; + opacity: 0; + } + + .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, + .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { + opacity: 0.7; + } + + .ai-customization-toolbar .ai-customization-header-total { + display: none; + opacity: 0.7; + font-size: 11px; + line-height: 1; + } + + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { + display: inline; + } + + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, + .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, + .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { + display: none; + } + + /* Button container - fills available space */ + .ai-customization-toolbar .customization-link-button-container { + overflow: hidden; + min-width: 0; + flex: 1; + } + + /* Button needs relative positioning for counts overlay */ + .ai-customization-toolbar .customization-link-button { + position: relative; + } + + /* Counts - floating right inside the button */ + .ai-customization-toolbar .customization-link-counts { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 6px; + } + + .ai-customization-toolbar .customization-link-counts.hidden { + display: none; + } + + .ai-customization-toolbar .source-count-badge { + display: flex; + align-items: center; + gap: 2px; + } + + .ai-customization-toolbar .source-count-icon { + font-size: 12px; + opacity: 0.6; + } + + .ai-customization-toolbar .source-count-num { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + } + + /* Collapsed state */ + .ai-customization-toolbar .ai-customization-toolbar-content { + max-height: 500px; + overflow: hidden; + transition: max-height 0.2s ease-out; + } + + .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { + max-height: 0; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 4b37cbc4323ab..1666be701275e 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -13,7 +13,6 @@ } /* Section headers - more prominent than time-based groupings */ - .ai-customization-header, .agent-sessions-header { font-size: 11px; font-weight: 500; @@ -23,130 +22,17 @@ letter-spacing: 0.05em; } - /* Customization header - clickable for collapse */ - .ai-customization-header { - display: flex; - align-items: center; - cursor: pointer; - user-select: none; - margin: 0 6px; - border-radius: 6px; - } - - .ai-customization-header:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .ai-customization-header:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .ai-customization-chevron, .agent-sessions-chevron { flex-shrink: 0; - margin-left: auto; - padding-right: 4px; opacity: 0; transition: opacity 0.1s ease-in-out; } - .ai-customization-header:hover .ai-customization-chevron, - .ai-customization-header:focus .ai-customization-chevron, .agent-sessions-header:hover .agent-sessions-chevron, .agent-sessions-header:focus .agent-sessions-chevron { opacity: 0.7; } - /* AI Customization section - pinned to bottom */ - .ai-customization-shortcuts { - display: flex; - flex-direction: column; - flex-shrink: 0; - border-top: 1px solid var(--vscode-widget-border); - margin-top: 8px; - padding-top: 4px; - padding-bottom: 8px; - } - - .ai-customization-shortcuts .ai-customization-links { - display: flex; - flex-direction: column; - max-height: 500px; - overflow: hidden; - transition: max-height 0.2s ease-out; - } - - .ai-customization-shortcuts .ai-customization-links.collapsed { - max-height: 0; - } - - .ai-customization-shortcuts .ai-customization-link { - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; - color: var(--vscode-foreground); - cursor: pointer; - text-decoration: none; - padding: 6px 14px; - margin: 0 6px; - line-height: 22px; - border-radius: 6px; - } - - .ai-customization-shortcuts .ai-customization-link:hover { - background-color: var(--vscode-list-hoverBackground); - } - - .ai-customization-shortcuts .ai-customization-link:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .ai-customization-shortcuts .ai-customization-link .link-icon { - flex-shrink: 0; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.85; - } - - .ai-customization-shortcuts .ai-customization-link .link-label { - flex: 1; - } - - .ai-customization-shortcuts .ai-customization-link .link-counts { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 6px; - margin-left: auto; - } - - .ai-customization-shortcuts .ai-customization-link .link-counts.hidden { - display: none; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-badge { - display: flex; - align-items: center; - gap: 2px; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-icon { - font-size: 12px; - opacity: 0.6; - } - - .ai-customization-shortcuts .ai-customization-link .source-count-num { - font-size: 11px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; - } - /* Sessions section - fills remaining space above customizations */ .agent-sessions-section { display: flex; @@ -162,6 +48,7 @@ gap: 4px; padding-top: 10px; padding-right: 12px; + -webkit-user-select: none; user-select: none; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts index bcadacb70d563..d8618d36a1b76 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts @@ -3,53 +3,106 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun } from '../../../../base/common/observable.js'; -import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts } from '../../../../base/common/observable.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { CHANGES_VIEW_ID, ChangesViewPane } from '../../changesView/browser/changesView.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; + +interface IPendingTurnState { + readonly hadChangesBeforeSend: boolean; + readonly submittedAt: number; +} export class SessionsAuxiliaryBarContribution extends Disposable { static readonly ID = 'workbench.contrib.sessionsAuxiliaryBarContribution'; - private readonly activeChangesListener = this._register(new MutableDisposable()); - private activeChangesView: ChangesViewPane | null = null; + private readonly pendingTurnStateByResource = new ResourceMap(); constructor( - @IViewsService private readonly viewsService: IViewsService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatService private readonly chatService: IChatService, ) { super(); - this.tryBindToChangesView(); + const activeSessionResourceObs = derivedOpts({ + equalsFn: isEqual, + }, (reader) => { + return this.sessionManagementService.activeSession.map(activeSession => activeSession?.resource).read(reader); + }).recomputeInitiallyAndOnChange(this._store); + + this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { + this.pendingTurnStateByResource.set(chatSessionResource, { + hadChangesBeforeSend: this.hasSessionChanges(chatSessionResource), + submittedAt: Date.now(), + }); + })); + + // When a turn is completed, check if there were changes before the turn and if there are changes after the turn. + // If there were no changes before the turn and there are changes after the turn, show the auxiliary bar. + this._register(autorun((reader) => { + const activeSessionResource = activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return; + } + + const pendingTurnState = this.pendingTurnStateByResource.get(activeSessionResource); + if (!pendingTurnState) { + return; + } + + const activeSession = this.agentSessionsService.getSession(activeSessionResource); + const turnCompleted = !!activeSession?.timing.lastRequestEnded && activeSession.timing.lastRequestEnded >= pendingTurnState.submittedAt; + if (!turnCompleted) { + return; + } + + const hasChangesAfterTurn = this.hasSessionChanges(activeSessionResource); + if (!pendingTurnState.hadChangesBeforeSend && hasChangesAfterTurn) { + this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + + this.pendingTurnStateByResource.delete(activeSessionResource); + })); - this._register(this.viewsService.onDidChangeViewVisibility(e => { - if (e.id !== CHANGES_VIEW_ID) { + // When the session is switched, show the auxiliary bar if there are pending changes from the session + this._register(autorun(reader => { + const sessionResource = activeSessionResourceObs.read(reader); + if (!sessionResource) { + this.syncAuxiliaryBarVisibility(false); return; } - this.tryBindToChangesView(); + const hasChanges = this.hasSessionChanges(sessionResource); + this.syncAuxiliaryBarVisibility(hasChanges); })); } - private tryBindToChangesView(): void { - const changesView = this.viewsService.getViewWithId(CHANGES_VIEW_ID); - if (!changesView) { - this.activeChangesView = null; - this.activeChangesListener.clear(); - return; - } + private hasSessionChanges(sessionResource: URI): boolean { + const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; - if (this.activeChangesView === changesView) { - return; + let editingSessionCount = 0; + if (!isBackgroundSession) { + const sessions = this.chatEditingService.editingSessionsObs.read(undefined); + const editingSession = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + editingSessionCount = editingSession ? editingSession.entries.read(undefined).length : 0; } - this.activeChangesView = changesView; - this.activeChangesListener.value = autorun(reader => { - const hasChanges = changesView.activeSessionHasChanges.read(reader); - this.syncAuxiliaryBarVisibility(hasChanges); - }); + const session = this.agentSessionsService.getSession(sessionResource); + const sessionFilesCount = session?.changes instanceof Array ? session.changes.length : 0; + + return editingSessionCount + sessionFilesCount > 0; } private syncAuxiliaryBarVisibility(hasChanges: boolean): void { @@ -61,4 +114,4 @@ export class SessionsAuxiliaryBarContribution extends Disposable { this.layoutService.setPartHidden(shouldHideAuxiliaryBar, Parts.AUXILIARYBAR_PART); } -} +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index a243503c2f5c7..5a6ffce5b90c5 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -3,9 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import '../../../browser/media/sidebarActionButton.css'; +import './media/customizationsToolbar.css'; import './media/sessionsViewPane.css'; import * as DOM from '../../../../base/browser/dom.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -23,51 +24,27 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; -import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; -import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; -import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js'; -import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { Menus } from '../../../browser/menus.js'; +import { getCustomizationTotalCount } from './customizationCounts.js'; const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); -/** - * Per-source breakdown of item counts. - */ -interface ISourceCounts { - readonly workspace: number; - readonly user: number; - readonly extension: number; -} - -interface IShortcutItem { - readonly label: string; - readonly icon: ThemeIcon; - readonly action: () => Promise; - readonly getSourceCounts?: () => Promise; - /** For items without per-source breakdown (MCP, Models). */ - readonly getCount?: () => Promise; - countContainer?: HTMLElement; -} - const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; export class AgenticSessionsViewPane extends ViewPane { @@ -76,7 +53,6 @@ export class AgenticSessionsViewPane extends ViewPane { private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; private aiCustomizationContainer: HTMLElement | undefined; - private readonly shortcuts: IShortcutItem[] = []; constructor( options: IViewPaneOptions, @@ -90,44 +66,13 @@ export class AgenticSessionsViewPane extends ViewPane { @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @ICommandService commandService: ICommandService, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IStorageService private readonly storageService: IStorageService, @IPromptsService private readonly promptsService: IPromptsService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IMcpService private readonly mcpService: IMcpService, - @IStorageService private readonly storageService: IStorageService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - - // Initialize shortcuts - this.shortcuts = [ - { label: localize('agents', "Agents"), icon: agentIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Agents), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.agent) }, - { label: localize('skills', "Skills"), icon: skillIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Skills), getSourceCounts: () => this.getSkillSourceCounts() }, - { label: localize('instructions', "Instructions"), icon: instructionsIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Instructions), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.instructions) }, - { label: localize('prompts', "Prompts"), icon: promptIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Prompts), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.prompt) }, - { label: localize('hooks', "Hooks"), icon: hookIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Hooks), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.hook) }, - { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, action: () => this.openAICustomizationSection(AICustomizationManagementSection.McpServers), getCount: () => Promise.resolve(this.mcpService.servers.get().length) }, - { label: localize('models', "Models"), icon: Codicon.vm, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Models), getCount: () => Promise.resolve(this.languageModelsService.getLanguageModelIds().length) }, - ]; - - // Listen to changes to update counts - this._register(this.promptsService.onDidChangeCustomAgents(() => this.updateCounts())); - this._register(this.promptsService.onDidChangeSlashCommands(() => this.updateCounts())); - this._register(this.languageModelsService.onDidChangeLanguageModels(() => this.updateCounts())); - this._register(autorun(reader => { - this.mcpService.servers.read(reader); - this.updateCounts(); - })); - - // Listen to workspace folder changes to update counts - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.updateCounts())); - this._register(autorun(reader => { - this.activeSessionService.activeSession.read(reader); - this.updateCounts(); - })); - } protected override renderBody(parent: HTMLElement): void { @@ -197,8 +142,8 @@ export class AgenticSessionsViewPane extends ViewPane { } })); - // AI Customization shortcuts (bottom, fixed height) - this.aiCustomizationContainer = DOM.append(sessionsContainer, $('.ai-customization-shortcuts')); + // AI Customization toolbar (bottom, fixed height) + this.aiCustomizationContainer = DOM.append(sessionsContainer, $('div')); this.createAICustomizationShortcuts(this.aiCustomizationContainer); } @@ -213,177 +158,86 @@ export class AgenticSessionsViewPane extends ViewPane { // Get initial collapsed state const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); + container.classList.add('ai-customization-toolbar'); + if (isCollapsed) { + container.classList.add('collapsed'); + } + // Header (clickable to toggle) const header = DOM.append(container, $('.ai-customization-header')); - header.tabIndex = 0; - header.setAttribute('role', 'button'); - header.setAttribute('aria-expanded', String(!isCollapsed)); - - // Header text - const headerText = DOM.append(header, $('span')); - headerText.textContent = localize('customizations', "CUSTOMIZATIONS"); + header.classList.toggle('collapsed', isCollapsed); + + const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); + const headerButton = this._register(new Button(headerButtonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); + headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); + headerButton.label = localize('customizations', "CUSTOMIZATIONS"); - // Chevron icon (right-aligned, shown on hover) - const chevron = DOM.append(header, $('.ai-customization-chevron')); + const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); + const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); + const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); - // Links container - const linksContainer = DOM.append(container, $('.ai-customization-links')); - if (isCollapsed) { - linksContainer.classList.add('collapsed'); - } + // Toolbar container + const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); + + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarCustomizations', + })); + + let updateCountRequestId = 0; + const updateHeaderTotalCount = async () => { + const requestId = ++updateCountRequestId; + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService); + if (requestId !== updateCountRequestId) { + return; + } + + headerTotalCount.classList.toggle('hidden', totalCount === 0); + headerTotalCount.textContent = `${totalCount}`; + }; + + this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); + this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + updateHeaderTotalCount(); + })); + updateHeaderTotalCount(); // Toggle collapse on header click const toggleCollapse = () => { - const collapsed = linksContainer.classList.toggle('collapsed'); + const collapsed = container.classList.toggle('collapsed'); + header.classList.toggle('collapsed', collapsed); this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); - header.setAttribute('aria-expanded', String(!collapsed)); + headerButton.element.setAttribute('aria-expanded', String(!collapsed)); chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); // Re-layout after the transition so sessions control gets the right height const onTransitionEnd = () => { - linksContainer.removeEventListener('transitionend', onTransitionEnd); + toolbarContainer.removeEventListener('transitionend', onTransitionEnd); if (this.viewPaneContainer) { const { offsetHeight, offsetWidth } = this.viewPaneContainer; this.layoutBody(offsetHeight, offsetWidth); } }; - linksContainer.addEventListener('transitionend', onTransitionEnd); - }; - - this._register(DOM.addDisposableListener(header, 'click', toggleCollapse)); - this._register(DOM.addDisposableListener(header, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleCollapse(); - } - })); - - for (const shortcut of this.shortcuts) { - const link = DOM.append(linksContainer, $('a.ai-customization-link')); - link.tabIndex = 0; - link.setAttribute('role', 'button'); - link.setAttribute('aria-label', shortcut.label); - - // Icon - const iconElement = DOM.append(link, $('.link-icon')); - iconElement.classList.add(...ThemeIcon.asClassNameArray(shortcut.icon)); - - // Label - const labelElement = DOM.append(link, $('.link-label')); - labelElement.textContent = shortcut.label; - - // Count container (right-aligned, shows per-source badges) - const countContainer = DOM.append(link, $('.link-counts')); - shortcut.countContainer = countContainer; - - this._register(DOM.addDisposableListener(link, 'click', (e) => { - DOM.EventHelper.stop(e); - shortcut.action(); - })); - - this._register(DOM.addDisposableListener(link, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - shortcut.action(); - } - })); - } - - // Load initial counts - this.updateCounts(); - } - - private async updateCounts(): Promise { - for (const shortcut of this.shortcuts) { - if (!shortcut.countContainer) { - continue; - } - - if (shortcut.getSourceCounts) { - const counts = await shortcut.getSourceCounts(); - this.renderSourceCounts(shortcut.countContainer, counts); - } else if (shortcut.getCount) { - const count = await shortcut.getCount(); - this.renderSimpleCount(shortcut.countContainer, count); - } - } - } - - private renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { - DOM.clearNode(container); - const total = counts.workspace + counts.user + counts.extension; - container.classList.toggle('hidden', total === 0); - if (total === 0) { - return; - } - - const sources: { count: number; icon: ThemeIcon; title: string }[] = [ - { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, - { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, - { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, - ]; - - for (const source of sources) { - if (source.count === 0) { - continue; - } - const badge = DOM.append(container, $('.source-count-badge')); - badge.title = source.title; - const icon = DOM.append(badge, $('.source-count-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); - const num = DOM.append(badge, $('.source-count-num')); - num.textContent = `${source.count}`; - } - } - - private renderSimpleCount(container: HTMLElement, count: number): void { - DOM.clearNode(container); - container.classList.toggle('hidden', count === 0); - if (count > 0) { - const badge = DOM.append(container, $('.source-count-badge')); - const num = DOM.append(badge, $('.source-count-num')); - num.textContent = `${count}`; - } - } - - private async getPromptSourceCounts(promptType: PromptsType): Promise { - const [workspaceItems, userItems, extensionItems] = await Promise.all([ - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), - this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), - ]); - - return { - workspace: workspaceItems.length, - user: userItems.length, - extension: extensionItems.length, - }; - } - - private async getSkillSourceCounts(): Promise { - const skills = await this.promptsService.findAgentSkills(CancellationToken.None); - if (!skills || skills.length === 0) { - return { workspace: 0, user: 0, extension: 0 }; - } - - const workspaceSkills = skills.filter(s => s.storage === PromptsStorage.local); - - return { - workspace: workspaceSkills.length, - user: skills.filter(s => s.storage === PromptsStorage.user).length, - extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + toolbarContainer.addEventListener('transitionend', onTransitionEnd); }; - } - private async openAICustomizationSection(sectionId: AICustomizationManagementSection): Promise { - const input = AICustomizationManagementEditorInput.getOrCreate(); - const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true }); - - if (editor instanceof AICustomizationManagementEditor) { - editor.selectSectionById(sectionId); - } + this._register(headerButton.onDidClick(() => toggleCollapse())); } private getSessionHoverPosition(): HoverPosition { diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index b9a4d26199cf9..b81b0de984e9c 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -191,6 +191,7 @@ import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contri import './contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; +import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; import './contrib/configuration/browser/configuration.contribution.js'; From f4d018faa2472cfc22e2de84979c170ef544196a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 18 Feb 2026 11:57:11 -0600 Subject: [PATCH 15/42] use URI instead of ID (#296050) fix #274403 --- src/vs/workbench/contrib/terminal/browser/terminal.ts | 7 ------- .../terminalContrib/chat/browser/terminalChatActions.ts | 8 ++++---- .../terminalContrib/chat/browser/terminalChatService.ts | 7 +------ 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index b13ee6aded3f4..a730051c4e7b9 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -164,13 +164,6 @@ export interface ITerminalChatService { * @returns The chat session resource if found, undefined otherwise */ getChatSessionResourceForInstance(instance: ITerminalInstance): URI | undefined; - /** - * @deprecated Use getChatSessionResourceForInstance instead - * Returns the chat session ID for a given terminal instance, if it has been registered. - * @param instance The terminal instance to look up - * @returns The chat session ID if found, undefined otherwise - */ - getChatSessionIdForInstance(instance: ITerminalInstance): string | undefined; /** * Check if a terminal is a background terminal (tool-driven terminal that may be hidden from diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts index 89dc39f4b9637..9a378d9ff826c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts @@ -14,7 +14,6 @@ import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/k import { ChatViewId, IChatWidgetService } from '../../../chat/browser/chat.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; -import { LocalChatSessionUri } from '../../../chat/common/model/chatUri.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../chat/common/constants.js'; import { isDetachedTerminalInstance, ITerminalChatService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../terminal/browser/terminal.js'; @@ -392,10 +391,11 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { const lastCommand = instance.capabilities.get(TerminalCapability.CommandDetection)?.commands.at(-1)?.command; // Get the chat session title - const chatSessionId = terminalChatService.getChatSessionIdForInstance(instance); + const chatSessionResource = terminalChatService.getChatSessionResourceForInstance(instance); let chatSessionTitle: string | undefined; - if (chatSessionId) { - chatSessionTitle = chatService.getSessionTitle(LocalChatSessionUri.forSession(chatSessionId)); + if (chatSessionResource) { + const liveTitle = chatService.getSession(chatSessionResource)?.title; + chatSessionTitle = liveTitle ?? chatService.getSessionTitle(chatSessionResource); } const description = chatSessionTitle; diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index e815d4e93cb81..acb2ca13eaa27 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -13,7 +13,7 @@ import { IContextKey, IContextKeyService } from '../../../../../platform/context import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; import { TerminalChatContextKeys } from './terminalChat.js'; -import { chatSessionResourceToId, LocalChatSessionUri } from '../../../chat/common/model/chatUri.js'; +import { LocalChatSessionUri } from '../../../chat/common/model/chatUri.js'; import { isNumber, isString } from '../../../../../base/common/types.js'; const enum StorageKeys { @@ -180,11 +180,6 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ return this._chatSessionResourceByTerminalInstance.get(instance); } - getChatSessionIdForInstance(instance: ITerminalInstance): string | undefined { - const resource = this._chatSessionResourceByTerminalInstance.get(instance); - return resource ? chatSessionResourceToId(resource) : undefined; - } - isBackgroundTerminal(terminalToolSessionId?: string): boolean { if (!terminalToolSessionId) { return false; From bae06f0a066ac22ab288c094ef0bc01b6edaab27 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:57:26 -0800 Subject: [PATCH 16/42] Refresh checkpoint UI (#294282) * Refresh checkpoint and restore checkpoint UI Replace bookmark icon and dashed divider with fading gradient lines flanking the checkpoint/restore labels. Add dot separator and undo button styling for the restore-checkpoint row. Use disabledForeground for label text with hover-to-reveal border treatment on toolbar actions. * Show checkpoint UI only on hover/focus, adjust spacing and colors * Add aria-hidden to dot separator for screen readers --- .../chat/browser/widget/chatListRenderer.ts | 13 ++-- .../chat/browser/widget/media/chat.css | 77 ++++++++++++------- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 37c8ecfc743e9..b3040e59317d0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -464,8 +464,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { @@ -483,7 +482,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { if (action instanceof MenuItemAction) { @@ -537,7 +538,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer Date: Wed, 18 Feb 2026 09:59:08 -0800 Subject: [PATCH 17/42] Brighten list selection foreground in 2026 dark theme (#296053) --- extensions/theme-2026/themes/2026-dark.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 17cb3439dddae..2ef19c0e59d42 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -54,9 +54,9 @@ "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", "list.activeSelectionBackground": "#3994BC26", - "list.activeSelectionForeground": "#bfbfbf", + "list.activeSelectionForeground": "#ededed", "list.inactiveSelectionBackground": "#2C2D2E", - "list.inactiveSelectionForeground": "#bfbfbf", + "list.inactiveSelectionForeground": "#ededed", "list.hoverBackground": "#262728", "list.hoverForeground": "#bfbfbf", "list.dropBackground": "#3994BC1A", From 10d738ca3c3deb49ecb7fe3b902b502eea106b23 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 18 Feb 2026 19:16:25 +0100 Subject: [PATCH 18/42] Add cyclic dependency check script and tests (#296035) * eng: add cyclic dependency check script and update workflow * refactor: export classes and functions in checkCyclicDependencies for better accessibility; add comprehensive tests for cycle detection and file processing * Update build/lib/checkCyclicDependencies.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr.yml | 3 + build/lib/checkCyclicDependencies.ts | 173 +++++++++++++++++ .../lib/test/checkCyclicDependencies.test.ts | 181 ++++++++++++++++++ package.json | 1 + 4 files changed, 358 insertions(+) create mode 100644 build/lib/checkCyclicDependencies.ts create mode 100644 build/lib/test/checkCyclicDependencies.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 831a02068fb3d..b988d19f49ea6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -81,6 +81,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check cyclic dependencies + run: node build/lib/checkCyclicDependencies.ts out-build + linux-cli-tests: name: Linux uses: ./.github/workflows/pr-linux-cli-test.yml diff --git a/build/lib/checkCyclicDependencies.ts b/build/lib/checkCyclicDependencies.ts new file mode 100644 index 0000000000000..dddaf76ad5a40 --- /dev/null +++ b/build/lib/checkCyclicDependencies.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fs from 'fs'; +import path from 'path'; +import * as ts from 'typescript'; + +// --- Graph (extracted from build/lib/tsb/utils.ts) --- + +export class Node { + readonly incoming = new Map(); + readonly outgoing = new Map(); + + readonly data: string; + + constructor(data: string) { + this.data = data; + } +} + +export class Graph { + private _nodes = new Map(); + + inertEdge(from: string, to: string): void { + const fromNode = this.lookupOrInsertNode(from); + const toNode = this.lookupOrInsertNode(to); + fromNode.outgoing.set(toNode.data, toNode); + toNode.incoming.set(fromNode.data, fromNode); + } + + lookupOrInsertNode(data: string): Node { + let node = this._nodes.get(data); + if (!node) { + node = new Node(data); + this._nodes.set(data, node); + } + return node; + } + + lookup(data: string): Node | undefined { + return this._nodes.get(data); + } + + findCycles(allData: string[]): Map { + const result = new Map(); + const checked = new Set(); + for (const data of allData) { + const node = this.lookup(data); + if (!node) { + continue; + } + const cycle = this._findCycle(node, checked, new Set()); + result.set(node.data, cycle); + } + return result; + } + + private _findCycle(node: Node, checked: Set, seen: Set): string[] | undefined { + if (checked.has(node.data)) { + return undefined; + } + for (const child of node.outgoing.values()) { + if (seen.has(child.data)) { + const seenArr = Array.from(seen); + const idx = seenArr.indexOf(child.data); + seenArr.push(child.data); + return idx > 0 ? seenArr.slice(idx) : seenArr; + } + seen.add(child.data); + const result = this._findCycle(child, checked, seen); + seen.delete(child.data); + if (result) { + return result; + } + } + checked.add(node.data); + return undefined; + } +} + +// --- Dependency scanning & cycle detection --- + +export function normalize(p: string): string { + return p.replace(/\\/g, '/'); +} + +export function collectJsFiles(dir: string): string[] { + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectJsFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + results.push(full); + } + } + return results; +} + +export function processFile(filename: string, graph: Graph): void { + const content = fs.readFileSync(filename, 'utf-8'); + const info = ts.preProcessFile(content, true); + + for (const ref of info.importedFiles) { + if (!ref.fileName.startsWith('.')) { + continue; // skip node_modules + } + if (ref.fileName.endsWith('.css')) { + continue; + } + + const dir = path.dirname(filename); + let resolvedPath = path.resolve(dir, ref.fileName); + if (resolvedPath.endsWith('.js')) { + resolvedPath = resolvedPath.slice(0, -3); + } + const normalizedResolved = normalize(resolvedPath); + + if (fs.existsSync(normalizedResolved + '.js')) { + graph.inertEdge(normalize(filename), normalizedResolved + '.js'); + } else if (fs.existsSync(normalizedResolved + '.ts')) { + graph.inertEdge(normalize(filename), normalizedResolved + '.ts'); + } + } +} + +function main(): void { + const folder = process.argv[2]; + if (!folder) { + console.error('Usage: node build/lib/checkCyclicDependencies.ts '); + process.exit(1); + } + + const rootDir = path.resolve(folder); + if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) { + console.error(`Not a directory: ${rootDir}`); + process.exit(1); + } + + const files = collectJsFiles(rootDir); + const graph = new Graph(); + + for (const file of files) { + processFile(file, graph); + } + + const allNormalized = files.map(normalize).sort((a, b) => a.localeCompare(b)); + const cycles = graph.findCycles(allNormalized); + + const cyclicPaths = new Set(); + for (const [_filename, cycle] of cycles) { + if (cycle) { + const path = cycle.join(' -> '); + if (cyclicPaths.has(path)) { + continue; + } + cyclicPaths.add(path); + console.error(`CYCLIC dependency: ${path}`); + } + } + + if (cyclicPaths.size > 0) { + process.exit(1); + } else { + console.log(`No cyclic dependencies found in ${files.length} files.`); + } +} + +if (process.argv[1] && normalize(path.resolve(process.argv[1])).endsWith('checkCyclicDependencies.ts')) { + main(); +} diff --git a/build/lib/test/checkCyclicDependencies.test.ts b/build/lib/test/checkCyclicDependencies.test.ts new file mode 100644 index 0000000000000..bbffbc55fabd7 --- /dev/null +++ b/build/lib/test/checkCyclicDependencies.test.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { Graph, collectJsFiles, processFile, normalize } from '../checkCyclicDependencies.ts'; + +suite('checkCyclicDependencies', () => { + + suite('Graph', () => { + + test('no cycles in linear chain', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'c'); + const cycles = graph.findCycles(['a', 'b', 'c']); + for (const [, cycle] of cycles) { + assert.strictEqual(cycle, undefined); + } + }); + + test('detects simple cycle', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'a'); + const cycles = graph.findCycles(['a', 'b']); + const hasCycle = Array.from(cycles.values()).some(c => c !== undefined); + assert.ok(hasCycle); + }); + + test('detects 3-node cycle', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'c'); + graph.inertEdge('c', 'a'); + const cycles = graph.findCycles(['a', 'b', 'c']); + const hasCycle = Array.from(cycles.values()).some(c => c !== undefined); + assert.ok(hasCycle); + }); + + test('no false positives with shared dependencies', () => { + const graph = new Graph(); + // diamond: a -> b, a -> c, b -> d, c -> d + graph.inertEdge('a', 'b'); + graph.inertEdge('a', 'c'); + graph.inertEdge('b', 'd'); + graph.inertEdge('c', 'd'); + const cycles = graph.findCycles(['a', 'b', 'c', 'd']); + for (const [, cycle] of cycles) { + assert.strictEqual(cycle, undefined); + } + }); + + test('lookupOrInsertNode returns same node for same data', () => { + const graph = new Graph(); + const node1 = graph.lookupOrInsertNode('x'); + const node2 = graph.lookupOrInsertNode('x'); + assert.strictEqual(node1, node2); + }); + + test('lookup returns undefined for unknown node', () => { + const graph = new Graph(); + assert.strictEqual(graph.lookup('unknown'), undefined); + }); + + test('findCycles skips unknown data', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + const cycles = graph.findCycles(['nonexistent']); + assert.strictEqual(cycles.get('nonexistent'), undefined); + }); + + test('cycle path contains the cycle nodes', () => { + const graph = new Graph(); + graph.inertEdge('a', 'b'); + graph.inertEdge('b', 'c'); + graph.inertEdge('c', 'b'); + const cycles = graph.findCycles(['a', 'b', 'c']); + const cyclePath = Array.from(cycles.values()).find(c => c !== undefined); + assert.ok(cyclePath); + assert.ok(cyclePath.includes('b')); + assert.ok(cyclePath.includes('c')); + // cycle should start and end with same node + assert.strictEqual(cyclePath[0], cyclePath[cyclePath.length - 1]); + }); + }); + + suite('normalize', () => { + + test('replaces backslashes with forward slashes', () => { + assert.strictEqual(normalize('a\\b\\c'), 'a/b/c'); + }); + + test('leaves forward slashes unchanged', () => { + assert.strictEqual(normalize('a/b/c'), 'a/b/c'); + }); + }); + + suite('collectJsFiles and processFile', () => { + + let tmpDir: string; + + setup(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cyclic-test-')); + }); + + teardown(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('collectJsFiles finds .js files recursively', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), ''); + fs.writeFileSync(path.join(tmpDir, 'b.ts'), ''); + fs.mkdirSync(path.join(tmpDir, 'sub')); + fs.writeFileSync(path.join(tmpDir, 'sub', 'c.js'), ''); + const files = collectJsFiles(tmpDir); + assert.strictEqual(files.length, 2); + assert.ok(files.some(f => f.endsWith('a.js'))); + assert.ok(files.some(f => f.endsWith('c.js'))); + }); + + test('processFile adds edges for relative imports', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import { x } from "./b";'); + fs.writeFileSync(path.join(tmpDir, 'b.js'), ''); + const graph = new Graph(); + processFile(path.join(tmpDir, 'a.js'), graph); + const aNode = graph.lookup(normalize(path.join(tmpDir, 'a.js'))); + assert.ok(aNode); + assert.strictEqual(aNode.outgoing.size, 1); + }); + + test('processFile skips non-relative imports', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import fs from "fs";'); + const graph = new Graph(); + processFile(path.join(tmpDir, 'a.js'), graph); + // no relative imports, so no edges and no node created + assert.strictEqual(graph.lookup(normalize(path.join(tmpDir, 'a.js'))), undefined); + }); + + test('processFile skips CSS imports', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import "./styles.css";'); + const graph = new Graph(); + processFile(path.join(tmpDir, 'a.js'), graph); + // CSS imports are ignored, so no edges and no node created + assert.strictEqual(graph.lookup(normalize(path.join(tmpDir, 'a.js'))), undefined); + }); + + test('end-to-end: detects cycle in JS files', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import { x } from "./b";'); + fs.writeFileSync(path.join(tmpDir, 'b.js'), 'import { y } from "./a";'); + const files = collectJsFiles(tmpDir); + const graph = new Graph(); + for (const file of files) { + processFile(file, graph); + } + const allNormalized = files.map(normalize); + const cycles = graph.findCycles(allNormalized); + const hasCycle = Array.from(cycles.values()).some(c => c !== undefined); + assert.ok(hasCycle); + }); + + test('end-to-end: no cycle in acyclic JS files', () => { + fs.writeFileSync(path.join(tmpDir, 'a.js'), 'import { x } from "./b";'); + fs.writeFileSync(path.join(tmpDir, 'b.js'), ''); + const files = collectJsFiles(tmpDir); + const graph = new Graph(); + for (const file of files) { + processFile(file, graph); + } + const allNormalized = files.map(normalize); + const cycles = graph.findCycles(allNormalized); + for (const [, cycle] of cycles) { + assert.strictEqual(cycle, undefined); + } + }); + }); +}); diff --git a/package.json b/package.json index 4a17850e8f0fb..f73c3d4705795 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test-node": "mocha test/unit/node/index.js --delay --ui=tdd --timeout=5000 --exit", "test-extension": "vscode-test", "test-build-scripts": "cd build && npm run test", + "check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out", "preinstall": "node build/npm/preinstall.ts", "postinstall": "node build/npm/postinstall.ts", "compile": "npm run gulp compile", From 3810f9746a72cbae51a056d1bc7ce3b1ede4d459 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:23:21 +0000 Subject: [PATCH 19/42] Add "Show Details" button to chat setup timeout message (#295653) --- product.json | 1 + src/vs/base/common/product.ts | 1 + .../browser/chatSetup/chatSetupProviders.ts | 55 +++++++++++++++---- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/product.json b/product.json index e3c6fbb58e530..fe7d07a079b11 100644 --- a/product.json +++ b/product.json @@ -87,6 +87,7 @@ "extensionId": "GitHub.copilot", "chatExtensionId": "GitHub.copilot-chat", "chatExtensionOutputId": "GitHub.copilot-chat.GitHub Copilot Chat.log", + "chatExtensionOutputExtensionStateCommand": "github.copilot.debug.extensionState", "documentationUrl": "https://aka.ms/github-copilot-overview", "termsStatementUrl": "https://aka.ms/github-copilot-terms-statement", "privacyStatementUrl": "https://aka.ms/github-copilot-privacy-statement", diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 11aba6dd528f5..2602572ba07c5 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -340,6 +340,7 @@ export interface IDefaultChatAgent { readonly chatExtensionId: string; readonly chatExtensionOutputId: string; + readonly chatExtensionOutputExtensionStateCommand: string; readonly documentationUrl: string; readonly skusDocumentationUrl: string; diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index ed6bad1e99315..14541ee5f4b1d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; -import { timeout } from '../../../../../base/common/async.js'; +import { raceTimeout, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; @@ -50,15 +50,17 @@ import { ChatSetupController } from './chatSetupController.js'; import { ChatSetupAnonymous, ChatSetupStep, IChatSetupResult } from './chatSetup.js'; import { ChatSetup } from './chatSetupRunner.js'; import { chatViewsWelcomeRegistry } from '../viewsWelcome/chatViewsWelcome.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; +import { IOutputService } from '../../../../services/output/common/output.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '', provider: product.defaultChatAgent?.provider ?? { default: { id: '', name: '' }, enterprise: { id: '', name: '' }, apple: { id: '', name: '' }, google: { id: '', name: '' } }, outputChannelId: product.defaultChatAgent?.chatExtensionOutputId ?? '', + outputExtensionStateCommand: product.defaultChatAgent?.chatExtensionOutputExtensionStateCommand ?? '', }; const ToolsAgentContextKey = ContextKeyExpr.and( @@ -175,6 +177,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { private static readonly TRUST_NEEDED_MESSAGE = new MarkdownString(localize('trustNeeded', "You need to trust this workspace to use Chat.")); private static readonly CHAT_RETRY_COMMAND_ID = 'workbench.action.chat.retrySetup'; + private static readonly CHAT_SHOW_OUTPUT_COMMAND_ID = 'workbench.action.chat.showOutput'; private readonly _onUnresolvableError = this._register(new Emitter()); readonly onUnresolvableError = this._onUnresolvableError.event; @@ -193,6 +196,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @IViewsService private readonly viewsService: IViewsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IOutputService private readonly outputService: IOutputService, ) { super(); @@ -211,6 +215,27 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { hostService.reload(); })); + + // Show output command: execute extension state command if available, then show output channel + this._register(CommandsRegistry.registerCommand(SetupAgent.CHAT_SHOW_OUTPUT_COMMAND_ID, async (accessor) => { + const commandService = accessor.get(ICommandService); + + if (defaultChat.outputExtensionStateCommand) { + // Command invocation may fail or is blocked by the extension activating + // so we just don't wait and timeout after a certain time, logging the error if it fails or times out. + raceTimeout( + commandService.executeCommand(defaultChat.outputExtensionStateCommand), + 5000, + () => this.logService.info('[chat setup] Timed out executing extension state command') + ).then(undefined, error => { + this.logService.info('[chat setup] Failed to execute extension state command', error); + }); + } + + if (defaultChat.outputChannelId) { + await commandService.executeCommand(`workbench.action.output.show.${defaultChat.outputChannelId}`); + } + })); } async invoke(request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void): Promise { @@ -465,14 +490,24 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { content: new MarkdownString(warningMessage) }); - progress({ - kind: 'command', - command: { - id: SetupAgent.CHAT_RETRY_COMMAND_ID, - title: localize('retryChat', "Restart"), - arguments: [requestModel.session.sessionResource] - } - }); + if (defaultChat.outputChannelId && this.outputService.getChannelDescriptor(defaultChat.outputChannelId)) { + progress({ + kind: 'command', + command: { + id: SetupAgent.CHAT_SHOW_OUTPUT_COMMAND_ID, + title: localize('showCopilotChatDetails', "Show Details") + } + }); + } else { + progress({ + kind: 'command', + command: { + id: SetupAgent.CHAT_RETRY_COMMAND_ID, + title: localize('retryChat', "Restart"), + arguments: [requestModel.session.sessionResource] + } + }); + } // This means Chat is unhealthy and we cannot retry the // request. Signal this to the outside via an event. From e253afd67ed5e94027c0420a831ba5c06864fb41 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 18 Feb 2026 19:23:57 +0100 Subject: [PATCH 20/42] fix pickers in chat view pane (#296041) * fix pickers in new chat view pane * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/newChatViewPane.ts | 231 ++++++++++++------ 1 file changed, 158 insertions(+), 73 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 1ada74835c253..014ad6d8d4028 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -7,9 +7,12 @@ import './media/chatWidget.css'; import './media/chatWelcomePart.css'; import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { Separator, toAction } from '../../../../base/common/actions.js'; import { Radio } from '../../../../base/browser/ui/radio/radio.js'; +import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; @@ -48,14 +51,40 @@ import { WorkspaceFolderCountContext } from '../../../../workbench/common/contex import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; +import { IWorkspacesService, IRecentFolder, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; +import { isString } from '../../../../base/common/types.js'; // #region --- Target Config --- +/** + * A dropdown menu action item that shows an icon, a text label, and a chevron. + */ +class LabeledDropdownMenuActionViewItem extends DropdownMenuActionViewItem { + protected override renderLabel(element: HTMLElement): null { + // Render icon as a separate codicon element + const classNames = typeof this.options.classNames === 'string' + ? this.options.classNames.split(/\s+/g).filter(s => !!s) + : (this.options.classNames ?? []); + if (classNames.length > 0) { + const icon = dom.append(element, dom.$('span')); + icon.classList.add('codicon', ...classNames); + } + + // Add text label (not affected by codicon font) + const label = dom.append(element, dom.$('span.sessions-chat-dropdown-label')); + label.textContent = this._action.label; + + // Add chevron + dom.append(element, renderIcon(Codicon.chevronDown)); + + return null; + } +} + /** * Tracks which agent session targets are available and which is selected. * Targets are fixed at construction time; only the selection changes. @@ -178,6 +207,8 @@ class NewChatWidget extends Disposable { private _localModePickersContainer: HTMLElement | undefined; private _localMode: 'workspace' | 'worktree' = 'worktree'; private _selectedFolderUri: URI | undefined; + private _recentlyPickedFolders: URI[] = []; + private _cachedRecentFolders: IRecentFolder[] = []; private readonly _pickerWidgets = new Map(); private readonly _pickerWidgetDisposables = this._register(new DisposableStore()); private readonly _optionEmitters = new Map>(); @@ -205,11 +236,24 @@ class NewChatWidget extends Disposable { this._targetConfig = this._register(new TargetConfig(options.targetConfig)); this._options = options; - // Restore last picked folder + // Restore last picked folder and recently picked folders const lastFolder = this.storageService.get('agentSessions.lastPickedFolder', StorageScope.PROFILE); if (lastFolder) { try { this._selectedFolderUri = URI.parse(lastFolder); } catch { /* ignore */ } } + try { + const stored = this.storageService.get('agentSessions.recentlyPickedFolders', StorageScope.PROFILE); + if (stored) { + this._recentlyPickedFolders = JSON.parse(stored).map((s: string) => URI.parse(s)); + } + } catch { /* ignore */ } + + // Pre-fetch recently opened folders + this.workspacesService.getRecentlyOpened().then(recent => { + this._cachedRecentFolders = recent.workspaces.filter(isRecentFolder).slice(0, 10); + }).catch(error => { + this.logService.error('Failed to fetch recently opened workspaces for agent sessions', error); + }); // When target changes, regenerate pending resource this._register(this._targetConfig.onDidChangeSelectedTarget(() => { @@ -228,10 +272,7 @@ class NewChatWidget extends Disposable { })); // Listen for option group changes to re-render pickers - this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { - this._notifyFolderSelection(); - this._renderExtensionPickers(); - })); + this._register(this.chatSessionsService.onDidChangeOptionGroups(() => this._renderExtensionPickers())); // React to chat session option changes this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => { @@ -304,17 +345,32 @@ class NewChatWidget extends Disposable { return target; } + private readonly _pendingSessionResources = new Map(); + private _generatePendingSessionResource(): void { const target = this._getEffectiveTarget(); if (!target || target === AgentSessionProviders.Local) { this._pendingSessionResource = undefined; return; } + + // Reuse existing pending resource for the same target type + const existing = this._pendingSessionResources.get(target); + if (existing) { + this._pendingSessionResource = existing; + return; + } + this._pendingSessionResource = getResourceForNewChatSession({ type: target, position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar, displayName: '', }); + this._pendingSessionResources.set(target, this._pendingSessionResource); + + // Create the session in the extension so that session options can be stored + this.chatSessionsService.getOrCreateChatSession(this._pendingSessionResource, CancellationToken.None) + .catch((err) => this.logService.trace('Failed to create pending session:', err)); } // --- Editor --- @@ -514,34 +570,30 @@ class NewChatWidget extends Disposable { : localize('localMode.worktree', "Worktree"); const modeIcon = this._localMode === 'workspace' ? Codicon.folder : Codicon.worktree; - const button = dom.append(this._localModeDropdownContainer, dom.$('.sessions-chat-dropdown-button')); - button.tabIndex = 0; - button.role = 'button'; - button.ariaHasPopup = 'true'; - dom.append(button, renderIcon(modeIcon)); - dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, modeLabel)); - dom.append(button, renderIcon(Codicon.chevronDown)); - - this._localModeDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, () => { - const actions = [ - toAction({ - id: 'localMode.workspace', - label: localize('localMode.workspace', "Workspace"), - checked: this._localMode === 'workspace', - run: () => this._setLocalMode('workspace'), - }), - toAction({ - id: 'localMode.worktree', - label: localize('localMode.worktree', "Worktree"), - checked: this._localMode === 'worktree', - run: () => this._setLocalMode('worktree'), - }), - ]; - this.contextMenuService.showContextMenu({ - getAnchor: () => button, - getActions: () => actions, - }); - })); + const modeAction = toAction({ id: 'localMode', label: modeLabel, run: () => { } }); + const modeDropdown = this._localModeDisposables.add(new LabeledDropdownMenuActionViewItem( + modeAction, + { + getActions: () => [ + toAction({ + id: 'localMode.workspace', + label: localize('localMode.workspace', "Workspace"), + checked: this._localMode === 'workspace', + run: () => this._setLocalMode('workspace'), + }), + toAction({ + id: 'localMode.worktree', + label: localize('localMode.worktree', "Worktree"), + checked: this._localMode === 'worktree', + run: () => this._setLocalMode('worktree'), + }), + ], + }, + this.contextMenuService, + { classNames: [...ThemeIcon.asClassNameArray(modeIcon)] } + )); + const modeSlot = dom.append(this._localModeDropdownContainer, dom.$('.sessions-chat-picker-slot')); + modeDropdown.render(modeSlot); // Render pickers in the right side this._renderLocalModePickers(); @@ -557,6 +609,7 @@ class NewChatWidget extends Disposable { } private _notifyFolderSelection(): void { + this._selectedOptions.clear(); if (!this._pendingSessionResource) { return; } @@ -569,6 +622,11 @@ class NewChatWidget extends Disposable { } } + private _addToRecentlyPickedFolders(folderUri: URI): void { + this._recentlyPickedFolders = [folderUri, ...this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri))].slice(0, 10); + this.storageService.store('agentSessions.recentlyPickedFolders', JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); + } + private _renderLocalModePickers(): void { if (!this._localModePickersContainer) { return; @@ -725,56 +783,71 @@ class NewChatWidget extends Disposable { const currentFolderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; const folderName = currentFolderUri ? basename(currentFolderUri) : localize('pickFolder', "Pick Folder"); - const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); - const button = dom.append(slot, dom.$('.sessions-chat-dropdown-button')); - button.tabIndex = 0; - button.role = 'button'; - button.ariaHasPopup = 'true'; - dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, folderName)); - dom.append(button, renderIcon(Codicon.chevronDown)); - const switchFolder = async (folderUri: URI) => { this._selectedFolderUri = folderUri; + this._addToRecentlyPickedFolders(folderUri); this.storageService.store('agentSessions.lastPickedFolder', folderUri.toString(), StorageScope.PROFILE, StorageTarget.MACHINE); this._notifyFolderSelection(); this._renderExtensionPickers(true); }; - disposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, async () => { - const recentlyOpened = await this.workspacesService.getRecentlyOpened(); - const recentFolders = recentlyOpened.workspaces - .filter(isRecentFolder) - .filter(r => !currentFolderUri || !isEqual(r.folderUri, currentFolderUri)) - .slice(0, 10); - - const actions = recentFolders.map(recent => toAction({ - id: recent.folderUri.toString(), - label: recent.label || basename(recent.folderUri), - run: () => switchFolder(recent.folderUri), - })); + const folderAction = toAction({ id: 'folderPicker', label: folderName, run: () => { } }); + const folderDropdown = disposables.add(new LabeledDropdownMenuActionViewItem( + folderAction, + { + getActions: () => this._getFolderPickerActions(currentFolderUri, switchFolder), + }, + this.contextMenuService, + { classNames: [...ThemeIcon.asClassNameArray(Codicon.folder)] } + )); + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + folderDropdown.render(slot); + } + + private _getFolderPickerActions(currentFolderUri: URI | undefined, switchFolder: (uri: URI) => Promise): (ReturnType | Separator)[] { + const seenUris = new Set(); + if (currentFolderUri) { + seenUris.add(currentFolderUri.toString()); + } + + const actions: (ReturnType | Separator)[] = []; - actions.push(new Separator()); + // Combine recently picked folders and recently opened folders (picked first, then opened) + const allFolders: { uri: URI; label?: string }[] = [ + ...this._recentlyPickedFolders.map(uri => ({ uri })), + ...this._cachedRecentFolders.map(r => ({ uri: r.folderUri, label: r.label })), + ]; + for (const folder of allFolders) { + const key = folder.uri.toString(); + if (seenUris.has(key)) { + continue; + } + seenUris.add(key); actions.push(toAction({ - id: 'browse', - label: localize('browseFolder', "Browse..."), - run: async () => { - const selected = await this.fileDialogService.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: localize('selectFolder', "Select Folder"), - }); - if (selected?.[0]) { - await switchFolder(selected[0]); - } - }, + id: key, + label: folder.label || basename(folder.uri), + run: () => switchFolder(folder.uri), })); + } - this.contextMenuService.showContextMenu({ - getAnchor: () => button, - getActions: () => actions, - }); + actions.push(new Separator()); + actions.push(toAction({ + id: 'browse', + label: localize('browseFolder', "Browse..."), + run: async () => { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectFolder', "Select Folder"), + }); + if (selected?.[0]) { + await switchFolder(selected[0]); + } + }, })); + + return actions; } private _renderExtensionPickersInContainer(container: HTMLElement, sessionType: AgentSessionProviders): void { @@ -853,7 +926,19 @@ class NewChatWidget extends Disposable { } private _getDefaultOptionForGroup(optionGroup: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { - return this._selectedOptions.get(optionGroup.id) ?? optionGroup.items.find((item) => item.default === true); + const selectedOption = this._selectedOptions.get(optionGroup.id); + if (selectedOption) { + return selectedOption; + } + + if (this._pendingSessionResource) { + const sessionOption = this.chatSessionsService.getSessionOption(this._pendingSessionResource, optionGroup.id); + if (!isString(sessionOption)) { + return sessionOption; + } + } + + return optionGroup.items.find((item) => item.default === true); } private _syncOptionsFromSession(sessionResource: URI): void { From 540f083a59f0728b9adc50b4c2221325d00c5b32 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 18 Feb 2026 19:35:05 +0100 Subject: [PATCH 21/42] use a simple action widget dropdown (#296048) * Use a simple action widget dropdown instead of list based which is too heavy * feedback --- .../actionWidget/browser/actionList.ts | 312 +----------- .../browser/actionListDropdown.css | 120 +++++ .../browser/actionListDropdown.ts | 473 ++++++++++++++++++ .../actionWidget/browser/actionWidget.css | 39 -- .../actionWidget/browser/actionWidget.ts | 13 +- .../browser/actionWidgetDropdown.ts | 10 +- .../browser/widget/input/chatInputPart.ts | 2 +- .../browser/widget/input/chatModelPicker.ts | 131 ++--- .../widget/input/modelPickerActionItem2.ts | 7 - .../contrib/chat/common/languageModels.ts | 77 +-- .../chatModelsViewModel.test.ts | 9 +- .../chat/test/common/languageModels.ts | 12 +- 12 files changed, 677 insertions(+), 528 deletions(-) create mode 100644 src/vs/platform/actionWidget/browser/actionListDropdown.css create mode 100644 src/vs/platform/actionWidget/browser/actionListDropdown.ts diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 554a7cde357ea..2a452609df613 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; -import { Button } from '../../../base/browser/ui/button/button.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; @@ -19,7 +18,7 @@ import './actionWidget.css'; import { localize } from '../../../nls.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; -import { defaultButtonStyles, defaultListStyles } from '../../theme/browser/defaultStyles.js'; +import { defaultListStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable } from '../../theme/common/colorRegistry.js'; import { ILayoutService } from '../../layout/browser/layoutService.js'; import { IHoverService } from '../../hover/browser/hover.js'; @@ -68,42 +67,16 @@ export interface IActionListItem { * Optional toolbar actions shown when the item is focused or hovered. */ readonly toolbarActions?: IAction[]; - /** - * Optional section identifier. Items with the same section belong to the same - * collapsible group. Only meaningful when the ActionList is created with - * collapsible sections. - */ - readonly section?: string; - /** - * When true, clicking this item toggles the section's collapsed state - * instead of selecting it. - */ - readonly isSectionToggle?: boolean; - /** - * Optional CSS class name to add to the row container. - */ - readonly className?: string; - /** - * Optional badge text to display after the label (e.g., "New"). - */ - readonly badge?: string; - /** - * When set, the description is rendered as a primary button. - * The callback is invoked when the button is clicked. - */ - readonly descriptionButton?: { readonly label: string; readonly onDidClick: () => void }; } interface IActionMenuTemplateData { readonly container: HTMLElement; readonly icon: HTMLElement; readonly text: HTMLElement; - readonly badge: HTMLElement; readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; readonly elementDisposables: DisposableStore; - previousClassName?: string; } export const enum ActionListItemKind { @@ -186,10 +159,6 @@ class ActionItemRenderer implements IListRenderer, IAction text.className = 'title'; container.append(text); - const badge = document.createElement('span'); - badge.className = 'action-item-badge'; - container.append(badge); - const description = document.createElement('span'); description.className = 'description'; container.append(description); @@ -202,7 +171,7 @@ class ActionItemRenderer implements IListRenderer, IAction const elementDisposables = new DisposableStore(); - return { container, icon, text, badge, description, keybinding, toolbar, elementDisposables }; + return { container, icon, text, description, keybinding, toolbar, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -225,40 +194,10 @@ class ActionItemRenderer implements IListRenderer, IAction dom.setVisibility(!element.hideIcon, data.icon); - // Apply optional className - clean up previous to avoid stale classes - // from virtualized row reuse - if (data.previousClassName) { - data.container.classList.remove(data.previousClassName); - } - data.container.classList.toggle('action-list-custom', !!element.className); - if (element.className) { - data.container.classList.add(element.className); - } - data.previousClassName = element.className; - data.text.textContent = stripNewlines(element.label); - // Render optional badge - if (element.badge) { - data.badge.textContent = element.badge; - data.badge.style.display = ''; - } else { - data.badge.textContent = ''; - data.badge.style.display = 'none'; - } - // if there is a keybinding, prioritize over description for now - if (element.descriptionButton) { - data.description!.textContent = ''; - data.description!.style.display = 'inline'; - const button = new Button(data.description!, { ...defaultButtonStyles, small: true }); - button.label = element.descriptionButton.label; - data.elementDisposables.add(button.onDidClick(e => { - e?.stopPropagation(); - element.descriptionButton!.onDidClick(); - })); - data.elementDisposables.add(button); - } else if (element.keybinding) { + if (element.keybinding) { data.description!.textContent = element.keybinding.getLabel(); data.description!.style.display = 'inline'; data.description!.style.letterSpacing = '0.5px'; @@ -322,26 +261,6 @@ function getKeyboardNavigationLabel(item: IActionListItem): string | undef return undefined; } -/** - * Options for configuring the action list. - */ -export interface IActionListOptions { - /** - * When true, shows a filter input at the bottom of the list. - */ - readonly showFilter?: boolean; - - /** - * Section IDs that should be collapsed by default. - */ - readonly collapsedByDefault?: ReadonlySet; - - /** - * Minimum width for the action list. - */ - readonly minWidth?: number; -} - export class ActionList extends Disposable { public readonly domNode: HTMLElement; @@ -358,20 +277,12 @@ export class ActionList extends Disposable { private _hover = this._register(new MutableDisposable()); - private readonly _collapsedSections = new Set(); - private _filterText = ''; - private readonly _filterInput: HTMLInputElement | undefined; - private readonly _filterContainer: HTMLElement | undefined; - private _lastMinWidth = 0; - private _hasLaidOut = false; - constructor( user: string, preview: boolean, items: readonly IActionListItem[], private readonly _delegate: IActionListDelegate, accessibilityProvider: Partial>> | undefined, - private readonly _options: IActionListOptions | undefined, @IContextViewService private readonly _contextViewService: IContextViewService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @ILayoutService private readonly _layoutService: ILayoutService, @@ -380,14 +291,6 @@ export class ActionList extends Disposable { super(); this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); - - // Initialize collapsed sections - if (this._options?.collapsedByDefault) { - for (const section of this._options.collapsedByDefault) { - this._collapsedSections.add(section); - } - } - const virtualDelegate: IListVirtualDelegate> = { getHeight: element => { switch (element.kind) { @@ -409,7 +312,7 @@ export class ActionList extends Disposable { new SeparatorRenderer(), ], { keyboardSupport: false, - typeNavigationEnabled: !this._options?.showFilter, + typeNavigationEnabled: true, keyboardNavigationLabelProvider: { getKeyboardNavigationLabel }, accessibilityProvider: { getAriaLabel: element => { @@ -449,151 +352,13 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); this._allMenuItems = items; - - // Create filter input - if (this._options?.showFilter) { - this._filterContainer = document.createElement('div'); - this._filterContainer.className = 'action-list-filter'; - - this._filterInput = document.createElement('input'); - this._filterInput.type = 'text'; - this._filterInput.className = 'action-list-filter-input'; - this._filterInput.placeholder = localize('actionList.filter.placeholder', "Search..."); - this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); - this._filterContainer.appendChild(this._filterInput); - - this._register(dom.addDisposableListener(this._filterInput, 'input', () => { - this._filterText = this._filterInput!.value; - this._applyFilter(); - })); - - // Keyboard navigation from filter input - this._register(dom.addDisposableListener(this._filterInput, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'ArrowUp') { - e.preventDefault(); - this._list.domFocus(); - const lastIndex = this._list.length - 1; - if (lastIndex >= 0) { - this._list.focusLast(undefined, this.focusCondition); - } - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - this._list.domFocus(); - this.focusNext(); - } else if (e.key === 'Enter') { - e.preventDefault(); - this.acceptSelected(); - } else if (e.key === 'Escape') { - if (this._filterText) { - e.preventDefault(); - e.stopPropagation(); - this._filterInput!.value = ''; - this._filterText = ''; - this._applyFilter(); - } - } - })); - } - - this._applyFilter(); + this._list.splice(0, this._list.length, this._allMenuItems); if (this._list.length) { this.focusNext(); } } - private _toggleSection(section: string): void { - if (this._collapsedSections.has(section)) { - this._collapsedSections.delete(section); - } else { - this._collapsedSections.add(section); - } - this._applyFilter(); - } - - private _applyFilter(): void { - const filterLower = this._filterText.toLowerCase(); - const isFiltering = filterLower.length > 0; - const visible: IActionListItem[] = []; - - for (const item of this._allMenuItems) { - if (item.kind === ActionListItemKind.Header) { - if (isFiltering) { - // When filtering, skip all headers - continue; - } - visible.push(item); - continue; - } - - if (item.kind === ActionListItemKind.Separator) { - if (isFiltering) { - continue; - } - visible.push(item); - continue; - } - - // Action item - if (isFiltering) { - // When filtering, skip section toggle items and only match content - if (item.isSectionToggle) { - continue; - } - // Match against label and description - const label = (item.label ?? '').toLowerCase(); - const desc = (item.description ?? '').toLowerCase(); - if (label.includes(filterLower) || desc.includes(filterLower)) { - visible.push(item); - } - } else { - // Update icon for section toggle items based on collapsed state - if (item.isSectionToggle && item.section) { - const collapsed = this._collapsedSections.has(item.section); - visible.push({ - ...item, - group: { ...item.group!, icon: collapsed ? Codicon.chevronRight : Codicon.chevronDown }, - }); - continue; - } - // Not filtering - check collapsed sections - if (item.section && this._collapsedSections.has(item.section)) { - continue; - } - visible.push(item); - } - } - - // Capture whether the filter input currently has focus before splice - // which may cause DOM changes that shift focus. - const filterInputHasFocus = this._filterInput && dom.isActiveElement(this._filterInput); - - this._list.splice(0, this._list.length, visible); - - // Re-layout to adjust height after items changed - if (this._hasLaidOut) { - this.layout(this._lastMinWidth); - // Restore focus after splice destroyed DOM elements, - // otherwise the blur handler in ActionWidgetService closes the widget. - // Keep focus on the filter input if the user is typing a filter. - if (filterInputHasFocus) { - this._filterInput!.focus(); - } else { - this._list.domFocus(); - } - // Reposition the context view so the widget grows in the correct direction - this._contextViewService.layout(); - } - } - - /** - * Returns the filter container element, if filter is enabled. - * The caller is responsible for appending it to the widget DOM. - */ - get filterContainer(): HTMLElement | undefined { - return this._filterContainer; - } - private focusCondition(element: IActionListItem): boolean { return !element.disabled && element.kind === ActionListItemKind.Action; } @@ -606,57 +371,39 @@ export class ActionList extends Disposable { } layout(minWidth: number): number { - this._hasLaidOut = true; - this._lastMinWidth = minWidth; - // Compute height based on currently visible items in the list - const visibleCount = this._list.length; - let listHeight = 0; - for (let i = 0; i < visibleCount; i++) { - const element = this._list.element(i); - switch (element.kind) { - case ActionListItemKind.Header: - listHeight += this._headerLineHeight; - break; - case ActionListItemKind.Separator: - listHeight += this._separatorLineHeight; - break; - default: - listHeight += this._actionLineHeight; - break; - } - } - - this._list.layout(listHeight); - const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0); - let maxWidth = effectiveMinWidth; - - if (visibleCount >= 50) { - maxWidth = Math.max(380, effectiveMinWidth); + // Updating list height, depending on how many separators and headers there are. + const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length; + const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length; + const itemsHeight = this._allMenuItems.length * this._actionLineHeight; + const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight; + const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight; + this._list.layout(heightWithSeparators); + let maxWidth = minWidth; + + if (this._allMenuItems.length >= 50) { + maxWidth = 380; } else { // For finding width dynamically (not using resize observer) - const itemWidths: number[] = []; - for (let i = 0; i < visibleCount; i++) { - const element = this._getRowElement(i); + const itemWidths: number[] = this._allMenuItems.map((_, index): number => { + const element = this._getRowElement(index); if (element) { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - itemWidths.push(width); + return width; } - } + return 0; + }); // resize observer - can be used in the future since list widget supports dynamic height but not width - maxWidth = Math.max(...itemWidths, effectiveMinWidth); + maxWidth = Math.max(...itemWidths, minWidth); } - const filterHeight = this._filterContainer ? 36 : 0; const maxVhPrecentage = 0.7; - const maxHeight = this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage; - const height = Math.min(listHeight + filterHeight, maxHeight); - const listFinalHeight = height - filterHeight; - this._list.layout(listFinalHeight, maxWidth); + const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage); + this._list.layout(height, maxWidth); - this.domNode.style.height = `${listFinalHeight}px`; + this.domNode.style.height = `${height}px`; this._list.domFocus(); return maxWidth; @@ -700,10 +447,6 @@ export class ActionList extends Disposable { } const element = e.elements[0]; - if (element.isSectionToggle) { - this._list.setSelection([]); - return; - } if (element.item && this.focusCondition(element)) { this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent); } else { @@ -783,11 +526,6 @@ export class ActionList extends Disposable { } private onListClick(e: IListMouseEvent>): void { - if (e.element && e.element.isSectionToggle && e.element.section) { - const section = e.element.section; - queueMicrotask(() => this._toggleSection(section)); - return; - } if (e.element && this.focusCondition(e.element)) { this._list.setFocus([]); } diff --git a/src/vs/platform/actionWidget/browser/actionListDropdown.css b/src/vs/platform/actionWidget/browser/actionListDropdown.css new file mode 100644 index 0000000000000..e4b24d38c6618 --- /dev/null +++ b/src/vs/platform/actionWidget/browser/actionListDropdown.css @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.action-list-dropdown { + font-size: 13px; + min-width: 100px; + max-width: 80vw; + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 5px; + background-color: var(--vscode-menu-background); + color: var(--vscode-menu-foreground); + padding: 4px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); +} + +.action-list-dropdown-items { + user-select: none; + -webkit-user-select: none; +} + +/* Action item rows */ +.action-list-dropdown .action-list-dropdown-item.action { + display: flex; + gap: 6px; + align-items: center; + padding: 0 4px 0 8px; + white-space: nowrap; + cursor: pointer; + border-radius: var(--vscode-cornerRadius-small); + color: var(--vscode-foreground); + outline: none; +} + +.action-list-dropdown .action-list-dropdown-item.action.focused:not(.option-disabled), +.action-list-dropdown .action-list-dropdown-item.action:hover:not(.option-disabled) { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-menu-selectionBorder, transparent); + outline-offset: -1px; +} + +.action-list-dropdown .action-list-dropdown-item.action.option-disabled { + cursor: default; + color: var(--vscode-disabledForeground); +} + +.action-list-dropdown .action-list-dropdown-item.action .icon { + font-size: 12px; +} + +.action-list-dropdown .action-list-dropdown-item.action .title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-list-dropdown .action-list-dropdown-item.action .description { + opacity: 0.7; + margin-left: 0.5em; + flex-shrink: 0; +} + +.action-list-dropdown .action-list-dropdown-item.action .action-list-dropdown-item-badge { + padding: 0px 6px; + border-radius: 10px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + line-height: 18px; + flex-shrink: 0; +} + +/* Separators */ +.action-list-dropdown .action-list-dropdown-item.separator { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + margin: 4px 0px; +} + +.action-list-dropdown .action-list-dropdown-item.separator:first-child { + border-top: none; + margin-top: 0; +} + +/* Filter input */ +.action-list-dropdown .action-list-dropdown-filter { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + padding: 4px; +} + +.action-list-dropdown .action-list-dropdown-filter-input { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 3px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + outline: none; +} + +.action-list-dropdown .action-list-dropdown-filter-input:focus { + border-color: var(--vscode-focusBorder); +} + +.action-list-dropdown .action-list-dropdown-filter-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +/* Manage models link */ +.action-list-dropdown .action-list-dropdown-item.action.manage-models-link { + color: var(--vscode-textLink-foreground); +} + +.action-list-dropdown .action-list-dropdown-item.action.manage-models-link:hover, +.action-list-dropdown .action-list-dropdown-item.action.manage-models-link.focused { + color: var(--vscode-textLink-activeForeground); +} diff --git a/src/vs/platform/actionWidget/browser/actionListDropdown.ts b/src/vs/platform/actionWidget/browser/actionListDropdown.ts new file mode 100644 index 0000000000000..ee332a2e0569f --- /dev/null +++ b/src/vs/platform/actionWidget/browser/actionListDropdown.ts @@ -0,0 +1,473 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { KeyCode } from '../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { localize } from '../../../nls.js'; +import { IContextViewService } from '../../contextview/browser/contextView.js'; +import { ILayoutService } from '../../layout/browser/layoutService.js'; +import { defaultButtonStyles } from '../../theme/browser/defaultStyles.js'; +import './actionListDropdown.css'; + +/** + * Represents an item in the action list dropdown. + */ +export interface IActionListDropdownItem { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly icon?: ThemeIcon; + readonly checked?: boolean; + readonly disabled?: boolean; + readonly tooltip?: string; + readonly className?: string; + readonly badge?: string; + readonly descriptionButton?: { readonly label: string; readonly onDidClick: () => void }; + readonly section?: string; + readonly isSectionToggle?: boolean; + readonly run: () => void; +} + +/** + * The kind of entry in the action list dropdown. + */ +export const enum ActionListDropdownItemKind { + Action = 'action', + Separator = 'separator' +} + +/** + * An entry in the action list dropdown, either an action item or a separator. + */ +export interface IActionListDropdownEntry { + readonly item?: IActionListDropdownItem; + readonly kind: ActionListDropdownItemKind; +} + +/** + * Options for the action list dropdown. + */ +export interface IActionListDropdownOptions { + readonly collapsedByDefault?: ReadonlySet; + readonly minWidth?: number; +} + +/** + * Delegate that receives callbacks from the action list dropdown. + */ +export interface IActionListDropdownDelegate { + onSelect(item: IActionListDropdownItem): void; + onHide(): void; +} + +const ACTION_ITEM_HEIGHT = 24; +const SEPARATOR_HEIGHT = 8; + +/** + * A DOM-based dropdown widget with filtering and collapsible groups. + * Renders items directly as DOM elements without using the List widget. + */ +export class ActionListDropdown extends Disposable { + + private _isVisible = false; + private _domNode: HTMLElement | undefined; + private _previousFocusedElement: HTMLElement | undefined; + private readonly _showDisposables = this._register(new DisposableStore()); + private readonly _collapsedSections = new Set(); + + get isVisible(): boolean { + return this._isVisible; + } + + constructor( + @IContextViewService private readonly _contextViewService: IContextViewService, + @ILayoutService private readonly _layoutService: ILayoutService, + ) { + super(); + } + + /** + * Show the dropdown anchored to the given element. + */ + show(entries: IActionListDropdownEntry[], delegate: IActionListDropdownDelegate, anchor: HTMLElement, options?: IActionListDropdownOptions): void { + this.hide(); + + this._showDisposables.clear(); + this._previousFocusedElement = dom.getDocument(anchor).activeElement as HTMLElement | undefined; + this._focusedIndex = -1; + + this._collapsedSections.clear(); + if (options?.collapsedByDefault) { + for (const section of options.collapsedByDefault) { + this._collapsedSections.add(section); + } + } + + let filterText = ''; + let itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[] = []; + let itemsContainer: HTMLElement; + let filterContainer: HTMLElement; + + const showDisposables = this._showDisposables; + + let filterInput: HTMLInputElement; + + const renderItems = () => { + dom.clearNode(itemsContainer); + itemElements = []; + + const filtered = this._getVisibleEntries(entries, filterText); + for (const entry of filtered) { + const el = this._renderEntry(entry, delegate, renderItems, showDisposables); + itemsContainer.appendChild(el); + itemElements.push({ element: el, entry }); + } + + this._focusedIndex = -1; + this._updateWidth(itemsContainer, itemElements, options?.minWidth); + this._constrainHeight(itemsContainer, filterContainer); + + // Re-focus filter input after re-render to prevent blur-to-close + filterInput?.focus(); + }; + + const contextView = this._contextViewService.showContextView({ + getAnchor: () => anchor, + render: (container) => { + const disposables = new DisposableStore(); + + const widget = dom.append(container, dom.$('.action-list-dropdown')); + this._domNode = widget; + + itemsContainer = dom.append(widget, dom.$('.action-list-dropdown-items')); + + filterContainer = dom.append(widget, dom.$('.action-list-dropdown-filter')); + filterInput = dom.append(filterContainer, dom.$('input.action-list-dropdown-filter-input')); + filterInput.type = 'text'; + filterInput.placeholder = localize('filterPlaceholder', "Filter..."); + + disposables.add(dom.addDisposableListener(filterInput, 'input', () => { + filterText = filterInput.value; + renderItems(); + })); + + disposables.add(dom.addDisposableListener(filterInput, 'keydown', (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.DownArrow) { + e.preventDefault(); + this._focusedIndex = -1; + this._moveFocus(itemElements, 1); + } else if (event.keyCode === KeyCode.UpArrow) { + e.preventDefault(); + this._focusedIndex = itemElements.length; + this._moveFocus(itemElements, -1); + } else if (event.keyCode === KeyCode.Escape) { + e.preventDefault(); + if (filterText) { + filterInput.value = ''; + filterText = ''; + renderItems(); + } else { + this.hide(); + } + } + })); + + disposables.add(dom.addDisposableListener(widget, 'keydown', (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.DownArrow) { + e.preventDefault(); + this._moveFocus(itemElements, 1); + } else if (event.keyCode === KeyCode.UpArrow) { + e.preventDefault(); + this._moveFocus(itemElements, -1); + } else if (event.keyCode === KeyCode.Enter) { + e.preventDefault(); + if (this._focusedIndex >= 0 && this._focusedIndex < itemElements.length) { + const { entry } = itemElements[this._focusedIndex]; + if (entry.kind === ActionListDropdownItemKind.Action && entry.item) { + if (entry.item.isSectionToggle) { + this._toggleSection(entry.item.section); + renderItems(); + } else { + delegate.onSelect(entry.item); + } + } + } + } else if (event.keyCode === KeyCode.Escape) { + e.preventDefault(); + if (filterText) { + filterInput.value = ''; + filterText = ''; + renderItems(); + } else { + this.hide(); + } + } + })); + + renderItems(); + + // Focus tracking + const focusTracker = dom.trackFocus(widget); + disposables.add(focusTracker); + disposables.add(focusTracker.onDidBlur(() => { + const activeElement = dom.getDocument(widget).activeElement; + if (!widget.contains(activeElement)) { + this.hide(); + } + })); + + filterInput.focus(); + + return disposables; + }, + onHide: () => { + this._isVisible = false; + delegate.onHide(); + if (this._previousFocusedElement) { + this._previousFocusedElement.focus(); + this._previousFocusedElement = undefined; + } + }, + }, undefined, false); + + this._showDisposables.add({ dispose: () => contextView.close() }); + this._isVisible = true; + } + + /** + * Hide the dropdown. + */ + hide(): void { + if (!this._isVisible) { + return; + } + this._isVisible = false; + this._showDisposables.clear(); + this._domNode = undefined; + } + + private _getVisibleEntries(entries: IActionListDropdownEntry[], filter: string): IActionListDropdownEntry[] { + const isFiltering = filter.length > 0; + const filterLower = filter.toLowerCase(); + const result: IActionListDropdownEntry[] = []; + const seenIds = new Set(); + let pendingSeparator: IActionListDropdownEntry | undefined; + + for (const entry of entries) { + if (entry.kind === ActionListDropdownItemKind.Separator) { + pendingSeparator = entry; + continue; + } + + const item = entry.item; + if (!item) { + continue; + } + + // Skip section toggle items when filtering + if (isFiltering && item.isSectionToggle) { + continue; + } + + // Skip collapsed section items (but not the toggle itself) + if (!isFiltering && item.section && !item.isSectionToggle && this._collapsedSections.has(item.section)) { + continue; + } + + // Apply text filter + if (isFiltering) { + const label = item.label.toLowerCase(); + const desc = (item.description ?? '').toLowerCase(); + if (!label.includes(filterLower) && !desc.includes(filterLower)) { + continue; + } + // Deduplicate by id when filtering across sections + if (seenIds.has(item.id)) { + continue; + } + seenIds.add(item.id); + } + + // Emit pending separator (skip if this would be the first item) + if (pendingSeparator && result.length > 0) { + result.push(pendingSeparator); + } + pendingSeparator = undefined; + + result.push(entry); + } + + return result; + } + + private _renderEntry( + entry: IActionListDropdownEntry, + delegate: IActionListDropdownDelegate, + rerender: () => void, + disposables: DisposableStore, + ): HTMLElement { + if (entry.kind === ActionListDropdownItemKind.Separator) { + const separator = dom.$('.action-list-dropdown-item.separator'); + separator.style.height = `${SEPARATOR_HEIGHT}px`; + return separator; + } + + const item = entry.item!; + const row = dom.$('.action-list-dropdown-item.action'); + row.style.height = `${ACTION_ITEM_HEIGHT}px`; + row.tabIndex = 0; + + if (item.disabled) { + row.classList.add('option-disabled'); + } + if (item.className) { + row.classList.add(item.className); + } + if (item.tooltip) { + row.title = item.tooltip; + } + + // Icon + const iconContainer = dom.append(row, dom.$('.icon')); + if (item.isSectionToggle) { + const toggleIcon = this._collapsedSections.has(item.section ?? '') ? Codicon.chevronRight : Codicon.chevronDown; + iconContainer.classList.add(...ThemeIcon.asClassNameArray(toggleIcon)); + } else if (item.checked !== undefined) { + const checkIcon = item.checked ? Codicon.check : Codicon.blank; + iconContainer.classList.add(...ThemeIcon.asClassNameArray(checkIcon)); + } else if (item.icon) { + iconContainer.classList.add(...ThemeIcon.asClassNameArray(item.icon)); + } + + // Title + const title = dom.append(row, dom.$('span.title')); + title.textContent = item.label; + + // Badge + if (item.badge) { + const badge = dom.append(row, dom.$('span.action-list-dropdown-item-badge')); + badge.textContent = item.badge; + } + + // Description or description button + if (item.descriptionButton) { + const descContainer = dom.append(row, dom.$('span.description')); + const btn = new Button(descContainer, { ...defaultButtonStyles }); + disposables.add(btn); + btn.label = item.descriptionButton.label; + disposables.add(btn.onDidClick(() => { + item.descriptionButton!.onDidClick(); + })); + } else if (item.description) { + const desc = dom.append(row, dom.$('span.description')); + desc.textContent = item.description; + } + + // Click handler + if (!item.disabled || item.isSectionToggle) { + disposables.add(dom.addDisposableListener(row, dom.EventType.CLICK, (e: MouseEvent) => { + e.stopPropagation(); + if (item.isSectionToggle) { + this._toggleSection(item.section); + rerender(); + } else { + delegate.onSelect(item); + } + })); + } + + return row; + } + + private _toggleSection(section: string | undefined): void { + if (!section) { + return; + } + if (this._collapsedSections.has(section)) { + this._collapsedSections.delete(section); + } else { + this._collapsedSections.add(section); + } + } + + private _moveFocus( + itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[], + direction: 1 | -1, + ): void { + let idx = this._focusedIndex; + while (true) { + idx += direction; + if (idx < 0 || idx >= itemElements.length) { + return; + } + const { entry } = itemElements[idx]; + if (entry.kind === ActionListDropdownItemKind.Action && entry.item && !entry.item.disabled) { + this._setFocusedIndex(itemElements, idx); + return; + } + } + } + + private _focusedIndex = -1; + + private _setFocusedIndex( + itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[], + index: number, + ): void { + // Remove previous focus + if (this._focusedIndex >= 0 && this._focusedIndex < itemElements.length) { + itemElements[this._focusedIndex].element.classList.remove('focused'); + } + this._focusedIndex = index; + if (index >= 0 && index < itemElements.length) { + const el = itemElements[index].element; + el.classList.add('focused'); + el.focus(); + } + } + + private _constrainHeight(itemsContainer: HTMLElement, filterContainer: HTMLElement): void { + if (!this._domNode) { + return; + } + const targetWindow = dom.getWindow(this._domNode); + const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; + const widgetTop = this._domNode.getBoundingClientRect().top; + const padding = 10; + const filterHeight = filterContainer.getBoundingClientRect().height || 30; + const availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + const maxHeight = Math.max(availableHeight, ACTION_ITEM_HEIGHT * 3 + filterHeight); + + itemsContainer.style.maxHeight = `${maxHeight - filterHeight}px`; + itemsContainer.style.overflowY = 'auto'; + } + + private _updateWidth( + itemsContainer: HTMLElement, + itemElements: { element: HTMLElement; entry: IActionListDropdownEntry }[], + minWidth?: number, + ): void { + let maxWidth = minWidth ?? 0; + for (const { element, entry } of itemElements) { + if (entry.kind !== ActionListDropdownItemKind.Action) { + continue; + } + element.style.width = 'auto'; + const width = element.getBoundingClientRect().width; + element.style.width = ''; + maxWidth = Math.max(maxWidth, width); + } + if (maxWidth > 0) { + itemsContainer.style.width = `${maxWidth}px`; + } + } +} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index e3969db45e1eb..4ea3a49bff7db 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -122,7 +122,6 @@ display: flex; gap: 6px; align-items: center; - color: var(--vscode-foreground) !important; } .action-widget .monaco-list-row.action .codicon { @@ -151,16 +150,6 @@ text-overflow: ellipsis; } -.action-widget .monaco-list-row.action .action-item-badge { - padding: 0px 6px; - border-radius: 10px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - font-size: 11px; - line-height: 18px; - flex-shrink: 0; -} - .action-widget .monaco-list-row.action .monaco-keybinding > .monaco-keybinding-key { background-color: var(--vscode-keybindingLabel-background); color: var(--vscode-keybindingLabel-foreground); @@ -216,10 +205,8 @@ .action-widget .monaco-list .monaco-list-row .description { opacity: 0.7; margin-left: 0.5em; - flex-shrink: 0; } - /* Item toolbar - shows on hover/focus */ .action-widget .monaco-list-row.action .action-list-item-toolbar { display: none; @@ -240,29 +227,3 @@ gap: 4px; font-size: 12px; } - -/* Filter input */ -.action-widget .action-list-filter { - border-top: 1px solid var(--vscode-editorHoverWidget-border); - padding: 4px; -} - -.action-widget .action-list-filter-input { - width: 100%; - box-sizing: border-box; - padding: 4px 8px; - border: 1px solid var(--vscode-input-border, transparent); - border-radius: 3px; - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - font-size: 12px; - outline: none; -} - -.action-widget .action-list-filter-input:focus { - border-color: var(--vscode-focusBorder); -} - -.action-widget .action-list-filter-input::placeholder { - color: var(--vscode-input-placeholderForeground); -} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 53483956586f0..21b49245bebcc 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -10,7 +10,7 @@ import { KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import './actionWidget.css'; import { localize, localize2 } from '../../../nls.js'; -import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, IActionListOptions, previewSelectedActionCommand } from './actionList.js'; +import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, previewSelectedActionCommand } from './actionList.js'; import { Action2, registerAction2 } from '../../actions/common/actions.js'; import { IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; @@ -36,7 +36,7 @@ export const IActionWidgetService = createDecorator('actio export interface IActionWidgetService { readonly _serviceBrand: undefined; - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void; + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void; hide(didCancel?: boolean): void; @@ -60,10 +60,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { super(); } - show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>, listOptions?: IActionListOptions): void { + show(user: string, supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate, anchor: HTMLElement | StandardMouseEvent | IAnchor, container: HTMLElement | undefined, actionBarActions?: readonly IAction[], accessibilityProvider?: Partial>>): void { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); - const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions); + const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider); this._contextViewService.showContextView({ getAnchor: () => anchor, render: (container: HTMLElement) => { @@ -137,11 +137,6 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { } } - // Filter input (appended after the list, before action bar visually) - if (this._list.value?.filterContainer) { - widget.appendChild(this._list.value.filterContainer); - } - const width = this._list.value?.layout(actionBarWidth); widget.style.width = `${width}px`; diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index b7b61da059f8e..0fb0d916c6ad9 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -6,7 +6,7 @@ import { IActionWidgetService } from './actionWidget.js'; import { IAction } from '../../../base/common/actions.js'; import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover } from './actionList.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { Codicon } from '../../../base/common/codicons.js'; import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js'; @@ -52,11 +52,6 @@ export interface IActionWidgetDropdownOptions extends IBaseDropdownOptions { * provided, no telemetry will be sent. */ readonly reporter?: { id: string; name?: string; includeOptions?: boolean }; - - /** - * Options for the underlying ActionList (filter, collapsible sections). - */ - readonly listOptions?: IActionListOptions; } /** @@ -206,8 +201,7 @@ export class ActionWidgetDropdown extends BaseDropdown { this._options.getAnchor?.() ?? this.element, undefined, actionBarActions, - accessibilityProvider, - this._options.listOptions + accessibilityProvider ); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index cfe1d784a8ba5..cf9f35ca48c64 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1000,7 +1000,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._currentLanguageModel.set(model, undefined); // Record usage for the recently used models list - this.languageModelsService.recordModelUsage(model.identifier); + this.languageModelsService.recordModelUsage(model); if (this.cachedWidth) { // For quick chat and editor chat, relayout because the input may need to shrink to accomodate the model name diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 8e7831378c1b0..8f3607618d88d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -10,12 +10,10 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; -import { ActionListItemKind, IActionListItem, IActionListOptions } from '../../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; -import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IActionListDropdownOptions, IActionListDropdownEntry, IActionListDropdownItem, ActionListDropdown, ActionListDropdownItemKind } from '../../../../../../platform/actionWidget/browser/actionListDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -54,16 +52,11 @@ type ChatModelChangeEvent = { }; function createModelItem( - action: IActionWidgetDropdownAction & { section?: string }, -): IActionListItem { + action: IActionListDropdownItem, +): IActionListDropdownEntry { return { item: action, - kind: ActionListItemKind.Action, - label: action.label, - description: action.description, - group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, - hideIcon: false, - section: action.section, + kind: ActionListDropdownItemKind.Action, }; } @@ -72,13 +65,11 @@ function createModelAction( selectedModelId: string | undefined, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, section?: string, -): IActionWidgetDropdownAction & { section?: string } { +): IActionListDropdownItem { return { id: model.identifier, - enabled: true, icon: model.metadata.statusIcon, checked: model.identifier === selectedModelId, - class: undefined, description: model.metadata.multiplier ?? model.metadata.detail, tooltip: model.metadata.name, label: model.metadata.name, @@ -107,8 +98,8 @@ function buildModelPickerItems( commandService: ICommandService, openerService: IOpenerService, upgradePlanUrl: string | undefined, -): IActionListItem[] { - const items: IActionListItem[] = []; +): IActionListDropdownEntry[] { + const items: IActionListDropdownEntry[] = []; // Collect all available models const allModelsMap = new Map(); @@ -131,9 +122,7 @@ function buildModelPickerItems( const autoDescription = defaultModel?.metadata.multiplier ?? defaultModel?.metadata.detail; items.push(createModelItem({ id: 'auto', - enabled: true, checked: isAutoSelected, - class: undefined, tooltip: localize('chat.modelPicker.auto', "Auto"), label: localize('chat.modelPicker.auto', "Auto"), description: autoDescription, @@ -154,15 +143,17 @@ function buildModelPickerItems( if (model && !placed.has(model.identifier) && model !== defaultModel) { promotedModels.push(model); placed.add(model.identifier); + placed.add(model.metadata.id); } } // Add curated - available ones become promoted, unavailable ones become disabled entries for (const curated of curatedModels) { const model = allModelsMap.get(curated.id) ?? modelsByMetadataId.get(curated.id); - if (model && !placed.has(model.identifier)) { + if (model && !placed.has(model.identifier) && !placed.has(model.metadata.id)) { promotedModels.push(model); placed.add(model.identifier); + placed.add(model.metadata.id); } else if (!model) { // Model is not available - determine reason if (!isProUser) { @@ -180,7 +171,7 @@ function buildModelPickerItems( if (promotedModels.length > 0 || unavailableCurated.length > 0) { items.push({ - kind: ActionListItemKind.Separator, + kind: ActionListDropdownItemKind.Separator, }); for (const model of promotedModels) { const action = createModelAction(model, selectedModelId, onSelect); @@ -202,21 +193,14 @@ function buildModelPickerItems( items.push({ item: { id: curated.id, - enabled: false, - checked: false, - class: undefined, tooltip: label, label: curated.id, - description: label, + disabled: true, + descriptionButton: { label, onDidClick: onButtonClick }, + className: 'unavailable-model', run: () => { } }, - kind: ActionListItemKind.Action, - label: curated.id, - descriptionButton: { label, onDidClick: onButtonClick }, - disabled: true, - group: { title: '', icon: Codicon.blank }, - hideIcon: false, - className: 'unavailable-model', + kind: ActionListDropdownItemKind.Action, }); } } @@ -224,7 +208,7 @@ function buildModelPickerItems( // --- 3. Other Models (collapsible) --- const otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; for (const model of models) { - if (!placed.has(model.identifier)) { + if (!placed.has(model.identifier) && !placed.has(model.metadata.id)) { // Skip the default model - it's already represented by the top-level "Auto" entry const isDefault = Object.values(model.metadata.isDefaultForLocation).some(v => v); if (isDefault) { @@ -236,24 +220,18 @@ function buildModelPickerItems( if (otherModels.length > 0) { items.push({ - kind: ActionListItemKind.Separator, + kind: ActionListDropdownItemKind.Separator, }); items.push({ item: { id: 'otherModels', - enabled: true, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.otherModels', "Other Models"), label: localize('chat.modelPicker.otherModels', "Other Models"), + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + section: ModelPickerSection.Other, + isSectionToggle: true, run: () => { /* toggle handled by isSectionToggle */ } }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.otherModels', "Other Models"), - group: { title: '', icon: Codicon.chevronDown }, - hideIcon: false, - section: ModelPickerSection.Other, - isSectionToggle: true, + kind: ActionListDropdownItemKind.Action, }); for (const model of otherModels) { const action = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other); @@ -264,34 +242,24 @@ function buildModelPickerItems( items.push({ item: { id: 'manageModels', - enabled: true, - checked: false, - class: 'manage-models-action', - tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), label: localize('chat.manageModels', "Manage Models..."), + tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), icon: Codicon.settingsGear, + section: ModelPickerSection.Other, + className: 'manage-models-link', run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } }, - kind: ActionListItemKind.Action, - label: localize('chat.manageModels', "Manage Models..."), - group: { title: '', icon: Codicon.settingsGear }, - hideIcon: false, - section: ModelPickerSection.Other, - className: 'manage-models-link', + kind: ActionListDropdownItemKind.Action, }); } return items; } -/** - * Returns the ActionList options for the model picker (filter + collapsed sections). - */ -function getModelPickerListOptions(): IActionListOptions { +function getActionListDropdownOptions(): IActionListDropdownOptions { return { - showFilter: true, collapsedByDefault: new Set([ModelPickerSection.Other]), minWidth: 300, }; @@ -320,6 +288,7 @@ export class ModelPickerWidget extends Disposable { private _domNode: HTMLElement | undefined; private _badgeIcon: HTMLElement | undefined; + private readonly _dropdown: ActionListDropdown; get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { return this._selectedModel; @@ -330,7 +299,7 @@ export class ModelPickerWidget extends Disposable { } constructor( - @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, @ICommandService private readonly _commandService: ICommandService, @IOpenerService private readonly _openerService: IOpenerService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -339,6 +308,7 @@ export class ModelPickerWidget extends Disposable { @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, ) { super(); + this._dropdown = this._register(this._instantiationService.createInstance(ActionListDropdown)); } setModels(models: ILanguageModelChatMetadataAndIdentifier[]): void { @@ -393,9 +363,6 @@ export class ModelPickerWidget extends Disposable { return; } - // Mark new models as seen immediately when the picker is opened - this._languageModelsService.markNewModelsAsSeen(); - const previousModel = this._selectedModel; const onSelect = (model: ILanguageModelChatMetadataAndIdentifier) => { @@ -415,7 +382,7 @@ export class ModelPickerWidget extends Disposable { const items = buildModelPickerItems( this._models, this._selectedModel?.identifier, - this._languageModelsService.getRecentlyUsedModelIds(7), + this._languageModelsService.getRecentlyUsedModelIds(), curatedForTier, isPro, this._productService.version, @@ -425,47 +392,21 @@ export class ModelPickerWidget extends Disposable { this._productService.defaultChatAgent?.upgradePlanUrl, ); - const listOptions = getModelPickerListOptions(); - const previouslyFocusedElement = dom.getActiveElement(); + const dropdownOptions = getActionListDropdownOptions(); const delegate = { - onSelect: (action: IActionWidgetDropdownAction) => { - this._actionWidgetService.hide(); - action.run(); + onSelect: (item: IActionListDropdownItem) => { + this._dropdown.hide(); + item.run(); }, onHide: () => { this._domNode?.setAttribute('aria-expanded', 'false'); - if (dom.isHTMLElement(previouslyFocusedElement)) { - previouslyFocusedElement.focus(); - } } }; this._domNode?.setAttribute('aria-expanded', 'true'); - this._actionWidgetService.show( - 'ChatModelPicker', - false, - items, - delegate, - anchorElement, - undefined, - [], - { - isChecked(element) { - return element.kind === 'action' && !!element?.item?.checked; - }, - getRole: (e) => { - switch (e.kind) { - case 'action': return 'menuitemcheckbox'; - case 'separator': return 'separator'; - default: return 'separator'; - } - }, - getWidgetRole: () => 'menu', - }, - listOptions - ); + this._dropdown.show(items, delegate, anchorElement, dropdownOptions); } private _updateBadge(): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts index c19279bee0abe..03b20e871faa6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts @@ -44,7 +44,6 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget)); this._pickerWidget.setModels(delegate.getModels()); this._pickerWidget.setSelectedModel(delegate.currentModel.get()); - this._updateBadge(); // Sync delegate → widget when model list or selection changes externally this._register(autorun(t => { @@ -64,7 +63,6 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { })); // Update badge when new models appear - this._register(this.languageModelsService.onDidChangeNewModelIds(() => this._updateBadge())); } override render(container: HTMLElement): void { @@ -93,11 +91,6 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { this._pickerWidget.show(this._getAnchorElement()); } - private _updateBadge(): void { - const hasNew = this.languageModelsService.getNewModelIds().length > 0; - this._pickerWidget.setBadge(hasNew ? 'info' : undefined); - } - private _updateTooltip(): void { if (!this.element) { return; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index e452196a385e0..8e5a16a4496bb 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -371,12 +371,12 @@ export interface ILanguageModelsService { * Returns the most recently used model identifiers, ordered by most-recent-first. * @param maxCount Maximum number of entries to return (default 7). */ - getRecentlyUsedModelIds(maxCount?: number): string[]; + getRecentlyUsedModelIds(): string[]; /** * Records that a model was used, updating the recently used list. */ - recordModelUsage(modelIdentifier: string): void; + recordModelUsage(model: ILanguageModelChatMetadataAndIdentifier): void; /** * Returns the curated models from the models control manifest, @@ -384,21 +384,6 @@ export interface ILanguageModelsService { */ getCuratedModels(): ICuratedModels; - /** - * Returns the IDs of curated models that are marked as new and have not been seen yet. - */ - getNewModelIds(): string[]; - - /** - * Fires when the set of new (unseen) model IDs changes. - */ - readonly onDidChangeNewModelIds: Event; - - /** - * Marks all new models as seen, clearing the new badge. - */ - markNewModelsAsSeen(): void; - /** * Observable map of restricted chat participant names to allowed extension publisher/IDs. * Fetched from the chat control manifest. @@ -504,7 +489,6 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist const CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY = 'chatModelPickerPreferences'; const CHAT_MODEL_RECENTLY_USED_STORAGE_KEY = 'chatModelRecentlyUsed'; -const CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY = 'chatModelSeenNewModels'; const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry'; const CHAT_CURATED_MODELS_STORAGE_KEY = 'chat.curatedModels'; @@ -549,8 +533,6 @@ export class LanguageModelsService implements ILanguageModelsService { private _recentlyUsedModelIds: string[] = []; private _curatedModels: ICuratedModels = { free: [], paid: [] }; - private _newModelIds: Set = new Set(); - private _seenNewModelIds: Set = new Set(); private _chatControlUrl: string | undefined; private _chatControlDisposed = false; @@ -558,9 +540,6 @@ export class LanguageModelsService implements ILanguageModelsService { private readonly _restrictedChatParticipants = observableValue<{ [name: string]: string[] }>(this, Object.create(null)); readonly restrictedChatParticipants: IObservable<{ [name: string]: string[] }> = this._restrictedChatParticipants; - private readonly _onDidChangeNewModelIds = this._store.add(new Emitter()); - readonly onDidChangeNewModelIds: Event = this._onDidChangeNewModelIds.event; - constructor( @IExtensionService private readonly _extensionService: IExtensionService, @ILogService private readonly _logService: ILogService, @@ -575,7 +554,6 @@ export class LanguageModelsService implements ILanguageModelsService { this._hasUserSelectableModels = ChatContextKeys.languageModelsAreUserSelectable.bindTo(_contextKeyService); this._modelPickerUserPreferences = this._readModelPickerPreferences(); this._recentlyUsedModelIds = this._readRecentlyUsedModels(); - this._seenNewModelIds = this._readSeenNewModels(); this._initChatControlData(); this._store.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, this._store)(() => this._onDidChangeModelPickerPreferences())); @@ -1393,21 +1371,25 @@ export class LanguageModelsService implements ILanguageModelsService { this._storageService.store(CHAT_MODEL_RECENTLY_USED_STORAGE_KEY, this._recentlyUsedModelIds, StorageScope.PROFILE, StorageTarget.USER); } - getRecentlyUsedModelIds(maxCount: number = 7): string[] { + getRecentlyUsedModelIds(): string[] { // Filter to only include models that still exist in the cache return this._recentlyUsedModelIds .filter(id => this._modelCache.has(id)) - .slice(0, maxCount); + .slice(0, 5); } - recordModelUsage(modelIdentifier: string): void { + recordModelUsage(model: ILanguageModelChatMetadataAndIdentifier): void { + if (model.metadata.id === 'auto' && this._vendors.get(model.metadata.vendor)?.isDefault) { + return; + } + // Remove if already present (to move to front) - const index = this._recentlyUsedModelIds.indexOf(modelIdentifier); + const index = this._recentlyUsedModelIds.indexOf(model.identifier); if (index !== -1) { this._recentlyUsedModelIds.splice(index, 1); } // Add to front - this._recentlyUsedModelIds.unshift(modelIdentifier); + this._recentlyUsedModelIds.unshift(model.identifier); // Cap at a reasonable max to avoid unbounded growth if (this._recentlyUsedModelIds.length > 20) { this._recentlyUsedModelIds.length = 20; @@ -1442,45 +1424,8 @@ export class LanguageModelsService implements ILanguageModelsService { newIds.add(model.id); } } - - this._newModelIds = newIds; - this._onDidChangeNewModelIds.fire(); } - getNewModelIds(): string[] { - const result: string[] = []; - for (const id of this._newModelIds) { - if (!this._seenNewModelIds.has(id)) { - result.push(id); - } - } - return result; - } - - markNewModelsAsSeen(): void { - let changed = false; - for (const id of this._newModelIds) { - if (!this._seenNewModelIds.has(id)) { - this._seenNewModelIds.add(id); - changed = true; - } - } - if (changed) { - this._saveSeenNewModels(); - this._onDidChangeNewModelIds.fire(); - } - } - - private _readSeenNewModels(): Set { - return new Set(this._storageService.getObject(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, StorageScope.PROFILE, [])); - } - - private _saveSeenNewModels(): void { - this._storageService.store(CHAT_MODEL_SEEN_NEW_MODELS_STORAGE_KEY, [...this._seenNewModelIds], StorageScope.PROFILE, StorageTarget.USER); - } - - //#endregion - //#region Chat control data private _initChatControlData(): void { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 0439386a34154..f38cca659ae72 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -136,12 +136,9 @@ class MockLanguageModelsService implements ILanguageModelsService { async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } - getRecentlyUsedModelIds(_maxCount?: number): string[] { return []; } - recordModelUsage(_modelIdentifier: string): void { } + getRecentlyUsedModelIds(): string[] { return []; } + recordModelUsage(): void { } getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } - getNewModelIds(): string[] { return []; } - onDidChangeNewModelIds = Event.None; - markNewModelsAsSeen(): void { } restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index ee3375bdef594..33da5451517c5 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -87,23 +87,15 @@ export class NullLanguageModelsService implements ILanguageModelsService { async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } - getRecentlyUsedModelIds(_maxCount?: number): string[] { + getRecentlyUsedModelIds(): string[] { return []; } - recordModelUsage(_modelIdentifier: string): void { } + recordModelUsage(): void { } getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } - getNewModelIds(): string[] { - return []; - } - - onDidChangeNewModelIds = Event.None; - - markNewModelsAsSeen(): void { } - restrictedChatParticipants = observableValue('restrictedChatParticipants', Object.create(null)); } From dd0c05bdd422bd766bfbd4283f82de1ae1efcfd0 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 18 Feb 2026 10:50:19 -0800 Subject: [PATCH 22/42] Add telemetry for stop button --- .../browser/actions/chatExecuteActions.ts | 20 +++++++++++++++++- .../chat/common/chatService/chatService.ts | 18 ++++++++++++++++ .../common/chatService/chatServiceImpl.ts | 21 +++++++++++++++++-- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 9ec8cb095ebb3..1fac629812d06 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -20,12 +20,13 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatVariableLeader } from '../../common/requestParser/chatParserTypes.js'; -import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatService } from '../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; @@ -874,14 +875,31 @@ export class CancelAction extends Action2 { run(accessor: ServicesAccessor, ...args: unknown[]) { const context = args[0] as IChatExecuteActionContext | undefined; const widgetService = accessor.get(IChatWidgetService); + const logService = accessor.get(ILogService); + const telemetryService = accessor.get(ITelemetryService); const widget = context?.widget ?? widgetService.lastFocusedWidget; if (!widget) { + telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'cancelAction', + reason: 'noWidget', + requestInProgress: 'unknown', + pendingRequests: 0, + }); + logService.info('ChatCancelAction#run: No focused chat widget was found'); return; } const chatService = accessor.get(IChatService); if (widget.viewModel) { chatService.cancelCurrentRequestForSession(widget.viewModel.sessionResource); + } else { + telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'cancelAction', + reason: 'noViewModel', + requestInProgress: 'unknown', + pendingRequests: 0, + }); + logService.info('ChatCancelAction#run: Canceled chat widget has no view model'); } } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 610e43616bf93..a6604f07fba4d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1455,3 +1455,21 @@ export interface IChatSessionStartOptions { canUseTools?: boolean; disableBackgroundKeepAlive?: boolean; } + +export const ChatStopCancellationNoopEventName = 'chat.stopCancellationNoop'; + +export type ChatStopCancellationNoopEvent = { + source: 'cancelAction' | 'chatService'; + reason: 'noWidget' | 'noViewModel' | 'noPendingRequest' | 'requestAlreadyCanceled' | 'requestIdUnavailable'; + requestInProgress: 'true' | 'false' | 'unknown'; + pendingRequests: number; +}; + +export type ChatStopCancellationNoopClassification = { + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The layer where stop cancellation no-op occurred.' }; + reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The no-op reason when stop cancellation did not dispatch fully.' }; + requestInProgress: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether request-in-progress was true, false, or unknown at no-op time.' }; + pendingRequests: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of queued pending requests at no-op time when known.'; isMeasurement: true }; + owner: 'roblourens'; + comment: 'Tracks possible no-op stop cancellation paths.'; +}; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index f295461bad9a9..490703770745a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -26,6 +26,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILogService } from '../../../../../platform/log/common/log.js'; import { Progress } from '../../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; @@ -38,7 +39,7 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; -import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSessionsService } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; @@ -146,6 +147,7 @@ export class ChatService extends Disposable implements IChatService { constructor( @IStorageService private readonly storageService: IStorageService, @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, @IExtensionService private readonly extensionService: IExtensionService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @@ -1395,7 +1397,22 @@ export class ChatService extends Disposable implements IChatService { cancelCurrentRequestForSession(sessionResource: URI): void { this.trace('cancelCurrentRequestForSession', `session: ${sessionResource}`); - this._pendingRequests.get(sessionResource)?.cancel(); + const pendingRequest = this._pendingRequests.get(sessionResource); + if (!pendingRequest) { + const model = this._sessionModels.get(sessionResource); + const requestInProgress = model?.requestInProgress.get(); + const pendingRequestsCount = model?.getPendingRequests().length ?? 0; + this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'chatService', + reason: 'noPendingRequest', + requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', + pendingRequests: pendingRequestsCount, + }); + this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + return; + } + + pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); } From e3f174c7c48a83869e10c8dc3a83c3fd5fafb4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 18 Feb 2026 20:22:22 +0100 Subject: [PATCH 23/42] Add Azure Pipeline skill (#295985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add Azure Pipeline skill * move script to typescript * :lipstick: --------- Co-authored-by: João Moreno <22350+joaomoreno@users.noreply.github.com> --- .github/skills/azure-pipelines/SKILL.md | 241 +++ .../skills/azure-pipelines/azure-pipeline.ts | 1858 +++++++++++++++++ .vscode/settings.json | 3 + 3 files changed, 2102 insertions(+) create mode 100644 .github/skills/azure-pipelines/SKILL.md create mode 100644 .github/skills/azure-pipelines/azure-pipeline.ts diff --git a/.github/skills/azure-pipelines/SKILL.md b/.github/skills/azure-pipelines/SKILL.md new file mode 100644 index 0000000000000..b7b2e164e038d --- /dev/null +++ b/.github/skills/azure-pipelines/SKILL.md @@ -0,0 +1,241 @@ +--- +name: azure-pipelines +description: Use when validating Azure DevOps pipeline changes for the VS Code build. Covers queueing builds, checking build status, viewing logs, and iterating on pipeline YAML changes without waiting for full CI runs. +--- + +# Validating Azure Pipeline Changes + +When modifying Azure DevOps pipeline files (YAML files in `build/azure-pipelines/`), you can validate changes locally using the Azure CLI before committing. This avoids the slow feedback loop of pushing changes, waiting for CI, and checking results. + +## Prerequisites + +1. **Check if Azure CLI is installed**: + ```bash + az --version + ``` + + If not installed, install it: + ```bash + # macOS + brew install azure-cli + + # Windows (PowerShell as Administrator) + winget install Microsoft.AzureCLI + + # Linux (Debian/Ubuntu) + curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash + ``` + +2. **Check if the DevOps extension is installed**: + ```bash + az extension show --name azure-devops + ``` + + If not installed, add it: + ```bash + az extension add --name azure-devops + ``` + +3. **Authenticate**: + ```bash + az login + az devops configure --defaults organization=https://dev.azure.com/monacotools project=Monaco + ``` + +## VS Code Main Build + +The main VS Code build pipeline: +- **Organization**: `monacotools` +- **Project**: `Monaco` +- **Definition ID**: `111` +- **URL**: https://dev.azure.com/monacotools/Monaco/_build?definitionId=111 + +## VS Code Insider Scheduled Builds + +Two Insider builds run automatically on a scheduled basis: +- **Morning build**: ~7:00 AM CET +- **Evening build**: ~7:00 PM CET + +These scheduled builds use the same pipeline definition (`111`) but run on the `main` branch to produce Insider releases. + +--- + +## Queueing a Build + +Use the [queue command](./azure-pipeline.ts) to queue a validation build: + +```bash +# Queue a build on the current branch +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue + +# Queue with a specific source branch +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --branch my-feature-branch + +# Queue with custom variables (e.g., to skip certain stages) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --variables "SKIP_TESTS=true" +``` + +> **Important**: Before queueing a new build, cancel any previous builds on the same branch that you no longer need. This frees up build agents and reduces resource waste: +> ```bash +> # Find the build ID from status, then cancel it +> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id +> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +> ``` + +### Script Options + +| Option | Description | +|--------|-------------| +| `--branch ` | Source branch to build (default: current git branch) | +| `--definition ` | Pipeline definition ID (default: 111) | +| `--variables ` | Pipeline variables in `KEY=value` format, space-separated | +| `--dry-run` | Print the command without executing | + +--- + +## Checking Build Status + +Use the [status command](./azure-pipeline.ts) to monitor a running build: + +```bash +# Get status of the most recent build on your branch +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status + +# Get overview of a specific build by ID +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 + +# Watch build status (refreshes every 30 seconds) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch + +# Watch with custom interval (60 seconds) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch 60 +``` + +### Script Options + +| Option | Description | +|--------|-------------| +| `--build-id ` | Specific build ID (default: most recent on current branch) | +| `--branch ` | Filter builds by branch name (shows last 20 builds for branch) | +| `--reason ` | Filter builds by reason: `manual`, `individualCI`, `batchedCI`, `schedule`, `pullRequest` | +| `--definition ` | Pipeline definition ID (default: 111) | +| `--watch [seconds]` | Continuously poll status until build completes (default: 30s) | +| `--download-log ` | Download a specific log to /tmp | +| `--download-artifact ` | Download artifact to /tmp | +| `--json` | Output raw JSON for programmatic consumption | + +--- + +## Cancelling a Build + +Use the [cancel command](./azure-pipeline.ts) to stop a running build: + +```bash +# Cancel a build by ID (use status command to find IDs) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 + +# Dry run (show what would be cancelled) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run +``` + +### Script Options + +| Option | Description | +|--------|-------------| +| `--build-id ` | Build ID to cancel (required) | +| `--definition ` | Pipeline definition ID (default: 111) | +| `--dry-run` | Print what would be cancelled without executing | + +--- + +## Common Workflows + +### 1. Quick Pipeline Validation + +```bash +# Make your YAML changes, then: +git add -A && git commit -m "test: pipeline changes" +git push origin HEAD + +# Check for any previous builds on this branch and cancel if needed +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id # if there's an active build + +# Queue and watch the new build +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch +``` + +### 2. Investigate a Build + +```bash +# Get overview of a build (shows stages, artifacts, and log IDs) +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 + +# Download a specific log for deeper inspection +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-log 5 + +# Download an artifact +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-artifact unsigned_vscode_cli_win32_x64_cli +``` + +### 3. Test with Modified Variables + +```bash +# Skip expensive stages during validation +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --variables "VSCODE_BUILD_SKIP_INTEGRATION_TESTS=true" +``` + +### 4. Cancel a Running Build + +```bash +# First, find the build ID +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status + +# Cancel a specific build by ID +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 + +# Dry run to see what would be cancelled +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run +``` + +### 5. Iterate on Pipeline Changes + +When iterating on pipeline YAML changes, always cancel obsolete builds before queueing new ones: + +```bash +# Push new changes +git add -A && git commit --amend --no-edit +git push --force-with-lease origin HEAD + +# Find the outdated build ID and cancel it +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id + +# Queue a fresh build and monitor +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch +``` + +--- + +## Troubleshooting + +### Authentication Issues +```bash +# Re-authenticate +az logout +az login + +# Check current account +az account show +``` + +### Extension Not Found +```bash +az extension add --name azure-devops --upgrade +``` + +### Rate Limiting +If you hit rate limits, add delays between API calls or use `--watch` with a longer interval. diff --git a/.github/skills/azure-pipelines/azure-pipeline.ts b/.github/skills/azure-pipelines/azure-pipeline.ts new file mode 100644 index 0000000000000..7fad554050bb3 --- /dev/null +++ b/.github/skills/azure-pipelines/azure-pipeline.ts @@ -0,0 +1,1858 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Azure DevOps Pipeline CLI + * + * A unified command-line tool for managing Azure Pipeline builds. + * + * Usage: + * node --experimental-strip-types azure-pipeline.ts [options] + * + * Commands: + * queue - Queue a new pipeline build + * status - Check build status and download logs/artifacts + * cancel - Cancel a running build + * + * Run with --help for detailed usage of each command. + */ + +import { spawn, execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// ============================================================================ +// Constants +// ============================================================================ + +const ORGANIZATION = 'https://dev.azure.com/monacotools'; +const PROJECT = 'Monaco'; +const DEFAULT_DEFINITION_ID = '111'; +const DEFAULT_WATCH_INTERVAL = 30; + +// Validation patterns +const NUMERIC_ID_PATTERN = /^\d+$/; +const MAX_ID_LENGTH = 15; +const BRANCH_PATTERN = /^[a-zA-Z0-9_\-./]+$/; +const MAX_BRANCH_LENGTH = 256; +const VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*=[a-zA-Z0-9_\-./: ]*$/; +const MAX_VARIABLE_LENGTH = 256; +const ARTIFACT_NAME_PATTERN = /^[a-zA-Z0-9_\-.]+$/; +const MAX_ARTIFACT_NAME_LENGTH = 256; +const MIN_WATCH_INTERVAL = 5; +const MAX_WATCH_INTERVAL = 3600; + +// ============================================================================ +// Types +// ============================================================================ + +interface Build { + id: number; + buildNumber: string; + status: string; + result?: string; + sourceBranch?: string; + reason?: string; + startTime?: string; + finishTime?: string; + requestedBy?: { displayName?: string }; + requestedFor?: { displayName?: string }; +} + +interface TimelineRecord { + id: string; + parentId?: string; + type: string; + name?: string; + state?: string; + result?: string; + order?: number; + log?: { id?: number }; +} + +interface Timeline { + records: TimelineRecord[]; +} + +interface Artifact { + name: string; + resource?: { + downloadUrl?: string; + properties?: { artifactsize?: string }; + }; +} + +interface QueueArgs { + branch: string; + definitionId: string; + variables: string; + dryRun: boolean; + help: boolean; +} + +interface StatusArgs { + buildId: string; + branch: string; + reason: string; + definitionId: string; + watch: boolean; + watchInterval: number; + downloadLog: string; + downloadArtifact: string; + jsonOutput: boolean; + help: boolean; +} + +interface CancelArgs { + buildId: string; + definitionId: string; + dryRun: boolean; + help: boolean; +} + +// ============================================================================ +// Colors +// ============================================================================ + +const colors = { + red: (text: string) => `\x1b[0;31m${text}\x1b[0m`, + green: (text: string) => `\x1b[0;32m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[0;33m${text}\x1b[0m`, + blue: (text: string) => `\x1b[0;34m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[0;36m${text}\x1b[0m`, + gray: (text: string) => `\x1b[0;90m${text}\x1b[0m`, +}; + +// ============================================================================ +// Validation Functions +// ============================================================================ + +function validateNumericId(value: string, name: string): void { + if (!value) { + return; + } + if (value.length > MAX_ID_LENGTH) { + console.error(colors.red(`Error: ${name} is too long (max ${MAX_ID_LENGTH} characters)`)); + process.exit(1); + } + if (!NUMERIC_ID_PATTERN.test(value)) { + console.error(colors.red(`Error: ${name} must contain only digits`)); + process.exit(1); + } +} + +function validateBranch(value: string): void { + if (!value) { + return; + } + if (value.length > MAX_BRANCH_LENGTH) { + console.error(colors.red(`Error: --branch is too long (max ${MAX_BRANCH_LENGTH} characters)`)); + process.exit(1); + } + if (!BRANCH_PATTERN.test(value)) { + console.error(colors.red('Error: --branch contains invalid characters')); + console.log('Allowed: alphanumeric, hyphens, underscores, slashes, dots'); + process.exit(1); + } +} + +function validateVariables(value: string): void { + if (!value) { + return; + } + const vars = value.split(' ').filter(v => v.length > 0); + for (const v of vars) { + if (v.length > MAX_VARIABLE_LENGTH) { + console.error(colors.red(`Error: Variable '${v.substring(0, 20)}...' is too long (max ${MAX_VARIABLE_LENGTH} characters)`)); + process.exit(1); + } + if (!VARIABLE_PATTERN.test(v)) { + console.error(colors.red(`Error: Invalid variable format '${v}'`)); + console.log('Expected format: KEY=value (alphanumeric, underscores, hyphens, dots, slashes, colons, spaces in value)'); + process.exit(1); + } + } +} + +function validateArtifactName(value: string): void { + if (!value) { + return; + } + if (value.length > MAX_ARTIFACT_NAME_LENGTH) { + console.error(colors.red(`Error: --download-artifact name is too long (max ${MAX_ARTIFACT_NAME_LENGTH} characters)`)); + process.exit(1); + } + if (!ARTIFACT_NAME_PATTERN.test(value)) { + console.error(colors.red('Error: --download-artifact name contains invalid characters')); + console.log('Allowed: alphanumeric, hyphens, underscores, dots'); + process.exit(1); + } + if (value.includes('..') || value.startsWith('.') || value.startsWith('/') || value.startsWith('\\')) { + console.error(colors.red('Error: --download-artifact name contains unsafe path components')); + process.exit(1); + } +} + +function validateWatchInterval(value: number): void { + if (value < MIN_WATCH_INTERVAL || value > MAX_WATCH_INTERVAL) { + console.error(colors.red(`Error: Watch interval must be between ${MIN_WATCH_INTERVAL} and ${MAX_WATCH_INTERVAL} seconds`)); + process.exit(1); + } +} + +// ============================================================================ +// CLI Helpers +// ============================================================================ + +function commandExists(command: string): boolean { + try { + execSync(`${process.platform === 'win32' ? 'where' : 'which'} ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function hasAzureDevOpsExtension(): boolean { + try { + execSync('az extension show --name azure-devops', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function getCurrentBranch(): string { + try { + return execSync('git branch --show-current', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +} + +function ensureAzureCli(): void { + if (!commandExists('az')) { + console.error(colors.red('Error: Azure CLI (az) is not installed.')); + console.log('Install it with: brew install azure-cli (macOS) or see https://docs.microsoft.com/en-us/cli/azure/install-azure-cli'); + console.log('Then add the DevOps extension: az extension add --name azure-devops'); + process.exit(1); + } + + if (!hasAzureDevOpsExtension()) { + console.log(colors.yellow('Installing azure-devops extension...')); + try { + execSync('az extension add --name azure-devops', { stdio: 'inherit' }); + } catch { + console.error(colors.red('Failed to install azure-devops extension.')); + process.exit(1); + } + } +} + +function sleep(seconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +} + +function clearScreen(): void { + process.stdout.write('\x1Bc'); +} + +// ============================================================================ +// Display Utilities +// ============================================================================ + +function formatStatus(status: string): string { + switch (status) { + case 'completed': + return colors.green('completed'); + case 'inProgress': + return colors.blue('in progress'); + case 'notStarted': + return colors.gray('not started'); + case 'cancelling': + case 'postponed': + return colors.yellow(status); + default: + return status || ''; + } +} + +function formatResult(result: string): string { + switch (result) { + case 'succeeded': + return colors.green('✓ succeeded'); + case 'failed': + return colors.red('✗ failed'); + case 'canceled': + return colors.yellow('⊘ canceled'); + case 'partiallySucceeded': + return colors.yellow('◐ partially succeeded'); + default: + return result || 'pending'; + } +} + +function formatTimelineStatus(state: string, result: string): string { + if (state === 'completed') { + if (result === 'succeeded') { + return colors.green('✓'); + } + if (result === 'failed') { + return colors.red('✗'); + } + if (result === 'skipped') { + return colors.gray('○'); + } + return colors.yellow('◐'); + } + if (state === 'inProgress') { + return colors.blue('●'); + } + return colors.gray('○'); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) { + return '0 B'; + } + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function formatRelativeTime(dateStr: string): string { + if (!dateStr) { + return ''; + } + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'just now'; + } + if (diffMins < 60) { + return `${diffMins}m ago`; + } + if (diffHours < 24) { + return `${diffHours}h ago`; + } + return `${diffDays}d ago`; +} + +function formatReason(reason: string): string { + switch (reason) { + case 'manual': + return 'Manual'; + case 'individualCI': + return 'CI'; + case 'batchedCI': + return 'Batched CI'; + case 'schedule': + return 'Scheduled'; + case 'pullRequest': + return 'PR'; + case 'buildCompletion': + return 'Build Completion'; + case 'resourceTrigger': + return 'Resource Trigger'; + default: + return reason || 'Unknown'; + } +} + +function padOrTruncate(str: string, width: number): string { + if (str.length > width) { + return str.slice(0, width - 1) + '…'; + } + return str.padEnd(width); +} + +function displayBuildSummary(build: Build): void { + const id = build.id; + const buildNumber = build.buildNumber; + const status = build.status; + const result = build.result; + const sourceBranch = (build.sourceBranch || '').replace('refs/heads/', ''); + const startTime = build.startTime; + const finishTime = build.finishTime; + const requestedBy = build.requestedBy?.displayName; + + console.log(''); + console.log(colors.blue('Azure Pipeline Build Status')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Build ID: ${colors.green(String(id))}`); + console.log(`Build Number: ${colors.green(buildNumber)}`); + console.log(`Branch: ${colors.green(sourceBranch)}`); + console.log(`Status: ${formatStatus(status)}`); + console.log(`Result: ${formatResult(result || '')}`); + if (requestedBy) { + console.log(`Requested By: ${colors.cyan(requestedBy)}`); + } + if (startTime) { + console.log(`Started: ${colors.gray(startTime)}`); + } + if (finishTime) { + console.log(`Finished: ${colors.gray(finishTime)}`); + } + console.log(`URL: ${colors.blue(`${ORGANIZATION}/${PROJECT}/_build/results?buildId=${id}`)}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +function displayBuildList(builds: Build[]): void { + console.log(''); + console.log(colors.blue('Recent Builds')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(colors.gray(`${'ID'.padEnd(10)} ${'Status'.padEnd(14)} ${'Reason'.padEnd(12)} ${'Branch'.padEnd(25)} ${'Requested By'.padEnd(20)} ${'Started'.padEnd(12)}`)); + console.log('─────────────────────────────────────────────────────────────────────────────────────────────────────────────────'); + + if (!builds || builds.length === 0) { + console.log(colors.gray('No builds found')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + for (const build of builds) { + const id = String(build.id).padEnd(10); + const branch = padOrTruncate((build.sourceBranch || '').replace('refs/heads/', ''), 25); + const requestedBy = padOrTruncate(build.requestedBy?.displayName || build.requestedFor?.displayName || 'Unknown', 20); + const reason = padOrTruncate(formatReason(build.reason || ''), 12); + const started = padOrTruncate(formatRelativeTime(build.startTime || ''), 12); + + let statusStr: string; + if (build.status === 'completed') { + switch (build.result) { + case 'succeeded': + statusStr = colors.green('✓ succeeded'.padEnd(14)); + break; + case 'failed': + statusStr = colors.red('✗ failed'.padEnd(14)); + break; + case 'canceled': + statusStr = colors.yellow('⊘ canceled'.padEnd(14)); + break; + case 'partiallySucceeded': + statusStr = colors.yellow('◐ partial'.padEnd(14)); + break; + default: + statusStr = colors.gray((build.result || 'unknown').padEnd(14)); + } + } else if (build.status === 'inProgress') { + statusStr = colors.blue('● in progress'.padEnd(14)); + } else if (build.status === 'notStarted') { + statusStr = colors.gray('○ queued'.padEnd(14)); + } else if (build.status === 'cancelling') { + statusStr = colors.yellow('⊘ cancelling'.padEnd(14)); + } else { + statusStr = colors.gray((build.status || 'unknown').padEnd(14)); + } + + console.log(`${colors.cyan(id)} ${statusStr} ${reason} ${branch} ${requestedBy} ${colors.gray(started)}`); + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + console.log(colors.gray('Use --build-id to see details for a specific build')); +} + +function displayTimeline(timeline: Timeline | null): void { + console.log(''); + console.log(colors.blue('Pipeline Stages')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if (!timeline || !timeline.records) { + console.log(colors.gray('Timeline not available')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + const records = timeline.records; + const stages = records.filter(r => r.type === 'Stage'); + const phases = records.filter(r => r.type === 'Phase'); + const jobs = records.filter(r => r.type === 'Job'); + + const phaseToStage = new Map(); + for (const phase of phases) { + if (phase.parentId) { + phaseToStage.set(phase.id, phase.parentId); + } + } + + stages.sort((a, b) => (a.order || 0) - (b.order || 0)); + + for (const stage of stages) { + const status = formatTimelineStatus(stage.state || '', stage.result || ''); + const name = stage.name || 'Unknown'; + console.log(`${status} ${name}`); + + const stagePhaseIds = new Set(phases.filter(p => p.parentId === stage.id).map(p => p.id)); + const stageJobs = jobs.filter(j => j.parentId && stagePhaseIds.has(j.parentId)); + + stageJobs.sort((a, b) => (a.order || 0) - (b.order || 0)); + + for (const job of stageJobs) { + const jobStatus = formatTimelineStatus(job.state || '', job.result || ''); + const jobName = job.name || 'Unknown'; + const logId = job.log?.id; + const logInfo = logId ? colors.gray(` (log #${logId})`) : ''; + console.log(` ${jobStatus} ${jobName}${logInfo}`); + } + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +function displayArtifacts(artifacts: Artifact[]): void { + console.log(''); + console.log(colors.blue('Build Artifacts')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if (!artifacts || artifacts.length === 0) { + console.log(colors.gray('No artifacts available')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + return; + } + + for (const artifact of artifacts) { + const name = artifact.name || 'Unknown'; + const size = artifact.resource?.properties?.artifactsize; + if (!size || parseInt(size, 10) === 0) { + continue; + } + const sizeStr = ` (${formatBytes(parseInt(size, 10))})`; + console.log(` ${colors.cyan(name)}${colors.gray(sizeStr)}`); + } + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +function displayNextSteps(buildId: string): void { + console.log(''); + console.log(colors.blue('Next Steps')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(colors.gray(` Download artifact: status --build-id ${buildId} --download-artifact `)); + console.log(colors.gray(` Download log: status --build-id ${buildId} --download-log `)); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); +} + +// ============================================================================ +// Azure DevOps Client +// ============================================================================ + +class AzureDevOpsClient { + protected readonly organization: string; + protected readonly project: string; + + constructor(organization: string, project: string) { + this.organization = organization; + this.project = project; + } + + protected runAzCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('az', args, { shell: true }); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr || stdout || `Command failed with code ${code}`)); + } + }); + + proc.on('error', reject); + }); + } + + private async rest(method: string, url: string, body?: string): Promise { + const args = [ + 'rest', + '--method', method, + '--url', url, + '--resource', '499b84ac-1321-427f-aa17-267ca6975798', + ]; + + if (body) { + const tmpDir = os.tmpdir(); + const bodyFile = path.join(tmpDir, `azdo-request-${Date.now()}.json`); + fs.writeFileSync(bodyFile, body); + args.push('--headers', 'Content-Type=application/json'); + args.push('--body', `@${bodyFile}`); + + try { + const result = await this.runAzCommand(args); + return JSON.parse(result); + } finally { + try { + fs.unlinkSync(bodyFile); + } catch { + // Ignore cleanup errors + } + } + } + + const result = await this.runAzCommand(args); + return JSON.parse(result); + } + + async queueBuild(definitionId: string, branch: string, variables?: string): Promise { + const args = [ + 'pipelines', 'run', + '--organization', this.organization, + '--project', this.project, + '--id', definitionId, + '--branch', branch, + ]; + + if (variables) { + args.push('--variables', ...variables.split(' ')); + } + + args.push('--output', 'json'); + const result = await this.runAzCommand(args); + return JSON.parse(result); + } + + async getBuild(buildId: string): Promise { + try { + const args = [ + 'pipelines', 'build', 'show', + '--organization', this.organization, + '--project', this.project, + '--id', buildId, + '--output', 'json', + ]; + const result = await this.runAzCommand(args); + return JSON.parse(result); + } catch { + return null; + } + } + + async listBuilds(definitionId: string, options: { branch?: string; reason?: string; top?: number } = {}): Promise { + try { + const args = [ + 'pipelines', 'build', 'list', + '--organization', this.organization, + '--project', this.project, + '--definition-ids', definitionId, + '--top', String(options.top || 20), + '--output', 'json', + ]; + if (options.branch) { + args.push('--branch', options.branch); + } + if (options.reason) { + args.push('--reason', options.reason); + } + const result = await this.runAzCommand(args); + return JSON.parse(result); + } catch { + return []; + } + } + + async findRecentBuild(branch: string, definitionId: string): Promise { + try { + const args = [ + 'pipelines', 'build', 'list', + '--organization', this.organization, + '--project', this.project, + '--definition-ids', definitionId, + '--branch', branch, + '--top', '1', + '--query', '[0].id', + '--output', 'tsv', + ]; + const result = await this.runAzCommand(args); + return result.trim(); + } catch { + return ''; + } + } + + async cancelBuild(buildId: string): Promise { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}?api-version=7.0`; + return this.rest('patch', url, JSON.stringify({ status: 'cancelling' })); + } + + async getTimeline(buildId: string): Promise { + try { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}/timeline?api-version=7.0`; + return await this.rest('get', url); + } catch { + return null; + } + } + + async getArtifacts(buildId: string): Promise { + try { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}/artifacts?api-version=7.0`; + const response = await this.rest<{ value: Artifact[] }>('get', url); + return response.value || []; + } catch { + return []; + } + } + + async downloadLog(buildId: string, logId: string): Promise { + const url = `${this.organization}/${this.project}/_apis/build/builds/${buildId}/logs/${logId}?api-version=7.0`; + const args = ['rest', '--method', 'get', '--url', url, '--resource', '499b84ac-1321-427f-aa17-267ca6975798']; + const content = await this.runAzCommand(args); + + const tmpDir = os.tmpdir(); + const outputPath = path.join(tmpDir, `build-${buildId}-log-${logId}.txt`); + + console.log(colors.blue(`Downloading log #${logId}...`)); + console.log(colors.gray(`Destination: ${outputPath}`)); + + fs.writeFileSync(outputPath, content); + return outputPath; + } + + async downloadArtifact(buildId: string, artifactName: string): Promise { + const artifacts = await this.getArtifacts(buildId); + const artifact = artifacts.find(a => a.name === artifactName); + + if (!artifact) { + const available = artifacts.map(a => a.name).join(', '); + throw new Error(`Artifact '${artifactName}' not found. Available artifacts: ${available || 'none'}`); + } + + const downloadUrl = artifact.resource?.downloadUrl; + if (!downloadUrl) { + throw new Error(`Artifact '${artifactName}' has no download URL`); + } + + const tmpDir = os.tmpdir(); + const outputPath = path.join(tmpDir, `${artifactName}.zip`); + + console.log(colors.blue(`Downloading artifact '${artifactName}'...`)); + console.log(colors.gray(`Destination: ${outputPath}`)); + + const tokenArgs = ['account', 'get-access-token', '--resource', '499b84ac-1321-427f-aa17-267ca6975798', '--query', 'accessToken', '--output', 'tsv']; + const token = (await this.runAzCommand(tokenArgs)).trim(); + + const response = await fetch(downloadUrl, { + headers: { 'Authorization': `Bearer ${token}` }, + redirect: 'follow', + }); + + if (!response.ok) { + throw new Error(`Failed to download artifact: ${response.status} ${response.statusText}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(outputPath, buffer); + + return outputPath; + } +} + +// ============================================================================ +// Queue Command +// ============================================================================ + +function printQueueUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue'; + console.log(`Usage: ${scriptName} [options]`); + console.log(''); + console.log('Queue an Azure DevOps pipeline build for VS Code.'); + console.log(''); + console.log('Options:'); + console.log(' --branch Source branch to build (default: current git branch)'); + console.log(' --definition Pipeline definition ID (default: 111)'); + console.log(' --variables Pipeline variables in "KEY=value KEY2=value2" format'); + console.log(' --dry-run Print the command without executing'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} # Queue build on current branch`); + console.log(` ${scriptName} --branch my-feature # Queue build on specific branch`); + console.log(` ${scriptName} --variables "SKIP_TESTS=true" # Queue with custom variables`); +} + +function parseQueueArgs(args: string[]): QueueArgs { + const result: QueueArgs = { + branch: '', + definitionId: DEFAULT_DEFINITION_ID, + variables: '', + dryRun: false, + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--branch': + result.branch = args[++i] || ''; + break; + case '--definition': + result.definitionId = args[++i] || DEFAULT_DEFINITION_ID; + break; + case '--variables': + result.variables = args[++i] || ''; + break; + case '--dry-run': + result.dryRun = true; + break; + case '--help': + result.help = true; + break; + default: + console.error(colors.red(`Error: Unknown option: ${arg}`)); + printQueueUsage(); + process.exit(1); + } + } + + return result; +} + +function validateQueueArgs(args: QueueArgs): void { + validateNumericId(args.definitionId, '--definition'); + validateBranch(args.branch); + validateVariables(args.variables); +} + +async function runQueueCommand(args: string[]): Promise { + const parsedArgs = parseQueueArgs(args); + + if (parsedArgs.help) { + printQueueUsage(); + process.exit(0); + } + + validateQueueArgs(parsedArgs); + ensureAzureCli(); + + let branch = parsedArgs.branch; + if (!branch) { + branch = getCurrentBranch(); + if (!branch) { + console.error(colors.red('Error: Could not determine current git branch.')); + console.log('Please specify a branch with --branch '); + process.exit(1); + } + validateBranch(branch); + } + + console.log(colors.blue('Queueing Azure Pipeline Build')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Organization: ${colors.green(ORGANIZATION)}`); + console.log(`Project: ${colors.green(PROJECT)}`); + console.log(`Definition: ${colors.green(parsedArgs.definitionId)}`); + console.log(`Branch: ${colors.green(branch)}`); + if (parsedArgs.variables) { + console.log(`Variables: ${colors.green(parsedArgs.variables)}`); + } + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + + if (parsedArgs.dryRun) { + console.log(colors.yellow('Dry run - command would be:')); + const cmdArgs = [ + 'pipelines', 'run', + '--organization', ORGANIZATION, + '--project', PROJECT, + '--id', parsedArgs.definitionId, + '--branch', branch, + ]; + if (parsedArgs.variables) { + cmdArgs.push('--variables', ...parsedArgs.variables.split(' ')); + } + cmdArgs.push('--output', 'json'); + console.log(`az ${cmdArgs.join(' ')}`); + process.exit(0); + } + + console.log(colors.blue('Queuing build...')); + + try { + const client = new AzureDevOpsClient(ORGANIZATION, PROJECT); + const data = await client.queueBuild(parsedArgs.definitionId, branch, parsedArgs.variables); + + const buildId = data.id; + const buildNumber = data.buildNumber; + const buildUrl = `${ORGANIZATION}/${PROJECT}/_build/results?buildId=${buildId}`; + + console.log(''); + console.log(colors.green('✓ Build queued successfully!')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Build ID: ${colors.green(String(buildId))}`); + if (buildNumber) { + console.log(`Build Number: ${colors.green(buildNumber)}`); + } + console.log(`URL: ${colors.blue(buildUrl)}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(''); + console.log('To check status, run:'); + console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); + console.log(''); + console.log('To watch progress:'); + console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId} --watch`); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + console.error(colors.red('Error queuing build:')); + console.error(error.message); + process.exit(1); + } +} + +// ============================================================================ +// Status Command +// ============================================================================ + +function printStatusUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status'; + console.log(`Usage: ${scriptName} [options]`); + console.log(''); + console.log('Get status and logs of an Azure DevOps pipeline build.'); + console.log(''); + console.log('Options:'); + console.log(' --build-id Specific build ID (default: list last 20 builds)'); + console.log(' --branch Filter builds by branch name (shows last 20 builds for branch)'); + console.log(' --reason Filter builds by reason (manual, individualCI, batchedCI, schedule, pullRequest)'); + console.log(' --definition Pipeline definition ID (default: 111)'); + console.log(' --watch [seconds] Continuously poll status until build completes (default: 30)'); + console.log(' --download-log Download a specific log to /tmp'); + console.log(' --download-artifact Download artifact to /tmp'); + console.log(' --json Output raw JSON'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} # List last 20 builds`); + console.log(` ${scriptName} --branch main # List last 20 builds for main branch`); + console.log(` ${scriptName} --reason schedule # List last 20 scheduled builds`); + console.log(` ${scriptName} --build-id 123456 # Status of specific build`); + console.log(` ${scriptName} --watch # Watch build until completion (30s interval)`); + console.log(` ${scriptName} --watch 60 # Watch with 60s interval`); + console.log(` ${scriptName} --build-id 123456 --download-log 5 # Download log to /tmp`); +} + +function parseStatusArgs(args: string[]): StatusArgs { + const result: StatusArgs = { + buildId: '', + branch: '', + reason: '', + definitionId: DEFAULT_DEFINITION_ID, + watch: false, + watchInterval: DEFAULT_WATCH_INTERVAL, + downloadLog: '', + downloadArtifact: '', + jsonOutput: false, + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--build-id': + result.buildId = args[++i] || ''; + break; + case '--branch': + result.branch = args[++i] || ''; + break; + case '--reason': + result.reason = args[++i] || ''; + break; + case '--definition': + result.definitionId = args[++i] || DEFAULT_DEFINITION_ID; + break; + case '--watch': + result.watch = true; + if (args[i + 1] && /^\d+$/.test(args[i + 1])) { + result.watchInterval = parseInt(args[++i], 10) || DEFAULT_WATCH_INTERVAL; + } + break; + case '--download-log': + result.downloadLog = args[++i] || ''; + break; + case '--download-artifact': + result.downloadArtifact = args[++i] || ''; + break; + case '--json': + result.jsonOutput = true; + break; + case '--help': + result.help = true; + break; + default: + console.error(colors.red(`Error: Unknown option: ${arg}`)); + printStatusUsage(); + process.exit(1); + } + } + + return result; +} + +function validateStatusArgs(args: StatusArgs): void { + validateNumericId(args.buildId, '--build-id'); + validateNumericId(args.definitionId, '--definition'); + validateNumericId(args.downloadLog, '--download-log'); + validateArtifactName(args.downloadArtifact); + if (args.watch) { + validateWatchInterval(args.watchInterval); + } +} + +async function runStatusCommand(args: string[]): Promise { + const parsedArgs = parseStatusArgs(args); + + if (parsedArgs.help) { + printStatusUsage(); + process.exit(0); + } + + validateStatusArgs(parsedArgs); + ensureAzureCli(); + + const client = new AzureDevOpsClient(ORGANIZATION, PROJECT); + + // If no build ID specified, show list of recent builds + let buildId = parsedArgs.buildId; + if (!buildId && !parsedArgs.downloadLog && !parsedArgs.downloadArtifact && !parsedArgs.watch) { + const builds = await client.listBuilds(parsedArgs.definitionId, { + branch: parsedArgs.branch, + reason: parsedArgs.reason, + top: 20, + }); + + if (parsedArgs.jsonOutput) { + console.log(JSON.stringify(builds, null, 2)); + } else { + const filters: string[] = []; + if (parsedArgs.branch) { + filters.push(`branch: ${parsedArgs.branch}`); + } + if (parsedArgs.reason) { + filters.push(`reason: ${parsedArgs.reason}`); + } + if (filters.length > 0) { + console.log(colors.gray(`Filtering by ${filters.join(', ')}`)); + } + displayBuildList(builds); + } + return; + } + + // For watch mode or download operations without a build ID, find the most recent build on current branch + if (!buildId) { + const branch = getCurrentBranch(); + if (!branch) { + console.error(colors.red('Error: Could not determine current git branch.')); + console.log('Please specify a build ID with --build-id '); + process.exit(1); + } + + console.log(colors.gray(`Finding most recent build for branch: ${branch}`)); + buildId = await client.findRecentBuild(branch, parsedArgs.definitionId); + + if (!buildId) { + console.error(colors.red(`Error: No builds found for branch '${branch}'.`)); + console.log('You can queue a new build with: node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue'); + process.exit(1); + } + } + + // Download specific log + if (parsedArgs.downloadLog) { + try { + const outputPath = await client.downloadLog(buildId, parsedArgs.downloadLog); + console.log(colors.green(`✓ Log downloaded to: ${outputPath}`)); + } catch (e) { + console.error(colors.red((e as Error).message)); + process.exit(1); + } + return; + } + + // Download artifact + if (parsedArgs.downloadArtifact) { + try { + const outputPath = await client.downloadArtifact(buildId, parsedArgs.downloadArtifact); + console.log(colors.green(`✓ Artifact downloaded to: ${outputPath}`)); + } catch (e) { + console.error(colors.red((e as Error).message)); + process.exit(1); + } + return; + } + + // Watch mode + if (parsedArgs.watch) { + console.log(colors.blue(`Watching build ${buildId} (Ctrl+C to stop)`)); + console.log(''); + + while (true) { + const build = await client.getBuild(buildId); + + if (!build) { + console.error(colors.red('Error: Could not fetch build status')); + process.exit(1); + } + + clearScreen(); + + if (parsedArgs.jsonOutput) { + console.log(JSON.stringify(build, null, 2)); + } else { + displayBuildSummary(build); + const timeline = await client.getTimeline(buildId); + displayTimeline(timeline); + + const artifacts = await client.getArtifacts(buildId); + displayArtifacts(artifacts); + displayNextSteps(buildId); + } + + if (build.status === 'completed') { + console.log(''); + console.log(colors.green('Build completed!')); + process.exit(0); + } + + console.log(''); + console.log(colors.gray(`Refreshing in ${parsedArgs.watchInterval} seconds... (Ctrl+C to stop)`)); + await sleep(parsedArgs.watchInterval); + } + } else { + // Single status check + const build = await client.getBuild(buildId); + + if (!build) { + console.error(colors.red(`Error: Could not fetch build status for ID ${buildId}`)); + process.exit(1); + } + + if (parsedArgs.jsonOutput) { + console.log(JSON.stringify(build, null, 2)); + } else { + displayBuildSummary(build); + const timeline = await client.getTimeline(buildId); + displayTimeline(timeline); + + const artifacts = await client.getArtifacts(buildId); + displayArtifacts(artifacts); + displayNextSteps(buildId); + } + } +} + +// ============================================================================ +// Cancel Command +// ============================================================================ + +function printCancelUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel'; + console.log(`Usage: ${scriptName} --build-id [options]`); + console.log(''); + console.log('Cancel a running Azure DevOps pipeline build.'); + console.log(''); + console.log('Options:'); + console.log(' --build-id Build ID to cancel (required)'); + console.log(' --definition Pipeline definition ID (default: 111)'); + console.log(' --dry-run Print what would be cancelled without executing'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} --build-id 123456 # Cancel specific build`); + console.log(` ${scriptName} --build-id 123456 --dry-run # Show what would be cancelled`); +} + +function parseCancelArgs(args: string[]): CancelArgs { + const result: CancelArgs = { + buildId: '', + definitionId: DEFAULT_DEFINITION_ID, + dryRun: false, + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--build-id': + result.buildId = args[++i] || ''; + break; + case '--definition': + result.definitionId = args[++i] || DEFAULT_DEFINITION_ID; + break; + case '--dry-run': + result.dryRun = true; + break; + case '--help': + result.help = true; + break; + default: + console.error(colors.red(`Error: Unknown option: ${arg}`)); + printCancelUsage(); + process.exit(1); + } + } + + return result; +} + +function validateCancelArgs(args: CancelArgs): void { + validateNumericId(args.buildId, '--build-id'); + validateNumericId(args.definitionId, '--definition'); +} + +async function runCancelCommand(args: string[]): Promise { + const parsedArgs = parseCancelArgs(args); + + if (parsedArgs.help) { + printCancelUsage(); + process.exit(0); + } + + validateCancelArgs(parsedArgs); + ensureAzureCli(); + + const buildId = parsedArgs.buildId; + + if (!buildId) { + console.error(colors.red('Error: --build-id is required.')); + console.log(''); + console.log('To find build IDs, run:'); + console.log(' node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status'); + process.exit(1); + } + + const client = new AzureDevOpsClient(ORGANIZATION, PROJECT); + const build = await client.getBuild(buildId); + + if (!build) { + console.error(colors.red(`Error: Could not fetch build status for ID ${buildId}`)); + process.exit(1); + } + + const buildUrl = `${ORGANIZATION}/${PROJECT}/_build/results?buildId=${buildId}`; + + console.log(''); + console.log(colors.blue('Azure Pipeline Build Cancel')); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`Build ID: ${colors.green(String(build.id))}`); + console.log(`Build Number: ${colors.green(build.buildNumber || 'N/A')}`); + console.log(`Status: ${colors.yellow(build.status)}`); + console.log(`URL: ${colors.blue(buildUrl)}`); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if (build.status === 'completed') { + console.log(''); + console.log(colors.yellow('Build is already completed. Nothing to cancel.')); + process.exit(0); + } + + if (build.status === 'cancelling') { + console.log(''); + console.log(colors.yellow('Build is already being cancelled.')); + process.exit(0); + } + + if (parsedArgs.dryRun) { + console.log(''); + console.log(colors.yellow('Dry run - would cancel build:')); + console.log(` Build ID: ${buildId}`); + console.log(` API: PATCH ${ORGANIZATION}/${PROJECT}/_apis/build/builds/${buildId}?api-version=7.0`); + console.log(` Body: {"status": "cancelling"}`); + process.exit(0); + } + + console.log(''); + console.log(colors.blue('Cancelling build...')); + + try { + await client.cancelBuild(buildId); + console.log(''); + console.log(colors.green('✓ Build cancellation requested successfully!')); + console.log(''); + console.log('The build will transition to "cancelling" state and then "canceled".'); + console.log('Check status with:'); + console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + console.error(''); + console.error(colors.red('Error cancelling build:')); + console.error(error.message); + process.exit(1); + } +} + +// ============================================================================ +// Testable Azure DevOps Client +// ============================================================================ + +/** + * A testable version of AzureDevOpsClient that captures az command calls + * instead of executing them. + */ +class TestableAzureDevOpsClient extends AzureDevOpsClient { + public capturedCommands: string[][] = []; + private mockResponses: Map = new Map(); + + constructor(organization: string, project: string) { + super(organization, project); + } + + setMockResponse(commandPattern: string, response: unknown): void { + this.mockResponses.set(commandPattern, response); + } + + protected override runAzCommand(args: string[]): Promise { + this.capturedCommands.push(args); + + // Find a matching mock response + const commandKey = args.join(' '); + for (const [pattern, response] of this.mockResponses) { + if (commandKey.includes(pattern)) { + return Promise.resolve(JSON.stringify(response)); + } + } + + // Default mock responses based on command type + if (args.includes('pipelines') && args.includes('run')) { + return Promise.resolve(JSON.stringify({ id: 12345, buildNumber: '20260218.1' })); + } + if (args.includes('pipelines') && args.includes('build') && args.includes('show')) { + return Promise.resolve(JSON.stringify({ + id: 12345, + buildNumber: '20260218.1', + status: 'inProgress', + sourceBranch: 'refs/heads/main' + })); + } + if (args.includes('pipelines') && args.includes('build') && args.includes('list')) { + return Promise.resolve(JSON.stringify([ + { id: 12345, buildNumber: '20260218.1', status: 'completed', result: 'succeeded' } + ])); + } + if (args.includes('rest') && args.includes('patch')) { + return Promise.resolve(JSON.stringify({ id: 12345, status: 'cancelling' })); + } + if (args.includes('rest') && args.includes('timeline')) { + return Promise.resolve(JSON.stringify({ records: [] })); + } + if (args.includes('rest') && args.includes('artifacts')) { + return Promise.resolve(JSON.stringify({ value: [] })); + } + + return Promise.resolve('{}'); + } +} + +// ============================================================================ +// Tests (using Node.js built-in test runner) +// ============================================================================ + +async function runAllTests(): Promise { + const { describe, it } = await import('node:test'); + const assert = await import('node:assert'); + + describe('Validation Functions', () => { + it('validateNumericId accepts valid numeric IDs', () => { + validateNumericId('12345', 'test'); + validateNumericId('1', 'test'); + validateNumericId('999999999999999', 'test'); + }); + + it('validateNumericId accepts empty string', () => { + validateNumericId('', 'test'); + }); + + it('validateBranch accepts valid branch names', () => { + validateBranch('main'); + validateBranch('feature/my-feature'); + validateBranch('release/v1.0.0'); + validateBranch('user/john_doe/fix-123'); + validateBranch('refs/heads/main'); + }); + + it('validateBranch accepts empty string', () => { + validateBranch(''); + }); + + it('validateVariables accepts valid variable formats', () => { + validateVariables('KEY=value'); + validateVariables('MY_VAR=some-value'); + validateVariables('A=1 B=2 C=3'); + validateVariables('PATH=/usr/bin:path'); + }); + + it('validateVariables accepts empty string', () => { + validateVariables(''); + }); + + it('validateArtifactName accepts valid artifact names', () => { + validateArtifactName('my-artifact'); + validateArtifactName('artifact_1.0.0'); + validateArtifactName('Build-Output'); + }); + + it('validateArtifactName accepts empty string', () => { + validateArtifactName(''); + }); + + it('validateWatchInterval accepts valid intervals', () => { + validateWatchInterval(5); + validateWatchInterval(30); + validateWatchInterval(3600); + }); + }); + + describe('Argument Parsing', () => { + it('parseQueueArgs parses --branch correctly', () => { + const args = parseQueueArgs(['--branch', 'my-feature']); + assert.strictEqual(args.branch, 'my-feature'); + }); + + it('parseQueueArgs parses --definition correctly', () => { + const args = parseQueueArgs(['--definition', '222']); + assert.strictEqual(args.definitionId, '222'); + }); + + it('parseQueueArgs parses --variables correctly', () => { + const args = parseQueueArgs(['--variables', 'KEY=value']); + assert.strictEqual(args.variables, 'KEY=value'); + }); + + it('parseQueueArgs parses --dry-run correctly', () => { + const args = parseQueueArgs(['--dry-run']); + assert.strictEqual(args.dryRun, true); + }); + + it('parseQueueArgs parses combined arguments', () => { + const args = parseQueueArgs(['--branch', 'main', '--definition', '333', '--variables', 'A=1 B=2', '--dry-run']); + assert.strictEqual(args.branch, 'main'); + assert.strictEqual(args.definitionId, '333'); + assert.strictEqual(args.variables, 'A=1 B=2'); + assert.strictEqual(args.dryRun, true); + }); + + it('parseStatusArgs parses --build-id correctly', () => { + const args = parseStatusArgs(['--build-id', '12345']); + assert.strictEqual(args.buildId, '12345'); + }); + + it('parseStatusArgs parses --branch correctly', () => { + const args = parseStatusArgs(['--branch', 'main']); + assert.strictEqual(args.branch, 'main'); + }); + + it('parseStatusArgs parses --watch without interval', () => { + const args = parseStatusArgs(['--watch']); + assert.strictEqual(args.watch, true); + assert.strictEqual(args.watchInterval, 30); + }); + + it('parseStatusArgs parses --watch with interval', () => { + const args = parseStatusArgs(['--watch', '60']); + assert.strictEqual(args.watch, true); + assert.strictEqual(args.watchInterval, 60); + }); + + it('parseStatusArgs parses --download-log correctly', () => { + const args = parseStatusArgs(['--download-log', '5']); + assert.strictEqual(args.downloadLog, '5'); + }); + + it('parseStatusArgs parses --download-artifact correctly', () => { + const args = parseStatusArgs(['--download-artifact', 'my-artifact']); + assert.strictEqual(args.downloadArtifact, 'my-artifact'); + }); + + it('parseStatusArgs parses --json correctly', () => { + const args = parseStatusArgs(['--json']); + assert.strictEqual(args.jsonOutput, true); + }); + + it('parseCancelArgs parses --build-id correctly', () => { + const args = parseCancelArgs(['--build-id', '12345']); + assert.strictEqual(args.buildId, '12345'); + }); + + it('parseCancelArgs parses --dry-run correctly', () => { + const args = parseCancelArgs(['--dry-run']); + assert.strictEqual(args.dryRun, true); + }); + }); + + describe('Azure Command Construction', () => { + it('queueBuild constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.queueBuild('111', 'main'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('run')); + assert.ok(cmd.includes('--organization')); + assert.ok(cmd.includes(ORGANIZATION)); + assert.ok(cmd.includes('--project')); + assert.ok(cmd.includes(PROJECT)); + assert.ok(cmd.includes('--id')); + assert.ok(cmd.includes('111')); + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('main')); + assert.ok(cmd.includes('--output')); + assert.ok(cmd.includes('json')); + }); + + it('queueBuild includes variables when provided', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.queueBuild('111', 'main', 'KEY=value OTHER=test'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--variables')); + assert.ok(cmd.includes('KEY=value')); + assert.ok(cmd.includes('OTHER=test')); + }); + + it('getBuild constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.getBuild('12345'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('build')); + assert.ok(cmd.includes('show')); + assert.ok(cmd.includes('--id')); + assert.ok(cmd.includes('12345')); + }); + + it('listBuilds constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('build')); + assert.ok(cmd.includes('list')); + assert.ok(cmd.includes('--definition-ids')); + assert.ok(cmd.includes('111')); + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('20')); + }); + + it('listBuilds includes branch filter when provided', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { branch: 'feature/test' }); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('feature/test')); + }); + + it('listBuilds includes reason filter when provided', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { reason: 'manual' }); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--reason')); + assert.ok(cmd.includes('manual')); + }); + + it('listBuilds includes custom top value', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { top: 50 }); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('50')); + }); + + it('findRecentBuild constructs correct az command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.findRecentBuild('main', '111'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('build')); + assert.ok(cmd.includes('list')); + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('main')); + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('1')); + assert.ok(cmd.includes('--query')); + assert.ok(cmd.includes('[0].id')); + assert.ok(cmd.includes('--output')); + assert.ok(cmd.includes('tsv')); + }); + + it('cancelBuild constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.cancelBuild('12345'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('patch')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345')); + }); + + it('getTimeline constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.getTimeline('12345'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('get')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345/timeline')); + }); + + it('getArtifacts constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.getArtifacts('12345'); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('get')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345/artifacts')); + }); + + it('downloadLog constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + + // Capture console output to avoid noise + const originalLog = console.log; + console.log = () => { }; + try { + await client.downloadLog('12345', '7'); + } finally { + console.log = originalLog; + } + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('get')); + assert.ok(cmd.join(' ').includes('_apis/build/builds/12345/logs/7')); + }); + }); + + describe('Display Format Functions', () => { + it('formatStatus returns correct format for completed', () => { + const result = formatStatus('completed'); + assert.ok(result.includes('completed')); + }); + + it('formatStatus returns correct format for inProgress', () => { + const result = formatStatus('inProgress'); + assert.ok(result.includes('in progress')); + }); + + it('formatResult returns correct format for succeeded', () => { + const result = formatResult('succeeded'); + assert.ok(result.includes('succeeded')); + assert.ok(result.includes('✓')); + }); + + it('formatResult returns correct format for failed', () => { + const result = formatResult('failed'); + assert.ok(result.includes('failed')); + assert.ok(result.includes('✗')); + }); + + it('formatResult returns correct format for canceled', () => { + const result = formatResult('canceled'); + assert.ok(result.includes('canceled')); + assert.ok(result.includes('⊘')); + }); + + it('formatBytes formats correctly', () => { + assert.strictEqual(formatBytes(0), '0 B'); + assert.strictEqual(formatBytes(1024), '1 KB'); + assert.strictEqual(formatBytes(1048576), '1 MB'); + assert.strictEqual(formatBytes(1073741824), '1 GB'); + }); + + it('formatReason returns correct labels', () => { + assert.strictEqual(formatReason('manual'), 'Manual'); + assert.strictEqual(formatReason('individualCI'), 'CI'); + assert.strictEqual(formatReason('pullRequest'), 'PR'); + assert.strictEqual(formatReason('schedule'), 'Scheduled'); + }); + + it('padOrTruncate pads short strings', () => { + assert.strictEqual(padOrTruncate('abc', 6), 'abc '); + }); + + it('padOrTruncate truncates long strings', () => { + assert.strictEqual(padOrTruncate('abcdefghij', 6), 'abcde…'); + }); + + it('formatTimelineStatus returns correct symbols', () => { + const succeeded = formatTimelineStatus('completed', 'succeeded'); + assert.ok(succeeded.includes('✓')); + + const failed = formatTimelineStatus('completed', 'failed'); + assert.ok(failed.includes('✗')); + + const inProgress = formatTimelineStatus('inProgress', ''); + assert.ok(inProgress.includes('●')); + }); + }); + + describe('Integration Tests', () => { + it('full queue command flow constructs correct az commands', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.queueBuild('111', 'feature/test', 'DEBUG=true'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + + assert.ok(cmd.includes('pipelines')); + assert.ok(cmd.includes('run')); + assert.ok(cmd.includes('--organization')); + assert.ok(cmd.includes(ORGANIZATION)); + assert.ok(cmd.includes('--project')); + assert.ok(cmd.includes(PROJECT)); + assert.ok(cmd.includes('--id')); + assert.ok(cmd.includes('111')); + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('feature/test')); + assert.ok(cmd.includes('--variables')); + assert.ok(cmd.includes('DEBUG=true')); + }); + + it('full status command flow constructs correct az commands', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + + await client.getBuild('99999'); + await client.getTimeline('99999'); + await client.getArtifacts('99999'); + + assert.strictEqual(client.capturedCommands.length, 3); + + const showCmd = client.capturedCommands[0]; + assert.ok(showCmd.includes('build')); + assert.ok(showCmd.includes('show')); + assert.ok(showCmd.includes('--id')); + assert.ok(showCmd.includes('99999')); + + const timelineCmd = client.capturedCommands[1]; + assert.ok(timelineCmd.includes('rest')); + assert.ok(timelineCmd.join(' ').includes('timeline')); + + const artifactsCmd = client.capturedCommands[2]; + assert.ok(artifactsCmd.includes('rest')); + assert.ok(artifactsCmd.join(' ').includes('artifacts')); + }); + + it('cancel command constructs correct REST API call', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.cancelBuild('88888'); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + + assert.ok(cmd.includes('rest')); + assert.ok(cmd.includes('--method')); + assert.ok(cmd.includes('patch')); + assert.ok(cmd.join(' ').includes('88888')); + assert.ok(cmd.join(' ').includes('api-version=7.0')); + }); + + it('list builds with filters constructs correct command', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.listBuilds('111', { branch: 'main', reason: 'pullRequest', top: 10 }); + + assert.strictEqual(client.capturedCommands.length, 1); + const cmd = client.capturedCommands[0]; + + assert.ok(cmd.includes('--branch')); + assert.ok(cmd.includes('main')); + assert.ok(cmd.includes('--reason')); + assert.ok(cmd.includes('pullRequest')); + assert.ok(cmd.includes('--top')); + assert.ok(cmd.includes('10')); + }); + }); +} + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +function printMainUsage(): void { + const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts'; + console.log(`Usage: ${scriptName} [options]`); + console.log(''); + console.log('Azure DevOps Pipeline CLI for VS Code builds.'); + console.log(''); + console.log('Commands:'); + console.log(' queue Queue a new pipeline build'); + console.log(' status Check build status, list builds, download logs/artifacts'); + console.log(' cancel Cancel a running build'); + console.log(''); + console.log('Options:'); + console.log(' --help Show help for a command'); + console.log(' --tests Run the test suite'); + console.log(''); + console.log('Examples:'); + console.log(` ${scriptName} queue # Queue build on current branch`); + console.log(` ${scriptName} status # List recent builds`); + console.log(` ${scriptName} status --build-id 123456 # Get build details`); + console.log(` ${scriptName} cancel --build-id 123456 # Cancel a build`); + console.log(` ${scriptName} --tests # Run test suite`); + console.log(''); + console.log('Run any command with --help for detailed usage.'); +} + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + printMainUsage(); + process.exit(0); + } + + if (args[0] === '--tests') { + await runAllTests(); + return; + } + + const command = args[0]; + const commandArgs = args.slice(1); + + switch (command) { + case 'queue': + await runQueueCommand(commandArgs); + break; + case 'status': + await runStatusCommand(commandArgs); + break; + case 'cancel': + await runCancelCommand(commandArgs); + break; + default: + console.error(colors.red(`Error: Unknown command: ${command}`)); + console.log(''); + printMainUsage(); + process.exit(1); + } +} + +main(); diff --git a/.vscode/settings.json b/.vscode/settings.json index 7307607bef73f..f65efbf06ee50 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,9 @@ "scripts/test-integration.bat": true, "scripts/test-integration.sh": true, }, + "chat.tools.edits.autoApprove": { + ".github/skills/azure-pipelines/azure-pipeline.ts": false + }, "chat.viewSessions.enabled": true, "chat.editing.explainChanges.enabled": true, // --- Editor --- From a67c466ac5f2c2cd6df5fd3284b517c55704eea8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:36:04 +0000 Subject: [PATCH 24/42] Remove context window usage tip (#296070) --- .../workbench/contrib/chat/browser/chatTipService.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 3a5d17812a83c..e5c133f81761c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -240,17 +240,6 @@ const TIP_CATALOG: ITipDefinition[] = [ when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['runSubagent'], }, - { - id: 'tip.contextUsage', - message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are used and what's consuming them."), - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ChatContextKeys.contextUsageHasBeenOpened.negate(), - ChatContextKeys.chatSessionIsEmpty.negate(), - ), - enabledCommands: ['workbench.action.chat.showContextUsage'], - excludeWhenCommandsExecuted: ['workbench.action.chat.showContextUsage'], - }, { id: 'tip.sendToNewChat', message: localize('tip.sendToNewChat', "Tip: Use [Send to New Chat](command:workbench.action.chat.sendToNewChat) to start a new conversation with a clean context window."), From 9cd04d0a594533cad396e27cd99fc3fa7c407018 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:37:10 -0800 Subject: [PATCH 25/42] Polish action widget styling (#296074) * Polish action widget styling - Use list hover colors instead of active selection for focused items - Use descriptionForeground and 12px font for description text - Update border radius to use CSS variables * rm dead rule --- .../platform/actionWidget/browser/actionWidget.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 4ea3a49bff7db..b2aaa8b1e00f7 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -5,14 +5,13 @@ .action-widget { font-size: 13px; - border-radius: 0; min-width: 100px; max-width: 80vw; z-index: 40; display: block; width: 100%; border: 1px solid var(--vscode-editorHoverWidget-border) !important; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-menu-background); color: var(--vscode-menu-foreground); padding: 4px; @@ -61,12 +60,12 @@ cursor: pointer; touch-action: none; width: 100%; - border-radius: var(--vscode-cornerRadius-small); + border-radius: var(--vscode-cornerRadius-medium); } .action-widget .monaco-list .monaco-list-row.action.focused:not(.option-disabled) { - background-color: var(--vscode-list-activeSelectionBackground) !important; - color: var(--vscode-list-activeSelectionForeground); + background-color: var(--vscode-list-hoverBackground) !important; + color: var(--vscode-list-hoverForeground); outline: 1px solid var(--vscode-menu-selectionBorder, transparent); outline-offset: -1px; } @@ -203,8 +202,9 @@ } .action-widget .monaco-list .monaco-list-row .description { - opacity: 0.7; + color: var(--vscode-descriptionForeground); margin-left: 0.5em; + font-size: 12px; } /* Item toolbar - shows on hover/focus */ From 67c89aa5fe0fb9834f4f3ab9f87836f341c669fb Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 18 Feb 2026 13:37:31 -0600 Subject: [PATCH 26/42] fix tip dismissal (#296058) --- .../contrib/chat/browser/chatTipService.ts | 126 +++++++++++------- .../chatContentParts/chatTipContentPart.ts | 24 +++- .../contrib/chat/browser/widget/chatWidget.ts | 1 - .../chat/test/browser/chatTipService.test.ts | 115 ++++++++++++++++ 4 files changed, 212 insertions(+), 54 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index e5c133f81761c..d5b32bea07591 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -67,7 +67,7 @@ export interface IChatTipService { /** * Dismisses the current tip and allows a new one to be picked for the same request. - * The dismissed tip will not be shown again in this profile. + * The dismissed tip will not be shown again for this user on this application installation. */ dismissTip(): void; @@ -84,15 +84,13 @@ export interface IChatTipService { /** * Navigates to the next tip in the catalog without permanently dismissing the current one. - * @param contextKeyService The context key service to evaluate tip eligibility. */ - navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined; + navigateToNextTip(): IChatTip | undefined; /** * Navigates to the previous tip in the catalog without permanently dismissing the current one. - * @param contextKeyService The context key service to evaluate tip eligibility. */ - navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined; + navigateToPreviousTip(): IChatTip | undefined; /** * Clears all dismissed tips so they can be shown again. @@ -250,8 +248,8 @@ const TIP_CATALOG: ITipDefinition[] = [ ]; /** - * Tracks workspace-level signals that determine whether certain tips should be - * excluded. Persists state to workspace storage and disposes listeners once all + * Tracks user-level signals that determine whether certain tips should be + * excluded. Persists state to application storage and disposes listeners once all * signals of interest have been observed. */ export class TipEligibilityTracker extends Disposable { @@ -295,13 +293,13 @@ export class TipEligibilityTracker extends Disposable { // --- Restore persisted state ------------------------------------------- - const storedCmds = this._storageService.get(TipEligibilityTracker._COMMANDS_STORAGE_KEY, StorageScope.WORKSPACE); + const storedCmds = this._readApplicationWithProfileFallback(TipEligibilityTracker._COMMANDS_STORAGE_KEY); this._executedCommands = new Set(storedCmds ? JSON.parse(storedCmds) : []); - const storedModes = this._storageService.get(TipEligibilityTracker._MODES_STORAGE_KEY, StorageScope.WORKSPACE); + const storedModes = this._readApplicationWithProfileFallback(TipEligibilityTracker._MODES_STORAGE_KEY); this._usedModes = new Set(storedModes ? JSON.parse(storedModes) : []); - const storedTools = this._storageService.get(TipEligibilityTracker._TOOLS_STORAGE_KEY, StorageScope.WORKSPACE); + const storedTools = this._readApplicationWithProfileFallback(TipEligibilityTracker._TOOLS_STORAGE_KEY); this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); // --- Derive what still needs tracking ---------------------------------- @@ -487,7 +485,21 @@ export class TipEligibilityTracker extends Disposable { } private _persistSet(key: string, set: Set): void { - this._storageService.store(key, JSON.stringify([...set]), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._storageService.store(key, JSON.stringify([...set]), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private _readApplicationWithProfileFallback(key: string): string | undefined { + const applicationValue = this._storageService.get(key, StorageScope.APPLICATION); + if (applicationValue) { + return applicationValue; + } + + const profileValue = this._storageService.get(key, StorageScope.PROFILE); + if (profileValue) { + this._storageService.store(key, profileValue, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + return profileValue; } } @@ -516,6 +528,13 @@ export class ChatTipService extends Disposable implements IChatTipService { */ private _shownTip: ITipDefinition | undefined; + /** + * The scoped context key service from the chat widget, stored when + * {@link getWelcomeTip} is first called so that navigation methods + * can evaluate when-clause eligibility against the correct context. + */ + private _contextKeyService: IContextKeyService | undefined; + private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; private static readonly _LAST_TIP_ID_KEY = 'chat.tip.lastTipId'; private readonly _tracker: TipEligibilityTracker; @@ -534,28 +553,32 @@ export class ChatTipService extends Disposable implements IChatTipService { resetSession(): void { this._shownTip = undefined; this._tipRequestId = undefined; + this._contextKeyService = undefined; } dismissTip(): void { if (this._shownTip) { - const dismissed = this._getDismissedTipIds(); - dismissed.push(this._shownTip.id); - this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.PROFILE, StorageTarget.MACHINE); + const dismissed = new Set(this._getDismissedTipIds()); + dismissed.add(this._shownTip.id); + this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify([...dismissed]), StorageScope.APPLICATION, StorageTarget.MACHINE); } - this._shownTip = undefined; + // Keep the current tip reference so callers can navigate relative to it + // (for example, dismiss -> next should mirror next/previous behavior). this._tipRequestId = undefined; this._onDidDismissTip.fire(); } clearDismissedTips(): void { + this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.APPLICATION); this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); this._shownTip = undefined; this._tipRequestId = undefined; + this._contextKeyService = undefined; this._onDidDismissTip.fire(); } private _getDismissedTipIds(): string[] { - const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); + const raw = this._readApplicationWithProfileFallback(ChatTipService._DISMISSED_TIP_KEY); if (!raw) { return []; } @@ -566,14 +589,15 @@ export class ChatTipService extends Disposable implements IChatTipService { return []; } - // Safety valve: if every known tip has been dismissed (for example, due to a - // past bug that dismissed the current tip on every new session), treat this - // as "no tips dismissed" so the feature can recover. - if (parsed.length >= TIP_CATALOG.length) { - return []; + const knownTipIds = new Set(TIP_CATALOG.map(tip => tip.id)); + const dismissed = new Set(); + for (const value of parsed) { + if (typeof value === 'string' && knownTipIds.has(value)) { + dismissed.add(value); + } } - return parsed; + return [...dismissed]; } catch { return []; } @@ -598,6 +622,9 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Store the scoped context key service for later navigation calls + this._contextKeyService = contextKeyService; + // Only show tips for Copilot if (!this._isCopilotEnabled()) { return undefined; @@ -619,7 +646,7 @@ export class ChatTipService extends Disposable implements IChatTipService { const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); if (nextTip) { this._shownTip = nextTip; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, nextTip.id, StorageScope.PROFILE, StorageTarget.USER); + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, nextTip.id, StorageScope.APPLICATION, StorageTarget.USER); const tip = this._createTip(nextTip); this._onDidNavigateTip.fire(tip); return tip; @@ -659,7 +686,7 @@ export class ChatTipService extends Disposable implements IChatTipService { let selectedTip: ITipDefinition | undefined; // Determine where to start in the catalog based on the last-shown tip. - const lastTipId = this._storageService.get(ChatTipService._LAST_TIP_ID_KEY, StorageScope.PROFILE); + const lastTipId = this._readApplicationWithProfileFallback(ChatTipService._LAST_TIP_ID_KEY); const lastCatalogIndex = lastTipId ? TIP_CATALOG.findIndex(tip => tip.id === lastTipId) : -1; const startIndex = lastCatalogIndex === -1 ? 0 : (lastCatalogIndex + 1) % TIP_CATALOG.length; @@ -674,28 +701,12 @@ export class ChatTipService extends Disposable implements IChatTipService { } } - // Pass 2: if everything was ineligible (e.g., user has already done all - // the suggested actions), still advance through the catalog but only skip - // tips that were explicitly dismissed. if (!selectedTip) { - for (let i = 0; i < TIP_CATALOG.length; i++) { - const idx = (startIndex + i) % TIP_CATALOG.length; - const candidate = TIP_CATALOG[idx]; - if (!dismissedIds.has(candidate.id)) { - selectedTip = candidate; - break; - } - } - } - - // Final fallback: if even that fails (all tips dismissed), stick with the - // catalog order so rotation still progresses. - if (!selectedTip) { - selectedTip = TIP_CATALOG[startIndex]; + return undefined; } // Persist the selected tip id so the next use advances to the following one. - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.PROFILE, StorageTarget.USER); + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.APPLICATION, StorageTarget.USER); // Record that we've shown a tip this session this._tipRequestId = sourceId; @@ -704,12 +715,18 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._createTip(selectedTip); } - navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined { - return this._navigateTip(1, contextKeyService); + navigateToNextTip(): IChatTip | undefined { + if (!this._contextKeyService) { + return undefined; + } + return this._navigateTip(1, this._contextKeyService); } - navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined { - return this._navigateTip(-1, contextKeyService); + navigateToPreviousTip(): IChatTip | undefined { + if (!this._contextKeyService) { + return undefined; + } + return this._navigateTip(-1, this._contextKeyService); } private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { @@ -728,7 +745,8 @@ export class ChatTipService extends Disposable implements IChatTipService { const candidate = TIP_CATALOG[idx]; if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { this._shownTip = candidate; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.PROFILE, StorageTarget.USER); + this._tipRequestId = 'welcome'; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); const tip = this._createTip(candidate); this._onDidNavigateTip.fire(tip); return tip; @@ -817,4 +835,18 @@ export class ChatTipService extends Disposable implements IChatTipService { enabledCommands: tipDef.enabledCommands, }; } + + private _readApplicationWithProfileFallback(key: string): string | undefined { + const applicationValue = this._storageService.get(key, StorageScope.APPLICATION); + if (applicationValue) { + return applicationValue; + } + + const profileValue = this._storageService.get(key, StorageScope.PROFILE); + if (profileValue) { + this._storageService.store(key, profileValue, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + return profileValue; + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 256999f36bd9b..6554db3c1489e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -40,7 +40,6 @@ export class ChatTipContentPart extends Disposable { constructor( tip: IChatTip, private readonly _renderer: IMarkdownRenderer, - private readonly _getNextTip: () => IChatTip | undefined, @IChatTipService private readonly _chatTipService: IChatTipService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IMenuService private readonly _menuService: IMenuService, @@ -63,7 +62,7 @@ export class ChatTipContentPart extends Disposable { this._renderTip(tip); this._register(this._chatTipService.onDidDismissTip(() => { - const nextTip = this._getNextTip(); + const nextTip = this._chatTipService.navigateToNextTip(); if (nextTip) { this._renderTip(nextTip); dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); @@ -152,8 +151,7 @@ registerAction2(class PreviousTipAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const chatTipService = accessor.get(IChatTipService); - const contextKeyService = accessor.get(IContextKeyService); - chatTipService.navigateToPreviousTip(contextKeyService); + chatTipService.navigateToPreviousTip(); } }); @@ -174,8 +172,7 @@ registerAction2(class NextTipAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const chatTipService = accessor.get(IChatTipService); - const contextKeyService = accessor.get(IContextKeyService); - chatTipService.navigateToNextTip(contextKeyService); + chatTipService.navigateToNextTip(); } }); @@ -275,4 +272,19 @@ registerAction2(class DisableTipsAction extends Action2 { } }); +registerAction2(class ResetDismissedTipsAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.resetDismissedTips', + title: localize2('chatTip.resetDismissedTips', "Reset Dismissed Tips"), + f1: true, + precondition: ChatContextKeys.enabled, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).clearDismissedTips(); + } +}); + //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 5caa093f45d0f..28c0fc83cc611 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1006,7 +1006,6 @@ export class ChatWidget extends Disposable implements IChatWidget { const tipPart = store.add(this.instantiationService.createInstance(ChatTipContentPart, tip, renderer, - () => this.chatTipService.getWelcomeTip(this.contextKeyService), )); tipContainer.appendChild(tipPart.domNode); this._gettingStartedTipPartRef = tipPart; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 5ee2250a37324..3f1eeb6b1e83c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -227,6 +227,20 @@ suite('ChatTipService', () => { } }); + test('dismissTip keeps navigation context for next tip traversal', () => { + const service = createService(); + + const tip1 = service.getWelcomeTip(contextKeyService); + assert.ok(tip1); + + service.dismissTip(); + + const tip2 = service.navigateToNextTip(); + if (tip2) { + assert.notStrictEqual(tip1.id, tip2.id, 'Dismissed tip should not be returned by next navigation'); + } + }); + test('dismissTip fires onDidDismissTip event', () => { const service = createService(); @@ -279,6 +293,62 @@ suite('ChatTipService', () => { assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); }); + test('dismissed tips stay dismissed after disabling and re-enabling tips', async () => { + const service = createService(); + + // Flush microtask queue so async file-check exclusions resolve before + // we start dismissing tips (otherwise excludeUntilChecked tips are + // temporarily excluded and never get dismissed in the loop below). + await new Promise(r => queueMicrotask(r)); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + + service.dismissTip(); + } + + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'No tip should remain once all tips are dismissed'); + + await service.disableTips(); + configurationService.setUserConfiguration('chat.tips.enabled', true); + + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'Dismissed tips should remain dismissed after re-enabling tips'); + }); + + test('clearDismissedTips restores tip visibility', () => { + const service = createService(); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + + service.dismissTip(); + } + + assert.strictEqual(service.getWelcomeTip(contextKeyService), undefined, 'No tip should remain once all tips are dismissed'); + + service.clearDismissedTips(); + + assert.ok(service.getWelcomeTip(contextKeyService), 'A tip should be visible again after clearing dismissed tips'); + }); + + test('migrates dismissed tips from profile to application storage', () => { + storageService.store('chat.tip.dismissed', JSON.stringify(['tip.switchToAuto']), StorageScope.PROFILE, StorageTarget.MACHINE); + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'gpt-4.1'); + + const tip = service.getWelcomeTip(contextKeyService); + + assert.ok(tip); + assert.notStrictEqual(tip.id, 'tip.switchToAuto', 'Should honor profile-stored dismissed tip id'); + assert.ok(storageService.get('chat.tip.dismissed', StorageScope.APPLICATION), 'Expected dismissed tips to migrate to application storage'); + }); + function createMockPromptsService( agentInstructions: IResolvedAgentFile[] = [], promptInstructions: IPromptPath[] = [], @@ -318,6 +388,51 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after command is executed'); }); + test('persists executed command exclusions in application storage', () => { + const tip: ITipDefinition = { + id: 'tip.undoChanges', + message: 'test', + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], + }; + + testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); + + assert.ok(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION), 'Expected executed command exclusions in application storage'); + assert.strictEqual(storageService.get('chat.tips.executedCommands', StorageScope.PROFILE), undefined, 'Did not expect executed command exclusions in profile storage'); + assert.strictEqual(storageService.get('chat.tips.executedCommands', StorageScope.WORKSPACE), undefined, 'Did not expect executed command exclusions in workspace storage'); + }); + + test('migrates executed command exclusions from profile to application storage', () => { + const tip: ITipDefinition = { + id: 'tip.undoChanges', + message: 'test', + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], + }; + + storageService.store('chat.tips.executedCommands', JSON.stringify(['workbench.action.chat.restoreCheckpoint']), StorageScope.PROFILE, StorageTarget.MACHINE); + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should honor profile-stored exclusions'); + assert.ok(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION), 'Expected migrated exclusion data in application storage'); + }); + test('excludes tip.customInstructions when copilot-instructions.md exists in workspace', async () => { const tip: ITipDefinition = { id: 'tip.customInstructions', From 942612aa6f48f9aedaf590a1fea329203f91fca7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:47:33 +0000 Subject: [PATCH 27/42] Remove modal dialog when disabling chat tips (#296068) * Initial plan * Remove modal dialog when disabling chat tips Replace the modal confirmation dialog with a direct action that disables tips and opens the settings editor focused on `chat.tips.enabled`. This follows VS Code UX guidelines that modal dialogs should only be used for data loss or big irreversible changes. Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> * improve descr --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> Co-authored-by: meganrogge --- .../contrib/chat/browser/chat.contribution.ts | 2 +- .../chatContentParts/chatTipContentPart.ts | 31 ++----------------- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index cd8b77b0a0c85..ff6113ad3bc49 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -284,7 +284,7 @@ configurationRegistry.registerConfiguration({ 'chat.tips.enabled': { type: 'boolean', scope: ConfigurationScope.APPLICATION, - description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. This is an experimental feature."), + description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. New tips are added frequently, so this is a helpful way to stay up to date with the latest features."), default: false, tags: ['experimental'], experiment: { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 6554db3c1489e..f8be5ed5ebbee 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -10,7 +10,6 @@ import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabe import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; @@ -18,7 +17,6 @@ import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../. import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; @@ -239,36 +237,11 @@ registerAction2(class DisableTipsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - const dialogService = accessor.get(IDialogService); const chatTipService = accessor.get(IChatTipService); const commandService = accessor.get(ICommandService); - const { result } = await dialogService.prompt({ - message: localize('chatTip.disableConfirmTitle', "Disable tips?"), - custom: { - markdownDetails: [{ - markdown: new MarkdownString(localize('chatTip.disableConfirmDetail', "New tips are added frequently to help you get the most out of Copilot. You can re-enable tips anytime from the `chat.tips.enabled` setting.")), - }], - }, - buttons: [ - { - label: localize('chatTip.disableConfirmButton', "Disable tips"), - run: () => true, - }, - { - label: localize('chatTip.openSettingButton', "Open Setting"), - run: () => { - commandService.executeCommand('workbench.action.openSettings', 'chat.tips.enabled'); - return false; - }, - }, - ], - cancelButton: true, - }); - - if (result) { - await chatTipService.disableTips(); - } + await chatTipService.disableTips(); + await commandService.executeCommand('workbench.action.openSettings', 'chat.tips.enabled'); } }); From 29866e5313ee3bd212c2a12b6177e7955c1ce39e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Wed, 18 Feb 2026 21:05:43 +0100 Subject: [PATCH 28/42] improve emergency alert banner update logic to avoid unnecessary updates when message and actions didn't change (#296081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Moreno <22350+joaomoreno@users.noreply.github.com> --- .../emergencyAlert.contribution.ts | 67 +++++++++++++------ .../services/banner/browser/bannerService.ts | 2 +- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts b/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts index 34995c7789839..f8e3072a4743e 100644 --- a/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts +++ b/src/vs/workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.ts @@ -12,6 +12,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { arch, platform } from '../../../../base/common/process.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { equals } from '../../../../base/common/arrays.js'; import { IntervalTimer } from '../../../../base/common/async.js'; import { mainWindow } from '../../../../base/browser/window.js'; @@ -20,10 +21,10 @@ interface IEmergencyAlert { readonly platform?: string; readonly arch?: string; readonly message: string; - readonly actions?: [{ + readonly actions?: ReadonlyArray<{ readonly label: string; readonly href: string; - }]; + }>; } interface IEmergencyAlerts { @@ -37,7 +38,8 @@ export class EmergencyAlert extends Disposable implements IWorkbenchContribution static readonly ID = 'workbench.contrib.emergencyAlert'; - private readonly pollingTimer = this._register(new IntervalTimer()); + private currentAlertMessage: string | undefined; + private currentAlertActions: IEmergencyAlert['actions'] | undefined; constructor( @IBannerService private readonly bannerService: IBannerService, @@ -53,7 +55,9 @@ export class EmergencyAlert extends Disposable implements IWorkbenchContribution } this.fetchAlerts(emergencyAlertUrl); - this.pollingTimer.cancelAndSet(() => this.fetchAlerts(emergencyAlertUrl), POLLING_INTERVAL, mainWindow); + + const pollingTimer = this._register(new IntervalTimer()); + pollingTimer.cancelAndSet(() => this.fetchAlerts(emergencyAlertUrl), POLLING_INTERVAL, mainWindow); } private async fetchAlerts(url: string): Promise { @@ -72,30 +76,49 @@ export class EmergencyAlert extends Disposable implements IWorkbenchContribution } const emergencyAlerts = await asJson(requestResult); - if (!emergencyAlerts) { + if (!emergencyAlerts || !Array.isArray(emergencyAlerts.alerts)) { + this.dismissAlert(); + return; + } + + // Find the first matching alert + const matchingAlert = emergencyAlerts.alerts.find(alert => + alert.commit === this.productService.commit && + (!alert.platform || alert.platform === platform) && + (!alert.arch || alert.arch === arch) + ); + + if (!matchingAlert) { + // No matching alert, dismiss the banner if it was shown + this.dismissAlert(); return; } - for (const emergencyAlert of emergencyAlerts.alerts) { - if ( - (emergencyAlert.commit !== this.productService.commit) || // version mismatch - (emergencyAlert.platform && emergencyAlert.platform !== platform) || // platform mismatch - (emergencyAlert.arch && emergencyAlert.arch !== arch) // arch mismatch - ) { - return; - } + // Don't update the banner if message and actions didn't change + if ( + this.currentAlertMessage === matchingAlert.message && + equals(this.currentAlertActions ?? [], matchingAlert.actions ?? [], (a, b) => a.label === b.label && a.href === b.href) + ) { + return; + } + + this.currentAlertMessage = matchingAlert.message; + this.currentAlertActions = matchingAlert.actions; + this.bannerService.show({ + id: BANNER_ID, + icon: Codicon.warning, + message: matchingAlert.message, + actions: matchingAlert.actions + }); + } + private dismissAlert(): void { + if (this.currentAlertMessage !== undefined) { + this.currentAlertMessage = undefined; + this.currentAlertActions = undefined; this.bannerService.hide(BANNER_ID); - this.bannerService.show({ - id: BANNER_ID, - icon: Codicon.warning, - message: emergencyAlert.message, - actions: emergencyAlert.actions - }); - - break; } } } -registerWorkbenchContribution2('workbench.emergencyAlert', EmergencyAlert, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(EmergencyAlert.ID, EmergencyAlert, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/services/banner/browser/bannerService.ts b/src/vs/workbench/services/banner/browser/bannerService.ts index 2db0fa42104e3..9c0e9b566540d 100644 --- a/src/vs/workbench/services/banner/browser/bannerService.ts +++ b/src/vs/workbench/services/banner/browser/bannerService.ts @@ -13,7 +13,7 @@ export interface IBannerItem { readonly id: string; readonly icon: ThemeIcon | URI | undefined; readonly message: string | MarkdownString; - readonly actions?: ILinkDescriptor[]; + readonly actions?: ReadonlyArray; readonly ariaLabel?: string; readonly onClose?: () => void; readonly closeLabel?: string; From 9b58579458b97297ab5bd57d20680f1f798faeb7 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 18 Feb 2026 21:06:04 +0100 Subject: [PATCH 29/42] Revert "sessions - show count for all groups (fix #291606)" (#296086) Revert "sessions - show count for all groups (fix #291606) (#295992)" This reverts commit 0a40ab4cce417f1013fc8bed5346be38d1c7434b. --- .../agentSessions/agentSessionsViewer.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index daabea59fff01..bcc94cad564c3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -758,19 +758,13 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map ({ - section, - label: localize('agentSessions.sectionWithCount', "{0} ({1})", AgentSessionSectionLabels[section], sessions.length), - sessions - }); - return new Map([ - [AgentSessionSection.InProgress, sectionWithCount(AgentSessionSection.InProgress, inProgressSessions)], - [AgentSessionSection.Today, sectionWithCount(AgentSessionSection.Today, todaySessions)], - [AgentSessionSection.Yesterday, sectionWithCount(AgentSessionSection.Yesterday, yesterdaySessions)], - [AgentSessionSection.Week, sectionWithCount(AgentSessionSection.Week, weekSessions)], - [AgentSessionSection.Older, sectionWithCount(AgentSessionSection.Older, olderSessions)], - [AgentSessionSection.Archived, sectionWithCount(AgentSessionSection.Archived, archivedSessions)], + [AgentSessionSection.InProgress, { section: AgentSessionSection.InProgress, label: AgentSessionSectionLabels[AgentSessionSection.InProgress], sessions: inProgressSessions }], + [AgentSessionSection.Today, { section: AgentSessionSection.Today, label: AgentSessionSectionLabels[AgentSessionSection.Today], sessions: todaySessions }], + [AgentSessionSection.Yesterday, { section: AgentSessionSection.Yesterday, label: AgentSessionSectionLabels[AgentSessionSection.Yesterday], sessions: yesterdaySessions }], + [AgentSessionSection.Week, { section: AgentSessionSection.Week, label: AgentSessionSectionLabels[AgentSessionSection.Week], sessions: weekSessions }], + [AgentSessionSection.Older, { section: AgentSessionSection.Older, label: AgentSessionSectionLabels[AgentSessionSection.Older], sessions: olderSessions }], + [AgentSessionSection.Archived, { section: AgentSessionSection.Archived, label: localize('agentSessions.archivedSectionWithCount', "Archived ({0})", archivedSessions.length), sessions: archivedSessions }], ]); } From f1eb6bb5f4383a2473c7ebc27d5767023de33b40 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 18 Feb 2026 12:13:09 -0800 Subject: [PATCH 30/42] Don't sync the tool picker with other sessions started in the same widget Fix #293002 --- .../contrib/chat/browser/widget/chatWidget.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 28c0fc83cc611..01123496922e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -21,7 +21,7 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable, thenIfNotD import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { filter } from '../../../../../base/common/objects.js'; -import { autorun, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { basename, extUri, isEqual } from '../../../../../base/common/resources.js'; import { MicrotaskDelay } from '../../../../../base/common/symbols.js'; import { isDefined } from '../../../../../base/common/types.js'; @@ -323,6 +323,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } private readonly _editingSession = observableValue(this, undefined); + private readonly _viewModelObs = observableFromEvent(this, this.onDidChangeViewModel, () => this.viewModel); private parsedChatRequest: IParsedChatRequest | undefined; get parsedInput() { @@ -405,7 +406,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewContext = viewContext ?? {}; - const viewModelObs = observableFromEvent(this, this.onDidChangeViewModel, () => this.viewModel); + const viewModelObs = this._viewModelObs; if (typeof location === 'object') { this._location = location; @@ -2510,9 +2511,26 @@ export class ChatWidget extends Disposable implements IChatWidget { } getModeRequestOptions(): Partial { + const sessionResource = this.viewModel?.sessionResource; + const userSelectedTools = this.input.selectedToolsModel.userSelectedTools; + + let lastToolsSnapshot = userSelectedTools.get(); + + // When the widget has loaded a new session, return a snapshot of the tools for this session. + // Only sync with the tools model when this session is shown. + const scopedTools = derived(reader => { + const activeSession = this._viewModelObs.read(reader)?.sessionResource; + if (isEqual(activeSession, sessionResource)) { + const tools = userSelectedTools.read(reader); + lastToolsSnapshot = tools; + return tools; + } + return lastToolsSnapshot; + }); + return { modeInfo: this.input.currentModeInfo, - userSelectedTools: this.input.selectedToolsModel.userSelectedTools, + userSelectedTools: scopedTools, }; } From 8eef7de16d7d4b33c7c7c67555fbe259fb21f11f Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Wed, 18 Feb 2026 21:22:19 +0100 Subject: [PATCH 31/42] Revert "adding inline completions in the chat input" (#296085) --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index cf9f35ca48c64..92e2c35e184d7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -126,7 +126,6 @@ import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js' import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; import { Target } from '../../../common/promptSyntax/service/promptsService.js'; -import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; const $ = dom.$; @@ -2074,7 +2073,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -2288,7 +2287,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let inputModel = this.modelService.getModel(this.inputUri); if (!inputModel) { - inputModel = this.modelService.createModel('', null, this.inputUri, false); + inputModel = this.modelService.createModel('', null, this.inputUri, true); } this.textModelResolverService.createModelReference(this.inputUri).then(ref => { From befc4f7bd842a88b0a95c8cae480d441617fbdae Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:36:41 +0000 Subject: [PATCH 32/42] Deduplicate identical markers from different owners in Problems panel (#295774) --- .../contrib/markers/browser/markersModel.ts | 22 +++++++++----- .../markers/test/browser/markersModel.test.ts | 30 +++++++++++++++++-- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/markers/browser/markersModel.ts b/src/vs/workbench/contrib/markers/browser/markersModel.ts index 91c033d4e187a..5dbf26eea826c 100644 --- a/src/vs/workbench/contrib/markers/browser/markersModel.ts +++ b/src/vs/workbench/contrib/markers/browser/markersModel.ts @@ -205,21 +205,27 @@ export class MarkersModel { } else { change.updated.add(resourceMarkers); } - const markersCountByKey = new Map(); - const markers = rawMarkers.map((rawMarker) => { - const key = IMarkerData.makeKey(rawMarker); - const index = markersCountByKey.get(key) || 0; - markersCountByKey.set(key, index + 1); + // Deduplicate markers with identical source, code, severity, message + // and range so that a diagnostic reported by both a task problem + // matcher and a language extension is only shown once (#244424). + const processedMarkerKeys = new Set(); + const markers: Marker[] = []; + for (const rawMarker of rawMarkers) { + const markerKey = IMarkerData.makeKey(rawMarker) + rawMarker.resource.toString(); + if (processedMarkerKeys.has(markerKey)) { + continue; + } + processedMarkerKeys.add(markerKey); - const markerId = this.id(resourceMarkers!.id, key, index, rawMarker.resource.toString()); + const markerId = this.id(resourceMarkers!.id, markerKey, 0, rawMarker.resource.toString()); let relatedInformation: RelatedInformation[] | undefined = undefined; if (rawMarker.relatedInformation) { relatedInformation = rawMarker.relatedInformation.map((r, index) => new RelatedInformation(this.id(markerId, r.resource.toString(), r.startLineNumber, r.startColumn, r.endLineNumber, r.endColumn, index), rawMarker, r)); } - return new Marker(markerId, rawMarker, relatedInformation); - }); + markers.push(new Marker(markerId, rawMarker, relatedInformation)); + } this._total -= resourceMarkers.total; resourceMarkers.set(resource, markers); diff --git a/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts b/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts index 48596e960b4db..b0eb8c858dcfb 100644 --- a/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts +++ b/src/vs/workbench/contrib/markers/test/browser/markersModel.test.ts @@ -34,13 +34,14 @@ suite('MarkersModel Test', () => { test('marker ids are unique', function () { const marker1 = anErrorWithRange(3); - const marker2 = anErrorWithRange(3); + const marker2 = anErrorWithRange(3, 5, 4, 10, 'different message'); const marker3 = aWarningWithRange(3); - const marker4 = aWarningWithRange(3); + const marker4 = aWarningWithRange(5); const testObject = new TestMarkersModel([marker1, marker2, marker3, marker4]); const actuals = testObject.resourceMarkers[0].markers; + assert.strictEqual(actuals.length, 4); assert.notStrictEqual(actuals[0].id, actuals[1].id); assert.notStrictEqual(actuals[0].id, actuals[2].id); assert.notStrictEqual(actuals[0].id, actuals[3].id); @@ -49,6 +50,31 @@ suite('MarkersModel Test', () => { assert.notStrictEqual(actuals[2].id, actuals[3].id); }); + test('duplicate markers from different owners are deduplicated', function () { + // Simulate a task problem matcher and language extension both reporting + // the same diagnostic for the same file (#244424). + const taskMarker = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'some message', 'eslint'); + taskMarker.owner = 'taskOwner'; + const extensionMarker = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'some message', 'eslint'); + extensionMarker.owner = 'extensionOwner'; + + const testObject = new TestMarkersModel([taskMarker, extensionMarker]); + const actuals = testObject.resourceMarkers[0].markers; + + assert.strictEqual(actuals.length, 1, 'identical markers from different owners should be deduplicated'); + assert.strictEqual(actuals[0].marker.message, 'some message'); + }); + + test('markers with different messages are not deduplicated', function () { + const marker1 = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'message without period', 'eslint'); + const marker2 = aMarker('some resource', MarkerSeverity.Error, 10, 5, 11, 10, 'message with period.', 'eslint'); + + const testObject = new TestMarkersModel([marker1, marker2]); + const actuals = testObject.resourceMarkers[0].markers; + + assert.strictEqual(actuals.length, 2, 'markers with different messages should not be deduplicated'); + }); + test('sort palces resources with no errors at the end', function () { const marker1 = aMarker('a/res1', MarkerSeverity.Warning); const marker2 = aMarker('a/res2'); From 4f18509dfb5f20aa85d9abe18f1aafd08cdcf21a Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 18 Feb 2026 21:45:01 +0100 Subject: [PATCH 33/42] Revert "sessions - remove CLI support (not used)" (#296088) * Revert "sessions - remove CLI support (not used) (#295801)" This reverts commit ebeb2e6828fbd510da159ec078ae7717850f946a. * reduce change --- src/vs/code/electron-main/app.ts | 4 ++-- src/vs/platform/environment/common/argv.ts | 1 + src/vs/platform/environment/node/argv.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index a055db7e3dde8..f80b86e9a8c51 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -1296,8 +1296,8 @@ export class CodeApplication extends Disposable { const context = isLaunchedFromCli(process.env) ? OpenContext.CLI : OpenContext.DESKTOP; const args = this.environmentMainService.args; - // Embedded app launches directly into the sessions window - if ((process as INodeProcess).isEmbeddedApp) { + // Handle sessions window first based on context + if ((process as INodeProcess).isEmbeddedApp || (args['sessions'] && this.productService.quality !== 'stable')) { return windowsMainService.openSessionsWindow({ context, contextWindowId: undefined }); } diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index a10f4c9b3bbc5..45cb23ba6f3f7 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -53,6 +53,7 @@ export interface NativeParsedArgs { goto?: boolean; 'new-window'?: boolean; 'reuse-window'?: boolean; + 'sessions'?: boolean; locale?: string; 'user-data-dir'?: string; 'prof-startup'?: boolean; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 35a833d5f903d..6d00ad0ae0908 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -99,6 +99,7 @@ export const OPTIONS: OptionDescriptions> = { 'goto': { type: 'boolean', cat: 'o', alias: 'g', args: 'file:line[:character]', description: localize('goto', "Open a file at the path on the specified line and character position.") }, 'new-window': { type: 'boolean', cat: 'o', alias: 'n', description: localize('newWindow', "Force to open a new window.") }, 'reuse-window': { type: 'boolean', cat: 'o', alias: 'r', description: localize('reuseWindow', "Force to open a file or folder in an already opened window.") }, + 'sessions': { type: 'boolean', cat: 'o', description: localize('sessions', "Opens the sessions window.") }, 'wait': { type: 'boolean', cat: 'o', alias: 'w', description: localize('wait', "Wait for the files to be closed before returning.") }, 'waitMarkerFilePath': { type: 'string' }, 'locale': { type: 'string', cat: 'o', args: 'locale', description: localize('locale', "The locale to use (e.g. en-US or zh-TW).") }, From 8e30c44fb7ac1aa12b09de0b8beac501748cfdbf Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 18 Feb 2026 14:56:16 -0600 Subject: [PATCH 34/42] hide tip on quota reaching zero (#296092) fix #295868 --- src/vs/workbench/contrib/chat/browser/chatTipService.ts | 8 ++++++++ .../contrib/chat/test/browser/chatTipService.test.ts | 3 +++ 2 files changed, 11 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index d5b32bea07591..a8f62d9bdc674 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -21,6 +21,7 @@ import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { localChatSessionType } from '../common/chatSessionsService.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -545,9 +546,16 @@ export class ChatTipService extends Disposable implements IChatTipService { @IStorageService private readonly _storageService: IStorageService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, + @IChatEntitlementService chatEntitlementService: IChatEntitlementService, ) { super(); this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); + + this._register(chatEntitlementService.onDidChangeQuotaExceeded(() => { + if (chatEntitlementService.quotas.chat?.percentRemaining === 0 && this._shownTip) { + this.hideTip(); + } + })); } resetSession(): void { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 3f1eeb6b1e83c..8b8147438b8a7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -23,6 +23,8 @@ import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { TestChatEntitlementService } from '../../../../test/common/workbenchTestServices.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { override contextMatchesRules(): boolean { @@ -89,6 +91,7 @@ suite('ChatTipService', () => { onDidChangeCustomAgents: Event.None, } as Partial as IPromptsService); instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); + instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); }); test('returns a welcome tip', () => { From 4831ff53df986c8102d9b2f507fdbd0ddc538acc Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 18 Feb 2026 22:14:05 +0100 Subject: [PATCH 35/42] sessions - disable hover for now (#296102) --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 1 + .../chat/browser/agentSessions/agentSessionsControl.ts | 1 + .../chat/browser/agentSessions/agentSessionsViewer.ts | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 7aaea04ff89a0..59ad9ff363031 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -118,6 +118,7 @@ export class AgenticSessionsViewPane extends ViewPane { source: 'agentSessionsViewPane', filter: sessionsFilter, overrideStyles: this.getLocationBasedColors().listOverrideStyles, + disableHover: true, getHoverPosition: () => this.getSessionHoverPosition(), trackActiveEditorSession: () => true, collapseOlderSections: () => true, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index d74a3f6a63253..1ae1bf5989fb6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -40,6 +40,7 @@ export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOption readonly overrideStyles: IStyleOverride; readonly filter: IAgentSessionsFilter; readonly source: string; + readonly disableHover?: boolean; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index bcc94cad564c3..562d91aeabcef 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -76,6 +76,7 @@ interface IAgentSessionItemTemplate { } export interface IAgentSessionRendererOptions { + readonly disableHover?: boolean; getHoverPosition(): HoverPosition; } @@ -364,6 +365,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } private renderHover(session: ITreeNode, template: IAgentSessionItemTemplate): void { + if (this.options.disableHover) { + return; + } + if (!isSessionInProgressStatus(session.element.status) && session.element.isRead()) { return; // the hover is complex and large, for now limit it to in-progress sessions only } From 430b0f928ea0bddaa74fe1be7873ba60ee22f475 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:15:07 +0000 Subject: [PATCH 36/42] Add toggle for thinking content in chat accessible view (#295017) --- .../chatResponseAccessibleView.ts | 25 +++++++- .../actions/chatAccessibilityActions.ts | 34 +++++++++++ .../browser/actions/chatAccessibilityHelp.ts | 3 +- .../chatResponseAccessibleView.test.ts | 58 ++++++++++++++++++- 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 0cd00519f0eb1..6cad0431d35bf 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -13,6 +13,7 @@ import { localize } from '../../../../../nls.js'; import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider } from '../../../../../platform/accessibility/browser/accessibleView.js'; import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { migrateLegacyTerminalToolSpecificData } from '../../common/chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -29,6 +30,7 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation readonly when = ChatContextKeys.inChatSession; getProvider(accessor: ServicesAccessor) { const widgetService = accessor.get(IChatWidgetService); + const storageService = accessor.get(IStorageService); const widget = widgetService.lastFocusedWidget; if (!widget) { return; @@ -53,13 +55,20 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation return; } - return new ChatResponseAccessibleProvider(verifiedWidget, focusedItem, chatInputFocused); + return new ChatResponseAccessibleProvider(verifiedWidget, focusedItem, chatInputFocused, storageService); } } type ToolSpecificData = IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData; type ResultDetails = Array | IToolResultInputOutputDetails | IToolResultOutputDetails | IToolResultOutputDetailsSerialized; +export const CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY = 'chat.accessibleView.includeThinking'; +const CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_DEFAULT = true; + +export function isThinkingContentIncludedInAccessibleView(storageService: IStorageService): boolean { + return storageService.getBoolean(CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, StorageScope.PROFILE, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_DEFAULT); +} + function isOutputDetailsSerialized(obj: unknown): obj is IToolResultOutputDetailsSerialized { return typeof obj === 'object' && obj !== null && 'output' in obj && typeof (obj as IToolResultOutputDetailsSerialized).output === 'object' && @@ -212,14 +221,19 @@ export function getToolInvocationA11yDescription( class ChatResponseAccessibleProvider extends Disposable implements IAccessibleViewContentProvider { private _focusedItem!: ChatTreeItem; private readonly _focusedItemDisposables = this._register(new DisposableStore()); + private readonly _storageDisposables = this._register(new DisposableStore()); private readonly _onDidChangeContent = this._register(new Emitter()); readonly onDidChangeContent: Event = this._onDidChangeContent.event; constructor( private readonly _widget: IChatWidget, item: ChatTreeItem, - private readonly _wasOpenedFromInput: boolean + private readonly _wasOpenedFromInput: boolean, + private readonly _storageService: IStorageService ) { super(); + this._storageDisposables.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, this._storageDisposables)(() => { + this._onDidChangeContent.fire(); + })); this._setFocusedItem(item); } @@ -258,6 +272,9 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi for (const part of item.response.value) { switch (part.kind) { case 'thinking': { + if (!this._shouldIncludeThinkingContent()) { + break; + } const thinkingValue = Array.isArray(part.value) ? part.value.join('') : (part.value || ''); const trimmed = thinkingValue.trim(); if (trimmed) { @@ -360,6 +377,10 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi return normalized.join('\n'); } + private _shouldIncludeThinkingContent(): boolean { + return isThinkingContentIncludedInAccessibleView(this._storageService); + } + onClose(): void { this._widget.reveal(this._focusedItem); if (this._wasOpenedFromInput) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts index 7be04e1760c8d..99bad32ce49b2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts @@ -9,13 +9,18 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { isResponseVM } from '../../common/model/chatViewModel.js'; +import { AccessibleViewProviderId } from '../../../../../platform/accessibility/browser/accessibleView.js'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../../platform/accessibility/common/accessibility.js'; +import { accessibleViewCurrentProviderId, accessibleViewIsShown } from '../../../../contrib/accessibility/browser/accessibilityConfiguration.js'; +import { CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, isThinkingContentIncludedInAccessibleView } from '../accessibility/chatResponseAccessibleView.js'; export const ACTION_ID_FOCUS_CHAT_CONFIRMATION = 'workbench.action.chat.focusConfirmation'; +export const ACTION_ID_TOGGLE_THINKING_CONTENT_ACCESSIBLE_VIEW = 'workbench.action.chat.toggleThinkingContentAccessibleView'; class AnnounceChatConfirmationAction extends Action2 { constructor() { @@ -73,6 +78,35 @@ class AnnounceChatConfirmationAction extends Action2 { } } +class ToggleThinkingContentAccessibleViewAction extends Action2 { + constructor() { + super({ + id: ACTION_ID_TOGGLE_THINKING_CONTENT_ACCESSIBLE_VIEW, + title: { value: localize('toggleThinkingContentAccessibleView', 'Toggle Thinking Content in Accessible View'), original: 'Toggle Thinking Content in Accessible View' }, + category: { value: localize('chat.category', 'Chat'), original: 'Chat' }, + precondition: ChatContextKeys.enabled, + f1: true, + keybinding: { + primary: KeyMod.Alt | KeyCode.KeyT, + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(accessibleViewIsShown, ContextKeyExpr.equals(accessibleViewCurrentProviderId.key, AccessibleViewProviderId.PanelChat)) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const storageService = accessor.get(IStorageService); + const includeThinking = isThinkingContentIncludedInAccessibleView(storageService); + const updatedValue = !includeThinking; + storageService.store(CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, updatedValue, StorageScope.PROFILE, StorageTarget.USER); + alert(updatedValue + ? localize('thinkingContentShown', 'Thinking content will be included in the accessible view.') + : localize('thinkingContentHidden', 'Thinking content will be hidden from the accessible view.') + ); + } +} + export function registerChatAccessibilityActions(): void { registerAction2(AnnounceChatConfirmationAction); + registerAction2(ToggleThinkingContentAccessibleViewAction); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 3c23b729b2a5d..bfff1a5f0309f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -74,7 +74,8 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age } content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); content.push(localize('chat.attachments.removal', 'To remove attached contexts, focus an attachment and press Delete or Backspace.')); - content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order.', '')); + content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '')); + content.push(localize('chat.inspectResponseThinkingToggle', 'To include or exclude thinking content in the accessible view, run the Toggle Thinking Content in Accessible View command from the Command Palette.')); content.push(localize('workbench.action.chat.focus', 'To focus the chat request and response list, invoke the Focus Chat command{0}. This will move focus to the most recent response, which you can then navigate using the up and down arrow keys.', getChatFocusKeybindingLabel(keybindingService, type, 'last'))); content.push(localize('workbench.action.chat.focusLastFocusedItem', 'To return to the last chat response you focused, invoke the Focus Last Focused Chat Response command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'lastFocused'))); content.push(localize('workbench.action.chat.focusInput', 'To focus the input box for chat requests, invoke the Focus Chat Input command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'input'))); diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index d756c7020876a..ba7821dd3585a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -11,9 +11,11 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { Range } from '../../../../../../editor/common/core/range.js'; import { Location } from '../../../../../../editor/common/languages.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { ChatResponseAccessibleView, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { ChatResponseAccessibleView, CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; suite('ChatResponseAccessibleView', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -394,10 +396,57 @@ suite('ChatResponseAccessibleView', () => { }); suite('getProvider', () => { + test('omits thinking content when disabled in storage', () => { + const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); + storageService.store(CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY, false, StorageScope.PROFILE, StorageTarget.USER); + + const responseItem = { + response: { value: [{ kind: 'thinking', value: 'Hidden reasoning' }, { kind: 'markdownContent', content: new MarkdownString('Response content') }] }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const items = [responseItem]; + let focusedItem: unknown = responseItem; + + const widget = { + hasInputFocus: () => false, + focusResponseItem: () => { focusedItem = responseItem; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + const content = provider.provideContent(); + assert.ok(content.includes('Response content')); + assert.ok(!content.includes('Thinking: Hidden reasoning')); + }); + test('prefers the latest response when focus is on a queued request', () => { const instantiationService = store.add(new TestInstantiationService()); + const storageService = store.add(new TestStorageService()); const responseItem = { - response: { value: [{ kind: 'markdownContent', content: new MarkdownString('Response content') }] }, + response: { value: [{ kind: 'thinking', value: 'Reasoning' }, { kind: 'markdownContent', content: new MarkdownString('Response content') }] }, model: { onDidChange: Event.None }, setVote: () => undefined }; @@ -427,12 +476,15 @@ suite('ChatResponseAccessibleView', () => { } as unknown as IChatWidgetService; instantiationService.stub(IChatWidgetService, widgetService); + instantiationService.stub(IStorageService, storageService); const accessibleView = new ChatResponseAccessibleView(); const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); assert.ok(provider); store.add(provider); - assert.ok(provider.provideContent().includes('Response content')); + const content = provider.provideContent(); + assert.ok(content.includes('Response content')); + assert.ok(content.includes('Thinking: Reasoning')); }); }); }); From 640df8492e78138b334991c35988311478bb26b7 Mon Sep 17 00:00:00 2001 From: David Dossett <25163139+daviddossett@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:21:33 -0800 Subject: [PATCH 37/42] Render action widget footer links as bottom list items (#296095) * actionWidgetDropdown: render footer actions as bottom list items * Use menuitem role for auxiliary actions in action widget dropdown --- .../browser/actionWidgetDropdown.ts | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 0fb0d916c6ad9..296286bd51494 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -77,7 +77,7 @@ export class ActionWidgetDropdown extends BaseDropdown { return; } - let actionBarActions = this._options.actionBarActions ?? this._options.actionBarActionProvider?.getActions() ?? []; + const actionBarActions = this._options.actionBarActions ?? this._options.actionBarActionProvider?.getActions() ?? []; const actions = this._options.actions ?? this._options.actionProvider?.getActions() ?? []; // Track the currently selected option before opening @@ -154,9 +154,13 @@ export class ActionWidgetDropdown extends BaseDropdown { const previouslyFocusedElement = getActiveElement(); + const auxiliaryActionIds = new Set(actionBarActions.map(action => action.id)); + const actionWidgetDelegate: IActionListDelegate = { onSelect: (action, preview) => { - selectedOption = action; + if (!auxiliaryActionIds.has(action.id)) { + selectedOption = action; + } this.actionWidgetService.hide(); action.run(); }, @@ -168,13 +172,30 @@ export class ActionWidgetDropdown extends BaseDropdown { } }; - actionBarActions = actionBarActions.map(action => ({ - ...action, - run: async (...args: unknown[]) => { - this.actionWidgetService.hide(); - return action.run(...args); + if (actionBarActions.length) { + if (actionWidgetItems.length) { + actionWidgetItems.push({ + label: '', + kind: ActionListItemKind.Separator, + canPreview: false, + disabled: false, + hideIcon: false, + }); + } + + for (const action of actionBarActions) { + actionWidgetItems.push({ + item: action, + tooltip: action.tooltip, + kind: ActionListItemKind.Action, + canPreview: false, + group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, + disabled: !action.enabled, + hideIcon: false, + label: action.label, + }); } - })); + } const accessibilityProvider: Partial>> = { isChecked(element) { @@ -183,7 +204,9 @@ export class ActionWidgetDropdown extends BaseDropdown { getRole: (e) => { switch (e.kind) { case ActionListItemKind.Action: - return 'menuitemcheckbox'; + // Auxiliary actions are not checkable options, so use 'menuitem' to + // avoid screen readers announcing them as unchecked checkboxes. + return e.item && auxiliaryActionIds.has(e.item.id) ? 'menuitem' : 'menuitemcheckbox'; case ActionListItemKind.Separator: return 'separator'; default: @@ -200,7 +223,7 @@ export class ActionWidgetDropdown extends BaseDropdown { actionWidgetDelegate, this._options.getAnchor?.() ?? this.element, undefined, - actionBarActions, + [], accessibilityProvider ); } From da59fee119c865d884434574331afd4b7541de4c Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 18 Feb 2026 22:26:05 +0100 Subject: [PATCH 38/42] sessions - move actions down out of title into view (#296107) * sessions - move actions down out of title into view * ccr --- .../browser/media/sidebarActionButton.css | 3 +- .../browser/media/sessionsViewPane.css | 2 -- .../sessions/browser/sessionsViewPane.ts | 33 +++++++++++-------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/browser/media/sidebarActionButton.css b/src/vs/sessions/browser/media/sidebarActionButton.css index f170a6ed4fd0f..3d9b3a393fa2f 100644 --- a/src/vs/sessions/browser/media/sidebarActionButton.css +++ b/src/vs/sessions/browser/media/sidebarActionButton.css @@ -19,7 +19,8 @@ border: none; padding: 4px 8px; margin: 0; - font-size: 12px; + font-size: 11px; + font-weight: 500; height: auto; white-space: nowrap; overflow: hidden; diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 1666be701275e..b054dfd8f0d9b 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -46,8 +46,6 @@ display: flex; align-items: center; gap: 4px; - padding-top: 10px; - padding-right: 12px; -webkit-user-select: none; user-select: none; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 59ad9ff363031..81f0aa6849afb 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -11,7 +11,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -44,6 +44,7 @@ import { getCustomizationTotalCount } from './customizationCounts.js'; const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); +const SessionsViewHeaderMenu = new MenuId('AgentSessionsViewHeaderMenu'); const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; @@ -87,15 +88,18 @@ export class AgenticSessionsViewPane extends ViewPane { private createControls(parent: HTMLElement): void { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); - // Sessions Filter (actions go to view title bar via menu registration) - const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { - filterMenuId: SessionsViewFilterSubMenu, - groupResults: () => AgentSessionsGrouping.Date - })); - // Sessions section (top, fills available space) const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); + // Sessions header with title and toolbar actions + const sessionsHeader = DOM.append(sessionsSection, $('.agent-sessions-header')); + const headerText = DOM.append(sessionsHeader, $('span')); + headerText.textContent = localize('sessions', "SESSIONS"); + const headerToolbarContainer = DOM.append(sessionsHeader, $('.agent-sessions-header-toolbar')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, headerToolbarContainer, SessionsViewHeaderMenu, { + menuOptions: { shouldForwardArgs: true }, + })); + // Sessions content container const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); @@ -112,6 +116,12 @@ export class AgenticSessionsViewPane extends ViewPane { keybindingHint.textContent = keybinding.getLabel() ?? ''; } + // Sessions filter: contributes filter actions via SessionsViewFilterSubMenu; actions are rendered in the sessions header toolbar (SessionsViewHeaderMenu) + const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + filterMenuId: SessionsViewFilterSubMenu, + groupResults: () => AgentSessionsGrouping.Date + })); + // Sessions Control this.sessionsControlContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container')); const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { @@ -287,13 +297,12 @@ KeybindingsRegistry.registerKeybindingRule({ primary: KeyMod.CtrlCmd | KeyCode.KeyN, }); -MenuRegistry.appendMenuItem(MenuId.ViewTitle, { +MenuRegistry.appendMenuItem(SessionsViewHeaderMenu, { submenu: SessionsViewFilterSubMenu, title: localize2('filterAgentSessions', "Filter Agent Sessions"), group: 'navigation', order: 3, icon: Codicon.filter, - when: ContextKeyExpr.equals('view', SessionsViewId) } satisfies ISubmenuItem); registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { @@ -303,10 +312,9 @@ registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { title: localize2('refresh', "Refresh Agent Sessions"), icon: Codicon.refresh, menu: [{ - id: MenuId.ViewTitle, + id: SessionsViewHeaderMenu, group: 'navigation', order: 1, - when: ContextKeyExpr.equals('view', SessionsViewId), }], }); } @@ -325,10 +333,9 @@ registerAction2(class FindAgentSessionInViewerAction extends Action2 { title: localize2('find', "Find Agent Session"), icon: Codicon.search, menu: [{ - id: MenuId.ViewTitle, + id: SessionsViewHeaderMenu, group: 'navigation', order: 2, - when: ContextKeyExpr.equals('view', SessionsViewId), }] }); } From 21d150a50a94ba53e2442de79460c845b701c6b9 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:33:31 +0100 Subject: [PATCH 39/42] Fix enabled implicit context in ask mode (#295974) Fixes #292936 --- .../contrib/chat/browser/attachments/chatImplicitContext.ts | 3 +++ .../contrib/chat/browser/widget/input/chatInputPart.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts index cf41a1821b44c..566242366618b 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts @@ -294,6 +294,7 @@ export class ChatImplicitContexts extends Disposable { private _values: DisposableMap = this._register(new DisposableMap()); private readonly _valuesDisposables: DisposableStore = this._register(new DisposableStore()); + private _enabled = false; setValues(values: ImplicitContextWithSelection[]): void { this._valuesDisposables.clear(); @@ -308,6 +309,7 @@ export class ChatImplicitContexts extends Disposable { for (const value of definedValues) { const implicitContext = new ChatImplicitContext(); implicitContext.setValue(value.value, value.isSelection); + implicitContext.enabled = this._enabled; const disposableStore = new DisposableStore(); disposableStore.add(implicitContext.onDidChangeValue(() => { this._onDidChangeValue.fire(); @@ -327,6 +329,7 @@ export class ChatImplicitContexts extends Disposable { } setEnabled(enabled: boolean): void { + this._enabled = enabled; this.values.forEach((v) => v.enabled = enabled); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 92e2c35e184d7..a8e16ae9913b1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -678,7 +678,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private setImplicitContextEnablement() { if (this.implicitContext && this.configurationService.getValue('chat.implicitContext.suggestedContext')) { - this.implicitContext.setEnabled(this._currentModeObservable.get().kind !== ChatMode.Agent.kind); + this.implicitContext.setEnabled(this._currentModeObservable.get().name.get().toLowerCase() === 'ask'); } } From f62aa0cf2bda3197d92be87d3cd545c5a0c299ee Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:41:43 -0800 Subject: [PATCH 40/42] Add `newChatSessionItemHandler` to let chat sessions create new session uris --- .../api/browser/mainThreadChatAgents2.ts | 17 ++++++++++-- .../api/browser/mainThreadChatSessions.ts | 15 +++++++++++ .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostChatSessions.ts | 27 +++++++++++++++++++ .../browser/mainThreadChatSessions.test.ts | 2 ++ .../chatSessions/chatSessions.contribution.ts | 12 ++++++++- .../chat/common/chatSessionsService.ts | 8 ++++++ .../test/common/mockChatSessionsService.ts | 6 ++++- .../vscode.proposed.chatSessionsProvider.d.ts | 19 +++++++++++++ 9 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 74800fefdee70..994c8c8010ffc 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -197,9 +197,22 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA const contributedSession = chatSession?.contributedChatSession; let chatSessionContext: IChatSessionContextDto | undefined; if (contributedSession) { + let chatSessionResource = contributedSession.chatSessionResource; + let isUntitled = contributedSession.isUntitled; + + // For new untitled sessions, invoke the controller's newChatSessionItemHandler + // to let the extension create a proper session item before the first request. + if (isUntitled) { + const newItem = await this._chatSessionService.createNewChatSessionItem(contributedSession.chatSessionType, request, token); + if (newItem) { + chatSessionResource = newItem.resource; + isUntitled = false; + } + } + chatSessionContext = { - chatSessionResource: contributedSession.chatSessionResource, - isUntitled: contributedSession.isUntitled, + chatSessionResource, + isUntitled, initialSessionOptions: contributedSession.initialSessionOptions?.map(o => ({ optionId: o.optionId, value: typeof o.value === 'string' ? o.value : o.value.id, diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 87399cad640c6..cd45ead9ff57d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -347,6 +347,21 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes return this._proxy.$refreshChatSessionItems(this._handle, token); } + async newChatSessionItem(request: IChatAgentRequest, token: CancellationToken): Promise { + const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, request, token), token); + if (!dto) { + return undefined; + } + const item: IChatSessionItem = { + ...dto, + resource: URI.revive(dto.resource), + changes: revive(dto.changes), + }; + this._items.set(item.resource, item); + this._onDidChangeChatSessionItems.fire(); + return item; + } + acceptChange(change: { readonly addedOrUpdated: readonly IChatSessionItem[]; readonly removed: readonly URI[] }): void { for (const item of change.addedOrUpdated) { this._items.set(item.resource, item); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2025e31c43636..14685130cb2db 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3442,6 +3442,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { export interface ExtHostChatSessionsShape { $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; + $newChatSessionItem(controllerHandle: number, request: Dto, token: CancellationToken): Promise | undefined>; $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, token: CancellationToken): Promise; $interruptChatSessionActiveResponse(providerHandle: number, sessionResource: UriComponents, requestId: string): Promise; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index e62fe6439249e..da032619287c2 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -379,6 +379,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('Not implemented for providers'); }, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, + newChatSessionItemHandler: undefined, dispose: () => { disposables.dispose(); }, @@ -422,6 +423,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const disposables = new DisposableStore(); let isDisposed = false; + let newChatSessionItemHandler: vscode.ChatSessionItemController['newChatSessionItemHandler']; const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const collection = new ChatSessionItemCollectionImpl(controllerHandle, this._proxy); @@ -451,6 +453,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); return item; }, + get newChatSessionItemHandler() { return newChatSessionItemHandler; }, + set newChatSessionItemHandler(handler: vscode.ChatSessionItemController['newChatSessionItemHandler']) { newChatSessionItemHandler = handler; }, dispose: () => { isDisposed = true; disposables.dispose(); @@ -768,6 +772,29 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio await controllerData.controller.refreshHandler(token); } + async $newChatSessionItem(handle: number, request: IChatAgentRequest, token: CancellationToken): Promise | undefined> { + const controllerData = this._chatSessionItemControllers.get(handle); + if (!controllerData) { + this._logService.warn(`No controller found for handle ${handle}`); + return undefined; + } + + const handler = controllerData.controller.newChatSessionItemHandler; + if (!handler) { + return undefined; + } + + const model = await this.getModelForRequest(request, controllerData.extension); + const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, model, [], new Map(), controllerData.extension, this._logService); + + const item = await handler({ request: chatRequest }, token); + if (!item) { + return undefined; + } + + return typeConvert.ChatSessionItem.from(item); + } + $onDidChangeChatSessionItemState(controllerHandle: number, sessionResourceComponents: UriComponents, archived: boolean): void { const controllerData = this._chatSessionItemControllers.get(controllerHandle); if (!controllerData) { diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 2f846d3c7fb27..78018645d7354 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -62,6 +62,7 @@ suite('ObservableChatSession', function () { $disposeChatSessionContent: sinon.stub(), $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), + $newChatSessionItem: sinon.stub().resolves(undefined), }; }); @@ -359,6 +360,7 @@ suite('MainThreadChatSessions', function () { $disposeChatSessionContent: sinon.stub(), $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), + $newChatSessionItem: sinon.stub().resolves(undefined), }; const extHostContext = new class implements IExtHostContext { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index bb70941b5ac4d..aab01ee1d492a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -29,7 +29,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { IExtensionService, isProposedApiEnabled } from '../../../../services/extensions/common/extensions.js'; import { ExtensionsRegistry } from '../../../../services/extensions/common/extensionsRegistry.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; -import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentRequest, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; @@ -992,6 +992,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return description ? renderAsPlaintext(description, { useLinkFormatter: true }) : ''; } + async createNewChatSessionItem(chatSessionType: string, request: IChatAgentRequest, token: CancellationToken): Promise { + const controllerData = this._itemControllers.get(chatSessionType); + if (!controllerData) { + return undefined; + } + + await controllerData.initialRefresh; + return controllerData.controller.newChatSessionItem?.(request, token); + } + public async getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise { { const existingSessionData = this._sessions.get(sessionResource); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 8ccd21ac64711..4c8f12a339f35 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -198,6 +198,8 @@ export interface IChatSessionItemController { get items(): readonly IChatSessionItem[]; refresh(token: CancellationToken): Promise; + + newChatSessionItem?(request: IChatAgentRequest, token: CancellationToken): Promise; } /** @@ -293,6 +295,12 @@ export interface IChatSessionsService { registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable; getInProgressSessionDescription(chatModel: IChatModel): string | undefined; + + /** + * Creates a new chat session item using the controller's newChatSessionItemHandler. + * Returns undefined if the controller doesn't have a handler or if no controller is registered. + */ + createNewChatSessionItem(chatSessionType: string, request: IChatAgentRequest, token: CancellationToken): Promise; } export function isSessionInProgressStatus(state: ChatSessionStatus): boolean { diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 932cbb036b06b..bbeaa0b4c058a 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -9,7 +9,7 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from '../../common/participants/chatAgents.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItemController, IChatSessionItem, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; @@ -217,6 +217,10 @@ export class MockChatSessionsService implements IChatSessionsService { return undefined; } + async createNewChatSessionItem(_chatSessionType: string, _request: IChatAgentRequest, _token: CancellationToken): Promise { + return undefined; + } + registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable { // Store the emitter so tests can trigger it this.onChange = onChange; diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 04df7ca03d790..5f93a7fe90990 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -92,6 +92,15 @@ declare module 'vscode' { */ export type ChatSessionItemControllerRefreshHandler = (token: CancellationToken) => Thenable; + export interface ChatSessionItemControllerNewItemHandlerContext { + readonly request: ChatRequest; + } + + /** + * Extension callback invoked when a new chat session is started. + */ + export type ChatSessionItemControllerNewItemHandler = (context: ChatSessionItemControllerNewItemHandlerContext, token: CancellationToken) => Thenable; + /** * Manages chat sessions for a specific chat session type */ @@ -120,6 +129,15 @@ declare module 'vscode' { */ readonly refreshHandler: ChatSessionItemControllerRefreshHandler; + /** + * Invoked when a new chat session is started. + * + * This allows the controller to initialize the chat session item with information from the initial request. + * + * The returned chat session is added to the collection and shown in the UI. + */ + newChatSessionItemHandler?: ChatSessionItemControllerNewItemHandler; + /** * Fired when an item's archived state changes. */ @@ -363,6 +381,7 @@ declare module 'vscode' { */ // TODO: Should we introduce our own type for `ChatRequestHandler` since not all field apply to chat sessions? // TODO: Revisit this to align with code. + // TODO: pass in options? readonly requestHandler: ChatRequestHandler | undefined; } From cba9372ab594152f0d99c2ac9e0dc4e8a872f645 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:43:32 -0800 Subject: [PATCH 41/42] custom thinking phrases (#295966) * custom thinking phrases * address some comments --- .../contrib/chat/browser/chat.contribution.ts | 24 ++++++++++++++ .../chatThinkingContentPart.ts | 32 ++++++++++++++++--- .../contrib/chat/common/constants.ts | 1 + 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ff6113ad3bc49..5f8f72a64e3c4 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1098,6 +1098,30 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.ThinkingPhrases]: { + type: 'object', + default: { + mode: 'append', + phrases: [] + }, + properties: { + mode: { + type: 'string', + enum: ['replace', 'append'], + default: 'append', + description: nls.localize('chat.agent.thinking.phrases.mode', "'replace' replaces all default phrases entirely; 'append' adds your phrases to all default categories.") + }, + phrases: { + type: 'array', + items: { type: 'string' }, + default: [], + description: nls.localize('chat.agent.thinking.phrases.phrases', "Custom loading messages to show during thinking, terminal, and tool operations.") + } + }, + additionalProperties: false, + markdownDescription: nls.localize('chat.agent.thinking.phrases', "Customize the loading messages shown during agent operations. Use `\"mode\": \"replace\"` to use only your phrases, or `\"mode\": \"append\"` to add them to the defaults."), + tags: ['experimental'], + }, [ChatConfiguration.AutoExpandToolFailures]: { type: 'boolean', default: true, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index e59763850e62e..5de04eb38b471 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -116,7 +116,7 @@ const enum WorkingMessageCategory { Tool = 'tool' } -const thinkingMessages = [ +const defaultThinkingMessages = [ localize('chat.thinking.thinking.1', 'Thinking'), localize('chat.thinking.thinking.2', 'Reasoning'), localize('chat.thinking.thinking.3', 'Considering'), @@ -181,18 +181,42 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { let pool = this.availableMessagesByCategory.get(category); if (!pool || pool.length === 0) { + let defaults: string[]; switch (category) { case WorkingMessageCategory.Thinking: - pool = [...thinkingMessages]; + defaults = [...defaultThinkingMessages]; break; case WorkingMessageCategory.Terminal: - pool = [...terminalMessages]; + defaults = [...terminalMessages]; break; case WorkingMessageCategory.Tool: default: - pool = [...toolMessages]; + defaults = [...toolMessages]; break; } + + // Read configured phrases from the single setting + const config = this.configurationService.getValue<{ mode?: 'replace' | 'append'; phrases?: string[] }>(ChatConfiguration.ThinkingPhrases); + const customPhrases = Array.isArray(config?.phrases) + ? config.phrases + .filter((phrase): phrase is string => typeof phrase === 'string') + .map(phrase => phrase.trim()) + .filter(phrase => phrase.length > 0) + : []; + const mode = config?.mode === 'replace' ? 'replace' : 'append'; + + if (customPhrases.length > 0) { + if (mode === 'replace') { + // Replace mode: use only custom phrases for all categories + pool = [...customPhrases]; + } else { + // Append mode: add custom phrases to defaults for this category + pool = [...defaults, ...customPhrases]; + } + } else { + pool = defaults; + } + this.availableMessagesByCategory.set(category, pool); } const index = Math.floor(Math.random() * pool.length); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8066635dc7477..693f7c1f9da74 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -35,6 +35,7 @@ export enum ChatConfiguration { ThinkingStyle = 'chat.agent.thinkingStyle', ThinkingGenerateTitles = 'chat.agent.thinking.generateTitles', TerminalToolsInThinking = 'chat.agent.thinking.terminalTools', + ThinkingPhrases = 'chat.agent.thinking.phrases', AutoExpandToolFailures = 'chat.tools.autoExpandFailures', TodosShowWidget = 'chat.tools.todos.showWidget', NotifyWindowOnResponseReceived = 'chat.notifyWindowOnResponseReceived', From cf65b7a4b534800a14e1e392b9b8b09f51937f1f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 18 Feb 2026 15:52:11 -0600 Subject: [PATCH 42/42] add tip for /create* (#296116) * fixes #296110 * fix & add tests for this case --- .../contrib/chat/browser/chatTipService.ts | 30 ++- .../createSlashCommandsUsageTracker.ts | 78 ++++++ .../chat/common/actions/chatContextKeys.ts | 6 + .../chat/test/browser/chatTipService.test.ts | 234 +++++++++++++++++- 4 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index a8f62d9bdc674..2e6c540a88fcb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -21,6 +21,8 @@ import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { localChatSessionType } from '../common/chatSessionsService.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { CreateSlashCommandsUsageTracker } from './createSlashCommandsUsageTracker.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -154,6 +156,26 @@ const TIP_CATALOG: ITipDefinition[] = [ enabledCommands: ['workbench.action.chat.openModelPicker'], onlyWhenModelIds: ['gpt-4.1'], }, + { + id: 'tip.createSlashCommands', + message: localize( + 'tip.createSlashCommands', + "Tip: Use [/create-instruction](command:workbench.action.chat.generateInstruction), [/create-prompt](command:workbench.action.chat.generatePrompt), [/create-agent](command:workbench.action.chat.generateAgent), or [/create-skill](command:workbench.action.chat.generateSkill) to generate reusable agent customization files." + ), + when: ChatContextKeys.hasUsedCreateSlashCommands.negate(), + enabledCommands: [ + 'workbench.action.chat.generateInstruction', + 'workbench.action.chat.generatePrompt', + 'workbench.action.chat.generateAgent', + 'workbench.action.chat.generateSkill', + ], + excludeWhenCommandsExecuted: [ + 'workbench.action.chat.generateInstruction', + 'workbench.action.chat.generatePrompt', + 'workbench.action.chat.generateAgent', + 'workbench.action.chat.generateSkill', + ], + }, { id: 'tip.agentMode', message: localize('tip.agentMode', "Tip: Try [Agents](command:workbench.action.chat.openEditSession) to make edits across your project and run commands."), @@ -539,18 +561,20 @@ export class ChatTipService extends Disposable implements IChatTipService { private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; private static readonly _LAST_TIP_ID_KEY = 'chat.tip.lastTipId'; private readonly _tracker: TipEligibilityTracker; + private readonly _createSlashCommandsUsageTracker: CreateSlashCommandsUsageTracker; constructor( @IProductService private readonly _productService: IProductService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService private readonly _storageService: IStorageService, + @IChatService private readonly _chatService: IChatService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @IChatEntitlementService chatEntitlementService: IChatEntitlementService, ) { super(); this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); - + this._createSlashCommandsUsageTracker = this._register(new CreateSlashCommandsUsageTracker(this._chatService, this._storageService, () => this._contextKeyService)); this._register(chatEntitlementService.onDidChangeQuotaExceeded(() => { if (chatEntitlementService.quotas.chat?.percentRemaining === 0 && this._shownTip) { this.hideTip(); @@ -625,6 +649,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } getWelcomeTip(contextKeyService: IContextKeyService): IChatTip | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); // Check if tips are enabled if (!this._configurationService.getValue('chat.tips.enabled')) { return undefined; @@ -669,6 +694,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _findNextEligibleTip(currentTipId: string, contextKeyService: IContextKeyService): ITipDefinition | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); const currentIndex = TIP_CATALOG.findIndex(tip => tip.id === currentTipId); if (currentIndex === -1) { return undefined; @@ -687,6 +713,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); // Record the current mode for future eligibility decisions. this._tracker.recordCurrentMode(contextKeyService); @@ -738,6 +765,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { + this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); if (!this._shownTip) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts new file mode 100644 index 0000000000000..0ad8f07e43c24 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/createSlashCommandsUsageTracker.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { ChatRequestSlashCommandPart } from '../common/requestParser/chatParserTypes.js'; + +export class CreateSlashCommandsUsageTracker extends Disposable { + private static readonly _USED_CREATE_SLASH_COMMANDS_KEY = 'chat.tips.usedCreateSlashCommands'; + + constructor( + private readonly _chatService: IChatService, + private readonly _storageService: IStorageService, + private readonly _getActiveContextKeyService: () => IContextKeyService | undefined, + ) { + super(); + + this._register(this._chatService.onDidSubmitRequest(e => { + const model = this._chatService.getSession(e.chatSessionResource); + const lastRequest = model?.lastRequest; + if (!lastRequest) { + return; + } + + for (const part of lastRequest.message.parts) { + if (part.kind === ChatRequestSlashCommandPart.Kind) { + const slash = part as ChatRequestSlashCommandPart; + if (CreateSlashCommandsUsageTracker._isCreateSlashCommand(slash.slashCommand.command)) { + this._markUsed(); + return; + } + } + } + + // Fallback when parsing doesn't produce a slash command part. + const trimmed = lastRequest.message.text.trimStart(); + const match = /^\/(create-(?:instruction|prompt|agent|skill))(?:\s|$)/.exec(trimmed); + if (match && CreateSlashCommandsUsageTracker._isCreateSlashCommand(match[1])) { + this._markUsed(); + } + })); + } + + syncContextKey(contextKeyService: IContextKeyService): void { + const used = this._storageService.getBoolean(CreateSlashCommandsUsageTracker._USED_CREATE_SLASH_COMMANDS_KEY, StorageScope.APPLICATION, false); + ChatContextKeys.hasUsedCreateSlashCommands.bindTo(contextKeyService).set(used); + } + + private _markUsed(): void { + if (this._storageService.getBoolean(CreateSlashCommandsUsageTracker._USED_CREATE_SLASH_COMMANDS_KEY, StorageScope.APPLICATION, false)) { + return; + } + + this._storageService.store(CreateSlashCommandsUsageTracker._USED_CREATE_SLASH_COMMANDS_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + + const contextKeyService = this._getActiveContextKeyService(); + if (contextKeyService) { + ChatContextKeys.hasUsedCreateSlashCommands.bindTo(contextKeyService).set(true); + } + } + + private static _isCreateSlashCommand(command: string): boolean { + switch (command) { + case 'create-instruction': + case 'create-prompt': + case 'create-agent': + case 'create-skill': + return true; + default: + return false; + } + } +} diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index b97209bce1587..a4c7f5316aac2 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -130,6 +130,12 @@ export namespace ChatContextKeys { export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); + /** + * True when the user has submitted a chat request using any of the `/create-*` slash commands. + * This is persisted in application storage and used to suppress onboarding tips once discovered. + */ + export const hasUsedCreateSlashCommands = new RawContextKey('chatHasUsedCreateSlashCommands', false, { type: 'boolean', description: localize('chatHasUsedCreateSlashCommands', "True when the user has used any of the /create-* slash commands.") }); + export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 8b8147438b8a7..d950b9bfbd1bc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; @@ -25,10 +25,16 @@ import { ILanguageModelToolsService } from '../../common/tools/languageModelTool import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { TestChatEntitlementService } from '../../../../test/common/workbenchTestServices.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { MockChatService } from '../common/chatService/mockChatService.js'; +import { CreateSlashCommandsUsageTracker } from '../../browser/createSlashCommandsUsageTracker.js'; +import { ChatRequestSlashCommandPart } from '../../common/requestParser/chatParserTypes.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { Range } from '../../../../../editor/common/core/range.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { - override contextMatchesRules(): boolean { - return true; + override contextMatchesRules(rules: ContextKeyExpression): boolean { + return rules.evaluate({ getValue: (key: string) => this.getContextKeyValue(key) }); } } @@ -92,6 +98,7 @@ suite('ChatTipService', () => { } as Partial as IPromptsService); instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); + instantiationService.stub(IChatService, new MockChatService()); }); test('returns a welcome tip', () => { @@ -756,6 +763,41 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); + test('shows tip.createSlashCommands when context key is false', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.hasUsedCreateSlashCommands.key, false); + + // Dismiss tips until we find createSlashCommands or run out + let found = false; + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + if (tip.id === 'tip.createSlashCommands') { + found = true; + break; + } + service.dismissTip(); + } + + assert.ok(found, 'Should eventually show tip.createSlashCommands when context key is false'); + }); + + test('does not show tip.createSlashCommands when context key is true', () => { + storageService.store('chat.tips.usedCreateSlashCommands', true, StorageScope.APPLICATION, StorageTarget.MACHINE); + const service = createService(); + + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.createSlashCommands', 'Should not show tip.createSlashCommands when context key is true'); + service.dismissTip(); + } + }); + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; @@ -790,3 +832,189 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after onDidChangeCustomAgents fires and agent files exist'); }); }); + +suite('CreateSlashCommandsUsageTracker', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let storageService: InMemoryStorageService; + let contextKeyService: MockContextKeyService; + let submitRequestEmitter: Emitter<{ readonly chatSessionResource: URI }>; + let sessions: Map; + + setup(() => { + storageService = testDisposables.add(new InMemoryStorageService()); + contextKeyService = new MockContextKeyService(); + submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI }>()); + sessions = new Map(); + }); + + function createMockChatServiceForTracker(): IChatService { + return { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: (resource: URI) => sessions.get(resource.toString()), + } as Partial as IChatService; + } + + function createTracker(chatService?: IChatService): CreateSlashCommandsUsageTracker { + return testDisposables.add(new CreateSlashCommandsUsageTracker( + chatService ?? createMockChatServiceForTracker(), + storageService, + () => contextKeyService, + )); + } + + test('syncContextKey sets context key to false when storage is empty', () => { + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, false, 'Context key should be false when no create commands have been used'); + }); + + test('syncContextKey sets context key to true when storage has recorded usage', () => { + storageService.store('chat.tips.usedCreateSlashCommands', true, StorageScope.APPLICATION, StorageTarget.MACHINE); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, true, 'Context key should be true when create commands have been used'); + }); + + test('detects create-instruction slash command via text fallback', () => { + const sessionResource = URI.parse('chat:session1'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/create-instruction test', + parts: [], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, true, 'Context key should be true after /create-instruction is used'); + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist the create slash command usage', + ); + }); + + test('detects create-prompt slash command via text fallback', () => { + const sessionResource = URI.parse('chat:session2'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/create-prompt my-prompt', + parts: [], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist the create-prompt usage', + ); + }); + + test('detects create-agent slash command via parsed part', () => { + const sessionResource = URI.parse('chat:session3'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/create-agent test', + parts: [ + new ChatRequestSlashCommandPart( + new OffsetRange(0, 13), + new Range(1, 1, 1, 14), + { command: 'create-agent', detail: '', locations: [] }, + ), + ], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + true, + 'Storage should persist when create-agent slash command part is detected', + ); + }); + + test('does not mark used for non-create slash commands', () => { + const sessionResource = URI.parse('chat:session4'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { + text: '/help test', + parts: [], + }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + const value = contextKeyService.getContextKeyValue(ChatContextKeys.hasUsedCreateSlashCommands.key); + assert.strictEqual(value, false, 'Context key should remain false for non-create slash commands'); + }); + + test('does not mark used when session has no last request', () => { + const sessionResource = URI.parse('chat:session5'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { lastRequest: undefined }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + + assert.strictEqual( + storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), + false, + 'Should not mark used when there is no last request', + ); + }); + + test('only marks used once even with multiple create commands', () => { + const sessionResource = URI.parse('chat:session6'); + const tracker = createTracker(); + tracker.syncContextKey(contextKeyService); + + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { text: '/create-skill test', parts: [] }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + assert.strictEqual(storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), true); + + // Fire again — should be a no-op + sessions.set(sessionResource.toString(), { + lastRequest: { + message: { text: '/create-prompt test', parts: [] }, + }, + }); + + submitRequestEmitter.fire({ chatSessionResource: sessionResource }); + assert.strictEqual(storageService.getBoolean('chat.tips.usedCreateSlashCommands', StorageScope.APPLICATION, false), true); + }); +});