From eddede8c8114d5d55d6598e104d7ec6148202eb3 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:11:54 -0800 Subject: [PATCH 01/16] Esbuild the ts extension --- .../.vscodeignore | 5 +- .../esbuild.browser.mts | 141 ++++++++++++++++++ ...xtension.webpack.config.js => esbuild.mts} | 22 +-- .../extension-browser.webpack.config.js | 78 ---------- .../typescript-language-features/package.json | 8 +- .../tsconfig.browser.json | 10 ++ 6 files changed, 171 insertions(+), 93 deletions(-) create mode 100644 extensions/typescript-language-features/esbuild.browser.mts rename extensions/typescript-language-features/{extension.webpack.config.js => esbuild.mts} (51%) delete mode 100644 extensions/typescript-language-features/extension-browser.webpack.config.js create mode 100644 extensions/typescript-language-features/tsconfig.browser.json diff --git a/extensions/typescript-language-features/.vscodeignore b/extensions/typescript-language-features/.vscodeignore index 5c4f06d4cd3dd..c9b6c88a79cc5 100644 --- a/extensions/typescript-language-features/.vscodeignore +++ b/extensions/typescript-language-features/.vscodeignore @@ -4,8 +4,7 @@ web/** test/** test-workspace/** out/** -tsconfig.json -extension.webpack.config.js -extension-browser.webpack.config.js +tsconfig*.json +esbuild*.mts cgmanifest.json package-lock.json diff --git a/extensions/typescript-language-features/esbuild.browser.mts b/extensions/typescript-language-features/esbuild.browser.mts new file mode 100644 index 0000000000000..b24d4781cb888 --- /dev/null +++ b/extensions/typescript-language-features/esbuild.browser.mts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type esbuild from 'esbuild'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +// https://esbuild.github.io/plugins/#webassembly-plugin +const wasmPlugin: esbuild.Plugin = { + name: 'wasm', + setup(build) { + build.onResolve({ filter: /\.wasm$/ }, args => { + if (args.namespace === 'wasm-stub') { + return { + path: args.path, + namespace: 'wasm-binary', + }; + } + + if (args.resolveDir === '') { + return; + } + return { + path: path.isAbsolute(args.path) + ? args.path + : path.join(args.resolveDir, args.path), + namespace: 'wasm-stub', + }; + }); + + build.onLoad({ filter: /.*/, namespace: 'wasm-stub' }, async (args) => ({ + contents: `import wasm from ${JSON.stringify(args.path)} + export default (imports) => + WebAssembly.instantiate(wasm, imports).then( + result => result.instance.exports)`, + })); + + build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({ + contents: await fs.promises.readFile(args.path), + loader: 'binary', + })); + }, +}; + +const languages = [ + 'zh-tw', + 'cs', + 'de', + 'es', + 'fr', + 'it', + 'ja', + 'ko', + 'pl', + 'pt-br', + 'ru', + 'tr', + 'zh-cn', +]; + +/** + * Copy TypeScript lib files (.d.ts, typesMap.json, and language packs) to the output directory. + */ +async function copyTypescriptLibFiles(outDir: string): Promise { + try { + const typescriptLibDir = path.join(import.meta.dirname, '..', 'node_modules', 'typescript', 'lib'); + const destDir = path.join(outDir, 'typescript'); + + await fs.promises.mkdir(destDir, { recursive: true }); + + // Copy .d.ts files + const libFiles = await fs.promises.readdir(typescriptLibDir); + for (const file of libFiles) { + if (file.endsWith('.d.ts')) { + await fs.promises.copyFile(path.join(typescriptLibDir, file), path.join(destDir, file)); + } + } + + // Copy typesMap.json + await fs.promises.copyFile(path.join(typescriptLibDir, 'typesMap.json'), path.join(destDir, 'typesMap.json')); + + // Copy language packs + for (const lang of languages) { + const langSrcDir = path.join(typescriptLibDir, lang); + const langDestDir = path.join(destDir, lang); + try { + await fs.promises.mkdir(langDestDir, { recursive: true }); + const langFiles = await fs.promises.readdir(langSrcDir); + for (const file of langFiles) { + const srcPath = path.join(langSrcDir, file); + const destPath = path.join(langDestDir, file); + const stat = await fs.promises.stat(srcPath); + if (stat.isFile()) { + await fs.promises.copyFile(srcPath, destPath); + } + } + } catch { + // Skip if language directory doesn't exist + } + } + } catch (error) { + console.error('Error copying TypeScript lib files:', error); + throw error; + } +} + + +await Promise.all([ + // Build the browser extension entry point + run({ + platform: 'browser', + entryPoints: { + 'extension': path.join(srcDir, 'extension.browser.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + plugins: [wasmPlugin], + }, + }, process.argv, copyTypescriptLibFiles), + + // Build the web tsserver worker + run({ + platform: 'browser', + entryPoints: { + 'typescript/tsserver.web': path.join(import.meta.dirname, 'web', 'src', 'webServer.ts'), + }, + srcDir: path.join(import.meta.dirname, 'web', 'src'), + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'web', 'tsconfig.json'), + external: ['perf_hooks'], + plugins: [wasmPlugin], + }, + }, process.argv), +]); diff --git a/extensions/typescript-language-features/extension.webpack.config.js b/extensions/typescript-language-features/esbuild.mts similarity index 51% rename from extensions/typescript-language-features/extension.webpack.config.js rename to extensions/typescript-language-features/esbuild.mts index 4928186ae556c..2b75ca703da06 100644 --- a/extensions/typescript-language-features/extension.webpack.config.js +++ b/extensions/typescript-language-features/esbuild.mts @@ -2,15 +2,17 @@ * 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 * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), }, - entry: { - extension: './src/extension.ts', - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/typescript-language-features/extension-browser.webpack.config.js b/extensions/typescript-language-features/extension-browser.webpack.config.js deleted file mode 100644 index 86733dfd1ee30..0000000000000 --- a/extensions/typescript-language-features/extension-browser.webpack.config.js +++ /dev/null @@ -1,78 +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 CopyPlugin from 'copy-webpack-plugin'; -import path from 'path'; -import defaultConfig, { browser as withBrowserDefaults, browserPlugins } from '../shared.webpack.config.mjs'; - -const languages = [ - 'zh-tw', - 'cs', - 'de', - 'es', - 'fr', - 'it', - 'ja', - 'ko', - 'pl', - 'pt-br', - 'ru', - 'tr', - 'zh-cn', -]; -export default [withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.browser.ts', - }, - plugins: [ - ...browserPlugins(import.meta.dirname), // add plugins, don't replace inherited - - // @ts-ignore - new CopyPlugin({ - patterns: [ - { - from: '../node_modules/typescript/lib/*.d.ts', - to: 'typescript/[name][ext]', - }, - { - from: '../node_modules/typescript/lib/typesMap.json', - to: 'typescript/' - }, - ...languages.map(lang => ({ - from: `../node_modules/typescript/lib/${lang}/**/*`, - to: (pathData) => { - const normalizedFileName = pathData.absoluteFilename.replace(/[\\/]/g, '/'); - const match = normalizedFileName.match(/typescript\/lib\/(.*)/); - if (match) { - return `typescript/${match[1]}`; - } - console.log(`Did not find typescript/lib in ${normalizedFileName}`); - return 'typescript/'; - } - })) - ], - }), - ], -}), withBrowserDefaults({ - context: import.meta.dirname, - entry: { - 'typescript/tsserver.web': './web/src/webServer.ts' - }, - module: { - exprContextCritical: false, - }, - ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/], - output: { - // all output goes into `dist`. - // packaging depends on that and this must always be like it - filename: '[name].js', - path: path.join(import.meta.dirname, 'dist', 'browser'), - libraryTarget: undefined, - }, - externals: { - 'perf_hooks': 'commonjs perf_hooks', - } -})]; diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 66a1c65819d74..28565c0e75125 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -54,8 +54,12 @@ }, "scripts": { "vscode:prepublish": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:typescript-language-features", - "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", - "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch" + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch" }, "activationEvents": [ "onLanguage:javascript", diff --git a/extensions/typescript-language-features/tsconfig.browser.json b/extensions/typescript-language-features/tsconfig.browser.json new file mode 100644 index 0000000000000..790349e7fec3f --- /dev/null +++ b/extensions/typescript-language-features/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/test/**" + ], + "files": [ + "./src/extension.browser.ts" + ] +} From b3df34cabdc94c7436fde5ce920e62cefb045bf1 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 18 Feb 2026 21:39:40 -0800 Subject: [PATCH 02/16] Don't break the chat view when session content provider fails to load Fix #291823 --- .../widgetHosts/viewPane/chatViewPane.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index a824546fe7792..9ff8c08231977 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -26,6 +26,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -63,6 +64,7 @@ import { IAgentSessionsService } from '../../agentSessions/agentSessionsService. import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; interface IChatViewPaneState extends Partial { @@ -113,6 +115,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @IChatService private readonly chatService: IChatService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @ILogService private readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ITelemetryService private readonly telemetryService: ITelemetryService, @@ -769,16 +772,27 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { queue = this.showModel(undefined, false).then(() => { }); }, 100); - const sessionType = getChatSessionType(sessionResource); - if (sessionType !== localChatSessionType) { - await this.chatSessionsService.canResolveChatSession(sessionResource); - } + try { + const sessionType = getChatSessionType(sessionResource); + if (sessionType !== localChatSessionType) { + await this.chatSessionsService.canResolveChatSession(sessionResource); + } - const newModelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); - clearWidget.dispose(); - await queue; + const newModelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + clearWidget.dispose(); + await queue; - return this.showModel(newModelRef); + return this.showModel(newModelRef); + } catch (err) { + clearWidget.dispose(); + await queue; + + // Recover by starting a fresh empty session so the widget + // is not left in a broken state without title or back button. + this.logService.error(`Failed to load chat session '${sessionResource.toString()}'`, err); + this.notificationService.error(localize('chat.loadSessionFailed', "Failed to open chat session: {0}", toErrorMessage(err))); + return this.showModel(undefined); + } }); } From 6be6f0bfa28e9c4facf73af8995cd86d1b1fd176 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:58:41 -0800 Subject: [PATCH 03/16] Fix loading of wasm We expect it to be loaded as a datauri --- .../esbuild.browser.mts | 42 +------------------ 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/extensions/typescript-language-features/esbuild.browser.mts b/extensions/typescript-language-features/esbuild.browser.mts index b24d4781cb888..51439d792a602 100644 --- a/extensions/typescript-language-features/esbuild.browser.mts +++ b/extensions/typescript-language-features/esbuild.browser.mts @@ -4,49 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type esbuild from 'esbuild'; import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); -// https://esbuild.github.io/plugins/#webassembly-plugin -const wasmPlugin: esbuild.Plugin = { - name: 'wasm', - setup(build) { - build.onResolve({ filter: /\.wasm$/ }, args => { - if (args.namespace === 'wasm-stub') { - return { - path: args.path, - namespace: 'wasm-binary', - }; - } - - if (args.resolveDir === '') { - return; - } - return { - path: path.isAbsolute(args.path) - ? args.path - : path.join(args.resolveDir, args.path), - namespace: 'wasm-stub', - }; - }); - - build.onLoad({ filter: /.*/, namespace: 'wasm-stub' }, async (args) => ({ - contents: `import wasm from ${JSON.stringify(args.path)} - export default (imports) => - WebAssembly.instantiate(wasm, imports).then( - result => result.instance.exports)`, - })); - - build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({ - contents: await fs.promises.readFile(args.path), - loader: 'binary', - })); - }, -}; - const languages = [ 'zh-tw', 'cs', @@ -120,7 +82,7 @@ await Promise.all([ srcDir, outdir: outDir, additionalOptions: { - plugins: [wasmPlugin], + loader: { '.wasm': 'dataurl' }, }, }, process.argv, copyTypescriptLibFiles), @@ -135,7 +97,7 @@ await Promise.all([ additionalOptions: { tsconfig: path.join(import.meta.dirname, 'web', 'tsconfig.json'), external: ['perf_hooks'], - plugins: [wasmPlugin], + loader: { '.wasm': 'dataurl' }, }, }, process.argv), ]); From 577d1def05b5ed53d312f0b6812e96efd3d00ff7 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:00:22 -0800 Subject: [PATCH 04/16] Add tsconfig --- extensions/typescript-language-features/esbuild.browser.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/typescript-language-features/esbuild.browser.mts b/extensions/typescript-language-features/esbuild.browser.mts index 51439d792a602..81011c2efb113 100644 --- a/extensions/typescript-language-features/esbuild.browser.mts +++ b/extensions/typescript-language-features/esbuild.browser.mts @@ -83,6 +83,7 @@ await Promise.all([ outdir: outDir, additionalOptions: { loader: { '.wasm': 'dataurl' }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv, copyTypescriptLibFiles), From b1aeba0370ede18ecb1ce5dfaaf7b52e63712099 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:07:25 -0800 Subject: [PATCH 05/16] Update terminal image setting description (#296146) Update image setting description --- .../workbench/contrib/terminal/common/terminalConfiguration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 3500a7243e18b..632938e0d5dd9 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -654,7 +654,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.EnableImages]: { restricted: true, - markdownDescription: localize('terminal.integrated.enableImages', "Enables image support in the terminal, this will only work when {0} is enabled. Both sixel and iTerm's inline image protocol are supported on Linux and macOS. This will only work on Windows for versions of ConPTY >= v2 which is shipped with Windows itself, see also {1}. Images will currently not be restored between window reloads/reconnects. When enabled, transparency mode is also turned on in the terminal.", `\`#${TerminalSettingId.GpuAcceleration}#\``, `\`#${TerminalSettingId.WindowsUseConptyDll}#\``), + markdownDescription: localize('terminal.integrated.enableImages', "Enables image support in the terminal, this will only work when {0} is enabled. Sixel and iTerm's inline image protocol are supported on Linux and macOS. The kitty graphics protocol is supported on all platforms. On Windows, all image protocols will only work for versions of ConPTY >= v2 which is shipped with Windows itself, see also {1}. Images will currently not be restored between window reloads/reconnects. When enabled, transparency mode is also turned on in the terminal.", `\`#${TerminalSettingId.GpuAcceleration}#\``, `\`#${TerminalSettingId.WindowsUseConptyDll}#\``), type: 'boolean', default: false }, From 881ce77c81a0d64cfe22eab60c32e4e4cd02c529 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:12:46 -0800 Subject: [PATCH 06/16] Bump `@vscode/vscode-languagedetection` Picks up https://github.com/microsoft/vscode-languagedetection/pull/34 --- package-lock.json | 9 +++++---- package.json | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8524540f7d49d..32c7949580296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@vscode/sqlite3": "5.1.12-vscode", "@vscode/sudo-prompt": "9.3.2", "@vscode/tree-sitter-wasm": "^0.3.0", - "@vscode/vscode-languagedetection": "1.0.21", + "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", @@ -3607,9 +3607,10 @@ "dev": true }, "node_modules/@vscode/vscode-languagedetection": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.21.tgz", - "integrity": "sha512-zSUH9HYCw5qsCtd7b31yqkpaCU6jhtkKLkvOOA8yTrIRfBSOFb8PPhgmMicD7B/m+t4PwOJXzU1XDtrM9Fd3/g==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.23.tgz", + "integrity": "sha512-Ywk6vXC81nUHvc9WX3uFJG/UHFLXDYJgNeUVirBLJGvmchXHdapsGeAYclNqO1thQLmykmJhSouIZV+JJS8o1A==", + "license": "MIT", "bin": { "vscode-languagedetection": "cli/index.js" } diff --git a/package.json b/package.json index f73c3d4705795..fb6ac1043c878 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@vscode/sqlite3": "5.1.12-vscode", "@vscode/sudo-prompt": "9.3.2", "@vscode/tree-sitter-wasm": "^0.3.0", - "@vscode/vscode-languagedetection": "1.0.21", + "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", @@ -245,4 +245,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} From 7a3b0181f09748aaa5d25a22098d91c4846129a8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:18:31 -0800 Subject: [PATCH 07/16] Update other packages as well --- remote/package-lock.json | 9 +++++---- remote/package.json | 2 +- remote/web/package-lock.json | 9 +++++---- remote/web/package.json | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/remote/package-lock.json b/remote/package-lock.json index 4cec83e52e86a..d58a430653403 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -19,7 +19,7 @@ "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", - "@vscode/vscode-languagedetection": "1.0.21", + "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", "@xterm/addon-clipboard": "^0.3.0-beta.165", @@ -528,9 +528,10 @@ "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.21.tgz", - "integrity": "sha512-zSUH9HYCw5qsCtd7b31yqkpaCU6jhtkKLkvOOA8yTrIRfBSOFb8PPhgmMicD7B/m+t4PwOJXzU1XDtrM9Fd3/g==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.23.tgz", + "integrity": "sha512-Ywk6vXC81nUHvc9WX3uFJG/UHFLXDYJgNeUVirBLJGvmchXHdapsGeAYclNqO1thQLmykmJhSouIZV+JJS8o1A==", + "license": "MIT", "bin": { "vscode-languagedetection": "cli/index.js" } diff --git a/remote/package.json b/remote/package.json index afaaabaf5c8c6..0784e097baf34 100644 --- a/remote/package.json +++ b/remote/package.json @@ -14,7 +14,7 @@ "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", - "@vscode/vscode-languagedetection": "1.0.21", + "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", "@xterm/addon-clipboard": "^0.3.0-beta.165", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index eeed651e576c2..93e41b4397009 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -13,7 +13,7 @@ "@vscode/codicons": "^0.0.45-8", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", - "@vscode/vscode-languagedetection": "1.0.21", + "@vscode/vscode-languagedetection": "1.0.23", "@xterm/addon-clipboard": "^0.3.0-beta.165", "@xterm/addon-image": "^0.10.0-beta.165", "@xterm/addon-ligatures": "^0.11.0-beta.165", @@ -91,9 +91,10 @@ "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.21.tgz", - "integrity": "sha512-zSUH9HYCw5qsCtd7b31yqkpaCU6jhtkKLkvOOA8yTrIRfBSOFb8PPhgmMicD7B/m+t4PwOJXzU1XDtrM9Fd3/g==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.23.tgz", + "integrity": "sha512-Ywk6vXC81nUHvc9WX3uFJG/UHFLXDYJgNeUVirBLJGvmchXHdapsGeAYclNqO1thQLmykmJhSouIZV+JJS8o1A==", + "license": "MIT", "bin": { "vscode-languagedetection": "cli/index.js" } diff --git a/remote/web/package.json b/remote/web/package.json index 35cf04c172a41..eb80319e29b22 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -8,7 +8,7 @@ "@vscode/codicons": "^0.0.45-8", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", - "@vscode/vscode-languagedetection": "1.0.21", + "@vscode/vscode-languagedetection": "1.0.23", "@xterm/addon-clipboard": "^0.3.0-beta.165", "@xterm/addon-image": "^0.10.0-beta.165", "@xterm/addon-ligatures": "^0.11.0-beta.165", From 0a67236a1550ba964868837447bf55938bd6f7ba Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:54:17 -0800 Subject: [PATCH 08/16] fix issue where working never finishes + showing too much (#296179) --- .../chatContentParts/chatThinkingContentPart.ts | 12 +++++++----- .../contrib/chat/browser/widget/chatListRenderer.ts | 8 +++++++- 2 files changed, 14 insertions(+), 6 deletions(-) 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 5de04eb38b471..58695641a7f53 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -756,8 +756,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen // case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position if (this.appendedItemCount === 1 && this.currentThinkingValue.trim() === '' && this.singleItemInfo) { - this.restoreSingleItemToOriginalPosition(); - return; + if (this.restoreSingleItemToOriginalPosition()) { + return; + } } // if exactly one actual extracted title and no tool invocations, use that as the final title. @@ -954,9 +955,9 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.setFallbackTitle(); } - private restoreSingleItemToOriginalPosition(): void { + private restoreSingleItemToOriginalPosition(): boolean { if (!this.singleItemInfo) { - return; + return false; } const { element, originalParent, originalNextSibling } = this.singleItemInfo; @@ -964,7 +965,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): // don't restore it to original position - it contains multiple rendered elements if (element.childElementCount > 1) { this.singleItemInfo = undefined; - return; + return false; } if (originalNextSibling && originalNextSibling.parentNode === originalParent) { @@ -975,6 +976,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): hide(this.domNode); this.singleItemInfo = undefined; + return true; } private setFallbackTitle(): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index b3040e59317d0..6532c59240f20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -970,12 +970,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && - part.presentation !== 'hidden' && this.shouldPinPart(part, element) )) { return false; } + + const hasRenderedThinkingPart = (templateData.renderedParts ?? []).some(part => part instanceof ChatThinkingContentPart); + const hasEditPillMarkdown = partsToRender.some(part => part.kind === 'markdownContent' && this.hasCodeblockUri(part)); + if (hasRenderedThinkingPart && hasEditPillMarkdown) { + return false; + } + // Don't show working spinner when there's any active subagent - subagents have their own progress indicator if (this.getSubagentPart(templateData.renderedParts)) { return false; From 0d26c20abce1bdd7fc8ab42642c37627a25bf9c8 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 19 Feb 2026 17:54:25 +1100 Subject: [PATCH 09/16] Fix PromptFileParser when description contains `:` (#296205) --- src/vs/base/common/yaml.ts | 10 +++ .../service/promptFileParser.test.ts | 84 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts index 1cb0388afea2e..d3f0f2bc2b461 100644 --- a/src/vs/base/common/yaml.ts +++ b/src/vs/base/common/yaml.ts @@ -126,6 +126,9 @@ class YamlScanner { private readonly tokens: Token[] = []; // Track flow nesting depth so commas and flow indicators are only special inside flow collections private flowDepth = 0; + // Track whether we've already seen a block colon on the current line. + // After the first key: value colon, subsequent ': ' on the same line is part of the scalar value. + private seenBlockColon = false; constructor(private readonly input: string) { } @@ -139,6 +142,7 @@ class YamlScanner { // Scan a single logical line (up to and including the newline character) private scanLine(): void { + this.seenBlockColon = false; // Handle blank lines / lines that are only whitespace if (this.peekChar() === '\n') { this.tokens.push(makeToken(TokenType.Newline, this.pos, this.pos + 1)); @@ -256,6 +260,9 @@ class YamlScanner { } else if (ch === ':' && this.isBlockColon()) { this.tokens.push(makeToken(TokenType.Colon, this.pos, this.pos + 1)); this.pos++; + if (this.flowDepth === 0) { + this.seenBlockColon = true; + } } else if (ch === ':' && this.flowDepth > 0 && this.lastTokenIsJsonLike()) { // In flow context, ':' immediately following a JSON-like node (quoted scalar, // flow mapping, or flow sequence) is a value indicator even without trailing space @@ -280,6 +287,9 @@ class YamlScanner { /** Check if ':' acts as a mapping value indicator (followed by space, newline, EOF, or flow indicator) */ private isBlockColon(): boolean { + // In block context, after the first key-value colon on a line, + // subsequent ': ' is part of the scalar value, not a mapping indicator. + if (this.seenBlockColon && this.flowDepth === 0) { return false; } const next = this.input[this.pos + 1]; if (next === undefined || next === ' ' || next === '\t' || next === '\n' || next === '\r') { return true; } // Flow indicators after colon only count inside flow context diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index ed6e2f47948eb..136ed9195ccac 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -451,4 +451,88 @@ suite('PromptFileParser', () => { assert.strictEqual(result4.header?.userInvocable, undefined); }); + test('agent with all header fields including colons in description', async () => { + const uri = URI.parse('file:///test/test.agent.md'); + const content = [ + '---', + 'name: Explore', + 'description: Fast read-only codebase exploration and Q&A subagent. Prefer over manually chaining multiple search and file-reading operations to avoid cluttering the main conversation. Safe to call in parallel. Specify thoroughness: quick, medium, or thorough.', + `argument-hint: Describe WHAT you're looking for and desired thoroughness (quick/medium/thorough)`, + `model: ['Claude Haiku 4.5 (copilot)', 'Gemini 3 Flash (Preview) (copilot)', 'Auto (copilot)']`, + 'target: vscode', + 'user-invocable: false', + `tools: ['search', 'read', 'web', 'vscode/memory', 'github/issue_read', 'github.vscode-pull-request-github/issue_fetch', 'github.vscode-pull-request-github/activePullRequest', 'execute/getTerminalOutput', 'execute/testFailure']`, + 'agents: []', + '---', + 'You are an exploration agent specialized in rapid codebase analysis and answering questions efficiently.', + '', + '## Search Strategy', + '', + '- Go **broad to narrow**:', + '\t1. Start with glob patterns or semantic codesearch to discover relevant areas', + '\t2. Narrow with text search (regex) or usages (LSP) for specific symbols or patterns', + '\t3. Read files only when you know the path or need full context', + '- Pay attention to provided agent instructions/rules/skills as they apply to areas of the codebase to better understand architecture and best practices.', + '- Use the github repo tool to search references in external dependencies.', + '', + '## Speed Principles', + '', + 'Adapt search strategy based on the requested thoroughness level.', + '', + '**Bias for speed** — return findings as quickly as possible:', + '- Parallelize independent tool calls (multiple greps, multiple reads)', + '- Stop searching once you have sufficient context', + '- Make targeted searches, not exhaustive sweeps', + '', + '## Output', + '', + 'Report findings directly as a message. Include:', + '- Files with absolute links', + '- Specific functions, types, or patterns that can be reused', + '- Analogous existing features that serve as implementation templates', + '- Clear answers to what was asked, not comprehensive overviews', + '', + 'Remember: Your goal is searching efficiently through MAXIMUM PARALLELISM to report concise and clear answers.', + ].join('\n'); + const result = new PromptFileParser().parse(uri, content); + assert.deepEqual(result.uri, uri); + assert.ok(result.header); + assert.ok(result.body); + + // Verify all header attributes are identified + assert.deepEqual(result.header.name, 'Explore'); + assert.deepEqual(result.header.description, 'Fast read-only codebase exploration and Q&A subagent. Prefer over manually chaining multiple search and file-reading operations to avoid cluttering the main conversation. Safe to call in parallel. Specify thoroughness: quick, medium, or thorough.'); + assert.deepEqual(result.header.argumentHint, `Describe WHAT you're looking for and desired thoroughness (quick/medium/thorough)`); + assert.deepEqual(result.header.model, ['Claude Haiku 4.5 (copilot)', 'Gemini 3 Flash (Preview) (copilot)', 'Auto (copilot)']); + assert.deepEqual(result.header.target, 'vscode'); + assert.deepEqual(result.header.userInvocable, false); + assert.deepEqual(result.header.tools, ['search', 'read', 'web', 'vscode/memory', 'github/issue_read', 'github.vscode-pull-request-github/issue_fetch', 'github.vscode-pull-request-github/activePullRequest', 'execute/getTerminalOutput', 'execute/testFailure']); + assert.deepEqual(result.header.agents, []); + + // Verify all 8 header attributes are present + assert.deepEqual(result.header.attributes.length, 8); + assert.deepEqual(result.header.attributes.map(a => a.key), [ + 'name', 'description', 'argument-hint', 'model', 'target', 'user-invocable', 'tools', 'agents' + ]); + }); + + test('agent with unquoted description containing colon-space', async () => { + const uri = URI.parse('file:///test/test.agent.md'); + const content = [ + '---', + 'name: Test', + 'description: This has a colon: in the middle', + 'target: vscode', + '---', + ].join('\n'); + const result = new PromptFileParser().parse(uri, content); + assert.ok(result.header); + + // The description contains ": " which could interfere with YAML parsing. + // All headers after it should still be identified. + assert.deepEqual(result.header.name, 'Test'); + assert.deepEqual(result.header.description, 'This has a colon: in the middle'); + assert.deepEqual(result.header.target, 'vscode'); + }); + }); From 9486004242fc3a8c60593225e38097ecd78bd755 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 18 Feb 2026 22:54:40 -0800 Subject: [PATCH 10/16] mcp: fix tool calls not associated with chat session (#296206) regression from cleanup earlier this week --- .../contrib/mcp/common/mcpLanguageModelToolContribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index f5f6687be947d..10224c53daa34 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -251,7 +251,7 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionResource: undefined }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionResource: invocation.context?.sessionResource }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], From 849dd0cbcd497546d4902d170cbe321e721f5d03 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Feb 2026 08:34:44 +0100 Subject: [PATCH 11/16] improve model picker (#296152) * improve model picker * undo revert --- .../actionWidget/browser/actionList.ts | 407 +++++++++++++-- .../browser/actionListDropdown.css | 120 ----- .../browser/actionListDropdown.ts | 473 ------------------ .../actionWidget/browser/actionWidget.css | 36 ++ .../actionWidget/browser/actionWidget.ts | 15 +- .../browser/actionWidgetDropdown.ts | 53 +- .../contrib/chat/browser/newChatViewPane.ts | 13 +- .../browser/widget/input/chatInputPart.ts | 3 +- .../browser/widget/input/chatModelPicker.ts | 298 ++++++++--- .../widget/input/modelPickerActionItem2.ts | 16 +- .../contrib/chat/common/languageModels.ts | 21 +- .../chatModelsViewModel.test.ts | 6 +- .../chat/test/common/languageModels.ts | 3 +- 13 files changed, 683 insertions(+), 781 deletions(-) delete mode 100644 src/vs/platform/actionWidget/browser/actionListDropdown.css delete 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 2a452609df613..88fa6f1d6ffb9 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; +import { renderMarkdown } from '../../../base/browser/markdownRenderer.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; @@ -10,19 +11,21 @@ import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/ import { IAction } from '../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; +import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { OS } from '../../../base/common/platform.js'; import { ThemeIcon } from '../../../base/common/themables.js'; +import { URI } from '../../../base/common/uri.js'; import './actionWidget.css'; import { localize } from '../../../nls.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; +import { IOpenerService } from '../../opener/common/opener.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'; -import { MarkdownString } from '../../../base/common/htmlContent.js'; import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; import { IHoverPositionOptions, IHoverWidget } from '../../../base/browser/ui/hover/hover.js'; @@ -43,7 +46,7 @@ export interface IActionListItemHover { /** * Content to display in the hover. */ - readonly content?: string; + readonly content?: string | MarkdownString; readonly position?: IHoverPositionOptions; } @@ -54,7 +57,7 @@ export interface IActionListItem { readonly group?: { kind?: unknown; icon?: ThemeIcon; title: string }; readonly disabled?: boolean; readonly label?: string; - readonly description?: string; + readonly description?: string | IMarkdownString; /** * Optional hover configuration shown when focusing/hovering over the item. */ @@ -67,16 +70,37 @@ 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; } 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 { @@ -145,7 +169,8 @@ class ActionItemRenderer implements IListRenderer, IAction constructor( private readonly _supportsPreview: boolean, - @IKeybindingService private readonly _keybindingService: IKeybindingService + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IOpenerService private readonly _openerService: IOpenerService, ) { } renderTemplate(container: HTMLElement): IActionMenuTemplateData { @@ -159,6 +184,10 @@ 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); @@ -171,7 +200,7 @@ class ActionItemRenderer implements IListRenderer, IAction const elementDisposables = new DisposableStore(); - return { container, icon, text, description, keybinding, toolbar, elementDisposables }; + return { container, icon, text, badge, description, keybinding, toolbar, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -194,15 +223,45 @@ 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); - // if there is a keybinding, prioritize over description for now + // 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 (element.keybinding) { data.description!.textContent = element.keybinding.getLabel(); data.description!.style.display = 'inline'; data.description!.style.letterSpacing = '0.5px'; } else if (element.description) { - data.description!.textContent = stripNewlines(element.description); + dom.clearNode(data.description!); + if (typeof element.description === 'string') { + data.description!.textContent = stripNewlines(element.description); + } else { + const rendered = renderMarkdown(element.description, { + actionHandler: (content: string) => { + this._openerService.open(URI.parse(content), { allowCommands: true }); + } + }); + data.elementDisposables.add(rendered); + data.description!.appendChild(rendered.element); + } data.description!.style.display = 'inline'; } else { data.description!.textContent = ''; @@ -211,7 +270,7 @@ class ActionItemRenderer implements IListRenderer, IAction const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel(); const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel(); - data.container.classList.toggle('option-disabled', element.disabled); + data.container.classList.toggle('option-disabled', !!element.disabled); if (element.hover !== undefined) { // Don't show tooltip when hover content is configured - the rich hover will show instead data.container.title = ''; @@ -261,6 +320,26 @@ 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; @@ -277,20 +356,38 @@ 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 _cachedMaxWidth: number | undefined; + 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, @IHoverService private readonly _hoverService: IHoverService, + @IOpenerService private readonly _openerService: IOpenerService, ) { 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) { @@ -307,19 +404,20 @@ export class ActionList extends Disposable { this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer>(preview, this._keybindingService), + new ActionItemRenderer>(preview, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { keyboardSupport: false, - typeNavigationEnabled: true, + typeNavigationEnabled: !this._options?.showFilter, keyboardNavigationLabelProvider: { getKeyboardNavigationLabel }, accessibilityProvider: { getAriaLabel: element => { if (element.kind === ActionListItemKind.Action) { let label = element.label ? stripNewlines(element?.label) : ''; if (element.description) { - label = label + ', ' + stripNewlines(element.description); + const descText = typeof element.description === 'string' ? element.description : element.description.value; + label = label + ', ' + stripNewlines(descText); } if (element.disabled) { label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled); @@ -352,17 +450,166 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); this._allMenuItems = items; - this._list.splice(0, this._list.length, this._allMenuItems); + + // 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(); 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(true); + } + + private _applyFilter(reposition?: boolean): 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 descValue = typeof item.description === 'string' ? item.description : item.description?.value ?? ''; + const desc = descValue.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 + if (reposition) { + 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; } + focus(): void { + if (this._filterInput) { + this._filterInput.focus(); + } else { + this._list.domFocus(); + } + } + hide(didCancel?: boolean): void { this._delegate.onHide(didCancel); this.cts.cancel(); @@ -371,41 +618,96 @@ export class ActionList extends Disposable { } layout(minWidth: number): number { - // 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[] = this._allMenuItems.map((_, index): number => { - const element = this._getRowElement(index); + 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; + + const totalItemCount = this._allMenuItems.length; + if (totalItemCount >= 50) { + maxWidth = Math.max(380, effectiveMinWidth); + } else if (this._cachedMaxWidth !== undefined) { + maxWidth = this._cachedMaxWidth; + } else if (totalItemCount > visibleCount) { + // Temporarily splice in all items to measure widths, + // preventing width jumps when expanding/collapsing sections. + const visibleItems: IActionListItem[] = []; + for (let i = 0; i < visibleCount; i++) { + visibleItems.push(this._list.element(i)); + } + + const allItems = [...this._allMenuItems]; + this._list.splice(0, visibleCount, allItems); + let allItemsHeight = 0; + for (const item of allItems) { + switch (item.kind) { + case ActionListItemKind.Header: allItemsHeight += this._headerLineHeight; break; + case ActionListItemKind.Separator: allItemsHeight += this._separatorLineHeight; break; + default: allItemsHeight += this._actionLineHeight; break; + } + } + this._list.layout(allItemsHeight); + + const itemWidths: number[] = []; + for (let i = 0; i < allItems.length; i++) { + const element = this._getRowElement(i); if (element) { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - return width; + itemWidths.push(width); } - return 0; - }); + } + + maxWidth = Math.max(...itemWidths, effectiveMinWidth); + this._cachedMaxWidth = maxWidth; - // resize observer - can be used in the future since list widget supports dynamic height but not width - maxWidth = Math.max(...itemWidths, minWidth); + // Restore visible items + this._list.splice(0, allItems.length, visibleItems); + this._list.layout(listHeight); + } else { + // All items are visible, measure them directly + const itemWidths: number[] = []; + for (let i = 0; i < visibleCount; i++) { + const element = this._getRowElement(i); + if (element) { + element.style.width = 'auto'; + const width = element.getBoundingClientRect().width; + element.style.width = ''; + itemWidths.push(width); + } + } + maxWidth = Math.max(...itemWidths, effectiveMinWidth); } + const filterHeight = this._filterContainer ? 36 : 0; const maxVhPrecentage = 0.7; - const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage); - this._list.layout(height, maxWidth); + 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); - this.domNode.style.height = `${height}px`; + this.domNode.style.height = `${listFinalHeight}px`; - this._list.domFocus(); return maxWidth; } @@ -447,6 +749,10 @@ 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 { @@ -476,12 +782,12 @@ export class ActionList extends Disposable { let newHover: IHoverWidget | undefined; // Show hover if the element has hover content - if (element.hover?.content && this.focusCondition(element)) { + if (element.hover?.content) { // The List widget separates data models from DOM elements, so we need to // look up the actual DOM node to use as the hover target. const rowElement = this._getRowElement(index); if (rowElement) { - const markdown = element.hover.content ? new MarkdownString(element.hover.content) : undefined; + const markdown = typeof element.hover.content === 'string' ? new MarkdownString(element.hover.content) : element.hover.content; newHover = this._hoverService.showDelayedHover({ content: markdown ?? '', target: rowElement, @@ -513,19 +819,32 @@ export class ActionList extends Disposable { return; } + // Set focus immediately for responsive hover feedback + this._list.setFocus(typeof e.index === 'number' ? [e.index] : []); + if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) { const result = await this._delegate.onHover(element.item, this.cts.token); - element.canPreview = result ? result.canPreview : undefined; - } - if (e.index) { - this._list.splice(e.index, 1, [element]); + const canPreview = result ? result.canPreview : undefined; + if (canPreview !== element.canPreview) { + element.canPreview = canPreview; + if (typeof e.index === 'number') { + this._list.splice(e.index, 1, [element]); + this._list.setFocus([e.index]); + } + } } - - this._list.setFocus(typeof e.index === 'number' ? [e.index] : []); + } else if (element && element.hover?.content && typeof e.index === 'number') { + // Show hover for disabled items that have hover content + this._showHoverForElement(element, e.index); } } 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 deleted file mode 100644 index e4b24d38c6618..0000000000000 --- a/src/vs/platform/actionWidget/browser/actionListDropdown.css +++ /dev/null @@ -1,120 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 deleted file mode 100644 index ee332a2e0569f..0000000000000 --- a/src/vs/platform/actionWidget/browser/actionListDropdown.ts +++ /dev/null @@ -1,473 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * 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 b2aaa8b1e00f7..eb97852b4c631 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -149,6 +149,16 @@ 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); @@ -227,3 +237,29 @@ 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 21b49245bebcc..88c6cf608e5ab 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, previewSelectedActionCommand } from './actionList.js'; +import { acceptSelectedActionCommand, ActionList, IActionListDelegate, IActionListItem, IActionListOptions, 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>>): void; + 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; 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>>): void { + 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 { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); - const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider); + const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions); this._contextViewService.showContextView({ getAnchor: () => anchor, render: (container: HTMLElement) => { @@ -137,9 +137,16 @@ 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`; + this._list.value?.focus(); + const focusTracker = renderDisposables.add(dom.trackFocus(element)); renderDisposables.add(focusTracker.onDidBlur(() => { // Don't hide if focus moved to a hover that belongs to this action widget diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 296286bd51494..b7b61da059f8e 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 } from './actionList.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } 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,6 +52,11 @@ 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; } /** @@ -77,7 +82,7 @@ export class ActionWidgetDropdown extends BaseDropdown { return; } - const actionBarActions = this._options.actionBarActions ?? this._options.actionBarActionProvider?.getActions() ?? []; + let 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,13 +159,9 @@ export class ActionWidgetDropdown extends BaseDropdown { const previouslyFocusedElement = getActiveElement(); - const auxiliaryActionIds = new Set(actionBarActions.map(action => action.id)); - const actionWidgetDelegate: IActionListDelegate = { onSelect: (action, preview) => { - if (!auxiliaryActionIds.has(action.id)) { - selectedOption = action; - } + selectedOption = action; this.actionWidgetService.hide(); action.run(); }, @@ -172,30 +173,13 @@ export class ActionWidgetDropdown extends BaseDropdown { } }; - 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, - }); + actionBarActions = actionBarActions.map(action => ({ + ...action, + run: async (...args: unknown[]) => { + this.actionWidgetService.hide(); + return action.run(...args); } - } + })); const accessibilityProvider: Partial>> = { isChecked(element) { @@ -204,9 +188,7 @@ export class ActionWidgetDropdown extends BaseDropdown { getRole: (e) => { switch (e.kind) { case ActionListItemKind.Action: - // 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'; + return 'menuitemcheckbox'; case ActionListItemKind.Separator: return 'separator'; default: @@ -223,8 +205,9 @@ export class ActionWidgetDropdown extends BaseDropdown { actionWidgetDelegate, this._options.getAnchor?.() ?? this.element, undefined, - [], - accessibilityProvider + actionBarActions, + accessibilityProvider, + this._options.listOptions ); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 65d4286e855aa..61fd3a1cd3dbb 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -468,6 +468,7 @@ class NewChatWidget extends Disposable { currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._currentLanguageModel.set(model, undefined); + this.languageModelsService.addToRecentlyUsedList(model); }, getModels: () => this._getAvailableModels(), canManageModels: () => true, @@ -509,7 +510,17 @@ class NewChatWidget extends Disposable { const metadata = this.languageModelsService.lookupLanguageModel(id); return metadata ? { metadata, identifier: id } : undefined; }) - .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && !!m.metadata.isUserSelectable); + .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && this.shouldShowModel(m)); + } + + private shouldShowModel(model: ILanguageModelChatMetadataAndIdentifier): boolean { + if (!model.metadata.isUserSelectable) { + return false; + } + if (model.metadata.targetChatSessionType === AgentSessionProviders.Background) { + return false; + } + return true; } // --- Welcome: Target & option pickers (dropdown row below input) --- 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 a8e16ae9913b1..a4afd7702f01f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -998,8 +998,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier) { this._currentLanguageModel.set(model, undefined); - // Record usage for the recently used models list - this.languageModelsService.recordModelUsage(model); + this.languageModelsService.addToRecentlyUsedList(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 8f3607618d88d..23af9f66d09d5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -8,21 +8,23 @@ import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEv import { renderIcon, renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.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 { IActionListDropdownOptions, IActionListDropdownEntry, IActionListDropdownItem, ActionListDropdown, ActionListDropdownItemKind } from '../../../../../../platform/actionWidget/browser/actionListDropdown.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 { 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'; import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; import { ICuratedModel, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; -import { URI } from '../../../../../../base/common/uri.js'; import * as semver from '../../../../../../base/common/semver/semver.js'; +import { IModelPickerDelegate } from './modelPickerActionItem.js'; function isVersionAtLeast(current: string, required: string): boolean { const currentSemver = semver.coerce(current); @@ -52,11 +54,18 @@ type ChatModelChangeEvent = { }; function createModelItem( - action: IActionListDropdownItem, -): IActionListDropdownEntry { + action: IActionWidgetDropdownAction & { section?: string }, + model?: ILanguageModelChatMetadataAndIdentifier, +): IActionListItem { return { item: action, - kind: ActionListDropdownItemKind.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, + hover: model ? { content: getModelHoverContent(model) } : undefined, }; } @@ -65,11 +74,13 @@ function createModelAction( selectedModelId: string | undefined, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, section?: string, -): IActionListDropdownItem { +): IActionWidgetDropdownAction & { section?: string } { 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, @@ -96,10 +107,9 @@ function buildModelPickerItems( currentVSCodeVersion: string, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, commandService: ICommandService, - openerService: IOpenerService, upgradePlanUrl: string | undefined, -): IActionListDropdownEntry[] { - const items: IActionListDropdownEntry[] = []; +): IActionListItem[] { + const items: IActionListItem[] = []; // Collect all available models const allModelsMap = new Map(); @@ -117,30 +127,33 @@ function buildModelPickerItems( const placed = new Set(); // --- 1. Auto --- - const isAutoSelected = !selectedModelId || !allModelsMap.has(selectedModelId); - const defaultModel = models.find(m => Object.values(m.metadata.isDefaultForLocation).some(v => v)); - const autoDescription = defaultModel?.metadata.multiplier ?? defaultModel?.metadata.detail; - items.push(createModelItem({ - id: 'auto', - checked: isAutoSelected, - tooltip: localize('chat.modelPicker.auto', "Auto"), - label: localize('chat.modelPicker.auto', "Auto"), - description: autoDescription, - run: () => { - if (defaultModel) { - onSelect(defaultModel); - } - } - })); + const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot')!; + // Always mark the auto model as placed + if (autoModel) { + placed.add(autoModel.identifier); + placed.add(autoModel.metadata.id); + const action = createModelAction(autoModel, selectedModelId, onSelect); + items.push(createModelItem(action, autoModel)); + } // --- 2. Promoted models (recently used + curated, merged & sorted alphabetically) --- const promotedModels: ILanguageModelChatMetadataAndIdentifier[] = []; const unavailableCurated: { curated: ICuratedModel; reason: 'upgrade' | 'update' | 'admin' }[] = []; - // Add recently used (skip the default model - it's already represented by "Auto") + // Always include the currently selected model in the promoted group + if (selectedModelId && selectedModelId !== autoModel?.identifier) { + const selectedModel = allModelsMap.get(selectedModelId); + if (selectedModel && !placed.has(selectedModel.identifier)) { + promotedModels.push(selectedModel); + placed.add(selectedModel.identifier); + placed.add(selectedModel.metadata.id); + } + } + + // Add recently used for (const id of recentModelIds) { const model = allModelsMap.get(id); - if (model && !placed.has(model.identifier) && model !== defaultModel) { + if (model && !placed.has(model.identifier)) { promotedModels.push(model); placed.add(model.identifier); placed.add(model.metadata.id); @@ -151,9 +164,13 @@ function buildModelPickerItems( for (const curated of curatedModels) { const model = allModelsMap.get(curated.id) ?? modelsByMetadataId.get(curated.id); if (model && !placed.has(model.identifier) && !placed.has(model.metadata.id)) { - promotedModels.push(model); placed.add(model.identifier); placed.add(model.metadata.id); + if (curated.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, curated.minVSCodeVersion)) { + unavailableCurated.push({ curated, reason: 'update' }); + } else { + promotedModels.push(model); + } } else if (!model) { // Model is not available - determine reason if (!isProUser) { @@ -171,36 +188,51 @@ function buildModelPickerItems( if (promotedModels.length > 0 || unavailableCurated.length > 0) { items.push({ - kind: ActionListDropdownItemKind.Separator, + kind: ActionListItemKind.Separator, }); for (const model of promotedModels) { const action = createModelAction(model, selectedModelId, onSelect); - items.push(createModelItem(action)); + items.push(createModelItem(action, model)); } - // Unavailable curated models shown as disabled with action button + // Unavailable curated models shown as disabled with action link for (const { curated, reason } of unavailableCurated) { - const label = reason === 'upgrade' - ? localize('chat.modelPicker.upgrade', "Upgrade") - : reason === 'update' - ? localize('chat.modelPicker.update', "Update VS Code") - : localize('chat.modelPicker.adminEnable', "Contact Admin"); - const onButtonClick = reason === 'upgrade' && upgradePlanUrl - ? () => openerService.open(URI.parse(upgradePlanUrl)) - : reason === 'update' - ? () => commandService.executeCommand('update.checkForUpdate') - : () => { }; + let description: string | MarkdownString; + if (reason === 'upgrade' && upgradePlanUrl) { + description = new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade]({0})", upgradePlanUrl), { isTrusted: true }); + } else if (reason === 'update') { + description = new MarkdownString(localize('chat.modelPicker.updateLink', "[Update VS Code](command:update.checkForUpdate)"), { isTrusted: true }); + } else { + description = localize('chat.modelPicker.adminEnable', "Contact Admin"); + } + + const hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + if (reason === 'upgrade' && upgradePlanUrl) { + hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "This model requires a paid plan. [Upgrade]({0}) to access it.", upgradePlanUrl)); + } else if (reason === 'update') { + hoverContent.appendMarkdown(localize('chat.modelPicker.updateHover', "This model requires a newer version of VS Code. [Update VS Code](command:update.checkForUpdate) to access it.")); + } else { + hoverContent.appendMarkdown(localize('chat.modelPicker.adminHover', "This model is not available. Contact your administrator to enable it.")); + } + items.push({ item: { id: curated.id, - tooltip: label, - label: curated.id, - disabled: true, - descriptionButton: { label, onDidClick: onButtonClick }, - className: 'unavailable-model', + enabled: false, + checked: false, + class: undefined, + tooltip: curated.label, + label: curated.label, + description: typeof description === 'string' ? description : undefined, run: () => { } }, - kind: ActionListDropdownItemKind.Action, + kind: ActionListItemKind.Action, + label: curated.label, + description, + disabled: true, + group: { title: '', icon: Codicon.blank }, + hideIcon: false, + hover: { content: hoverContent }, }); } } @@ -209,57 +241,81 @@ function buildModelPickerItems( const otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; for (const model of models) { 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) { - continue; - } otherModels.push(model); } } + // Copilot models first, then by vendor, each sub-group sorted alphabetically + otherModels.sort((a, b) => { + const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; + const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; + if (aCopilot !== bCopilot) { + return aCopilot - bCopilot; + } + const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); + if (vendorCmp !== 0) { + return vendorCmp; + } + return a.metadata.name.localeCompare(b.metadata.name); + }); if (otherModels.length > 0) { items.push({ - kind: ActionListDropdownItemKind.Separator, + kind: ActionListItemKind.Separator, }); items.push({ item: { id: 'otherModels', - label: localize('chat.modelPicker.otherModels', "Other Models"), + enabled: true, + checked: false, + class: undefined, tooltip: localize('chat.modelPicker.otherModels', "Other Models"), - section: ModelPickerSection.Other, - isSectionToggle: true, + label: localize('chat.modelPicker.otherModels', "Other Models"), run: () => { /* toggle handled by isSectionToggle */ } }, - kind: ActionListDropdownItemKind.Action, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.otherModels', "Other Models"), + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: ModelPickerSection.Other, + isSectionToggle: true, }); for (const model of otherModels) { const action = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other); - items.push(createModelItem(action)); + items.push(createModelItem(action, model)); } // "Manage Models..." entry inside Other Models section, styled as a link items.push({ item: { id: 'manageModels', - label: localize('chat.manageModels', "Manage Models..."), + enabled: true, + checked: false, + class: 'manage-models-action', tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), + label: localize('chat.manageModels', "Manage Models..."), icon: Codicon.settingsGear, - section: ModelPickerSection.Other, - className: 'manage-models-link', run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } }, - kind: ActionListDropdownItemKind.Action, + kind: ActionListItemKind.Action, + label: localize('chat.manageModels', "Manage Models..."), + group: { title: '', icon: Codicon.settingsGear }, + hideIcon: false, + section: ModelPickerSection.Other, + className: 'manage-models-link', }); } return items; } -function getActionListDropdownOptions(): IActionListDropdownOptions { +/** + * Returns the ActionList options for the model picker (filter + collapsed sections). + */ +function getModelPickerListOptions(): IActionListOptions { return { + showFilter: true, collapsedByDefault: new Set([ModelPickerSection.Other]), minWidth: 300, }; @@ -282,13 +338,11 @@ export class ModelPickerWidget extends Disposable { private readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; - private _models: ILanguageModelChatMetadataAndIdentifier[] = []; private _selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; private _badge: ModelPickerBadge | undefined; private _domNode: HTMLElement | undefined; private _badgeIcon: HTMLElement | undefined; - private readonly _dropdown: ActionListDropdown; get selectedModel(): ILanguageModelChatMetadataAndIdentifier | undefined { return this._selectedModel; @@ -299,21 +353,15 @@ export class ModelPickerWidget extends Disposable { } constructor( - @IInstantiationService private readonly _instantiationService: IInstantiationService, + private readonly _delegate: IModelPickerDelegate, + @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @ICommandService private readonly _commandService: ICommandService, - @IOpenerService private readonly _openerService: IOpenerService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IProductService private readonly _productService: IProductService, @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, ) { super(); - this._dropdown = this._register(this._instantiationService.createInstance(ActionListDropdown)); - } - - setModels(models: ILanguageModelChatMetadataAndIdentifier[]): void { - this._models = models; - this._renderLabel(); } setSelectedModel(model: ILanguageModelChatMetadataAndIdentifier | undefined): void { @@ -380,7 +428,7 @@ export class ModelPickerWidget extends Disposable { const curatedForTier = isPro ? curatedModels.paid : curatedModels.free; const items = buildModelPickerItems( - this._models, + this._delegate.getModels(), this._selectedModel?.identifier, this._languageModelsService.getRecentlyUsedModelIds(), curatedForTier, @@ -388,25 +436,50 @@ export class ModelPickerWidget extends Disposable { this._productService.version, onSelect, this._commandService, - this._openerService, this._productService.defaultChatAgent?.upgradePlanUrl, ); - const dropdownOptions = getActionListDropdownOptions(); + const listOptions = getModelPickerListOptions(); + const previouslyFocusedElement = dom.getActiveElement(); const delegate = { - onSelect: (item: IActionListDropdownItem) => { - this._dropdown.hide(); - item.run(); + onSelect: (action: IActionWidgetDropdownAction) => { + this._actionWidgetService.hide(); + action.run(); }, onHide: () => { this._domNode?.setAttribute('aria-expanded', 'false'); + if (dom.isHTMLElement(previouslyFocusedElement)) { + previouslyFocusedElement.focus(); + } } }; this._domNode?.setAttribute('aria-expanded', 'true'); - this._dropdown.show(items, delegate, anchorElement, dropdownOptions); + 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 + ); } private _updateBadge(): void { @@ -452,3 +525,66 @@ export class ModelPickerWidget extends Disposable { this._domNode.ariaLabel = localize('chat.modelPicker.ariaLabel', "Pick Model, {0}", modelName); } } + + +function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString { + const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + markdown.appendMarkdown(`**${model.metadata.name}**`); + if (model.metadata.id !== model.metadata.version) { + markdown.appendMarkdown(`  _${model.metadata.id}@${model.metadata.version}_ `); + } else { + markdown.appendMarkdown(`  _${model.metadata.id}_ `); + } + markdown.appendText(`\n`); + + if (model.metadata.statusIcon && model.metadata.tooltip) { + if (model.metadata.statusIcon) { + markdown.appendMarkdown(`$(${model.metadata.statusIcon.id}) `); + } + markdown.appendMarkdown(`${model.metadata.tooltip}`); + markdown.appendText(`\n`); + } + + if (model.metadata.multiplier) { + markdown.appendMarkdown(`${localize('models.cost', 'Multiplier')}: `); + markdown.appendMarkdown(model.metadata.multiplier); + markdown.appendMarkdown(` - ${localize('multiplier.tooltip', "Every chat message counts {0} towards your premium model request quota", model.metadata.multiplier)}`); + markdown.appendText(`\n`); + } + + if (model.metadata.maxInputTokens || model.metadata.maxOutputTokens) { + const totalTokens = (model.metadata.maxInputTokens ?? 0) + (model.metadata.maxOutputTokens ?? 0); + markdown.appendMarkdown(`${localize('models.contextSize', 'Context Size')}: `); + markdown.appendMarkdown(`${formatTokenCount(totalTokens)}`); + markdown.appendText(`\n`); + } + + if (model.metadata.capabilities) { + markdown.appendMarkdown(`${localize('models.capabilities', 'Capabilities')}: `); + if (model.metadata.capabilities?.toolCalling) { + markdown.appendMarkdown(`  _${localize('models.toolCalling', 'Tools')}_ `); + } + if (model.metadata.capabilities?.vision) { + markdown.appendMarkdown(`  _${localize('models.vision', 'Vision')}_ `); + } + if (model.metadata.capabilities?.agentMode) { + markdown.appendMarkdown(`  _${localize('models.agentMode', 'Agent Mode')}_ `); + } + for (const editTool of model.metadata.capabilities.editTools ?? []) { + markdown.appendMarkdown(`  _${editTool}_ `); + } + markdown.appendText(`\n`); + } + + return markdown; +} + + +function formatTokenCount(count: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } else if (count >= 1000) { + return `${(count / 1000).toFixed(0)}K`; + } + return count.toString(); +} 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 03b20e871faa6..6d302d2e00366 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts @@ -15,7 +15,6 @@ import { localize } from '../../../../../../nls.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; -import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ModelPickerWidget } from './chatModelPicker.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; @@ -37,12 +36,10 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IKeybindingService private readonly keybindingService: IKeybindingService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, ) { super(undefined, action); - this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget)); - this._pickerWidget.setModels(delegate.getModels()); + this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget, delegate)); this._pickerWidget.setSelectedModel(delegate.currentModel.get()); // Sync delegate → widget when model list or selection changes externally @@ -53,16 +50,7 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { })); // Sync widget → delegate when user picks a model - this._register(this._pickerWidget.onDidChangeSelection(model => { - delegate.setModel(model); - })); - - // Update models when language models change - this._register(this.languageModelsService.onDidChangeLanguageModels(() => { - this._pickerWidget.setModels(delegate.getModels()); - })); - - // Update badge when new models appear + this._register(this._pickerWidget.onDidChangeSelection(model => delegate.setModel(model))); } override render(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 8e5a16a4496bb..c40a1dd2d11e6 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -376,7 +376,7 @@ export interface ILanguageModelsService { /** * Records that a model was used, updating the recently used list. */ - recordModelUsage(model: ILanguageModelChatMetadataAndIdentifier): void; + addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void; /** * Returns the curated models from the models control manifest, @@ -384,6 +384,11 @@ export interface ILanguageModelsService { */ getCuratedModels(): ICuratedModels; + /** + * Fires when curated models change. + */ + readonly onDidChangeCuratedModels: Event; + /** * Observable map of restricted chat participant names to allowed extension publisher/IDs. * Fetched from the chat control manifest. @@ -393,6 +398,7 @@ export interface ILanguageModelsService { export interface ICuratedModel { readonly id: string; + readonly label: string; readonly isNew?: boolean; readonly minVSCodeVersion?: string; } @@ -494,6 +500,7 @@ const CHAT_CURATED_MODELS_STORAGE_KEY = 'chat.curatedModels'; interface IRawCuratedModel { readonly id: string; + readonly label: string; readonly isNew?: boolean; readonly minVSCodeVersion?: string; } @@ -532,6 +539,10 @@ export class LanguageModelsService implements ILanguageModelsService { readonly onDidChangeLanguageModels: Event = this._onLanguageModelChange.event; private _recentlyUsedModelIds: string[] = []; + + private readonly _onDidChangeCuratedModels = this._store.add(new Emitter()); + readonly onDidChangeCuratedModels = this._onDidChangeCuratedModels.event; + private _curatedModels: ICuratedModels = { free: [], paid: [] }; private _chatControlUrl: string | undefined; @@ -1374,11 +1385,11 @@ export class LanguageModelsService implements ILanguageModelsService { getRecentlyUsedModelIds(): string[] { // Filter to only include models that still exist in the cache return this._recentlyUsedModelIds - .filter(id => this._modelCache.has(id)) + .filter(id => this._modelCache.has(id) && id !== 'auto') .slice(0, 5); } - recordModelUsage(model: ILanguageModelChatMetadataAndIdentifier): void { + addToRecentlyUsedList(model: ILanguageModelChatMetadataAndIdentifier): void { if (model.metadata.id === 'auto' && this._vendors.get(model.metadata.vendor)?.isDefault) { return; } @@ -1406,7 +1417,7 @@ export class LanguageModelsService implements ILanguageModelsService { } private _setCuratedModels(free: IRawCuratedModel[], paid: IRawCuratedModel[]): void { - const toPublic = (m: IRawCuratedModel): ICuratedModel => ({ id: m.id, isNew: m.isNew, minVSCodeVersion: m.minVSCodeVersion }); + const toPublic = (m: IRawCuratedModel): ICuratedModel => ({ id: m.id, label: m.label, isNew: m.isNew, minVSCodeVersion: m.minVSCodeVersion }); this._curatedModels = { free: [], paid: [] }; const newIds = new Set(); @@ -1424,6 +1435,8 @@ export class LanguageModelsService implements ILanguageModelsService { newIds.add(model.id); } } + + this._onDidChangeCuratedModels.fire(this._curatedModels); } //#region Chat control data 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 f38cca659ae72..a050ec83e5eb0 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 } from '../../../../../../base/common/event.js'; +import { Emitter, Event } 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'; @@ -29,6 +29,8 @@ class MockLanguageModelsService implements ILanguageModelsService { private readonly _onDidChangeLanguageModelVendors = new Emitter(); readonly onDidChangeLanguageModelVendors = this._onDidChangeLanguageModelVendors.event; + onDidChangeCuratedModels = Event.None; + addVendor(vendor: IUserFriendlyLanguageModel): void { this.vendors.push(vendor); this.modelsByVendor.set(vendor.vendor, []); @@ -137,7 +139,7 @@ class MockLanguageModelsService implements ILanguageModelsService { async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } getRecentlyUsedModelIds(): string[] { return []; } - recordModelUsage(): void { } + addToRecentlyUsedList(): void { } getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; } 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 33da5451517c5..ee74cce64c1d2 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -24,6 +24,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { onDidChangeLanguageModels = Event.None; onDidChangeLanguageModelVendors = Event.None; + onDidChangeCuratedModels = Event.None; updateModelPickerPreference(modelIdentifier: string, showInModelPicker: boolean): void { return; @@ -91,7 +92,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { return []; } - recordModelUsage(): void { } + addToRecentlyUsedList(): void { } getCuratedModels(): ICuratedModels { return { free: [], paid: [] }; From eadcfc2413831fcafa29506858a38410ec7eb352 Mon Sep 17 00:00:00 2001 From: Pierce Boggan Date: Wed, 18 Feb 2026 23:42:48 -0800 Subject: [PATCH 12/16] Add ability to fork conversations (from Chat or with /fork) (#296093) * Add ability to fork conversations (from Chat or with /fork) * fix padding/width * fix styling, add a dot --------- Co-authored-by: Justin Chen <54879025+justschen@users.noreply.github.com> Co-authored-by: justschen --- .../chat/browser/actions/chatForkActions.ts | 192 ++++++++++++++++++ .../contrib/chat/browser/chat.contribution.ts | 2 + .../contrib/chat/browser/chatSlashCommands.ts | 10 + .../chat/browser/widget/media/chat.css | 20 ++ 4 files changed, 224 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts new file mode 100644 index 0000000000000..1521270c59bec --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { revive } from '../../../../../base/common/marshalling.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; +import type { ISerializableChatData } from '../../common/model/chatModel.js'; +import { isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { CHAT_CATEGORY } from './chatActions.js'; +import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; + +export function registerChatForkActions() { + registerAction2(class ForkConversationAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.forkConversation', + title: localize2('chat.forkConversation.label', "Fork Conversation"), + tooltip: localize2('chat.forkConversation.tooltip', "Fork conversation from this point"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.repoForked, + precondition: ChatContextKeys.enabled, + menu: [ + { + id: MenuId.ChatMessageCheckpoint, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and(ChatContextKeys.isRequest, ChatContextKeys.lockedToCodingAgent.negate()) + } + ] + }); + } + + async run(accessor: ServicesAccessor, ...args: unknown[]) { + const chatWidgetService = accessor.get(IChatWidgetService); + const chatService = accessor.get(IChatService); + const forkedTitlePrefix = localize('chat.forked.titlePrefix', "Forked: "); + + // When invoked via /fork slash command, args[0] is a URI (sessionResource). + // Fork at the last request in that session. + if (URI.isUri(args[0])) { + const sourceSessionResource = args[0]; + const chatModel = chatService.getSession(sourceSessionResource); + if (!chatModel) { + return; + } + + const serializedData = chatModel.toJSON(); + if (serializedData.requests.length === 0) { + return; + } + + const cleanData = revive(JSON.parse(JSON.stringify(serializedData))) as ISerializableChatData; + cleanData.sessionId = generateUuid(); + const forkTimestamp = Date.now(); + cleanData.creationDate = forkTimestamp; + cleanData.customTitle = chatModel.title.startsWith(forkedTitlePrefix) + ? chatModel.title + : localize('chat.forked.title', "Forked: {0}", chatModel.title); + for (const [index, req] of cleanData.requests.entries()) { + req.shouldBeRemovedOnSend = undefined; + req.isHidden = undefined; + // Generate fresh IDs so the tree doesn't reuse stale DOM from the source session + req.requestId = generateUuid(); + req.responseId = req.responseId ? generateUuid() : undefined; + req.timestamp = forkTimestamp + index; + if (req.response) { + req.modelState = { value: ResponseModelState.Complete, completedAt: forkTimestamp + index }; + } + } + + const modelRef = chatService.loadSessionFromContent(cleanData); + if (!modelRef) { + return; + } + + // Defer navigation until after the slash command flow completes. + const newSessionResource = modelRef.object.sessionResource; + setTimeout(async () => { + try { + await chatWidgetService.openSession(newSessionResource, ChatViewPaneTarget); + } finally { + modelRef.dispose(); + } + }, 0); + return; + } + + // When invoked from the checkpoint menu, args[0] is a ChatTreeItem. + const arg = args[0] as { element?: unknown; context?: unknown; item?: unknown } | undefined; + let item: ChatTreeItem | undefined = isChatTreeItem(arg) + ? arg + : isChatTreeItem(arg?.element) + ? arg.element + : isChatTreeItem(arg?.context) + ? arg.context + : isChatTreeItem(arg?.item) + ? arg.item + : undefined; + const widget = (item && chatWidgetService.getWidgetBySessionResource(item.sessionResource)) || chatWidgetService.lastFocusedWidget; + if (!isResponseVM(item) && !isRequestVM(item)) { + item = widget?.getFocus(); + } + + if (!item) { + return; + } + + const sessionResource = widget?.viewModel?.sessionResource ?? (isChatTreeItem(item) ? item.sessionResource : undefined); + if (!sessionResource) { + return; + } + + const chatModel = chatService.getSession(sessionResource); + if (!chatModel) { + return; + } + + // Get all requests and find the target request index + const targetRequestId = isRequestVM(item) ? item.id : isResponseVM(item) ? item.requestId : undefined; + if (!targetRequestId) { + return; + } + + // Export the full session data and truncate to include only requests up to and including the target + const serializedData = chatModel.toJSON(); + const isRequestItem = isRequestVM(item); + let targetIndex = -1; + if (widget?.viewModel) { + let requestIndex = -1; + for (const entry of widget.viewModel.getItems()) { + if (isRequestVM(entry)) { + requestIndex += 1; + } + if (entry.id === item?.id) { + targetIndex = isRequestVM(entry) ? Math.max(0, requestIndex - 1) : requestIndex; + break; + } + } + } + if (targetIndex < 0) { + const requestIndex = chatModel.getRequests().findIndex(r => r.id === targetRequestId); + targetIndex = isRequestItem ? Math.max(0, requestIndex - 1) : requestIndex; + } + if (targetIndex < 0) { + return; + } + + const forkedData = revive(JSON.parse(JSON.stringify({ + ...serializedData, + requests: serializedData.requests.slice(0, targetIndex + 1), + }))) as ISerializableChatData; + forkedData.sessionId = generateUuid(); + const forkedTimestamp = Date.now(); + forkedData.creationDate = forkedTimestamp; + forkedData.customTitle = chatModel.title.startsWith(forkedTitlePrefix) + ? chatModel.title + : localize('chat.forked.title', "Forked: {0}", chatModel.title); + for (const [index, req] of forkedData.requests.entries()) { + req.shouldBeRemovedOnSend = undefined; + req.isHidden = undefined; + // Generate fresh IDs so the tree doesn't reuse stale DOM from the source session + req.requestId = generateUuid(); + req.responseId = req.responseId ? generateUuid() : undefined; + req.timestamp = forkedTimestamp + index; + if (req.response) { + req.modelState = { value: ResponseModelState.Complete, completedAt: forkedTimestamp + index }; + } + } + + const modelRef = chatService.loadSessionFromContent(forkedData); + + if (!modelRef) { + return; + } + + // Navigate to the new session in the chat view pane + const newSessionResource = modelRef.object.sessionResource; + await chatWidgetService.openSession(newSessionResource, ChatViewPaneTarget); + modelRef.dispose(); + } + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 5144e6372af8b..88aeddb80f474 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -74,6 +74,7 @@ import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js' import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; import { registerChatFileTreeActions } from './actions/chatFileTreeActions.js'; import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; +import { registerChatForkActions } from './actions/chatForkActions.js'; import { registerChatExportActions } from './actions/chatImportExport.js'; import { registerLanguageModelActions } from './actions/chatLanguageModelActions.js'; import { registerMoveActions } from './actions/chatMoveActions.js'; @@ -1503,6 +1504,7 @@ registerChatExecuteActions(); registerChatQueueActions(); registerQuickChatActions(); registerChatExportActions(); +registerChatForkActions(); registerMoveActions(); registerNewChatActions(); registerChatContextActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index b701123469531..297e6d823003b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -128,6 +128,16 @@ export class ChatSlashCommandsContribution extends Disposable { }, async () => { await commandService.executeCommand(CONFIGURE_PROMPTS_ACTION_ID); })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'fork', + detail: nls.localize('fork', "Fork conversation into a new chat session"), + sortText: 'z2_fork', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async (_prompt, _progress, _history, _location, sessionResource) => { + await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'rename', detail: nls.localize('rename', "Rename this chat"), diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index e0c99f4eec624..9ff7e671ca371 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2788,6 +2788,7 @@ have to be updated for changes to the rules above, or to support more deeply nes width: fit-content; user-select: none; flex-shrink: 0; + gap: 4px; } .monaco-toolbar .action-label { @@ -2797,6 +2798,25 @@ have to be updated for changes to the rules above, or to support more deeply nes border: 1px solid transparent; background-color: transparent; padding: 1px 5px; + margin-right: 5px; + } + + .monaco-toolbar .action-label.codicon.codicon-repo-forked { + width: fit-content; + padding: 2px 5px; + } + + .monaco-toolbar .actions-container > .action-item:not(:first-child):has(.action-label.codicon.codicon-repo-forked) { + display: flex; + align-items: center; + gap: 4px; + } + + .monaco-toolbar .actions-container > .action-item:not(:first-child):has(.action-label.codicon.codicon-repo-forked)::before { + content: '\00B7'; + font-size: 12px; + line-height: 18px; + color: var(--vscode-descriptionForeground); } .monaco-toolbar .action-label:hover { From 0249c2acf51e07f34244edfff66a3841fb7ad371 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:46:21 -0800 Subject: [PATCH 13/16] Fix terminal-suggest for esbuild Missed that this extension was making `require` calls with dynamic names. I think the best approach is to convert it to use static imports instead. These files were already being loaded eagerly by `terminalSuggestMain` so it shouldn't be a big change Also makes `update-specs.js` be the source of truth for the packages to include instead of pulling in from the extension --- .../terminal-suggest/scripts/update-specs.js | 148 +++++++++++++++++- extensions/terminal-suggest/src/constants.ts | 124 --------------- .../src/terminalSuggestMain.ts | 6 +- .../terminal-suggest/src/upstreamSpecs.ts | 106 +++++++++++++ 4 files changed, 255 insertions(+), 129 deletions(-) create mode 100644 extensions/terminal-suggest/src/upstreamSpecs.ts diff --git a/extensions/terminal-suggest/scripts/update-specs.js b/extensions/terminal-suggest/scripts/update-specs.js index 4376830d6fcbc..15b9bd3b2f829 100644 --- a/extensions/terminal-suggest/scripts/update-specs.js +++ b/extensions/terminal-suggest/scripts/update-specs.js @@ -8,7 +8,130 @@ const fs = require('fs'); const path = require('path'); -const upstreamSpecs = require('../out/constants.js').upstreamSpecs; +/** @type {string[]} */ +const upstreamSpecs = [ + 'basename', + 'cat', + 'chmod', + 'chown', + 'clear', + 'cp', + 'curl', + 'cut', + 'date', + 'dd', + 'df', + 'diff', + 'dig', + 'dirname', + 'du', + 'echo', + 'env', + 'export', + 'fdisk', + 'find', + 'fmt', + 'fold', + 'grep', + 'head', + 'htop', + 'id', + 'jq', + 'kill', + 'killall', + 'less', + 'ln', + 'ls', + 'lsblk', + 'lsof', + 'mkdir', + 'more', + 'mount', + 'mv', + 'nl', + 'od', + 'paste', + 'ping', + 'pkill', + 'ps', + 'pwd', + 'readlink', + 'rm', + 'rmdir', + 'rsync', + 'scp', + 'sed', + 'seq', + 'shred', + 'sort', + 'source', + 'split', + 'stat', + 'su', + 'sudo', + 'tac', + 'tail', + 'tar', + 'tee', + 'time', + 'top', + 'touch', + 'tr', + 'traceroute', + 'tree', + 'truncate', + 'uname', + 'uniq', + 'unzip', + 'wc', + 'wget', + 'where', + 'whereis', + 'which', + 'who', + 'xargs', + 'xxd', + 'zip', + + // OS package management + 'apt', + 'brew', + + // Editors + 'nano', + 'vim', + + // Shells + 'ssh', + + // Android + 'adb', + + // Docker + 'docker', + 'docker-compose', + + // Dotnet + 'dotnet', + + // Go + 'go', + + // JavaScript / TypeScript + 'node', + 'nvm', + 'yo', + + // Python + 'python', + 'python3', + 'ruff', + + // Ruby + 'bundle', + 'ruby', +]; + const extRoot = path.resolve(path.join(__dirname, '..')); const replaceStrings = [ [ @@ -94,3 +217,26 @@ for (const spec of upstreamSpecs) { fs.writeFileSync(destination, content); } + +// Generate upstreamSpecs.ts with re-exports +function specToExportName(spec) { + return spec.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +} + +function specToReExport(spec) { + return `export { default as ${specToExportName(spec)} } from './completions/upstream/${spec}';`; +} + +const copyright = `/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/`; + +const lines = [ + copyright, + '', + '// This file is generated by scripts/update-specs.js', + ...upstreamSpecs.map(specToReExport), + '', +]; +fs.writeFileSync(path.join(extRoot, 'src/upstreamSpecs.ts'), lines.join('\n')); diff --git a/extensions/terminal-suggest/src/constants.ts b/extensions/terminal-suggest/src/constants.ts index db376c2c3b455..3258ce1ce2f35 100644 --- a/extensions/terminal-suggest/src/constants.ts +++ b/extensions/terminal-suggest/src/constants.ts @@ -3,130 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export const upstreamSpecs = [ - 'basename', - 'cat', - 'chmod', - 'chown', - 'clear', - 'cp', - 'curl', - 'cut', - 'date', - 'dd', - 'df', - 'diff', - 'dig', - 'dirname', - 'du', - 'echo', - 'env', - 'export', - 'fdisk', - 'find', - 'fmt', - 'fold', - 'grep', - 'head', - 'htop', - 'id', - 'jq', - 'kill', - 'killall', - 'less', - 'ln', - 'ls', - 'lsblk', - 'lsof', - 'mkdir', - 'more', - 'mount', - 'mv', - 'nl', - 'od', - 'paste', - 'ping', - 'pkill', - 'ps', - 'pwd', - 'readlink', - 'rm', - 'rmdir', - 'rsync', - 'scp', - 'sed', - 'seq', - 'shred', - 'sort', - 'source', - 'split', - 'stat', - 'su', - 'sudo', - 'tac', - 'tail', - 'tar', - 'tee', - 'time', - 'top', - 'touch', - 'tr', - 'traceroute', - 'tree', - 'truncate', - 'uname', - 'uniq', - 'unzip', - 'wc', - 'wget', - 'where', - 'whereis', - 'which', - 'who', - 'xargs', - 'xxd', - 'zip', - - // OS package management - 'apt', - 'brew', - - // Editors - 'nano', - 'vim', - - // Shells - 'ssh', - - // Android - 'adb', - - // Docker - 'docker', - 'docker-compose', - - // Dotnet - 'dotnet', - - // Go - 'go', - - // JavaScript / TypeScript - 'node', - 'nvm', - 'yo', - - // Python - 'python', - 'python3', - 'ruff', - - // Ruby - 'bundle', - 'ruby', -]; - - export const enum SettingsIds { SuggestPrefix = 'terminal.integrated.suggest', CachedWindowsExecutableExtensions = 'terminal.integrated.suggest.windowsExecutableExtensions', diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index e0193e01db6c7..c10d0012082c2 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -21,7 +21,7 @@ import npxCompletionSpec from './completions/npx'; import pnpmCompletionSpec from './completions/pnpm'; import setLocationSpec from './completions/set-location'; import yarnCompletionSpec from './completions/yarn'; -import { upstreamSpecs } from './constants'; +import * as upstreamSpecs from './upstreamSpecs'; import { ITerminalEnvironment, PathExecutableCache } from './env/pathExecutableCache'; import { executeCommand, executeCommandTimeout, IFigExecuteExternals } from './fig/execute'; import { getFigSuggestions } from './fig/figInterface'; @@ -77,10 +77,8 @@ export const availableSpecs: Fig.Spec[] = [ pnpmCompletionSpec, setLocationSpec, yarnCompletionSpec, + ...Object.values(upstreamSpecs) ]; -for (const spec of upstreamSpecs) { - availableSpecs.push(require(`./completions/upstream/${spec}`).default); -} const getShellSpecificGlobals: Map) => Promise<(string | ICompletionResource)[]>> = new Map([ [TerminalShellType.Bash, getBashGlobals], diff --git a/extensions/terminal-suggest/src/upstreamSpecs.ts b/extensions/terminal-suggest/src/upstreamSpecs.ts new file mode 100644 index 0000000000000..328e8fd8c6fa1 --- /dev/null +++ b/extensions/terminal-suggest/src/upstreamSpecs.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// This file is generated by scripts/update-specs.js +export { default as basename } from './completions/upstream/basename'; +export { default as cat } from './completions/upstream/cat'; +export { default as chmod } from './completions/upstream/chmod'; +export { default as chown } from './completions/upstream/chown'; +export { default as clear } from './completions/upstream/clear'; +export { default as cp } from './completions/upstream/cp'; +export { default as curl } from './completions/upstream/curl'; +export { default as cut } from './completions/upstream/cut'; +export { default as date } from './completions/upstream/date'; +export { default as dd } from './completions/upstream/dd'; +export { default as df } from './completions/upstream/df'; +export { default as diff } from './completions/upstream/diff'; +export { default as dig } from './completions/upstream/dig'; +export { default as dirname } from './completions/upstream/dirname'; +export { default as du } from './completions/upstream/du'; +export { default as echo } from './completions/upstream/echo'; +export { default as env } from './completions/upstream/env'; +export { default as export } from './completions/upstream/export'; +export { default as fdisk } from './completions/upstream/fdisk'; +export { default as find } from './completions/upstream/find'; +export { default as fmt } from './completions/upstream/fmt'; +export { default as fold } from './completions/upstream/fold'; +export { default as grep } from './completions/upstream/grep'; +export { default as head } from './completions/upstream/head'; +export { default as htop } from './completions/upstream/htop'; +export { default as id } from './completions/upstream/id'; +export { default as jq } from './completions/upstream/jq'; +export { default as kill } from './completions/upstream/kill'; +export { default as killall } from './completions/upstream/killall'; +export { default as less } from './completions/upstream/less'; +export { default as ln } from './completions/upstream/ln'; +export { default as ls } from './completions/upstream/ls'; +export { default as lsblk } from './completions/upstream/lsblk'; +export { default as lsof } from './completions/upstream/lsof'; +export { default as mkdir } from './completions/upstream/mkdir'; +export { default as more } from './completions/upstream/more'; +export { default as mount } from './completions/upstream/mount'; +export { default as mv } from './completions/upstream/mv'; +export { default as nl } from './completions/upstream/nl'; +export { default as od } from './completions/upstream/od'; +export { default as paste } from './completions/upstream/paste'; +export { default as ping } from './completions/upstream/ping'; +export { default as pkill } from './completions/upstream/pkill'; +export { default as ps } from './completions/upstream/ps'; +export { default as pwd } from './completions/upstream/pwd'; +export { default as readlink } from './completions/upstream/readlink'; +export { default as rm } from './completions/upstream/rm'; +export { default as rmdir } from './completions/upstream/rmdir'; +export { default as rsync } from './completions/upstream/rsync'; +export { default as scp } from './completions/upstream/scp'; +export { default as sed } from './completions/upstream/sed'; +export { default as seq } from './completions/upstream/seq'; +export { default as shred } from './completions/upstream/shred'; +export { default as sort } from './completions/upstream/sort'; +export { default as source } from './completions/upstream/source'; +export { default as split } from './completions/upstream/split'; +export { default as stat } from './completions/upstream/stat'; +export { default as su } from './completions/upstream/su'; +export { default as sudo } from './completions/upstream/sudo'; +export { default as tac } from './completions/upstream/tac'; +export { default as tail } from './completions/upstream/tail'; +export { default as tar } from './completions/upstream/tar'; +export { default as tee } from './completions/upstream/tee'; +export { default as time } from './completions/upstream/time'; +export { default as top } from './completions/upstream/top'; +export { default as touch } from './completions/upstream/touch'; +export { default as tr } from './completions/upstream/tr'; +export { default as traceroute } from './completions/upstream/traceroute'; +export { default as tree } from './completions/upstream/tree'; +export { default as truncate } from './completions/upstream/truncate'; +export { default as uname } from './completions/upstream/uname'; +export { default as uniq } from './completions/upstream/uniq'; +export { default as unzip } from './completions/upstream/unzip'; +export { default as wc } from './completions/upstream/wc'; +export { default as wget } from './completions/upstream/wget'; +export { default as where } from './completions/upstream/where'; +export { default as whereis } from './completions/upstream/whereis'; +export { default as which } from './completions/upstream/which'; +export { default as who } from './completions/upstream/who'; +export { default as xargs } from './completions/upstream/xargs'; +export { default as xxd } from './completions/upstream/xxd'; +export { default as zip } from './completions/upstream/zip'; +export { default as apt } from './completions/upstream/apt'; +export { default as brew } from './completions/upstream/brew'; +export { default as nano } from './completions/upstream/nano'; +export { default as vim } from './completions/upstream/vim'; +export { default as ssh } from './completions/upstream/ssh'; +export { default as adb } from './completions/upstream/adb'; +export { default as docker } from './completions/upstream/docker'; +export { default as dockerCompose } from './completions/upstream/docker-compose'; +export { default as dotnet } from './completions/upstream/dotnet'; +export { default as go } from './completions/upstream/go'; +export { default as node } from './completions/upstream/node'; +export { default as nvm } from './completions/upstream/nvm'; +export { default as yo } from './completions/upstream/yo'; +export { default as python } from './completions/upstream/python'; +export { default as python3 } from './completions/upstream/python3'; +export { default as ruff } from './completions/upstream/ruff'; +export { default as bundle } from './completions/upstream/bundle'; +export { default as ruby } from './completions/upstream/ruby'; From ec6ebb10ef79d845320cb393760cbdf2d0cd687a Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:09:59 -0800 Subject: [PATCH 14/16] make sure to respect attachCSS setting for integrated browser (#296210) make sure to respect attachCSS setting --- .../browserView/electron-browser/browserEditor.ts | 14 +++++++++----- .../attachments/simpleBrowserEditorOverlay.ts | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 42f9c41bf5313..28c2dd24f25ee 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -718,12 +718,14 @@ export class BrowserEditor extends EditorPane { name: displayName, fullName: displayName, value: value, - modelDescription: 'Structured browser element context with HTML path, attributes, and computed styles.', + modelDescription: attachCss + ? 'Structured browser element context with HTML path, attributes, and computed styles.' + : 'Structured browser element context with HTML path and attributes.', kind: 'element', icon: ThemeIcon.fromId(Codicon.layout.id), ancestors: elementData.ancestors, attributes: elementData.attributes, - computedStyles: elementData.computedStyles, + computedStyles: attachCss ? elementData.computedStyles : undefined, dimensions: elementData.dimensions, innerText: elementData.innerText, }); @@ -864,9 +866,11 @@ export class BrowserEditor extends EditorPane { sections.push(`Attributes:\n${attributeTable}`); } - const computedStyleTable = this.formatElementMap(elementData.computedStyles); - if (computedStyleTable) { - sections.push(`Computed Styles:\n${computedStyleTable}`); + if (attachCss) { + const computedStyleTable = this.formatElementMap(elementData.computedStyles); + if (computedStyleTable) { + sections.push(`Computed Styles:\n${computedStyleTable}`); + } } if (elementData.dimensions) { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts index a270c52b91ddd..382fbd87e0838 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts @@ -300,7 +300,7 @@ class SimpleBrowserOverlayWidget { icon: ThemeIcon.fromId(Codicon.layout.id), ancestors: elementData.ancestors, attributes: elementData.attributes, - computedStyles: elementData.computedStyles, + computedStyles: attachCss ? elementData.computedStyles : undefined, dimensions: elementData.dimensions, innerText: elementData.innerText, }); From 8db7ca3a3c31725f1e5457dc8cc791f9cce0a72e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Feb 2026 10:34:58 +0100 Subject: [PATCH 15/16] fix layout (#296228) --- .../actionWidget/browser/actionList.ts | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 88fa6f1d6ffb9..10cf26dc73fae 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -617,9 +617,7 @@ export class ActionList extends Disposable { this._contextViewService.hideContextView(); } - layout(minWidth: number): number { - this._hasLaidOut = true; - this._lastMinWidth = minWidth; + private computeHeight(): number { // Compute height based on currently visible items in the list const visibleCount = this._list.length; let listHeight = 0; @@ -638,16 +636,32 @@ export class ActionList extends Disposable { } } - this._list.layout(listHeight); + const filterHeight = this._filterContainer ? 36 : 0; + const padding = 10; + const targetWindow = dom.getWindow(this.domNode); + const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; + const widgetTop = this.domNode.getBoundingClientRect().top; + const availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + const maxHeight = Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight); + const height = Math.min(listHeight + filterHeight, maxHeight); + return height - filterHeight; + } + + private computeMaxWidth(minWidth: number): number { + const visibleCount = this._list.length; const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0); let maxWidth = effectiveMinWidth; const totalItemCount = this._allMenuItems.length; if (totalItemCount >= 50) { - maxWidth = Math.max(380, effectiveMinWidth); - } else if (this._cachedMaxWidth !== undefined) { - maxWidth = this._cachedMaxWidth; - } else if (totalItemCount > visibleCount) { + return Math.max(380, effectiveMinWidth); + } + + if (this._cachedMaxWidth !== undefined) { + return this._cachedMaxWidth; + } + + if (totalItemCount > visibleCount) { // Temporarily splice in all items to measure widths, // preventing width jumps when expanding/collapsing sections. const visibleItems: IActionListItem[] = []; @@ -679,36 +693,37 @@ export class ActionList extends Disposable { } maxWidth = Math.max(...itemWidths, effectiveMinWidth); - this._cachedMaxWidth = maxWidth; // Restore visible items this._list.splice(0, allItems.length, visibleItems); - this._list.layout(listHeight); - } else { - // All items are visible, measure them directly - const itemWidths: number[] = []; - for (let i = 0; i < visibleCount; i++) { - const element = this._getRowElement(i); - if (element) { - element.style.width = 'auto'; - const width = element.getBoundingClientRect().width; - element.style.width = ''; - itemWidths.push(width); - } + return maxWidth; + } + + // All items are visible, measure them directly + const itemWidths: number[] = []; + for (let i = 0; i < visibleCount; i++) { + const element = this._getRowElement(i); + if (element) { + element.style.width = 'auto'; + const width = element.getBoundingClientRect().width; + element.style.width = ''; + itemWidths.push(width); } - maxWidth = Math.max(...itemWidths, effectiveMinWidth); } + return Math.max(...itemWidths, effectiveMinWidth); + } - 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); + layout(minWidth: number): number { + this._hasLaidOut = true; + this._lastMinWidth = minWidth; - this.domNode.style.height = `${listFinalHeight}px`; + const listHeight = this.computeHeight(); + this._list.layout(listHeight); - return maxWidth; + this._cachedMaxWidth = this.computeMaxWidth(minWidth); + this._list.layout(listHeight, this._cachedMaxWidth); + this.domNode.style.height = `${listHeight}px`; + return this._cachedMaxWidth; } focusPrevious() { From c9224d87a5ea1b577adf4777846804206c9bc3de Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 19 Feb 2026 11:09:27 +0100 Subject: [PATCH 16/16] render account menu at default anchor (#296090) * refactor: simplify context menu anchor handling in AccountWidget * fix: update context menu anchor handling in AccountWidget * Copilot CLI session 302d4e28-2523-424f-9740-26ba6ae80374 changes --- .../contrib/accountMenu/browser/account.contribution.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index d19bc8ff3ae87..9b4701fe419f3 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -16,7 +16,6 @@ import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { AnchorAlignment } from '../../../../base/browser/ui/contextview/contextview.js'; import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { $, append } from '../../../../base/browser/dom.js'; import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -136,11 +135,9 @@ class AccountWidget extends ActionViewItem { fillInActionBarActions(menu.getActions(), actions); menu.dispose(); - const rect = anchor.getBoundingClientRect(); this.contextMenuService.showContextMenu({ - getAnchor: () => ({ x: rect.right, y: rect.top }), + getAnchor: () => anchor, getActions: () => actions, - anchorAlignment: AnchorAlignment.LEFT, }); }