diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b5c5a087962..3ec839df4ef 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -139,6 +139,7 @@ function f(x: number, y: string): void { } - When adding tooltips to UI elements, prefer the use of IHoverService service. - Do not duplicate code. Always look for existing utility functions, helpers, or patterns in the codebase before implementing new functionality. Reuse and extend existing code whenever possible. - You MUST deal with disposables by registering them immediately after creation for later disposal. Use helpers such as `DisposableStore`, `MutableDisposable` or `DisposableMap`. Do NOT register a disposable to the containing class if the object is created within a method that is called repeadedly to avoid leaks. Instead, return a `IDisposable` from such method and let the caller register it. +- You MUST NOT use storage keys of another component only to make changes to that component. You MUST come up with proper API to change another component. ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index e880ae2e23d..d17509b7758 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -12,7 +12,7 @@ { "kind": 2, "language": "github-issues", - "value": "$NOT_TEAM_MEMBERS=-author:aeschli -author:alexdima -author:alexr00 -author:AmandaSilver -author:bamurtaugh -author:bpasero -author:chrmarti -author:Chuxel -author:claudiaregio -author:connor4312 -author:dbaeumer -author:deepak1556 -author:devinvalenciano -author:digitarald -author:DonJayamanne -author:egamma -author:fiveisprime -author:ntrogh -author:hediet -author:isidorn -author:joaomoreno -author:jrieken -author:kieferrm -author:lramos15 -author:lszomoru -author:meganrogge -author:misolori -author:mjbvz -author:rebornix -author:roblourens -author:rzhao271 -author:sandy081 -author:sbatten -author:stevencl -author:TylerLeonhardt -author:Tyriar -author:weinand -author:amunger -author:karthiknadig -author:eleanorjboyd -author:Yoyokrazy -author:ulugbekna -author:aiday-mar -author:bhavyaus -author:justschen -author:benibenj -author:luabud -author:anthonykim1 -author:joshspicer -author:osortega -author:hawkticehurst -author:pierceboggan -author:benvillalobos -author:dileepyavan -author:dineshc-msft -author:dmitrivMS -author:eli-w-king -author:jo-oikawa -author:jruales -author:jytjyt05 -author:kycutler -author:mrleemurray -author:pwang347 -author:vijayupadya -author:bryanchen-d -author:cwebster-99" + "value": "$NOT_TEAM_MEMBERS=-author:aeschli -author:alexdima -author:alexr00 -author:AmandaSilver -author:bamurtaugh -author:bpasero -author:chrmarti -author:Chuxel -author:claudiaregio -author:connor4312 -author:dbaeumer -author:deepak1556 -author:devinvalenciano -author:digitarald -author:DonJayamanne -author:egamma -author:fiveisprime -author:ntrogh -author:hediet -author:isidorn -author:joaomoreno -author:jrieken -author:kieferrm -author:lramos15 -author:lszomoru -author:meganrogge -author:misolori -author:mjbvz -author:rebornix -author:roblourens -author:rzhao271 -author:sandy081 -author:sbatten -author:stevencl -author:TylerLeonhardt -author:Tyriar -author:weinand -author:amunger -author:karthiknadig -author:eleanorjboyd -author:Yoyokrazy -author:ulugbekna -author:aiday-mar -author:bhavyaus -author:justschen -author:benibenj -author:luabud -author:anthonykim1 -author:joshspicer -author:osortega -author:hawkticehurst -author:pierceboggan -author:benvillalobos -author:dileepyavan -author:dineshc-msft -author:dmitrivMS -author:eli-w-king -author:jo-oikawa -author:jruales -author:jytjyt05 -author:kycutler -author:mrleemurray -author:pwang347 -author:vijayupadya -author:bryanchen-d -author:cwebster-99 -author:rwoll -author:lostintangent -author:jukasper -author:zhichli" }, { "kind": 1, diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index ebf415ba719..305ce4fea29 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -89,9 +89,9 @@ jobs: - ${{ if ne(parameters.container, '') }}: - task: Cache@2 inputs: - key: 'docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" | $(TEST_DIR)/containers/${{ parameters.container }}.dockerfile' + key: 'docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "${{ parameters.pageSize }}" | "$(Agent.OS)" | $(TEST_DIR)/containers/${{ parameters.container }}.dockerfile' path: $(DOCKER_CACHE_DIR) - restoreKeys: docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "$(Agent.OS)" + restoreKeys: docker-v3 | "${{ parameters.container }}" | "${{ parameters.arch }}" | "${{ parameters.pageSize }}" | "$(Agent.OS)" cacheHitVar: DOCKER_CACHE_HIT displayName: Download Docker Image @@ -110,16 +110,16 @@ jobs: --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ - --verbose + --verbose \ + ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests - - ${{ if eq(parameters.pageSize, '') }}: - - bash: | - mkdir -p "$(DOCKER_CACHE_DIR)" - docker save -o "$(DOCKER_CACHE_FILE)" "${{ parameters.container }}" - condition: and(succeeded(), ne(variables.DOCKER_CACHE_HIT, 'true')) - displayName: Save Docker Image + - bash: | + mkdir -p "$(DOCKER_CACHE_DIR)" + docker save -o "$(DOCKER_CACHE_FILE)" "${{ parameters.container }}" + condition: and(succeeded(), ne(variables.DOCKER_CACHE_HIT, 'true')) + displayName: Save Docker Image - task: PublishTestResults@2 inputs: diff --git a/build/azure-pipelines/darwin/product-build-darwin-ci.yml b/build/azure-pipelines/darwin/product-build-darwin-ci.yml index 3920c4ec799..45af3707590 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-ci.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-ci.yml @@ -7,7 +7,7 @@ parameters: jobs: - job: macOS${{ parameters.VSCODE_TEST_SUITE }} displayName: ${{ parameters.VSCODE_TEST_SUITE }} Tests - timeoutInMinutes: 30 + timeoutInMinutes: 90 variables: VSCODE_ARCH: arm64 templateContext: diff --git a/build/azure-pipelines/product-npm-package-validate.yml b/build/azure-pipelines/product-npm-package-validate.yml index d596f9f7b37..b256107437d 100644 --- a/build/azure-pipelines/product-npm-package-validate.yml +++ b/build/azure-pipelines/product-npm-package-validate.yml @@ -17,7 +17,6 @@ jobs: name: 1es-ubuntu-22.04-x64 os: linux timeoutInMinutes: 40000 - continueOnError: true variables: VSCODE_ARCH: x64 steps: @@ -106,6 +105,12 @@ jobs: timeoutInMinutes: 400 condition: and(succeeded(), eq(variables['SHOULD_VALIDATE'], 'true')) + - script: | + set -e + find . -name 'package-lock.json' -exec sed -i "s|$NPM_REGISTRY|https://registry.npmjs.org/|g" {} \; + displayName: Restore registry URLs in package-lock.json + condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none'), eq(variables['SHOULD_VALIDATE'], 'true')) + - script: .github/workflows/check-clean-git-state.sh displayName: Check clean git state condition: and(succeeded(), eq(variables['SHOULD_VALIDATE'], 'true')) diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index ec53d46f366..3875aaa5333 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -94,11 +94,11 @@ extends: os: windows args: --no-detection --grep "win32-arm64" - # Alpine 3.23 + # Alpine 3.22 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: name: alpine_amd64 - displayName: Alpine 3.23 amd64 + displayName: Alpine 3.22 amd64 poolName: 1es-ubuntu-22.04-x64 container: alpine arch: amd64 @@ -106,8 +106,8 @@ extends: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: name: alpine_arm64 - displayName: Alpine 3.23 arm64 - poolName: 1es-mariner-2.0-arm64 + displayName: Alpine 3.22 arm64 + poolName: 1es-azure-linux-3-arm64 container: alpine arch: arm64 @@ -124,7 +124,7 @@ extends: parameters: name: centos_stream9_arm64 displayName: CentOS Stream 9 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: centos arch: arm64 @@ -141,7 +141,7 @@ extends: parameters: name: debian_10_arm32 displayName: Debian 10 arm32 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: debian-10 arch: arm @@ -149,7 +149,7 @@ extends: parameters: name: debian_10_arm64 displayName: Debian 10 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: debian-10 arch: arm64 @@ -166,7 +166,7 @@ extends: parameters: name: debian_12_arm32 displayName: Debian 12 arm32 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: debian-12 arch: arm @@ -174,7 +174,7 @@ extends: parameters: name: debian_12_arm64 displayName: Debian 12 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: debian-12 arch: arm64 @@ -192,7 +192,7 @@ extends: parameters: name: fedora_36_arm64 displayName: Fedora 36 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: fedora baseImage: fedora:36 arch: arm64 @@ -211,7 +211,7 @@ extends: parameters: name: fedora_40_arm64 displayName: Fedora 40 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: fedora baseImage: fedora:40 arch: arm64 @@ -229,7 +229,7 @@ extends: parameters: name: opensuse_leap_arm64 displayName: openSUSE Leap 16.0 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: opensuse arch: arm64 @@ -246,7 +246,7 @@ extends: parameters: name: redhat_ubi9_arm64 displayName: Red Hat UBI 9 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: redhat arch: arm64 @@ -272,7 +272,7 @@ extends: parameters: name: ubuntu_22_04_arm32 displayName: Ubuntu 22.04 arm32 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: ubuntu baseImage: ubuntu:22.04 arch: arm @@ -281,7 +281,7 @@ extends: parameters: name: ubuntu_22_04_arm64 displayName: Ubuntu 22.04 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: ubuntu baseImage: ubuntu:22.04 arch: arm64 @@ -300,7 +300,7 @@ extends: parameters: name: ubuntu_24_04_arm32 displayName: Ubuntu 24.04 arm32 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: ubuntu baseImage: ubuntu:24.04 arch: arm @@ -309,7 +309,7 @@ extends: parameters: name: ubuntu_24_04_arm64 displayName: Ubuntu 24.04 arm64 - poolName: 1es-mariner-2.0-arm64 + poolName: 1es-azure-linux-3-arm64 container: ubuntu baseImage: ubuntu:24.04 arch: arm64 @@ -323,3 +323,4 @@ extends: baseImage: ubuntu:24.04 arch: arm64 pageSize: 64k + args: --grep "desktop-linux-arm64" diff --git a/build/darwin/create-dmg.ts b/build/darwin/create-dmg.ts index 6bea7e76d5f..dcfb8001a8e 100644 --- a/build/darwin/create-dmg.ts +++ b/build/darwin/create-dmg.ts @@ -106,7 +106,7 @@ async function main(buildDir?: string, outDir?: string): Promise { 'text-size': 12, window: { position: { x: 100, y: 400 }, - size: { width: 480, height: 320 } + size: { width: 480, height: 352 } }, contents: [ { diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 24462a3b26e..e06f1510a66 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -98,14 +98,22 @@ function fromLocalWebpack(extensionPath: string, webpackConfigFileName: string, const result = es.through(); const packagedDependencies: string[] = []; + const stripOutSourceMaps: string[] = []; const packageJsonConfig = require(path.join(extensionPath, 'package.json')); if (packageJsonConfig.dependencies) { - const webpackRootConfig = require(path.join(extensionPath, webpackConfigFileName)).default; + const webpackConfig = require(path.join(extensionPath, webpackConfigFileName)); + const webpackRootConfig = webpackConfig.default; for (const key in webpackRootConfig.externals) { if (key in packageJsonConfig.dependencies) { packagedDependencies.push(key); } } + + if (webpackConfig.StripOutSourceMaps) { + for (const filePath of webpackConfig.StripOutSourceMaps) { + stripOutSourceMaps.push(filePath); + } + } } // TODO: add prune support based on packagedDependencies to vsce.PackageManager.Npm similar @@ -177,10 +185,15 @@ function fromLocalWebpack(extensionPath: string, webpackConfigFileName: string, // * rewrite sourceMappingURL // * save to disk so that upload-task picks this up if (path.extname(data.basename) === '.js') { - const contents = (data.contents as Buffer).toString('utf8'); - data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, function (_m, g1) { - return `\n//# sourceMappingURL=${sourceMappingURLBase}/extensions/${path.basename(extensionPath)}/${relativeOutputPath}/${g1}`; - }), 'utf8'); + if (stripOutSourceMaps.indexOf(data.relative) >= 0) { // remove source map + const contents = (data.contents as Buffer).toString('utf8'); + data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, ''), 'utf8'); + } else { + const contents = (data.contents as Buffer).toString('utf8'); + data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, function (_m, g1) { + return `\n//# sourceMappingURL=${sourceMappingURLBase}/extensions/${path.basename(extensionPath)}/${relativeOutputPath}/${g1}`; + }), 'utf8'); + } } this.emit('data', data); diff --git a/build/lib/fetch.ts b/build/lib/fetch.ts index 970887b3e55..0d2c47a7fd8 100644 --- a/build/lib/fetch.ts +++ b/build/lib/fetch.ts @@ -50,7 +50,7 @@ export async function fetchUrl(url: string, options: IFetchOptions, retries = 10 startTime = new Date().getTime(); } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30 * 1000); + let timeout = setTimeout(() => controller.abort(), 30 * 1000); try { const response = await fetch(url, { ...options.nodeFetchOptions, @@ -60,6 +60,9 @@ export async function fetchUrl(url: string, options: IFetchOptions, retries = 10 log(`Fetch completed: Status ${response.status}. Took ${ansiColors.magenta(`${new Date().getTime() - startTime} ms`)}`); } if (response.ok && (response.status >= 200 && response.status < 300)) { + // Reset timeout for body download - large files need more time + clearTimeout(timeout); + timeout = setTimeout(() => controller.abort(), 5 * 60 * 1000); const contents = Buffer.from(await response.arrayBuffer()); if (options.checksumSha256) { const actualSHA256Checksum = crypto.createHash('sha256').update(contents).digest('hex'); diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index f5c0817dca7..5dee2dcd04f 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -39,6 +39,7 @@ "--vscode-button-foreground", "--vscode-button-hoverBackground", "--vscode-button-secondaryBackground", + "--vscode-button-secondaryBorder", "--vscode-button-secondaryForeground", "--vscode-button-secondaryHoverBackground", "--vscode-button-separator", @@ -360,6 +361,7 @@ "--vscode-extensionBadge-remoteBackground", "--vscode-extensionBadge-remoteForeground", "--vscode-extensionButton-background", + "--vscode-extensionButton-border", "--vscode-extensionButton-foreground", "--vscode-extensionButton-hoverBackground", "--vscode-extensionButton-prominentBackground", @@ -384,8 +386,8 @@ "--vscode-inlineChat-background", "--vscode-inlineChat-border", "--vscode-inlineChat-foreground", - "--vscode-inlineChat-regionHighlight", "--vscode-inlineChat-shadow", + "--vscode-inlineChat-regionHighlight", "--vscode-inlineChatDiff-inserted", "--vscode-inlineChatDiff-removed", "--vscode-inlineChatInput-background", diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 1ca858e42d2..a4ef0b2fada 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", - "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index c4bbbf52960..b6a934f74b3 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -11,6 +11,7 @@ import { dirs } from './dirs.ts'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const root = path.dirname(path.dirname(import.meta.dirname)); +const rootNpmrcConfigKeys = getNpmrcConfigKeys(path.join(root, '.npmrc')); function log(dir: string, message: string) { if (process.stdout.isTTY) { @@ -125,6 +126,36 @@ function removeParcelWatcherPrebuild(dir: string) { } } +function getNpmrcConfigKeys(npmrcPath: string): string[] { + if (!fs.existsSync(npmrcPath)) { + return []; + } + const lines = fs.readFileSync(npmrcPath, 'utf8').split('\n'); + const keys: string[] = []; + for (const line of lines) { + const trimmedLine = line.trim(); + if (trimmedLine && !trimmedLine.startsWith('#')) { + const eqIndex = trimmedLine.indexOf('='); + if (eqIndex > 0) { + keys.push(trimmedLine.substring(0, eqIndex).trim()); + } + } + } + return keys; +} + +function clearInheritedNpmrcConfig(dir: string, env: NodeJS.ProcessEnv): void { + const dirNpmrcPath = path.join(root, dir, '.npmrc'); + if (fs.existsSync(dirNpmrcPath)) { + return; + } + + for (const key of rootNpmrcConfigKeys) { + const envKey = `npm_config_${key.replace(/-/g, '_')}`; + delete env[envKey]; + } +} + for (const dir of dirs) { if (dir === '') { @@ -179,7 +210,10 @@ for (const dir of dirs) { continue; } - npmInstall(dir, opts); + // For directories that don't define their own .npmrc, clear inherited config + const env = { ...process.env }; + clearInheritedNpmrcConfig(dir, env); + npmInstall(dir, { env }); } child_process.execSync('git config pull.rebase merges'); diff --git a/build/package-lock.json b/build/package-lock.json index 1a544ba854f..542c66d69f4 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -53,7 +53,7 @@ "ansi-colors": "^3.2.3", "byline": "^5.0.0", "debug": "^4.3.2", - "dmg-builder": "^26.5.0", + "dmg-builder": "^26.6.0", "esbuild": "0.27.2", "extract-zip": "^2.0.1", "gulp-merge-json": "^2.1.1", @@ -813,14 +813,13 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.1.tgz", - "integrity": "sha512-iMGXb6Ib7H/Q3v+BKZJoETgF9g6KMNZVbsO4b7Dmpgb5qTFqyFTzqW9F3TOSHdybv2vKYKzSS9OiZL+dcJb+1Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", + "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", "dev": true, "license": "MIT", "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", @@ -831,7 +830,7 @@ "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", - "tar": "^6.0.5", + "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { @@ -841,142 +840,6 @@ "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@electron/rebuild/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@electron/rebuild/node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/rebuild/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@electron/rebuild/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@electron/rebuild/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@electron/rebuild/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@electron/rebuild/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@electron/rebuild/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/@electron/rebuild/node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@electron/rebuild/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@electron/rebuild/node_modules/node-abi": { "version": "4.26.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.26.0.tgz", @@ -1003,38 +866,6 @@ "node": ">=10" } }, - "node_modules/@electron/rebuild/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@electron/rebuild/node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -3136,18 +2967,19 @@ "license": "MIT" }, "node_modules/app-builder-lib": { - "version": "26.5.0", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.5.0.tgz", - "integrity": "sha512-iRRiJhM0uFMauDeIuv8ESHZSn+LESbdDEuHi7rKdeETjrvBObecXnWJx1f3vs3KtoGcd3hCk1zURKypyvZOtFQ==", + "version": "26.6.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.6.0.tgz", + "integrity": "sha512-P2naoSaGOqJY54cqTceO9lms2M790UM7BA8AlOuaolQhRp/LOshAVc4vzVlYFw4YNPtiuBJqdAhWALuoEKnayQ==", "dev": true, "license": "MIT", "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "4.0.1", + "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", @@ -3160,7 +2992,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.4.1", + "electron-publish": "26.6.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -3170,9 +3002,10 @@ "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", + "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", - "tar": "7.5.3", + "tar": "^7.5.6", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" @@ -3181,8 +3014,55 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.5.0", - "electron-builder-squirrel-windows": "26.5.0" + "dmg-builder": "26.6.0", + "electron-builder-squirrel-windows": "26.6.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/app-builder-lib/node_modules/@electron/osx-sign": { @@ -3242,6 +3122,29 @@ "node": ">=12" } }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/app-builder-lib/node_modules/isexe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", @@ -3265,19 +3168,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/app-builder-lib/node_modules/minimatch": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", @@ -3307,16 +3197,6 @@ "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/app-builder-lib/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -3398,6 +3278,13 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/async-done": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", @@ -4595,13 +4482,13 @@ } }, "node_modules/dmg-builder": { - "version": "26.5.0", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.5.0.tgz", - "integrity": "sha512-AyOCzpS1TCxDkSWxAzpfw5l7jBX4C8jKCucmT/6y6/24H5VKSHpjcVJD0W8o5BrFi+skC7Z7+F4aNyHmvn4AAw==", + "version": "26.6.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.6.0.tgz", + "integrity": "sha512-IkGlOLfJ3q7y9iaDMnNSArDdPg3Ntx8Ps6aL7yTEIpL6znA+t5L/LRTAGFz1J/12hM/NiNEYg0LoBEheqGdZXw==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.5.0", + "app-builder-lib": "26.6.0", "builder-util": "26.4.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", @@ -4883,9 +4770,9 @@ } }, "node_modules/electron-publish": { - "version": "26.4.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.4.1.tgz", - "integrity": "sha512-nByal9K5Ar3BNJUfCSglXltpKUhJqpwivNpKVHnkwxTET9LKl+NxoojpGF1dSXVFcoBKVm+OhsVa28ZsoshEPA==", + "version": "26.6.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.6.0.tgz", + "integrity": "sha512-LsyHMMqbvJ2vsOvuWJ19OezgF2ANdCiHpIucDHNiLhuI+/F3eW98ouzWSRmXXi82ZOPZXC07jnIravY4YYwCLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4893,7 +4780,7 @@ "builder-util": "26.4.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", - "form-data": "^4.0.0", + "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" @@ -5513,9 +5400,9 @@ "dev": true }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { @@ -6332,13 +6219,6 @@ "node": ">=10" } }, - "node_modules/jake/node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -7011,19 +6891,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -7907,6 +7774,25 @@ "node": ">=10" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -9054,10 +8940,9 @@ } }, "node_modules/tar": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", - "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/package.json b/build/package.json index e45161dc2c3..82a9974ba79 100644 --- a/build/package.json +++ b/build/package.json @@ -47,7 +47,7 @@ "ansi-colors": "^3.2.3", "byline": "^5.0.0", "debug": "^4.3.2", - "dmg-builder": "^26.5.0", + "dmg-builder": "^26.6.0", "esbuild": "0.27.2", "extract-zip": "^2.0.1", "gulp-merge-json": "^2.1.1", diff --git a/build/win32/code.iss b/build/win32/code.iss index 6b39799ec73..650384d4dc1 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1564,7 +1564,7 @@ begin #if "user" == InstallTarget ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Add-AppxPackage -Path ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''' -ExternalLocation ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx') + ''''), '', SW_HIDE, ewWaitUntilTerminated, AddAppxPackageResultCode); #else - ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Add-AppxPackage -Stage ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''' -ExternalLocation ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx') + '''; Add-AppxProvisionedPackage -Online -SkipLicense -PackagePath ''' + ExpandConstant('{app}\appx\{#AppxPackage}') + ''''), '', SW_HIDE, ewWaitUntilTerminated, AddAppxPackageResultCode); + ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Add-AppxPackage -Stage ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''' -ExternalLocation ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx') + '''; Add-AppxProvisionedPackage -Online -SkipLicense -PackagePath ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''''), '', SW_HIDE, ewWaitUntilTerminated, AddAppxPackageResultCode); #endif Log('Add-AppxPackage complete.'); end; @@ -1589,7 +1589,7 @@ begin #if "user" == InstallTarget ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Remove-AppxPackage -Package ''' + AppxPackageFullname + ''''), '', SW_HIDE, ewWaitUntilTerminated, RemoveAppxPackageResultCode); #else - ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('$packages = Get-AppxPackage ''' + AppxPackageFullname + '''; foreach ($package in $packages) { Remove-AppxProvisionedPackage -PackageName $package.PackageFullName -Online }; foreach ($package in $packages) { Remove-AppxPackage -Package $package.PackageFullName -AllUsers }'), '', SW_HIDE, ewWaitUntilTerminated, RemoveAppxPackageResultCode); + ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('$packages = Get-AppxPackage ''' + ExpandConstant('{#AppxPackageName}') + '''; foreach ($package in $packages) { Remove-AppxProvisionedPackage -PackageName $package.PackageFullName -Online }; foreach ($package in $packages) { Remove-AppxPackage -Package $package.PackageFullName -AllUsers }'), '', SW_HIDE, ewWaitUntilTerminated, RemoveAppxPackageResultCode); #endif Log('Remove-AppxPackage for current appx installation complete.'); end; diff --git a/extensions/git/extension.webpack.config.js b/extensions/git/extension.webpack.config.js index 15cf273015b..34f801e2eca 100644 --- a/extensions/git/extension.webpack.config.js +++ b/extensions/git/extension.webpack.config.js @@ -13,3 +13,5 @@ export default withDefaults({ ['git-editor-main']: './src/git-editor-main.ts' } }); + +export const StripOutSourceMaps = ['dist/askpass-main.js']; diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 4c865974639..4932b07d5d4 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -100,8 +100,11 @@ export class ApiRepository implements Repository { filterEvent(this.#repository.onDidRunOperation, e => e.operation.kind === OperationKind.Checkout || e.operation.kind === OperationKind.CheckoutTracking), () => null); } - apply(patch: string, reverse?: boolean): Promise { - return this.#repository.apply(patch, reverse); + apply(patch: string, reverse?: boolean): Promise; + apply(patch: string, options?: { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean }): Promise; + apply(patch: string, reverseOrOptions?: boolean | { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean }): Promise { + const options = typeof reverseOrOptions === 'boolean' ? { reverse: reverseOrOptions } : reverseOrOptions; + return this.#repository.apply(patch, options); } getConfigs(): Promise<{ key: string; value: string }[]> { diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index b43eaaa6184..8b28d3cb48a 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -256,6 +256,7 @@ export interface Repository { clean(paths: string[]): Promise; apply(patch: string, reverse?: boolean): Promise; + apply(patch: string, options?: { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean; }): Promise; diff(cached?: boolean): Promise; diffWithHEAD(): Promise; diffWithHEAD(path: string): Promise; diff --git a/extensions/git/src/askpassManager.ts b/extensions/git/src/askpassManager.ts index 15a6b2fa0e6..9b610346420 100644 --- a/extensions/git/src/askpassManager.ts +++ b/extensions/git/src/askpassManager.ts @@ -128,6 +128,74 @@ async function copyFileSecure( await setWindowsPermissions(dest, logger); } +/** + * Updates the modification time of a directory to mark it as recently used. + */ +async function updateDirectoryMtime(dirPath: string, logger: LogOutputChannel): Promise { + try { + const now = new Date(); + await fs.promises.utimes(dirPath, now, now); + logger.trace(`[askpassManager] Updated mtime for ${dirPath}`); + } catch (err) { + logger.warn(`[askpassManager] Failed to update mtime for ${dirPath}: ${err}`); + } +} + +/** + * Garbage collects old content-addressed askpass directories that haven't been used in 7 days. + * This prevents accumulation of old versions when VS Code updates. + */ +async function garbageCollectOldDirectories( + askpassBaseDir: string, + currentHash: string, + logger: LogOutputChannel +): Promise { + try { + // Check if the askpass base directory exists + try { + await fs.promises.access(askpassBaseDir); + } catch { + // Directory doesn't exist, nothing to clean + return; + } + + const entries = await fs.promises.readdir(askpassBaseDir); + const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); + + for (const entry of entries) { + // Skip the current content-addressed directory + if (entry === currentHash) { + continue; + } + + const entryPath = path.join(askpassBaseDir, entry); + + try { + const stat = await fs.promises.stat(entryPath); + + // Only process directories + if (!stat.isDirectory()) { + continue; + } + + // Check if the directory hasn't been used in 7 days + if (stat.mtime.getTime() < sevenDaysAgo) { + logger.info(`[askpassManager] Removing old askpass directory: ${entryPath} (last used: ${stat.mtime.toISOString()})`); + + // Remove the directory and all its contents + await fs.promises.rm(entryPath, { recursive: true, force: true }); + } else { + logger.trace(`[askpassManager] Keeping askpass directory: ${entryPath} (last used: ${stat.mtime.toISOString()})`); + } + } catch (err) { + logger.warn(`[askpassManager] Failed to process/remove directory ${entryPath}: ${err}`); + } + } + } catch (err) { + logger.warn(`[askpassManager] Failed to garbage collect old directories: ${err}`); + } +} + export interface AskpassPaths { readonly askpass: string; readonly askpassMain: string; @@ -144,7 +212,7 @@ export interface AskpassPaths { * @param storageDir The user-controlled storage directory (context.storageUri.fsPath) * @param logger Logger for diagnostic output */ -async function ensureAskpassScripts( +export async function ensureAskpassScripts( sourceDir: string, storageDir: string, logger: LogOutputChannel @@ -162,7 +230,8 @@ async function ensureAskpassScripts( logger.trace(`[askpassManager] Content hash: ${contentHash}`); // Create content-addressed directory - const askpassDir = path.join(storageDir, 'askpass', contentHash); + const askpassBaseDir = path.join(storageDir, 'askpass'); + const askpassDir = path.join(askpassBaseDir, contentHash); const destPaths: AskpassPaths = { askpass: path.join(askpassDir, 'askpass.sh'), @@ -177,6 +246,10 @@ async function ensureAskpassScripts( const stat = await fs.promises.stat(destPaths.askpass); if (stat.isFile()) { logger.trace(`[askpassManager] Using existing content-addressed askpass at ${askpassDir}`); + + // Update mtime to mark this directory as recently used + await updateDirectoryMtime(askpassDir, logger); + return destPaths; } } catch { @@ -200,6 +273,12 @@ async function ensureAskpassScripts( logger.info(`[askpassManager] Successfully created content-addressed askpass scripts`); + // Update mtime to mark this directory as recently used + await updateDirectoryMtime(askpassDir, logger); + + // Garbage collect old directories + await garbageCollectOldDirectories(askpassBaseDir, contentHash, logger); + return destPaths; } diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index fb431257d19..a4bd92c9812 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -1679,11 +1679,19 @@ export class Repository { } } - async apply(patch: string, reverse?: boolean): Promise { + async apply(patch: string, options?: { reverse?: boolean; threeWay?: boolean; allowEmpty?: boolean }): Promise { const args = ['apply', patch]; - if (reverse) { - args.push('-R'); + if (options?.allowEmpty) { + args.push('--allow-empty'); + } + + if (options?.reverse) { + args.push('--reverse'); + } + + if (options?.threeWay) { + args.push('--3way'); } try { diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index b2bcadb3a71..aabfa256039 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -290,6 +290,7 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi this._unsafeRepositoriesManager = new UnsafeRepositoriesManager(); workspace.onDidChangeWorkspaceFolders(this.onDidChangeWorkspaceFolders, this, this.disposables); + workspace.onDidChangeWorkspaceTrustedFolders(this.onDidChangeWorkspaceTrustedFolders, this, this.disposables); window.onDidChangeVisibleTextEditors(this.onDidChangeVisibleTextEditors, this, this.disposables); window.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor, this, this.disposables); workspace.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables); @@ -488,6 +489,27 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi } } + private async onDidChangeWorkspaceTrustedFolders(): Promise { + try { + const openRepositoriesToDispose: OpenRepository[] = []; + + for (const openRepository of this.openRepositories) { + const dotGitPath = openRepository.repository.dotGit.commonPath ?? openRepository.repository.dotGit.path; + const isTrusted = await workspace.isResourceTrusted(Uri.file(path.dirname(dotGitPath))); + + if (!isTrusted) { + openRepositoriesToDispose.push(openRepository); + this.logger.trace(`[Model][onDidChangeWorkspaceTrustedFolders] Repository is no longer trusted: ${openRepository.repository.root}`); + } + } + + openRepositoriesToDispose.forEach(r => r.dispose()); + } + catch (err) { + this.logger.warn(`[Model][onDidChangeWorkspaceTrustedFolders] Error: ${err}`); + } + } + private onDidChangeConfiguration(): void { const possibleRepositoryFolders = (workspace.workspaceFolders || []) .filter(folder => workspace.getConfiguration('git', folder.uri).get('enabled') === true) diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 862223a2fca..7ae8527b101 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -2387,8 +2387,8 @@ export class Repository implements Disposable { return this.run(Operation.Show, () => this.repository.detectObjectType(object)); } - async apply(patch: string, reverse?: boolean): Promise { - return await this.run(Operation.Apply, () => this.repository.apply(patch, reverse)); + async apply(patch: string, options?: { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean }): Promise { + return await this.run(Operation.Apply, () => this.repository.apply(patch, options)); } async getStashes(): Promise { diff --git a/extensions/git/src/test/askpassManager.test.ts b/extensions/git/src/test/askpassManager.test.ts new file mode 100644 index 00000000000..3a90c078873 --- /dev/null +++ b/extensions/git/src/test/askpassManager.test.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'mocha'; +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ensureAskpassScripts } from '../askpassManager'; +import { Event, EventEmitter, LogLevel, LogOutputChannel } from 'vscode'; + +class MockLogOutputChannel implements LogOutputChannel { + logLevel: LogLevel = LogLevel.Info; + onDidChangeLogLevel: Event = new EventEmitter().event; + private logs: { level: string; message: string }[] = []; + + trace(message: string, ..._args: any[]): void { + this.logs.push({ level: 'trace', message }); + } + debug(message: string, ..._args: any[]): void { + this.logs.push({ level: 'debug', message }); + } + info(message: string, ..._args: any[]): void { + this.logs.push({ level: 'info', message }); + } + warn(message: string, ..._args: any[]): void { + this.logs.push({ level: 'warn', message }); + } + error(error: string | Error, ..._args: any[]): void { + this.logs.push({ level: 'error', message: error.toString() }); + } + + name: string = 'MockLogOutputChannel'; + append(_value: string): void { } + appendLine(_value: string): void { } + replace(_value: string): void { } + clear(): void { } + show(_column?: unknown, _preserveFocus?: unknown): void { } + hide(): void { } + dispose(): void { } + + getLogs(): { level: string; message: string }[] { + return this.logs; + } + + hasLog(level: string, messageSubstring: string): boolean { + return this.logs.some(log => log.level === level && log.message.includes(messageSubstring)); + } +} + +// Helper to set mtime on a directory +async function setDirectoryMtime(dirPath: string, mtime: Date): Promise { + await fs.promises.utimes(dirPath, mtime, mtime); +} + +suite('askpassManager', () => { + let tempDir: string; + let sourceDir: string; + + setup(async () => { + // Create a temporary directory for testing + tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'askpass-test-')); + + // Create source directory with dummy askpass files + sourceDir = path.join(tempDir, 'source'); + await fs.promises.mkdir(sourceDir, { recursive: true }); + + const askpassFiles = ['askpass.sh', 'askpass-main.js', 'ssh-askpass.sh', 'askpass-empty.sh', 'ssh-askpass-empty.sh']; + for (const file of askpassFiles) { + await fs.promises.writeFile(path.join(sourceDir, file), `#!/bin/sh\n# ${file}\n`); + } + }); + + teardown(async () => { + // Clean up temporary directory + try { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors during cleanup + } + }); + + test('garbage collection removes old directories', async function () { + const storageDir = path.join(tempDir, 'storage'); + const askpassBaseDir = path.join(storageDir, 'askpass'); + const logger = new MockLogOutputChannel(); + + // Create old directories with old mtimes (8 days ago) + const oldDate = new Date(Date.now() - (8 * 24 * 60 * 60 * 1000)); + const oldDirs = ['oldhash1', 'oldhash2']; + + for (const dirName of oldDirs) { + const dirPath = path.join(askpassBaseDir, dirName); + await fs.promises.mkdir(dirPath, { recursive: true }); + await fs.promises.writeFile(path.join(dirPath, 'test.txt'), 'old'); + await setDirectoryMtime(dirPath, oldDate); + } + + // Create a recent directory (1 day ago) + const recentDate = new Date(Date.now() - (1 * 24 * 60 * 60 * 1000)); + const recentDir = path.join(askpassBaseDir, 'recenthash'); + await fs.promises.mkdir(recentDir, { recursive: true }); + await fs.promises.writeFile(path.join(recentDir, 'test.txt'), 'recent'); + await setDirectoryMtime(recentDir, recentDate); + + // Call ensureAskpassScripts which should trigger garbage collection when creating a new directory + await ensureAskpassScripts(sourceDir, storageDir, logger); + + // Check that old directories were removed + for (const dirName of oldDirs) { + const dirPath = path.join(askpassBaseDir, dirName); + const exists = await fs.promises.access(dirPath).then(() => true).catch(() => false); + assert.strictEqual(exists, false, `Old directory ${dirName} should have been removed`); + } + + // Check that recent directory still exists + const recentExists = await fs.promises.access(recentDir).then(() => true).catch(() => false); + assert.strictEqual(recentExists, true, 'Recent directory should still exist'); + + // Check logs + assert.ok(logger.hasLog('info', 'Removing old askpass directory'), 'Should log removal of old directories'); + }); + + test('garbage collection skips non-directory entries', async function () { + const storageDir = path.join(tempDir, 'storage'); + const askpassBaseDir = path.join(storageDir, 'askpass'); + const logger = new MockLogOutputChannel(); + + // Create a file in the askpass directory (not a directory) + await fs.promises.mkdir(askpassBaseDir, { recursive: true }); + const filePath = path.join(askpassBaseDir, 'somefile.txt'); + await fs.promises.writeFile(filePath, 'test'); + + // Set old mtime + const oldDate = new Date(Date.now() - (8 * 24 * 60 * 60 * 1000)); + await fs.promises.utimes(filePath, oldDate, oldDate); + + // Call ensureAskpassScripts which should trigger garbage collection + await ensureAskpassScripts(sourceDir, storageDir, logger); + + // Check that file still exists (should not be removed) + const exists = await fs.promises.access(filePath).then(() => true).catch(() => false); + assert.strictEqual(exists, true, 'Non-directory file should not be removed'); + }); + + test('mtime is updated on existing directory', async function () { + const storageDir = path.join(tempDir, 'storage'); + const logger = new MockLogOutputChannel(); + + // Call ensureAskpassScripts to create the directory + const paths1 = await ensureAskpassScripts(sourceDir, storageDir, logger); + + // Get the directory path and its initial mtime + const askpassDir = path.dirname(paths1.askpass); + const stat1 = await fs.promises.stat(askpassDir); + const mtime1 = stat1.mtime.getTime(); + + // Wait a bit to ensure time difference + await new Promise(resolve => setTimeout(resolve, 100)); + + // Call again (should update mtime) + await ensureAskpassScripts(sourceDir, storageDir, logger); + + // Check that mtime was updated + const stat2 = await fs.promises.stat(askpassDir); + const mtime2 = stat2.mtime.getTime(); + + assert.ok(mtime2 > mtime1, 'Mtime should be updated on subsequent calls'); + }); + + test('garbage collection handles empty askpass directory', async function () { + const storageDir = path.join(tempDir, 'storage'); + const logger = new MockLogOutputChannel(); + + // Don't create any askpass directories, just call ensureAskpassScripts + await ensureAskpassScripts(sourceDir, storageDir, logger); + + // Should complete without errors + assert.ok(true, 'Should handle empty or non-existent askpass directory gracefully'); + }); + + test('current content-addressed directory is not removed', async function () { + const storageDir = path.join(tempDir, 'storage'); + const logger = new MockLogOutputChannel(); + + // Create the current content-addressed directory + const paths = await ensureAskpassScripts(sourceDir, storageDir, logger); + const currentDir = path.dirname(paths.askpass); + + // Set its mtime to 8 days ago (would normally be removed) + const oldDate = new Date(Date.now() - (8 * 24 * 60 * 60 * 1000)); + await setDirectoryMtime(currentDir, oldDate); + + // Call again which should trigger GC + await ensureAskpassScripts(sourceDir, storageDir, logger); + + // Current directory should still exist + const exists = await fs.promises.access(currentDir).then(() => true).catch(() => false); + assert.strictEqual(exists, true, 'Current content-addressed directory should not be removed'); + }); +}); diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index 95d0a131b7c..eb336e8f89b 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -650,8 +650,6 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP async function getSchemaAssociations(forceRefresh: boolean): Promise { if (!schemaAssociationsCache || forceRefresh) { schemaAssociationsCache = computeSchemaAssociations(); - runtime.logOutputChannel.info(`Computed schema associations: ${(await schemaAssociationsCache).map(a => `${a.uri} -> [${a.fileMatch.join(', ')}]`).join('\n')}`); - } return schemaAssociationsCache; } diff --git a/extensions/mermaid-chat-features/chat-webview-src/mermaidWebview.ts b/extensions/mermaid-chat-features/chat-webview-src/mermaidWebview.ts index a2fb1081995..fb11ad44e5c 100644 --- a/extensions/mermaid-chat-features/chat-webview-src/mermaidWebview.ts +++ b/extensions/mermaid-chat-features/chat-webview-src/mermaidWebview.ts @@ -35,7 +35,7 @@ export class PanZoomHandler { this.content = content; this.content.style.transformOrigin = '0 0'; this.container.style.overflow = 'hidden'; - this.container.style.cursor = 'grab'; + this.container.style.cursor = 'default'; this.setupEventListeners(); } @@ -77,11 +77,11 @@ export class PanZoomHandler { if ((e.key === 'Alt' || e.key === 'Shift') && !this.isPanning) { e.preventDefault(); if (e.altKey && !e.shiftKey) { - this.container.style.cursor = 'zoom-in'; + this.container.style.cursor = 'grab'; } else if (e.altKey && e.shiftKey) { this.container.style.cursor = 'zoom-out'; } else { - this.container.style.cursor = 'grab'; + this.container.style.cursor = 'default'; } } } @@ -91,11 +91,11 @@ export class PanZoomHandler { return; } if (e.altKey && !e.shiftKey) { - this.container.style.cursor = 'zoom-in'; + this.container.style.cursor = 'grab'; } else if (e.altKey && e.shiftKey) { this.container.style.cursor = 'zoom-out'; } else { - this.container.style.cursor = 'grab'; + this.container.style.cursor = 'default'; } } @@ -118,9 +118,15 @@ export class PanZoomHandler { } private handleWheel(e: WheelEvent): void { + // Only zoom when Alt is held (or ctrlKey for pinch-to-zoom gestures) // ctrlKey is set by browsers for pinch-to-zoom gestures const isPinchZoom = e.ctrlKey; + if (!e.altKey && !isPinchZoom) { + // Allow normal scrolling when Alt is not held + return; + } + if (isPinchZoom || e.altKey) { // Pinch gesture or Alt + two-finger drag = zoom e.preventDefault(); @@ -131,7 +137,9 @@ export class PanZoomHandler { const mouseY = e.clientY - rect.top; // Calculate zoom (scroll up = zoom in, scroll down = zoom out) - const delta = -e.deltaY * this.zoomFactor; + // Pinch gestures have smaller deltaY values, so use a higher factor + const effectiveZoomFactor = isPinchZoom ? this.zoomFactor * 5 : this.zoomFactor; + const delta = -e.deltaY * effectiveZoomFactor; const newScale = Math.min(this.maxScale, Math.max(this.minScale, this.scale * (1 + delta))); // Zoom toward mouse position @@ -146,7 +154,7 @@ export class PanZoomHandler { } private handleMouseDown(e: MouseEvent): void { - if (e.button !== 0) { + if (e.button !== 0 || !e.altKey) { return; } e.preventDefault(); @@ -182,7 +190,7 @@ export class PanZoomHandler { private handleMouseUp(): void { if (this.isPanning) { this.isPanning = false; - this.container.style.cursor = 'grab'; + this.container.style.cursor = 'default'; this.saveState(); } } diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 6a447e16d57..690bb9ce086 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -62,10 +62,6 @@ "command": "_mermaid-chat.resetPanZoom", "when": "webviewId == 'vscode.chat-mermaid-features.chatOutputItem'" }, - { - "command": "_mermaid-chat.openInEditor", - "when": "webviewId == 'vscode.chat-mermaid-features.chatOutputItem'" - }, { "command": "_mermaid-chat.copySource", "when": "webviewId == 'vscode.chat-mermaid-features.chatOutputItem' || webviewId == 'vscode.chat-mermaid-features.preview'" @@ -77,12 +73,9 @@ "properties": { "mermaid-chat.enabled": { "type": "boolean", - "default": false, + "default": true, "description": "%config.enabled.description%", - "scope": "application", - "tags": [ - "experimental" - ] + "scope": "application" } } }, diff --git a/extensions/mermaid-chat-features/src/chatOutputRenderer.ts b/extensions/mermaid-chat-features/src/chatOutputRenderer.ts index 810fa2011b1..4bf4d9de7e7 100644 --- a/extensions/mermaid-chat-features/src/chatOutputRenderer.ts +++ b/extensions/mermaid-chat-features/src/chatOutputRenderer.ts @@ -105,6 +105,7 @@ class MermaidChatOutputRenderer implements vscode.ChatOutputRenderer { opacity: 1; } .open-in-editor-btn:hover { + opacity: 1; background: var(--vscode-toolbar-hoverBackground); } diff --git a/extensions/theme-2026/package.json b/extensions/theme-2026/package.json index b425a019e2f..305cc066c89 100644 --- a/extensions/theme-2026/package.json +++ b/extensions/theme-2026/package.json @@ -17,14 +17,14 @@ "contributes": { "themes": [ { - "id": "2026-light-experimental", - "label": "2026 Light", + "id": "Experimental Light", + "label": "VS Code Light", "uiTheme": "vs", "path": "./themes/2026-light.json" }, { - "id": "2026-dark-experimental", - "label": "2026 Dark", + "id": "Experimental Dark", + "label": "VS Code Dark", "uiTheme": "vs-dark", "path": "./themes/2026-dark.json" } diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 4010ebd1e13..3e3c676bac1 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -6,7 +6,7 @@ "foreground": "#bfbfbf", "disabledForeground": "#444444", "errorForeground": "#f48771", - "descriptionForeground": "#888888", + "descriptionForeground": "#999999", "icon.foreground": "#888888", "focusBorder": "#3994BCB3", "textBlockQuote.background": "#242526", @@ -20,9 +20,6 @@ "button.foreground": "#FFFFFF", "button.hoverBackground": "#3E9BC4", "button.border": "#2A2B2CFF", - "button.secondaryBackground": "#242526", - "button.secondaryForeground": "#bfbfbf", - "button.secondaryHoverBackground": "#313233", "checkbox.background": "#242526", "checkbox.border": "#333536", "checkbox.foreground": "#bfbfbf", @@ -53,9 +50,9 @@ "badge.background": "#3994BC", "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", - "list.activeSelectionBackground": "#3994BC26", + "list.activeSelectionBackground": "#3994BC55", "list.activeSelectionForeground": "#bfbfbf", - "list.inactiveSelectionBackground": "#242526", + "list.inactiveSelectionBackground": "#2C2D2E", "list.inactiveSelectionForeground": "#bfbfbf", "list.hoverBackground": "#262728", "list.hoverForeground": "#bfbfbf", @@ -71,10 +68,11 @@ "activityBar.foreground": "#bfbfbf", "activityBar.inactiveForeground": "#888888", "activityBar.border": "#2A2B2CFF", - "activityBar.activeBorder": "#2A2B2CFF", + "activityBar.activeBorder": "#bfbfbf", "activityBar.activeFocusBorder": "#3994BCB3", "activityBarBadge.background": "#3994BC", "activityBarBadge.foreground": "#FFFFFF", + "activityBarTop.activeBorder": "#bfbfbf", "sideBar.background": "#191A1B", "sideBar.foreground": "#bfbfbf", "sideBar.border": "#2A2B2CFF", @@ -184,6 +182,7 @@ "tab.border": "#2A2B2CFF", "tab.lastPinnedBorder": "#2A2B2CFF", "tab.activeBorder": "#121314", + "tab.activeBorderTop": "#3994BC", "tab.hoverBackground": "#262728", "tab.hoverForeground": "#bfbfbf", "tab.unfocusedActiveBackground": "#121314", @@ -224,6 +223,9 @@ "quickInputList.focusIconForeground": "#bfbfbf", "quickInputList.hoverBackground": "#515253", "terminal.selectionBackground": "#3994BC33", + "terminal.background": "#121314", + "terminal.border": "#2A2B2CFF", + "terminal.tab.activeBorder": "#3994BC00", "terminalCursor.foreground": "#bfbfbf", "terminalCursor.background": "#191A1B", "gitDecoration.addedResourceForeground": "#73c991", @@ -246,6 +248,8 @@ "gauge.errorBackground": "#F287724D", "chat.requestBubbleBackground": "#488FAE26", "chat.requestBubbleHoverBackground": "#488FAE46", + "editorCommentsWidget.rangeBackground": "#488FAE26", + "editorCommentsWidget.rangeActiveBackground": "#488FAE46", "charts.foreground": "#CCCCCC", "charts.lines": "#C8CACC80", "charts.blue": "#57A3F8", @@ -287,6 +291,15 @@ "foreground": "#C48081" } }, + { + "name": "Language constants", + "scope": [ + "constant.language" + ], + "settings": { + "foreground": "#4F8FDD" + } + }, { "name": "HTML/XML tags", "scope": [ @@ -536,4 +549,4 @@ "customLiteral": "#DCDCAA", "numberLiteral": "#b5cea8" } -} \ No newline at end of file +} diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 0b2736f937b..97c155e4d96 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -6,7 +6,7 @@ "foreground": "#202020", "disabledForeground": "#BBBBBB", "errorForeground": "#ad0707", - "descriptionForeground": "#666666", + "descriptionForeground": "#555555", "icon.foreground": "#666666", "focusBorder": "#0069CCFF", "textBlockQuote.background": "#EDEDED", @@ -31,10 +31,10 @@ "dropdown.foreground": "#202020", "dropdown.listBackground": "#FFFFFF", "input.background": "#FFFFFF", - "input.border": "#D8D8D880", + "input.border": "#D8D8D866", "input.foreground": "#202020", "input.placeholderForeground": "#999999", - "inputOption.activeBackground": "#0069CC33", + "inputOption.activeBackground": "#0069CC26", "inputOption.activeForeground": "#202020", "inputOption.activeBorder": "#ECEDEEFF", "inputValidation.errorBackground": "#FFFFFF", @@ -46,35 +46,41 @@ "inputValidation.warningBackground": "#FFFFFF", "inputValidation.warningBorder": "#ECEDEEFF", "inputValidation.warningForeground": "#202020", - "scrollbar.shadow": "#FFFFFF4D", - "scrollbarSlider.background": "#99999933", - "scrollbarSlider.hoverBackground": "#9999994D", - "scrollbarSlider.activeBackground": "#99999966", + "scrollbar.shadow": "#00000000", + "widget.shadow": "#00000000", + "editorStickyScroll.shadow": "#00000000", + "sideBarStickyScroll.shadow": "#00000000", + "panelStickyScroll.shadow": "#00000000", + "listFilterWidget.shadow": "#00000000", + "scrollbarSlider.background": "#99999926", + "scrollbarSlider.hoverBackground": "#99999940", + "scrollbarSlider.activeBackground": "#99999955", "badge.background": "#0069CC", "badge.foreground": "#FFFFFF", "progressBar.background": "#0069CC", - "list.activeSelectionBackground": "#0069CC26", + "list.activeSelectionBackground": "#0069CC44", "list.activeSelectionForeground": "#202020", - "list.inactiveSelectionBackground": "#EDEDED", + "list.inactiveSelectionBackground": "#E0E0E0", "list.inactiveSelectionForeground": "#202020", "list.hoverBackground": "#F7F7F7", "list.hoverForeground": "#202020", - "list.dropBackground": "#0069CC1A", - "list.focusBackground": "#0069CC26", + "list.dropBackground": "#0069CC15", + "list.focusBackground": "#0069CC1A", "list.focusForeground": "#202020", "list.focusOutline": "#0069CCFF", "list.highlightForeground": "#0069CC", "list.invalidItemForeground": "#BBBBBB", "list.errorForeground": "#ad0707", "list.warningForeground": "#667309", - "activityBar.background": "#FFFFFF", + "activityBar.background": "#E8ECF2", "activityBar.foreground": "#202020", "activityBar.inactiveForeground": "#666666", "activityBar.border": "#ECEDEEFF", - "activityBar.activeBorder": "#ECEDEEFF", + "activityBar.activeBorder": "#000000", "activityBar.activeFocusBorder": "#0069CCFF", "activityBarBadge.background": "#0069CC", "activityBarBadge.foreground": "#FFFFFF", + "activityBarTop.activeBorder": "#000000", "sideBar.background": "#FFFFFF", "sideBar.foreground": "#202020", "sideBar.border": "#ECEDEEFF", @@ -82,7 +88,7 @@ "sideBarSectionHeader.background": "#FFFFFF", "sideBarSectionHeader.foreground": "#202020", "sideBarSectionHeader.border": "#ECEDEEFF", - "titleBar.activeBackground": "#FFFFFF", + "titleBar.activeBackground": "#E8ECF2", "titleBar.activeForeground": "#424242", "titleBar.inactiveBackground": "#FFFFFF", "titleBar.inactiveForeground": "#666666", @@ -91,62 +97,61 @@ "menubar.selectionForeground": "#202020", "menu.background": "#FFFFFF", "menu.foreground": "#202020", - "menu.selectionBackground": "#0069CC26", + "menu.selectionBackground": "#0069CC1A", "menu.selectionForeground": "#202020", "menu.separatorBackground": "#F7F7F7", "menu.border": "#ECEDEEFF", "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", - "commandCenter.background": "#FFFFFF", - "commandCenter.activeBackground": "#FFFFFFCC", - "commandCenter.border": "#D8D8D880", - "editor.background": "#FAFAFA", + "commandCenter.background": "#E8ECF2", + "commandCenter.activeBackground": "#E8ECF2B3", + "commandCenter.border": "#D8D8D866", + "editor.background": "#FFFFFF", "editor.foreground": "#202020", "editorLineNumber.foreground": "#666666", "editorLineNumber.activeForeground": "#202020", "editorCursor.foreground": "#202020", - "editor.selectionBackground": "#0069CC26", - "editor.inactiveSelectionBackground": "#0069CC26", - "editor.selectionHighlightBackground": "#0069CC1A", - "editor.wordHighlightBackground": "#0069CC33", - "editor.wordHighlightStrongBackground": "#0069CC33", - "editor.findMatchBackground": "#0069CC4D", - "editor.findMatchHighlightBackground": "#0069CC26", + "editor.selectionBackground": "#0069CC1A", + "editor.inactiveSelectionBackground": "#0069CC1A", + "editor.selectionHighlightBackground": "#0069CC15", + "editor.wordHighlightBackground": "#0069CC26", + "editor.wordHighlightStrongBackground": "#0069CC26", + "editor.findMatchBackground": "#0069CC40", + "editor.findMatchHighlightBackground": "#0069CC1A", "editor.findRangeHighlightBackground": "#EDEDED", "editor.hoverHighlightBackground": "#EDEDED", - "editor.lineHighlightBackground": "#EDEDED55", + "editor.lineHighlightBackground": "#EDEDED40", "editor.rangeHighlightBackground": "#EDEDED", "editorLink.activeForeground": "#0069CC", - "editorWhitespace.foreground": "#6666664D", - "editorIndentGuide.background": "#F7F7F74D", + "editorWhitespace.foreground": "#66666640", + "editorIndentGuide.background": "#F7F7F740", "editorIndentGuide.activeBackground": "#F7F7F7", "editorRuler.foreground": "#F7F7F7", "editorCodeLens.foreground": "#666666", - "editorBracketMatch.background": "#0069CC55", + "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#ECEDEEFF", - "editorWidget.background": "#FFFFFF99", + "editorWidget.background": "#E8ECF2E6", "editorWidget.border": "#ECEDEEFF", "editorWidget.foreground": "#202020", - "editorSuggestWidget.background": "#FFFFFF", + "editorSuggestWidget.background": "#E8ECF2E6", "editorSuggestWidget.border": "#ECEDEEFF", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", - "editorHoverWidget.background": "#FFFFFF", + "editorHoverWidget.background": "#E8ECF2E6", "editorHoverWidget.border": "#ECEDEEFF", "peekView.border": "#0069CC", - "peekViewEditor.background": "#FFFFFF", + "peekViewEditor.background": "#E8ECF2E6", "peekViewEditor.matchHighlightBackground": "#0069CC33", - "peekViewResult.background": "#FFFFFF", + "peekViewResult.background": "#E8ECF2E6", "peekViewResult.fileForeground": "#202020", "peekViewResult.lineForeground": "#666666", "peekViewResult.matchHighlightBackground": "#0069CC33", "peekViewResult.selectionBackground": "#0069CC26", "peekViewResult.selectionForeground": "#202020", - "peekViewTitle.background": "#FFFFFF", + "peekViewTitle.background": "#E8ECF2E6", "peekViewTitleDescription.foreground": "#666666", "peekViewTitleLabel.foreground": "#202020", - "editorGutter.background": "#FAFAFA", "editorGutter.addedBackground": "#587c0c", "editorGutter.deletedBackground": "#ad0707", "diffEditor.insertedTextBackground": "#587c0c26", @@ -158,18 +163,19 @@ "editorOverviewRuler.deletedForeground": "#ad0707", "editorOverviewRuler.errorForeground": "#ad0707", "editorOverviewRuler.warningForeground": "#667309", + "editorGutter.background": "#FFFFFF", "panel.background": "#FFFFFF", - "panel.border": "#ECEDEEFF", - "panelTitle.activeBorder": "#0069CC", + "panel.border": "#00000000", + "panelTitle.activeBorder": "#000000", "panelTitle.activeForeground": "#202020", "panelTitle.inactiveForeground": "#666666", - "statusBar.background": "#FFFFFF", + "statusBar.background": "#E8ECF2", "statusBar.foreground": "#666666", - "statusBar.border": "#ECEDEEFF", + "statusBar.border": "#00000000", "statusBar.focusBorder": "#0069CCFF", "statusBar.debuggingBackground": "#0069CC", "statusBar.debuggingForeground": "#FFFFFF", - "statusBar.noFolderBackground": "#FFFFFF", + "statusBar.noFolderBackground": "#E8ECF2", "statusBar.noFolderForeground": "#666666", "statusBarItem.activeBackground": "#E6E6E6", "statusBarItem.hoverBackground": "#F7F7F7", @@ -177,32 +183,33 @@ "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", - "tab.activeBackground": "#FAFAFA", + "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FFFFFF", "tab.inactiveForeground": "#666666", "tab.border": "#ECEDEEFF", "tab.lastPinnedBorder": "#ECEDEEFF", - "tab.activeBorder": "#FAFAFA", + "tab.activeBorder": "#FFFFFF", + "tab.activeBorderTop": "#000000", "tab.hoverBackground": "#F7F7F7", "tab.hoverForeground": "#202020", - "tab.unfocusedActiveBackground": "#FAFAFA", + "tab.unfocusedActiveBackground": "#FFFFFF", "tab.unfocusedActiveForeground": "#666666", "tab.unfocusedInactiveBackground": "#FFFFFF", "tab.unfocusedInactiveForeground": "#BBBBBB", "editorGroupHeader.tabsBackground": "#FFFFFF", "editorGroupHeader.tabsBorder": "#ECEDEEFF", "breadcrumb.foreground": "#666666", - "breadcrumb.background": "#FAFAFA", + "breadcrumb.background": "#FFFFFF", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", - "breadcrumbPicker.background": "#FFFFFF", + "breadcrumbPicker.background": "#E8ECF2E6", "notificationCenter.border": "#ECEDEEFF", "notificationCenterHeader.foreground": "#202020", - "notificationCenterHeader.background": "#FFFFFF", + "notificationCenterHeader.background": "#E8ECF2E6", "notificationToast.border": "#ECEDEEFF", "notifications.foreground": "#202020", - "notifications.background": "#FFFFFF", + "notifications.background": "#E8ECF2E6", "notifications.border": "#ECEDEEFF", "notificationLink.foreground": "#0069CC", "notificationsWarningIcon.foreground": "#B69500", @@ -217,13 +224,13 @@ "extensionButton.prominentHoverBackground": "#0064CC", "pickerGroup.border": "#ECEDEEFF", "pickerGroup.foreground": "#202020", - "quickInput.background": "#FFFFFF", + "quickInput.background": "#E8ECF2E6", "quickInput.foreground": "#202020", - "quickInputList.focusBackground": "#0069CC26", + "quickInputList.focusBackground": "#0069CC1A", "quickInputList.focusForeground": "#202020", "quickInputList.focusIconForeground": "#202020", - "quickInputList.hoverBackground": "#E7E7E7", - "terminal.selectionBackground": "#0069CC33", + "quickInputList.hoverBackground": "#EDF0F5E6", + "terminal.selectionBackground": "#0069CC26", "terminalCursor.foreground": "#202020", "terminalCursor.background": "#FFFFFF", "gitDecoration.addedResourceForeground": "#587c0c", @@ -234,21 +241,23 @@ "gitDecoration.conflictingResourceForeground": "#ad0707", "gitDecoration.stageModifiedResourceForeground": "#667309", "gitDecoration.stageDeletedResourceForeground": "#ad0707", - "commandCenter.activeBorder": "#D8D8D8CC", + "commandCenter.activeBorder": "#D8D8D8A6", "quickInput.border": "#D8D8D8", "gauge.foreground": "#0069CC", - "gauge.background": "#0069CC4D", + "gauge.background": "#0069CC40", "gauge.border": "#ECEDEEFF", "gauge.warningForeground": "#B69500", - "gauge.warningBackground": "#B695004D", + "gauge.warningBackground": "#B6950040", "gauge.errorForeground": "#ad0707", - "gauge.errorBackground": "#ad07074D", + "gauge.errorBackground": "#ad070740", "statusBarItem.prominentHoverForeground": "#FFFFFF", - "quickInputTitle.background": "#FFFFFF", + "quickInputTitle.background": "#E8ECF2E6", "chat.requestBubbleBackground": "#EEF4FB", "chat.requestBubbleHoverBackground": "#E6EDFA", + "editorCommentsWidget.rangeBackground": "#EEF4FB", + "editorCommentsWidget.rangeActiveBackground": "#E6EDFA", "charts.foreground": "#202020", - "charts.lines": "#20202080", + "charts.lines": "#20202066", "charts.blue": "#1A5CFF", "charts.red": "#ad0707", "charts.yellow": "#667309", @@ -288,6 +297,15 @@ "foreground": "#B86855" } }, + { + "name": "Language constants", + "scope": [ + "constant.language" + ], + "settings": { + "foreground": "#5460C1" + } + }, { "name": "HTML/XML tags", "scope": [ diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 33ba0ada647..2b4a8734afe 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -38,10 +38,12 @@ .monaco-workbench .part.auxiliarybar { box-shadow: var(--shadow-md); z-index: 40; position: relative; } /* Ensure iframe containers in pane-body render above sidebar z-index */ +/* Commented out - may cause content to be hidden by z-index issues .monaco-workbench > div[data-keybinding-context], .monaco-workbench > div[data-keybinding-context] { z-index: 50 !important; } +*/ /* Ensure webview containers render above sidebar z-index */ .monaco-workbench .part.sidebar .webview, @@ -74,17 +76,37 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); position: relative; z-index: 5; - /* border-radius: var(--radius-sm) var(--radius-sm) 0 0; */ + border-radius: 0; border-top: none !important; + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--vscode-focusBorder) 10%, transparent) 0%, + transparent 100% + ), var(--vscode-tab-activeBackground) !important; +} +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { + box-shadow: var(--shadow-sm); } -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { box-shadow: var(--shadow-sm); } /* Tab border bottom - make transparent */ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab { --tab-border-bottom-color: transparent !important; } /* Title Bar */ -.monaco-workbench .part.titlebar { box-shadow: var(--shadow-md); z-index: 60; position: relative; overflow: visible !important; } +.monaco-workbench.vs .part.titlebar { box-shadow: var(--shadow-md); } + +.monaco-workbench.vs-dark .part.titlebar { + position: relative; + overflow: visible !important; + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--vscode-focusBorder) 10%, transparent) 0%, + transparent 100% + ), var(--vscode-titleBar-activeBackground) !important; +} +.monaco-workbench .part.titlebar.inactive { + background: var(--vscode-titleBar-inactiveBackground) !important; +} .monaco-workbench .part.titlebar .titlebar-container, .monaco-workbench .part.titlebar .titlebar-center, .monaco-workbench .part.titlebar .titlebar-center .window-title, @@ -281,6 +303,12 @@ color: var(--vscode-icon-foreground) !important; } +/* Chat input toolbar icons should use proper foreground color, not the muted icon.foreground */ +.monaco-workbench .interactive-session .chat-input-toolbars .monaco-action-bar .action-item .codicon, +.monaco-workbench .interactive-session .chat-input-toolbars .action-label .codicon { + color: var(--vscode-foreground) !important; +} + /* Buttons */ .monaco-workbench .monaco-button { box-shadow: var(--shadow-xs); } .monaco-workbench .monaco-button:hover { box-shadow: var(--shadow-sm); } @@ -313,13 +341,19 @@ .monaco-workbench .monaco-dropdown .dropdown-menu { box-shadow: var(--shadow-lg); border: none; border-radius: var(--radius-lg); } /* Terminal */ -.monaco-workbench .pane-body.integrated-terminal { box-shadow: var(--shadow-inset-white); } +.monaco-workbench.vs .pane-body.integrated-terminal { box-shadow: var(--shadow-inset-white); } /* SCM */ .monaco-workbench .scm-view .scm-provider { box-shadow: var(--shadow-sm); border-radius: var(--radius-md); } /* Debug Toolbar */ -.monaco-workbench .debug-toolbar { box-shadow: var(--shadow-lg); border: none; border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-lg) !important; -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; } +.monaco-workbench .debug-toolbar { + box-shadow: var(--shadow-lg); + border: none; + border-radius: var(--radius-lg); + backdrop-filter: var(--backdrop-blur-lg) !important; + -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; +} .monaco-workbench .debug-hover-widget { box-shadow: var(--shadow-hover); @@ -430,6 +464,12 @@ border-top-width: 0; } +/* Quick Input List - use descriptionForeground color for descriptions */ +.monaco-workbench .quick-input-list .monaco-icon-label .label-description { + opacity: 1; + color: var(--vscode-descriptionForeground); +} + /* Remove Borders */ .monaco-workbench.vs .part.sidebar { border-right: none !important; border-left: none !important; } .monaco-workbench.vs .part.auxiliarybar { border-right: none !important; border-left: none !important; } diff --git a/extensions/vb/language-configuration.json b/extensions/vb/language-configuration.json index 734448101e0..2fefe78dbe7 100644 --- a/extensions/vb/language-configuration.json +++ b/extensions/vb/language-configuration.json @@ -32,7 +32,7 @@ "flags": "i" }, "increaseIndentPattern": { - "pattern": "^\\s*((If|ElseIf).*Then(?!.*End\\s+If)\\s*(('|REM).*)?|(Else|While|For|Do|Select\\s+Case|Case|Sub|Function|Class|Module|Enum|Structure|Interface|Namespace|With|Try|Catch|Finally|SyncLock|Using|Property|Get|Set|AddHandler|RaiseEvent|RemoveHandler|Event|Operator)\\b(?!.*\\bEnd\\s+(If|Sub|Function|Class|Module|Enum|Structure|Interface|Namespace|With|Select|Try|While|For|Property|Get|Set|SyncLock|Using|AddHandler|RaiseEvent|RemoveHandler|Event|Operator)\\b).*(('|REM).*)?)$", + "pattern": "^\\s*((If|ElseIf)\\b.*\\bThen\\s*(('|REM).*)?|(Else|While|For|Do|Select\\s+Case|Case|Sub|Function|Class|Module|Enum|Structure|Interface|Namespace|With|Try|Catch|Finally|SyncLock|Using|Property|Get|Set|AddHandler|RaiseEvent|RemoveHandler|Event|Operator)\\b(?!.*\\bEnd\\s+(If|Sub|Function|Class|Module|Enum|Structure|Interface|Namespace|With|Select|Try|While|For|Property|Get|Set|SyncLock|Using|AddHandler|RaiseEvent|RemoveHandler|Event|Operator)\\b).*(('|REM).*)?)$", "flags": "i" } }, diff --git a/package-lock.json b/package-lock.json index 7c1db07073a..801e87b04c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-0", + "@vscode/codicons": "^0.0.45-4", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -48,7 +48,7 @@ "minimist": "^1.2.8", "native-is-elevated": "0.9.0", "native-keymap": "^3.3.5", - "node-pty": "^1.2.0-beta.8", + "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", "tas-client": "0.3.1", "undici": "^7.18.2", @@ -149,7 +149,7 @@ "source-map": "0.6.1", "source-map-support": "^0.3.2", "style-loader": "^3.3.2", - "tar": "^7.5.4", + "tar": "^7.5.7", "ts-loader": "^9.5.1", "tsec": "0.2.7", "tslib": "^2.6.3", @@ -2947,9 +2947,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-0", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-0.tgz", - "integrity": "sha512-ixvw4auQobMOnMX9cOk8/3GfEgkTKCchsab2O6QvyL6+x4FJegOrK3Wgn4Y+Qua51LqnAsgpB5n74q8HEPh1pA==", + "version": "0.0.45-4", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-4.tgz", + "integrity": "sha512-uuWqpry+FcHAw1JDkXwEW0YIuTtX3n6KqSshNlvLUjuP92PSrfq99jW52AWJ7qeunmPvgKCaZOeSSLUqHRHjmw==", "license": "CC-BY-4.0" }, "node_modules/@vscode/deviceid": { @@ -12876,9 +12876,9 @@ } }, "node_modules/node-pty": { - "version": "1.2.0-beta.8", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.8.tgz", - "integrity": "sha512-2gDjTGB/VaMV8cmFMg0d7IfLcWkxtyekn9VSqpq+tUOiu5+nnLfVXYHZbjZCq1kXhnxbdlBjaKLvvVWIbFkicw==", + "version": "1.2.0-beta.10", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.10.tgz", + "integrity": "sha512-vONwSCtAiOVNxeaP/lzDdRw733Q6uB/ELOCFM8DUfKMw6rTFovwFCuvqr9usya7JXV2pfaers3EwuzZfv0QtwA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -16377,9 +16377,9 @@ } }, "node_modules/tar": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", - "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/package.json b/package.json index bba9b07dbbe..9997a328a33 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.109.0", - "distro": "1468752e41ea530021336a556d31c13ff82e02b9", + "distro": "6c9f72a1ba8565301b303ec4314f5a24d585f012", "author": { "name": "Microsoft Corporation" }, @@ -33,7 +33,7 @@ "watch-extensions": "npm run gulp watch-extensions watch-extension-media", "watch-extensionsd": "deemon npm run watch-extensions", "kill-watch-extensionsd": "deemon --kill npm run watch-extensions", - "precommit": "node build/hygiene.ts", + "precommit": "node --experimental-strip-types build/hygiene.ts", "gulp": "node --max-old-space-size=8192 ./node_modules/gulp/bin/gulp.js", "electron": "node build/lib/electron.ts", "7z": "7z", @@ -77,7 +77,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-0", + "@vscode/codicons": "^0.0.45-4", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -110,7 +110,7 @@ "minimist": "^1.2.8", "native-is-elevated": "0.9.0", "native-keymap": "^3.3.5", - "node-pty": "^1.2.0-beta.8", + "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", "tas-client": "0.3.1", "undici": "^7.18.2", @@ -211,7 +211,7 @@ "source-map": "0.6.1", "source-map-support": "^0.3.2", "style-loader": "^3.3.2", - "tar": "^7.5.4", + "tar": "^7.5.7", "ts-loader": "^9.5.1", "tsec": "0.2.7", "tslib": "^2.6.3", diff --git a/remote/package-lock.json b/remote/package-lock.json index d7e6a9a38ce..5af6b961bff 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -38,7 +38,7 @@ "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.8", - "node-pty": "^1.2.0-beta.8", + "node-pty": "^1.2.0-beta.10", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", @@ -1053,9 +1053,9 @@ } }, "node_modules/node-pty": { - "version": "1.2.0-beta.8", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.8.tgz", - "integrity": "sha512-2gDjTGB/VaMV8cmFMg0d7IfLcWkxtyekn9VSqpq+tUOiu5+nnLfVXYHZbjZCq1kXhnxbdlBjaKLvvVWIbFkicw==", + "version": "1.2.0-beta.10", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.10.tgz", + "integrity": "sha512-vONwSCtAiOVNxeaP/lzDdRw733Q6uB/ELOCFM8DUfKMw6rTFovwFCuvqr9usya7JXV2pfaers3EwuzZfv0QtwA==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/remote/package.json b/remote/package.json index 9d5968c3f89..7f9b6f8b80d 100644 --- a/remote/package.json +++ b/remote/package.json @@ -33,7 +33,7 @@ "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.8", - "node-pty": "^1.2.0-beta.8", + "node-pty": "^1.2.0-beta.10", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index a970a788d3a..9ebe85bde28 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-0", + "@vscode/codicons": "^0.0.45-4", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-0", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-0.tgz", - "integrity": "sha512-ixvw4auQobMOnMX9cOk8/3GfEgkTKCchsab2O6QvyL6+x4FJegOrK3Wgn4Y+Qua51LqnAsgpB5n74q8HEPh1pA==", + "version": "0.0.45-4", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-4.tgz", + "integrity": "sha512-uuWqpry+FcHAw1JDkXwEW0YIuTtX3n6KqSshNlvLUjuP92PSrfq99jW52AWJ7qeunmPvgKCaZOeSSLUqHRHjmw==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index db9eed29f88..f940ea309ad 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-0", + "@vscode/codicons": "^0.0.45-4", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", diff --git a/resources/workbenchModes/agent-sessions.code-workbench-mode b/resources/workbenchModes/agent-sessions.code-workbench-mode index 0fd107b02de..7aa37f33888 100644 --- a/resources/workbenchModes/agent-sessions.code-workbench-mode +++ b/resources/workbenchModes/agent-sessions.code-workbench-mode @@ -19,6 +19,7 @@ "workbench.sideBar.location": "right", "workbench.statusBar.visible": false, "workbench.secondarySideBar.forceMaximized": true, + "workbench.secondarySideBar.defaultVisibility": "maximized", "workbench.startupEditor": "none", "workbench.tips.enabled": false, "workbench.layoutControl.type": "toggles", diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index a9f9fcfee7e..df985c36c16 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2804,3 +2804,33 @@ function setOrRemoveAttribute(element: HTMLOrSVGElement, key: string, value: unk type ElementAttributeKeys = Partial<{ [K in keyof T]: T[K] extends Function ? never : T[K] extends object ? ElementAttributeKeys : Value; }>; + +/** + * A custom element that fires callbacks when connected to or disconnected from the DOM. + * Useful for tracking whether a template or component is currently mounted, especially + * with iframes/webviews that are sensitive to movement. + * + * @example + * ```ts + * const observer = document.createElement('connection-observer') as ConnectionObserverElement; + * observer.onDidConnect = () => console.log('mounted'); + * observer.onDidDisconnect = () => console.log('unmounted'); + * container.appendChild(observer); + * ``` + */ +export class ConnectionObserverElement extends HTMLElement { + public onDidConnect?: () => void; + public onDidDisconnect?: () => void; + + disconnectedCallback() { + this.onDidDisconnect?.(); + } + + connectedCallback() { + this.onDidConnect?.(); + } +} + +if (!customElements.get('connection-observer')) { + customElements.define('connection-observer', ConnectionObserverElement); +} diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index fa1fa93d545..5b32ddc9d85 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -48,6 +48,7 @@ export interface IButtonStyles { readonly buttonSecondaryBackground: string | undefined; readonly buttonSecondaryHoverBackground: string | undefined; readonly buttonSecondaryForeground: string | undefined; + readonly buttonSecondaryBorder: string | undefined; readonly buttonBorder: string | undefined; } @@ -59,7 +60,8 @@ export const unthemedButtonStyles: IButtonStyles = { buttonBorder: undefined, buttonSecondaryBackground: undefined, buttonSecondaryForeground: undefined, - buttonSecondaryHoverBackground: undefined + buttonSecondaryHoverBackground: undefined, + buttonSecondaryBorder: undefined }; export interface IButton extends IDisposable { @@ -120,9 +122,13 @@ export class Button extends Disposable implements IButton { this._element.classList.toggle('small', !!options.small); const background = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground; const foreground = options.secondary ? options.buttonSecondaryForeground : options.buttonForeground; + const border = options.secondary ? options.buttonSecondaryBorder : options.buttonBorder; this._element.style.color = foreground || ''; this._element.style.backgroundColor = background || ''; + if (border) { + this._element.style.border = `1px solid ${border}`; + } if (options.supportShortLabel) { this._labelShortElement = document.createElement('div'); @@ -223,16 +229,20 @@ export class Button extends Disposable implements IButton { private updateStyles(hover: boolean): void { let background; let foreground; + let border; if (this.options.secondary) { background = hover ? this.options.buttonSecondaryHoverBackground : this.options.buttonSecondaryBackground; foreground = this.options.buttonSecondaryForeground; + border = this.options.buttonSecondaryBorder; } else { background = hover ? this.options.buttonHoverBackground : this.options.buttonBackground; foreground = this.options.buttonForeground; + border = this.options.buttonBorder; } this._element.style.backgroundColor = background || ''; this._element.style.color = foreground || ''; + this._element.style.border = border ? `1px solid ${border}` : ''; } get element(): HTMLElement { diff --git a/src/vs/base/browser/ui/toggle/toggle.ts b/src/vs/base/browser/ui/toggle/toggle.ts index f310bff8965..0b2fcbbb274 100644 --- a/src/vs/base/browser/ui/toggle/toggle.ts +++ b/src/vs/base/browser/ui/toggle/toggle.ts @@ -173,6 +173,7 @@ export class Toggle extends Widget { this.checked = !this._checked; this._onChange.fire(false); ev.preventDefault(); + ev.stopPropagation(); } }); diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 90cd1bab10f..0ff9490a058 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -652,4 +652,6 @@ export const codiconsLibrary = { worktree: register('worktree', 0xec7e), screenCut: register('screen-cut', 0xec7f), ask: register('ask', 0xec80), + openai: register('openai', 0xec81), + claude: register('claude', 0xec82), } as const; diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 6b7f5d76d50..352fa5e4b34 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -59,5 +59,4 @@ export interface IDefaultAccount { readonly sessionId: string; readonly enterprise: boolean; readonly entitlementsData?: IEntitlementsData | null; - readonly policyData?: IPolicyData; } diff --git a/src/vs/base/common/policy.ts b/src/vs/base/common/policy.ts index 8141b0f9b5d..c27030fe03a 100644 --- a/src/vs/base/common/policy.ts +++ b/src/vs/base/common/policy.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../nls.js'; -import { IDefaultAccount } from './defaultAccount.js'; +import { IPolicyData } from './defaultAccount.js'; /** * System-wide policy file path for Linux systems. @@ -96,5 +96,5 @@ export interface IPolicy { * * If `undefined`, the feature's setting is not locked and can be overridden by other means. */ - readonly value?: (account: IDefaultAccount) => string | number | boolean | undefined; + readonly value?: (policyData: IPolicyData) => string | number | boolean | undefined; } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 6db227f4d8e..11aba6dd528 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -205,6 +205,7 @@ export interface IProductConfiguration { readonly hasPrereleaseVersion?: boolean; readonly excludeVersionRange?: string; }>; + readonly extensionsForceVersionByQuality?: readonly string[]; readonly msftInternalDomains?: string[]; readonly linkProtectionTrustedDomains?: readonly string[]; diff --git a/src/vs/code/electron-browser/workbench/workbench.ts b/src/vs/code/electron-browser/workbench/workbench.ts index 7102f4eb9f8..767fbe08db3 100644 --- a/src/vs/code/electron-browser/workbench/workbench.ts +++ b/src/vs/code/electron-browser/workbench/workbench.ts @@ -24,14 +24,7 @@ function showSplash(configuration: INativeWindowConfiguration) { performance.mark('code/willShowPartsSplash'); - - const isAgentSessionsWindow = configuration.profiles?.profile?.id === 'agent-sessions'; - if (isAgentSessionsWindow) { - showAgentSessionsSplash(configuration); - } else { - showDefaultSplash(configuration); - } - + showDefaultSplash(configuration); performance.mark('code/didShowPartsSplash'); } @@ -278,63 +271,6 @@ } } - function showAgentSessionsSplash(configuration: INativeWindowConfiguration) { - - // Agent sessions windows render a very opinionated splash: - // - Dark theme background (agent sessions use 2026-dark-experimental) - // - Title bar only for window controls - // - Secondary sidebar takes all remaining space (maximized) - // - No status bar, no activity bar, no sidebar - - const baseTheme = 'vs-dark'; - const shellBackground = '#191A1B'; // 2026-dark-experimental sidebar background - const shellForeground = '#CCCCCC'; - - // Apply base colors - const style = document.createElement('style'); - style.className = 'initialShellColors'; - window.document.head.appendChild(style); - style.textContent = `body { background-color: ${shellBackground}; color: ${shellForeground}; margin: 0; padding: 0; }`; - - // Set zoom level from splash data if available - if (typeof configuration.partsSplash?.zoomLevel === 'number' && typeof preloadGlobals?.webFrame?.setZoomLevel === 'function') { - preloadGlobals.webFrame.setZoomLevel(configuration.partsSplash.zoomLevel); - } - - const splash = document.createElement('div'); - splash.id = 'monaco-parts-splash'; - splash.className = baseTheme; - - // Title bar height - use stored value or default - const titleBarHeight = configuration.partsSplash?.layoutInfo?.titleBarHeight ?? 35; - - // Title bar for window dragging - if (titleBarHeight > 0) { - const titleDiv = document.createElement('div'); - titleDiv.style.position = 'absolute'; - titleDiv.style.width = '100%'; - titleDiv.style.height = `${titleBarHeight}px`; - titleDiv.style.left = '0'; - titleDiv.style.top = '0'; - titleDiv.style.backgroundColor = shellBackground; - (titleDiv.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'drag'; - splash.appendChild(titleDiv); - } - - // Secondary sidebar (maximized, takes all remaining space) - // This is the main content area for agent sessions - const auxSideDiv = document.createElement('div'); - auxSideDiv.style.position = 'absolute'; - auxSideDiv.style.width = '100%'; - auxSideDiv.style.height = `calc(100% - ${titleBarHeight}px)`; - auxSideDiv.style.top = `${titleBarHeight}px`; - auxSideDiv.style.left = '0'; - auxSideDiv.style.backgroundColor = shellBackground; - splash.appendChild(auxSideDiv); - - window.document.body.appendChild(splash); - } - //#endregion //#region Window Helpers diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index e0a2115ade8..41d94cc492f 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -314,27 +314,27 @@ class CliMain extends Disposable { // List Extensions if (this.argv['list-extensions']) { - return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).listExtensions(!!this.argv['show-versions'], this.argv['category'], profileLocation); + return instantiationService.createInstance(ExtensionManagementCLI, [], new ConsoleLogger(LogLevel.Info, false)).listExtensions(!!this.argv['show-versions'], this.argv['category'], profileLocation); } // Install Extension else if (this.argv['install-extension'] || this.argv['install-builtin-extension']) { const installOptions: InstallOptions = { isMachineScoped: !!this.argv['do-not-sync'], installPreReleaseVersion: !!this.argv['pre-release'], donotIncludePackAndDependencies: !!this.argv['do-not-include-pack-dependencies'], profileLocation }; - return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).installExtensions(this.asExtensionIdOrVSIX(this.argv['install-extension'] || []), this.asExtensionIdOrVSIX(this.argv['install-builtin-extension'] || []), installOptions, !!this.argv['force']); + return instantiationService.createInstance(ExtensionManagementCLI, [], new ConsoleLogger(LogLevel.Info, false)).installExtensions(this.asExtensionIdOrVSIX(this.argv['install-extension'] || []), this.asExtensionIdOrVSIX(this.argv['install-builtin-extension'] || []), installOptions, !!this.argv['force']); } // Uninstall Extension else if (this.argv['uninstall-extension']) { - return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).uninstallExtensions(this.asExtensionIdOrVSIX(this.argv['uninstall-extension']), !!this.argv['force'], profileLocation); + return instantiationService.createInstance(ExtensionManagementCLI, [], new ConsoleLogger(LogLevel.Info, false)).uninstallExtensions(this.asExtensionIdOrVSIX(this.argv['uninstall-extension']), !!this.argv['force'], profileLocation); } else if (this.argv['update-extensions']) { - return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).updateExtensions(profileLocation); + return instantiationService.createInstance(ExtensionManagementCLI, [], new ConsoleLogger(LogLevel.Info, false)).updateExtensions(profileLocation); } // Locate Extension else if (this.argv['locate-extension']) { - return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).locateExtension(this.argv['locate-extension']); + return instantiationService.createInstance(ExtensionManagementCLI, [], new ConsoleLogger(LogLevel.Info, false)).locateExtension(this.argv['locate-extension']); } // Install MCP server diff --git a/src/vs/editor/browser/services/editorWorkerService.ts b/src/vs/editor/browser/services/editorWorkerService.ts index c5d160cb899..d5c878af3b3 100644 --- a/src/vs/editor/browser/services/editorWorkerService.ts +++ b/src/vs/editor/browser/services/editorWorkerService.ts @@ -37,6 +37,7 @@ import { EditorWorkerHost } from '../../common/services/editorWorkerHost.js'; import { StringEdit } from '../../common/core/edits/stringEdit.js'; import { OffsetRange } from '../../common/core/ranges/offsetRange.js'; import { FileAccess } from '../../../base/common/network.js'; +import { isCompletionsEnabledWithTextResourceConfig } from '../../common/services/completionsEnablement.js'; /** * Stop the worker if it was not needed for 5 min. @@ -280,7 +281,9 @@ class WordBasedCompletionItemProvider implements languages.CompletionItemProvide return undefined; } - if (config.wordBasedSuggestions === 'offWithInlineSuggestions' && this.languageFeaturesService.inlineCompletionsProvider.has(model)) { + if (config.wordBasedSuggestions === 'offWithInlineSuggestions' + && this.languageFeaturesService.inlineCompletionsProvider.has(model) + && isCompletionsEnabledWithTextResourceConfig(this._configurationService, model.uri, model.getLanguageId())) { return undefined; } diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index 10dd3e6db9f..174eea21c6a 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -1300,7 +1300,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE reason = source; sourceStr = source.metadata.source; } else { - reason = EditSources.unknown({ name: sourceStr }); + reason = EditSources.unknown({ name: source }); sourceStr = source; } diff --git a/src/vs/editor/common/core/edits/stringEdit.ts b/src/vs/editor/common/core/edits/stringEdit.ts index 9f6c9800f41..83d40b5e8c2 100644 --- a/src/vs/editor/common/core/edits/stringEdit.ts +++ b/src/vs/editor/common/core/edits/stringEdit.ts @@ -97,6 +97,7 @@ export abstract class BaseStringEdit = BaseSt let baseIdx = 0; let ourIdx = 0; let offset = 0; + let lastEndEx = -1; // Track end of last added edit to ensure sorted/disjoint invariant while (ourIdx < this.replacements.length || baseIdx < base.replacements.length) { // take the edit that starts first @@ -108,10 +109,17 @@ export abstract class BaseStringEdit = BaseSt break; } else if (!baseEdit) { // no more edits from base - newEdits.push(new StringReplacement( - ourEdit.replaceRange.delta(offset), - ourEdit.newText - )); + const transformedRange = ourEdit.replaceRange.delta(offset); + // Check if the transformed edit would violate the sorted/disjoint invariant + if (transformedRange.start < lastEndEx) { + if (noOverlap) { + return undefined; + } + ourIdx++; // Skip this edit as it conflicts with a previously added edit + continue; + } + newEdits.push(new StringReplacement(transformedRange, ourEdit.newText)); + lastEndEx = transformedRange.endExclusive; ourIdx++; } else if (ourEdit.replaceRange.intersects(baseEdit.replaceRange) || areConcurrentInserts(ourEdit.replaceRange, baseEdit.replaceRange)) { ourIdx++; // Don't take our edit, as it is conflicting -> skip @@ -120,10 +128,17 @@ export abstract class BaseStringEdit = BaseSt } } else if (ourEdit.replaceRange.start < baseEdit.replaceRange.start) { // Our edit starts first - newEdits.push(new StringReplacement( - ourEdit.replaceRange.delta(offset), - ourEdit.newText - )); + const transformedRange = ourEdit.replaceRange.delta(offset); + // Check if the transformed edit would violate the sorted/disjoint invariant + if (transformedRange.start < lastEndEx) { + if (noOverlap) { + return undefined; + } + ourIdx++; // Skip this edit as it conflicts with a previously added edit + continue; + } + newEdits.push(new StringReplacement(transformedRange, ourEdit.newText)); + lastEndEx = transformedRange.endExclusive; ourIdx++; } else { baseIdx++; diff --git a/src/vs/editor/common/services/completionsEnablement.ts b/src/vs/editor/common/services/completionsEnablement.ts new file mode 100644 index 00000000000..b113f24da41 --- /dev/null +++ b/src/vs/editor/common/services/completionsEnablement.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import product from '../../../platform/product/common/product.js'; +import { isObject } from '../../../base/common/types.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { ITextResourceConfigurationService } from './textResourceConfiguration.js'; +import { URI } from '../../../base/common/uri.js'; + +/** + * Get the completions enablement setting name from product configuration. + */ +function getCompletionsEnablementSettingName(): string | undefined { + return product.defaultChatAgent?.completionsEnablementSetting; +} + +/** + * Checks if completions (e.g., Copilot) are enabled for a given language ID + * using `IConfigurationService`. + * + * @param configurationService The configuration service to read settings from. + * @param modeId The language ID to check. Defaults to '*' which checks the global setting. + * @returns `true` if completions are enabled for the language, `false` otherwise. + */ +export function isCompletionsEnabled(configurationService: IConfigurationService, modeId: string = '*'): boolean { + const settingName = getCompletionsEnablementSettingName(); + if (!settingName) { + return false; + } + + return isCompletionsEnabledFromObject( + configurationService.getValue>(settingName), + modeId + ); +} + +/** + * Checks if completions (e.g., Copilot) are enabled for a given language ID + * using `ITextResourceConfigurationService`. + * + * @param configurationService The text resource configuration service to read settings from. + * @param modeId The language ID to check. Defaults to '*' which checks the global setting. + * @returns `true` if completions are enabled for the language, `false` otherwise. + */ +export function isCompletionsEnabledWithTextResourceConfig(configurationService: ITextResourceConfigurationService, resource: URI, modeId: string = '*'): boolean { + const settingName = getCompletionsEnablementSettingName(); + if (!settingName) { + return false; + } + + // Pass undefined as resource to get the global setting + return isCompletionsEnabledFromObject( + configurationService.getValue>(resource, settingName), + modeId + ); +} + +/** + * Checks if completions are enabled for a given language ID using a pre-fetched + * completions enablement object. + * + * @param completionsEnablementObject The object containing per-language enablement settings. + * @param modeId The language ID to check. Defaults to '*' which checks the global setting. + * @returns `true` if completions are enabled for the language, `false` otherwise. + */ +export function isCompletionsEnabledFromObject(completionsEnablementObject: Record | undefined, modeId: string = '*'): boolean { + if (!isObject(completionsEnablementObject)) { + return false; // default to disabled if setting is not available + } + + if (typeof completionsEnablementObject[modeId] !== 'undefined') { + return Boolean(completionsEnablementObject[modeId]); // go with setting if explicitly defined + } + + return Boolean(completionsEnablementObject['*']); // fallback to global setting otherwise +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts index b936f4d216d..83d4831495d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts @@ -28,6 +28,7 @@ import { Command, InlineCompletionEndOfLifeReasonKind, InlineCompletionTriggerKi import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { ITextModel } from '../../../../common/model.js'; import { offsetEditFromContentChanges } from '../../../../common/model/textModelStringEdit.js'; +import { isCompletionsEnabledFromObject } from '../../../../common/services/completionsEnablement.js'; import { IFeatureDebounceInformation } from '../../../../common/services/languageFeatureDebounce.js'; import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js'; import { formatRecordableLogEntry, IRecordableEditorLogEntry, IRecordableLogEntry, StructuredLogger } from '../structuredLogger.js'; @@ -445,7 +446,7 @@ export class InlineCompletionsSource extends Disposable { } - if (!isCompletionsEnabled(this._completionsEnabled, this._textModel.getLanguageId())) { + if (!isCompletionsEnabledFromObject(this._completionsEnabled, this._textModel.getLanguageId())) { return; } @@ -571,18 +572,6 @@ function isSubset(set1: Set, set2: Set): boolean { return [...set1].every(item => set2.has(item)); } -function isCompletionsEnabled(completionsEnablementObject: Record | undefined, modeId: string = '*'): boolean { - if (completionsEnablementObject === undefined) { - return false; // default to disabled if setting is not available - } - - if (typeof completionsEnablementObject[modeId] !== 'undefined') { - return Boolean(completionsEnablementObject[modeId]); // go with setting if explicitly defined - } - - return Boolean(completionsEnablementObject['*']); // fallback to global setting otherwise -} - class UpdateOperation implements IDisposable { constructor( public readonly request: UpdateRequest, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index 1748629085f..84c14a69639 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -380,7 +380,7 @@ export class InlineSuggestData { public async reportInlineEditShown(commandService: ICommandService, updatedInsertText: string, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData, editKind: InlineSuggestionEditKind | undefined, timeWhenShown: number): Promise { this.updateShownDuration(viewKind); - if (this._didShow) { + if (this._didShow || this._didReportEndOfLife) { return; } this.addPerformanceMarker('shown'); @@ -429,6 +429,12 @@ export class InlineSuggestData { reason = this._lastSetEndOfLifeReason ?? { kind: InlineCompletionEndOfLifeReasonKind.Ignored, userTypingDisagreed: false, supersededBy: undefined }; } + // A suggestion can only be "rejected" if it was actually shown to the user. + // If the suggestion was never shown, downgrade to "ignored". + if (reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected && !this._didShow) { + reason = { kind: InlineCompletionEndOfLifeReasonKind.Ignored, userTypingDisagreed: false, supersededBy: undefined }; + } + if (reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected && this.source.provider.handleRejection) { this.source.provider.handleRejection(this.source.inlineSuggestions, this.sourceInlineCompletion); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts index a39de3bf3a8..af04a4122d9 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/theme.ts @@ -8,7 +8,7 @@ import { Color } from '../../../../../../base/common/color.js'; import { BugIndicatingError } from '../../../../../../base/common/errors.js'; import { IObservable, observableFromEventOpts } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; -import { buttonBackground, buttonForeground, buttonSecondaryBackground, buttonSecondaryForeground, diffInserted, diffInsertedLine, diffRemoved, editorBackground } from '../../../../../../platform/theme/common/colorRegistry.js'; +import { buttonBackground, buttonForeground, diffInserted, diffInsertedLine, diffRemoved, editorBackground, editorHoverBackground, editorHoverBorder, editorHoverForeground } from '../../../../../../platform/theme/common/colorRegistry.js'; import { asCssVariable, ColorIdentifier, darken, registerColor, transparent } from '../../../../../../platform/theme/common/colorUtils.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { InlineCompletionEditorType } from '../../model/provideInlineCompletions.js'; @@ -85,17 +85,17 @@ export const inlineEditIndicatorPrimaryBackground = registerColor( export const inlineEditIndicatorSecondaryForeground = registerColor( 'inlineEdit.gutterIndicator.secondaryForeground', - buttonSecondaryForeground, + editorHoverForeground, localize('inlineEdit.gutterIndicator.secondaryForeground', 'Foreground color for the secondary inline edit gutter indicator.') ); export const inlineEditIndicatorSecondaryBorder = registerColor( 'inlineEdit.gutterIndicator.secondaryBorder', - buttonSecondaryBackground, + editorHoverBorder, localize('inlineEdit.gutterIndicator.secondaryBorder', 'Border color for the secondary inline edit gutter indicator.') ); export const inlineEditIndicatorSecondaryBackground = registerColor( 'inlineEdit.gutterIndicator.secondaryBackground', - inlineEditIndicatorSecondaryBorder, + editorHoverBackground, localize('inlineEdit.gutterIndicator.secondaryBackground', 'Background color for the secondary inline edit gutter indicator.') ); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 5b61a15bfb8..d3b3856cef3 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -267,6 +267,8 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( options.serviceCollection.set(IDefaultAccountService, { _serviceBrand: undefined, onDidChangeDefaultAccount: Event.None, + onDidChangePolicyData: Event.None, + policyData: null, getDefaultAccount: async () => null, setDefaultAccountProvider: () => { }, getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 61c629eda52..61aa37410e2 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -100,7 +100,7 @@ import { IDataChannelService, NullDataChannelService } from '../../../platform/d import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; import { StandaloneWebWorkerService } from './services/standaloneWebWorkerService.js'; import { IDefaultAccountService } from '../../../platform/defaultAccount/common/defaultAccount.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; class SimpleModel implements IResolvedTextEditorModel { @@ -1115,6 +1115,8 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { declare readonly _serviceBrand: undefined; readonly onDidChangeDefaultAccount: Event = Event.None; + readonly onDidChangePolicyData: Event = Event.None; + readonly policyData: IPolicyData | null = null; async getDefaultAccount(): Promise { return null; diff --git a/src/vs/editor/test/common/core/stringEdit.test.ts b/src/vs/editor/test/common/core/stringEdit.test.ts index c54e132580e..9189dd62be3 100644 --- a/src/vs/editor/test/common/core/stringEdit.test.ts +++ b/src/vs/editor/test/common/core/stringEdit.test.ts @@ -154,6 +154,62 @@ suite('Edit', () => { // This should return undefined because both are inserts at the same position assert.strictEqual(rebasedEdit, undefined); }); + + test('tryRebase should return undefined when rebasing would produce non-disjoint edits (negative offset case)', () => { + // ourEdit1: [100, 110) -> "A" + // ourEdit2: [120, 120) -> "B" + // baseEdit: [110, 125) -> "" (delete 15 chars, offset = -15) + // After transformation, ourEdit2 at [105, 105) < ourEdit1 end (110) + + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(OffsetRange.emptyAt(120), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(110, 125), ''), + ]); + + const result = ourEdit.tryRebase(baseEdit); + assert.strictEqual(result, undefined); + }); + + test('tryRebase should succeed when edits remain disjoint after rebasing', () => { + // ourEdit1: [100, 110) -> "A" + // ourEdit2: [200, 210) -> "B" + // baseEdit: [50, 60) -> "" (delete 10 chars, offset = -10) + // After: ourEdit1 at [90, 100), ourEdit2 at [190, 200) - still disjoint + + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(new OffsetRange(200, 210), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(50, 60), ''), + ]); + + const result = ourEdit.tryRebase(baseEdit); + assert.ok(result); + assert.strictEqual(result?.replacements[0].replaceRange.start, 90); + assert.strictEqual(result?.replacements[1].replaceRange.start, 190); + }); + + test('rebaseSkipConflicting should skip edits that would produce non-disjoint results', () => { + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(OffsetRange.emptyAt(120), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(110, 125), ''), + ]); + + // Should not throw, and should skip the conflicting edit + const result = ourEdit.rebaseSkipConflicting(baseEdit); + assert.strictEqual(result.replacements.length, 1); + assert.strictEqual(result.replacements[0].replaceRange.start, 100); + }); }); suite('ArrayEdit', () => { diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 5ce46fadff9..3c12b59418e 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -24,7 +24,7 @@ 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 { IHoverWidget } from '../../../base/browser/ui/hover/hover.js'; +import { IHoverPositionOptions, IHoverWidget } from '../../../base/browser/ui/hover/hover.js'; export const acceptSelectedActionCommand = 'acceptSelectedCodeAction'; export const previewSelectedActionCommand = 'previewSelectedCodeAction'; @@ -44,6 +44,8 @@ export interface IActionListItemHover { * Content to display in the hover. */ readonly content?: string; + + readonly position?: IHoverPositionOptions; } export interface IActionListItem { @@ -479,6 +481,7 @@ export class ActionList extends Disposable { position: { hoverPosition: HoverPosition.LEFT, forcePosition: false, + ...element.hover.position, }, appearance: { showPointer: true, diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index cd7ec8bab16..0c63d247286 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -209,6 +209,10 @@ display: flex; } +.action-widget .monaco-list-row .action-list-item-toolbar .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover{ + background-color: var(--vscode-list-activeSelectionBackground); +} + .action-widget-delegate-label { display: flex; align-items: center; diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 3d86bf537a1..5e76962350b 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -27,6 +27,7 @@ export interface IBrowserViewState { canGoForward: boolean; loading: boolean; focused: boolean; + visible: boolean; isDevToolsOpen: boolean; lastScreenshot: VSBuffer | undefined; lastFavicon: string | undefined; @@ -55,6 +56,10 @@ export interface IBrowserViewFocusEvent { focused: boolean; } +export interface IBrowserViewVisibilityEvent { + visible: boolean; +} + export interface IBrowserViewDevToolsStateEvent { isDevToolsOpen: boolean; } @@ -112,6 +117,7 @@ export interface IBrowserViewService { onDynamicDidNavigate(id: string): Event; onDynamicDidChangeLoadingState(id: string): Event; onDynamicDidChangeFocus(id: string): Event; + onDynamicDidChangeVisibility(id: string): Event; onDynamicDidChangeDevToolsState(id: string): Event; onDynamicDidKeyCommand(id: string): Event; onDynamicDidChangeTitle(id: string): Event; diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index ed86bb2762a..33717cb54c2 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,7 +7,7 @@ import { WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent } from '../common/browserView.js'; import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; @@ -51,6 +51,9 @@ export class BrowserView extends Disposable { private readonly _onDidChangeFocus = this._register(new Emitter()); readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + private readonly _onDidChangeDevToolsState = this._register(new Emitter()); readonly onDidChangeDevToolsState: Event = this._onDidChangeDevToolsState.event; @@ -281,6 +284,7 @@ export class BrowserView extends Disposable { canGoForward: webContents.navigationHistory.canGoForward(), loading: webContents.isLoading(), focused: webContents.isFocused(), + visible: this._view.getVisible(), isDevToolsOpen: webContents.isDevToolsOpened(), lastScreenshot: this._lastScreenshot, lastFavicon: this._lastFavicon, @@ -322,12 +326,17 @@ export class BrowserView extends Disposable { * Set the visibility of this view */ setVisible(visible: boolean): void { + if (this._view.getVisible() === visible) { + return; + } + // If the view is focused, pass focus back to the window when hiding if (!visible && this._view.webContents.isFocused()) { this._window?.win?.webContents.focus(); } this._view.setVisible(visible); + this._onDidChangeVisibility.fire({ visible }); } /** diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 2a13abf70e0..a462d108ca0 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -123,6 +123,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidChangeFocus; } + onDynamicDidChangeVisibility(id: string) { + return this._getBrowserView(id).onDidChangeVisibility; + } + onDynamicDidChangeDevToolsState(id: string) { return this._getBrowserView(id).onDidChangeDevToolsState; } diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index d3bee567a79..63a9b956608 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -5,11 +5,13 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { Event } from '../../../base/common/event.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; export interface IDefaultAccountProvider { readonly defaultAccount: IDefaultAccount | null; readonly onDidChangeDefaultAccount: Event; + readonly policyData: IPolicyData | null; + readonly onDidChangePolicyData: Event; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; @@ -20,6 +22,8 @@ export const IDefaultAccountService = createDecorator('d export interface IDefaultAccountService { readonly _serviceBrand: undefined; readonly onDidChangeDefaultAccount: Event; + readonly onDidChangePolicyData: Event; + readonly policyData: IPolicyData | null; getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index e74641e055e..9772de66828 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -54,7 +54,7 @@ export interface IRawGalleryExtensionVersion { readonly assetUri: string; readonly fallbackAssetUri: string; readonly files: IRawGalleryExtensionFile[]; - readonly properties?: IRawGalleryExtensionProperty[]; + properties?: IRawGalleryExtensionProperty[]; readonly targetPlatform?: string; } @@ -348,6 +348,11 @@ function getEngine(version: IRawGalleryExtensionVersion): string { return (values.length > 0 && values[0].value) || ''; } +function setEngine(version: IRawGalleryExtensionVersion, engine: string): void { + version.properties = version.properties ?? []; + version.properties.push({ key: PropertyType.Engine, value: engine }); +} + function isPreReleaseVersion(version: IRawGalleryExtensionVersion): boolean { const values = version.properties ? version.properties.filter(p => p.key === PropertyType.PreRelease) : []; return values.length > 0 && values[0].value === 'true'; @@ -442,6 +447,24 @@ export function sortExtensionVersions(versions: IRawGalleryExtensionVersion[], p return versions; } +/** + * Filters extension versions to return only the relevant versions for a given target platform. + * + * This function processes a list of extension versions (expected to be sorted by version descending) + * and returns a filtered list containing: + * 1. All versions that are NOT compatible with the target platform (for other platforms) + * 2. At most one compatible release version (the first/latest one encountered) + * 3. At most one compatible pre-release version (the first/latest one encountered) + * + * When a platform-specific version (exactly matching targetPlatform) is encountered with the same + * version number as a previously stored universal/undefined version, it replaces that version. + * This ensures platform-specific builds are preferred over universal builds for the same version. + * + * @param versions - Array of extension versions, expected to be sorted by version number descending + * @param targetPlatform - The target platform to filter for (e.g., LINUX_X64, WIN32_X64) + * @param allTargetPlatforms - All target platforms the extension supports + * @returns Filtered array of versions relevant for the target platform + */ export function filterLatestExtensionVersionsForTargetPlatform(versions: IRawGalleryExtensionVersion[], targetPlatform: TargetPlatform, allTargetPlatforms: TargetPlatform[]): IRawGalleryExtensionVersion[] { const latestVersions: IRawGalleryExtensionVersion[] = []; @@ -458,19 +481,19 @@ export function filterLatestExtensionVersionsForTargetPlatform(versions: IRawGal } // For compatible versions, only include the first (latest) of each type - // Prefer specific target platform matches over undefined/universal platforms + // Prefer specific target platform matches over undefined/universal platforms only when version numbers are the same if (isPreReleaseVersion(version)) { if (preReleaseVersionIndex === -1) { preReleaseVersionIndex = latestVersions.length; latestVersions.push(version); - } else if (versionTargetPlatform === targetPlatform) { + } else if (versionTargetPlatform === targetPlatform && latestVersions[preReleaseVersionIndex].version === version.version) { latestVersions[preReleaseVersionIndex] = version; } } else { if (releaseVersionIndex === -1) { releaseVersionIndex = latestVersions.length; latestVersions.push(version); - } else if (versionTargetPlatform === targetPlatform) { + } else if (versionTargetPlatform === targetPlatform && latestVersions[releaseVersionIndex].version === version.version) { latestVersions[releaseVersionIndex] = version; } } @@ -829,11 +852,24 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return 'NOT_FOUND'; } - const targetPlatform = options.targetPlatform ?? CURRENT_TARGET_PLATFORM; const allTargetPlatforms = getAllTargetPlatforms(rawGalleryExtension); - const rawGalleryExtensionVersion = await this.getValidRawGalleryExtensionVersion( + const rawGalleryExtensionVersion = await this.getValidRawGalleryExtensionVersionFromLatestVersions(rawGalleryExtension, rawGalleryExtension.versions, extensionInfo, options, allTargetPlatforms); + + if (!rawGalleryExtensionVersion) { + return 'NOT_COMPATIBLE'; + } + + return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, this.productService); + } + + private async getValidRawGalleryExtensionVersionFromLatestVersions(rawGalleryExtension: IRawGalleryExtension, latestVersions: IRawGalleryExtensionVersion[], extensionInfo: IExtensionInfo, options: IExtensionQueryOptions, allTargetPlatforms: TargetPlatform[]): Promise { + const targetPlatform = options.targetPlatform ?? CURRENT_TARGET_PLATFORM; + const latestExtensionVersionsForTargetPlatform = filterLatestExtensionVersionsForTargetPlatform(latestVersions, targetPlatform, allTargetPlatforms); + + // First, find a valid version matching the requested type (pre-release or release) + const result = await this.getValidRawGalleryExtensionVersion( rawGalleryExtension, - filterLatestExtensionVersionsForTargetPlatform(rawGalleryExtension.versions, targetPlatform, allTargetPlatforms), + latestExtensionVersionsForTargetPlatform, { targetPlatform, compatible: !!options.compatible, @@ -841,14 +877,63 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle version: this.productService.version, date: this.productService.date }, - version: extensionInfo.preRelease ? VersionKind.Latest : VersionKind.Release + version: extensionInfo.preRelease ? VersionKind.Prerelease : VersionKind.Release }, allTargetPlatforms); - if (rawGalleryExtensionVersion) { - return toExtension(rawGalleryExtension, rawGalleryExtensionVersion, allTargetPlatforms, extensionGalleryManifest, this.productService); + // For release version requests, simply return the found release version + if (!extensionInfo.preRelease) { + return result; } - return 'NOT_COMPATIBLE'; + // For pre-release version requests, we need to consider both pre-release and release versions + const prereleaseVersion = result; + const releaseVersion = await this.getValidRawGalleryExtensionVersion( + rawGalleryExtension, + latestExtensionVersionsForTargetPlatform, + { + targetPlatform, + compatible: !!options.compatible, + productVersion: options.productVersion ?? { + version: this.productService.version, + date: this.productService.date + }, + version: VersionKind.Release + }, allTargetPlatforms); + + // When both versions exist, return whichever has the higher version number + if (prereleaseVersion && releaseVersion) { + return semver.gt(releaseVersion.version, prereleaseVersion.version) ? releaseVersion : prereleaseVersion; + } + + // Special handling for compatible version requests + if (options.compatible) { + // If we have a compatible release version, check if it's better than any pre-release + if (releaseVersion) { + // Check if there exists any pre-release version (ignoring compatibility) + const anyPrereleaseVersion = await this.getValidRawGalleryExtensionVersion( + rawGalleryExtension, + latestExtensionVersionsForTargetPlatform, + { + targetPlatform, + compatible: false, + productVersion: options.productVersion ?? { + version: this.productService.version, + date: this.productService.date + }, + version: VersionKind.Prerelease + }, allTargetPlatforms); + + // If no pre-release exists or the release version is greater, prefer the compatible release + // This ensures users get a stable compatible version when pre-releases aren't newer or compatible + if (!anyPrereleaseVersion || semver.gt(releaseVersion.version, anyPrereleaseVersion.version)) { + return releaseVersion; + } + } + return prereleaseVersion; + } + + // Return pre-release if available, otherwise release, otherwise null + return prereleaseVersion ?? releaseVersion ?? null; } async getCompatibleExtension(extension: IGalleryExtension, includePreRelease: boolean, targetPlatform: TargetPlatform, productVersion: IProductVersion = { version: this.productService.version, date: this.productService.date }): Promise { @@ -962,40 +1047,54 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle private async isEngineValid(extensionId: string, version: string, engine: string | undefined, manifestAsset: IGalleryExtensionAsset | null, productVersion: IProductVersion): Promise { if (!engine) { - if (!manifestAsset) { - this.logService.error(`Missing engine and manifest asset for the extension ${extensionId} with version ${version}`); - return false; - } try { - type GalleryServiceEngineFallbackClassification = { - owner: 'sandy081'; - comment: 'Fallback request when engine is not found in properties of an extension version'; - extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'extension name' }; - extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'version' }; - }; - type GalleryServiceEngineFallbackEvent = { - extension: string; - extensionVersion: string; - }; - this.telemetryService.publicLog2('galleryService:engineFallback', { extension: extensionId, extensionVersion: version }); - - const headers = { 'Accept-Encoding': 'gzip' }; - const context = await this.getAsset(extensionId, manifestAsset, AssetType.Manifest, version, { headers }); - const manifest = await asJson(context); - if (!manifest) { - this.logService.error(`Manifest was not found for the extension ${extensionId} with version ${version}`); - return false; - } - engine = manifest.engines.vscode; + engine = await this.getEngine(extensionId, version, manifestAsset); } catch (error) { this.logService.error(`Error while getting the engine for the version ${version}.`, getErrorMessage(error)); return false; } } + if (!engine) { + this.logService.error(`Missing engine for the extension ${extensionId} with version ${version}`); + return false; + } + return isEngineValid(engine, productVersion.version, productVersion.date); } + private async getEngine(extensionId: string, version: string, manifestAsset: IGalleryExtensionAsset | null): Promise { + if (!manifestAsset) { + this.logService.error(`Missing engine and manifest asset for the extension ${extensionId} with version ${version}`); + return undefined; + } + try { + type GalleryServiceEngineFallbackClassification = { + owner: 'sandy081'; + comment: 'Fallback request when engine is not found in properties of an extension version'; + extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'extension name' }; + extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'version' }; + }; + type GalleryServiceEngineFallbackEvent = { + extension: string; + extensionVersion: string; + }; + this.telemetryService.publicLog2('galleryService:engineFallback', { extension: extensionId, extensionVersion: version }); + + const headers = { 'Accept-Encoding': 'gzip' }; + const context = await this.getAsset(extensionId, manifestAsset, AssetType.Manifest, version, { headers }); + const manifest = await asJson(context); + if (!manifest) { + this.logService.error(`Manifest was not found for the extension ${extensionId} with version ${version}`); + return undefined; + } + return manifest.engines.vscode; + } catch (error) { + this.logService.error(`Error while getting the engine for the version ${version}.`, getErrorMessage(error)); + return undefined; + } + } + async query(options: IQueryOptions, token: CancellationToken): Promise> { const extensionGalleryManifest = await this.extensionGalleryManifestService.getExtensionGalleryManifest(); @@ -1211,6 +1310,9 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle for (let index = 0; index < rawGalleryExtensionVersions.length; index++) { const rawGalleryExtensionVersion = rawGalleryExtensionVersions[index]; + if (criteria.compatible) { + await this.setEngineIfNotExists(extensionIdentifier.id, rawGalleryExtensionVersion); + } if (await this.isValidVersion( { id: extensionIdentifier.id, @@ -1243,6 +1345,21 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return rawGalleryExtension.versions[0]; } + private async setEngineIfNotExists(extensionId: string, rawGalleryExtensionVersion: IRawGalleryExtensionVersion): Promise { + if (getEngine(rawGalleryExtensionVersion)) { + return; + } + + try { + const engine = await this.getEngine(extensionId, rawGalleryExtensionVersion.version, getVersionAsset(rawGalleryExtensionVersion, AssetType.Manifest)); + if (engine) { + setEngine(rawGalleryExtensionVersion, engine); + } + } catch (error) { + this.logService.error(`Error while getting the engine for the version ${rawGalleryExtensionVersion.version}.`, getErrorMessage(error)); + } + } + private async queryRawGalleryExtensions(query: Query, extensionGalleryManifest: IExtensionGalleryManifest, token: CancellationToken): Promise { const extensionsQueryApi = getExtensionGalleryManifestResourceUri(extensionGalleryManifest, ExtensionGalleryResourceType.ExtensionQueryService); diff --git a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts index a40e4319815..3bc65f49b03 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts @@ -14,6 +14,7 @@ import { EXTENSION_IDENTIFIER_REGEX, IExtensionGalleryService, IExtensionInfo, I import { areSameExtensions, getExtensionId, getGalleryExtensionId, getIdAndVersion } from './extensionManagementUtil.js'; import { ExtensionType, EXTENSION_CATEGORIES, IExtensionManifest } from '../../extensions/common/extensions.js'; import { ILogger } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; const notFound = (id: string) => localize('notFound', "Extension '{0}' not found.", id); @@ -25,10 +26,14 @@ type InstallGalleryExtensionInfo = { id: string; version?: string; installOption export class ExtensionManagementCLI { constructor( + private readonly extensionsForceVersionByQuality: readonly string[], protected readonly logger: ILogger, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, - ) { } + @IProductService private readonly productService: IProductService, + ) { + this.extensionsForceVersionByQuality = this.extensionsForceVersionByQuality.map(e => e.toLowerCase()); + } protected get location(): string | undefined { return undefined; @@ -81,6 +86,9 @@ export class ExtensionManagementCLI { const installVSIXInfos: InstallVSIXInfo[] = []; const installExtensionInfos: InstallGalleryExtensionInfo[] = []; const addInstallExtensionInfo = (id: string, version: string | undefined, isBuiltin: boolean) => { + if (this.extensionsForceVersionByQuality?.some(e => e === id.toLowerCase())) { + version = this.productService.quality !== 'stable' ? 'prerelease' : undefined; + } installExtensionInfos.push({ id, version: version !== 'prerelease' ? version : undefined, installOptions: { ...installOptions, isBuiltin, installPreReleaseVersion: version === 'prerelease' || installOptions.installPreReleaseVersion } }); }; for (const extension of extensions) { diff --git a/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts b/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts index 5dbc39a3205..415cdcb9775 100644 --- a/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts +++ b/src/vs/platform/extensionManagement/test/common/extensionGalleryService.test.ts @@ -133,19 +133,32 @@ suite('Extension Gallery Service', () => { assert.deepStrictEqual(result, versions); }); - test('should include both release and pre-release versions for same platform', () => { - const version1 = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); - const version2 = aPreReleaseExtensionVersion('0.9.0', TargetPlatform.WIN32_X64); // Different version number - const versions = [version1, version2]; + test('should include latest release and latest pre-release versions for same platform', () => { + const release = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); + const prerelease = aPreReleaseExtensionVersion('0.9.0', TargetPlatform.WIN32_X64); + const versions = [release, prerelease]; const allTargetPlatforms = [TargetPlatform.WIN32_X64]; const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms); // Should include both since they have different version numbers assert.strictEqual(result.length, 2); - assert.strictEqual(result[0], version1); - assert.strictEqual(result[1], version2); + assert.strictEqual(result[0], release); + assert.strictEqual(result[1], prerelease); + }); + test('should include latest prerelease and latest release versions for same platform', () => { + const prerelease = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64); + const release = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); + const versions = [prerelease, release]; + const allTargetPlatforms = [TargetPlatform.WIN32_X64]; + + const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms); + + // Should include both since they have different version numbers + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0], prerelease); + assert.strictEqual(result[1], release); }); test('should include one version per target platform for release versions', () => { @@ -164,33 +177,6 @@ suite('Extension Gallery Service', () => { assert.ok(result.includes(version3)); // Non-compatible, included }); - test('should separate release and pre-release versions', () => { - const releaseVersion = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); - const preReleaseVersion = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64); - const versions = [releaseVersion, preReleaseVersion]; - const allTargetPlatforms = [TargetPlatform.WIN32_X64]; - - const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms); - - // Should include both since they are different types (release vs pre-release) - assert.strictEqual(result.length, 2); - assert.ok(result.includes(releaseVersion)); - assert.ok(result.includes(preReleaseVersion)); - }); - - test('should include both release and pre-release versions for same platform with different version numbers', () => { - const preRelease1 = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64); - const release2 = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Different version number - const versions = [preRelease1, release2]; - const allTargetPlatforms = [TargetPlatform.WIN32_X64]; - - const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms); - - // Should include both since they have different version numbers - assert.strictEqual(result.length, 2); - assert.strictEqual(result[0], preRelease1); - assert.strictEqual(result[1], release2); - }); test('should handle versions without target platform (UNDEFINED)', () => { const version1 = aExtensionVersion('1.0.0'); // No target platform specified @@ -281,20 +267,21 @@ suite('Extension Gallery Service', () => { assert.ok(!result.includes(lowerVersionUniversal)); // Filtered (second compatible release) }); - test('should handle lower version with specific platform vs higher version with universal platform', () => { - // Reverse scenario: older version for specific platform vs newer version with universal compatibility - const lowerVersionSpecificPlatform = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); + test('should handle higher version with universal platform vs lower version with specific platform', () => { + // Scenario: higher universal version comes first, then lower platform-specific version const higherVersionUniversal = aExtensionVersion('2.0.0'); // UNDEFINED/universal platform + const lowerVersionSpecificPlatform = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); - const versions = [lowerVersionSpecificPlatform, higherVersionUniversal]; + const versions = [higherVersionUniversal, lowerVersionSpecificPlatform]; const allTargetPlatforms = [TargetPlatform.WIN32_X64, TargetPlatform.DARWIN_X64]; const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms); - // Both are compatible with WIN32_X64, but only the first release version should be included + // Both are compatible with WIN32_X64, the first (higher) version should be kept + // Platform-specific version should NOT replace since it has a different (lower) version number assert.strictEqual(result.length, 1); - assert.ok(result.includes(lowerVersionSpecificPlatform)); // First compatible release - assert.ok(!result.includes(higherVersionUniversal)); // Filtered (second compatible release) + assert.ok(result.includes(higherVersionUniversal)); // First compatible release (higher version) + assert.ok(!result.includes(lowerVersionSpecificPlatform)); // Filtered (lower version) }); test('should handle multiple specific platforms vs universal platform with version differences', () => { @@ -391,19 +378,20 @@ suite('Extension Gallery Service', () => { assert.ok(!result.includes(universalVersion)); }); - test('should handle both release and pre-release with replacement', () => { - // Both release and pre-release starting with undefined and then getting specific platform - const undefinedRelease = aExtensionVersion('1.0.0'); // UNDEFINED release - const specificRelease = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific release + test('should handle both release and pre-release with same version replacement', () => { + // Both release and pre-release with undefined platform, then specific platform with same versions + // Versions sorted by version descending (pre-release 1.1.0, release 1.0.0, then same versions with specific platform) const undefinedPreRelease = aPreReleaseExtensionVersion('1.1.0'); // UNDEFINED pre-release - const specificPreRelease = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64); // Specific pre-release + const specificPreRelease = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64); // Specific pre-release (same version) + const undefinedRelease = aExtensionVersion('1.0.0'); // UNDEFINED release + const specificRelease = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific release (same version) - const versions = [undefinedRelease, undefinedPreRelease, specificRelease, specificPreRelease]; + const versions = [undefinedPreRelease, specificPreRelease, undefinedRelease, specificRelease]; const allTargetPlatforms = [TargetPlatform.WIN32_X64]; const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms); - // Should return both specific platform versions + // Should return both specific platform versions (they replaced the undefined ones) assert.strictEqual(result.length, 2); assert.ok(result.includes(specificRelease)); assert.ok(result.includes(specificPreRelease)); @@ -427,21 +415,47 @@ suite('Extension Gallery Service', () => { }); test('should handle replacement with non-compatible versions in between', () => { + // Versions sorted by version descending const undefinedVersion = aExtensionVersion('1.0.0'); // UNDEFINED, compatible with WIN32_X64 - const nonCompatibleVersion = aExtensionVersion('0.9.0', TargetPlatform.LINUX_ARM64); // Non-compatible platform - const specificVersion = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific for WIN32_X64 + const specificVersion = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific for WIN32_X64 (same version) + const nonCompatibleVersion = aExtensionVersion('0.9.0', TargetPlatform.LINUX_ARM64); // Non-compatible platform (lower version) - const versions = [undefinedVersion, nonCompatibleVersion, specificVersion]; + const versions = [undefinedVersion, specificVersion, nonCompatibleVersion]; const allTargetPlatforms = [TargetPlatform.WIN32_X64, TargetPlatform.DARWIN_X64]; const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms); - // Should return specific WIN32_X64 version (replacing undefined) and non-compatible LINUX_ARM64 version + // Should return specific WIN32_X64 version (replacing undefined since same version) and non-compatible LINUX_ARM64 version assert.strictEqual(result.length, 2); assert.ok(result.includes(specificVersion)); assert.ok(result.includes(nonCompatibleVersion)); assert.ok(!result.includes(undefinedVersion)); }); + test('should filter versions for linux-x64 target platform with mixed universal and platform-specific versions', () => { + // Data from real extension versions (sorted by version descending, as returned by gallery API): + // 0.15.0 - pre-release, universal + // 0.14.0 - release, universal + // 0.6.0 - release, linux-x64 + // 0.5.1 - pre-release, linux-x64 + const versions = [ + aPreReleaseExtensionVersion('0.15.0'), // pre-release, universal (highest version) + aExtensionVersion('0.14.0'), // release, universal + aExtensionVersion('0.6.0', TargetPlatform.LINUX_X64), // release, linux-x64 + aPreReleaseExtensionVersion('0.5.1', TargetPlatform.LINUX_X64), // pre-release, linux-x64 (lowest version) + ]; + const allTargetPlatforms = [TargetPlatform.LINUX_X64]; + + const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.LINUX_X64, allTargetPlatforms); + + // Expected: + // - 0.15.0 universal (first compatible pre-release, higher version than 0.5.1 linux-x64) + // - 0.14.0 universal (first compatible release, higher version than 0.6.0 linux-x64) + // Platform-specific versions are NOT preferred when they have lower version numbers + assert.strictEqual(result.length, 2); + assert.ok(result.includes(versions[0])); // 0.15.0 universal (pre-release) + assert.ok(result.includes(versions[1])); // 0.14.0 universal (release) + }); + }); }); diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index e8470285f7f..021ad016e02 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -235,6 +235,7 @@ export interface IExtensionContributions { readonly chatPromptFiles?: ReadonlyArray; readonly chatInstructions?: ReadonlyArray; readonly chatAgents?: ReadonlyArray; + readonly chatSkills?: ReadonlyArray; readonly languageModelTools?: ReadonlyArray; readonly languageModelToolSets?: ReadonlyArray; readonly mcpServerDefinitionProviders?: ReadonlyArray; diff --git a/src/vs/platform/mcp/common/mcpManagementService.ts b/src/vs/platform/mcp/common/mcpManagementService.ts index 6a50e743653..5f72d29a8fe 100644 --- a/src/vs/platform/mcp/common/mcpManagementService.ts +++ b/src/vs/platform/mcp/common/mcpManagementService.ts @@ -73,7 +73,9 @@ export abstract class AbstractCommonMcpManagementService extends Disposable impl // remote if (packageType === RegistryType.REMOTE && manifest.remotes?.length) { - const { inputs, variables } = this.processKeyValueInputs(manifest.remotes[0].headers ?? []); + const url = manifest.remotes[0].url; + const headers = manifest.remotes[0].headers ?? []; + const { inputs, variables } = this.processKeyValueInputs(url.startsWith('https://api.githubcopilot.com/mcp') ? headers.filter(h => h.name.toLowerCase() !== 'authorization') : headers); return { mcpServerConfiguration: { config: { @@ -149,7 +151,7 @@ export abstract class AbstractCommonMcpManagementService extends Disposable impl args.push(serverPackage.version ? `${serverPackage.identifier}@${serverPackage.version}` : serverPackage.identifier); args.push('--yes'); // installation is confirmed by the UI, so --yes is appropriate here if (serverPackage.registryBaseUrl) { - args.push('--add-source', serverPackage.registryBaseUrl); + args.push('--source', serverPackage.registryBaseUrl); } if (serverPackage.packageArguments?.length) { args.push('--'); diff --git a/src/vs/platform/mcp/node/mcpManagementService.ts b/src/vs/platform/mcp/node/mcpManagementService.ts index aed19e04a5e..687ddb55d58 100644 --- a/src/vs/platform/mcp/node/mcpManagementService.ts +++ b/src/vs/platform/mcp/node/mcpManagementService.ts @@ -32,7 +32,11 @@ export class McpUserResourceManagementService extends CommonMcpUserResourceManag try { const manifest = await this.updateMetadataFromGallery(server); - const packageType = options?.packageType ?? manifest.packages?.[0]?.registryType ?? RegistryType.REMOTE; + const packageType = options?.packageType ?? ( + manifest.remotes?.length + ? RegistryType.REMOTE + : (manifest.packages?.[0]?.registryType ?? RegistryType.REMOTE) + ); const { mcpServerConfiguration, notices } = this.getMcpServerConfigurationFromManifest(manifest, packageType); diff --git a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts index e3d63c7d275..78ee3299652 100644 --- a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts +++ b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts @@ -548,7 +548,7 @@ suite('McpManagementService - getMcpServerConfigurationFromManifest', () => { assert.deepStrictEqual(result.mcpServerConfiguration.config.args, [ 'Company.Internal.McpServer@4.5.6', '--yes', - '--add-source', 'https://nuget.company.com/v3/index.json' + '--source', 'https://nuget.company.com/v3/index.json' ]); } }); diff --git a/src/vs/platform/policy/common/policy.ts b/src/vs/platform/policy/common/policy.ts index a02d49e28d9..f8b86c80704 100644 --- a/src/vs/platform/policy/common/policy.ts +++ b/src/vs/platform/policy/common/policy.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IStringDictionary } from '../../../base/common/collections.js'; -import { IDefaultAccount } from '../../../base/common/defaultAccount.js'; +import { IPolicyData } from '../../../base/common/defaultAccount.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Iterable } from '../../../base/common/iterator.js'; import { Disposable } from '../../../base/common/lifecycle.js'; @@ -14,7 +14,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; export type PolicyValue = string | number | boolean; export type PolicyDefinition = { type: 'string' | 'number' | 'boolean'; - value?: (account: IDefaultAccount) => string | number | boolean | undefined; + value?: (policyData: IPolicyData) => string | number | boolean | undefined; }; export const IPolicyService = createDecorator('policy'); diff --git a/src/vs/platform/request/node/requestService.ts b/src/vs/platform/request/node/requestService.ts index 73f6f826d39..45209759090 100644 --- a/src/vs/platform/request/node/requestService.ts +++ b/src/vs/platform/request/node/requestService.ts @@ -6,7 +6,7 @@ import type * as http from 'http'; import type * as https from 'https'; import { parse as parseUrl } from 'url'; -import { Promises } from '../../../base/common/async.js'; +import { Promises, timeout } from '../../../base/common/async.js'; import { streamToBufferReadableStream } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError, getErrorMessage } from '../../../base/common/errors.js'; @@ -21,6 +21,26 @@ import { AbstractRequestService, AuthInfo, Credentials, IRequestService, systemC import { Agent, getProxyAgent } from './proxy.js'; import { createGunzip } from 'zlib'; +const TRANSIENT_ERROR_CODES = new Set([ + 'EAI_AGAIN', // DNS lookup timed out + 'ECONNREFUSED', // Connection refused by server + 'EHOSTDOWN', // Host is down + 'EHOSTUNREACH', // No route to host + 'ENETDOWN', // Network is down + 'ENETUNREACH', // Network is unreachable + 'EPROTO' // Protocol error (TLS/SSL handshake failure) +]); + +const IDEMPOTENT_HTTP_METHODS_REGEX = /^(GET|HEAD|OPTIONS)$/i; + +function isTransientError(error: unknown): boolean { + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + return !!code && TRANSIENT_ERROR_CODES.has(code); + } + return false; +} + export interface IRawRequestFunction { (options: http.RequestOptions, callback?: (res: http.IncomingMessage) => void): http.ClientRequest; } @@ -153,6 +173,31 @@ async function getNodeRequest(options: IRequestOptions): Promise { + const maxRetries = 3; + let lastError: Error | undefined; + const isIdempotent = IDEMPOTENT_HTTP_METHODS_REGEX.test(options.type || 'GET'); + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await nodeRequestAttempt(options, token); + } catch (error) { + lastError = error as Error; + if (error instanceof CancellationError) { + throw error; + } + + if (!isIdempotent || !isTransientError(error) || attempt === maxRetries) { + throw error; + } + + await timeout(100 * attempt, token); + } + } + + throw lastError; +} + +async function nodeRequestAttempt(options: NodeRequestOptions, token: CancellationToken): Promise { return Promises.withAsyncBody(async (resolve, reject) => { const endpoint = parseUrl(options.url!); const rawRequest = options.getRawRequest @@ -238,10 +283,14 @@ export async function nodeRequest(options: NodeRequestOptions, token: Cancellati req.end(); - token.onCancellationRequested(() => { + const cancellationListener = token.onCancellationRequested(() => { + cancellationListener.dispose(); req.abort(); reject(new CancellationError()); }); + + req.on('response', () => cancellationListener.dispose()); + req.on('error', () => cancellationListener.dispose()); }); } diff --git a/src/vs/platform/request/test/node/requestService.test.ts b/src/vs/platform/request/test/node/requestService.test.ts index 18de3cafb8b..8e8c8850149 100644 --- a/src/vs/platform/request/test/node/requestService.test.ts +++ b/src/vs/platform/request/test/node/requestService.test.ts @@ -6,9 +6,10 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { lookupKerberosAuthorization } from '../../node/requestService.js'; +import { IRawRequestFunction, lookupKerberosAuthorization, nodeRequest } from '../../node/requestService.js'; import { isWindows } from '../../../../base/common/platform.js'; - +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; suite('Request Service', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -28,4 +29,263 @@ suite('Request Service', () => { , `Unexpected error: ${err}`); } }); + + test('Request cancellation during retry backoff', async () => { + const cts = store.add(new CancellationTokenSource()); + const startTime = Date.now(); + setTimeout(() => cts.cancel(), 50); + + try { + await nodeRequest({ url: 'http://localhost:9999/nonexistent' }, cts.token); + assert.fail('Request should have been cancelled'); + } catch (err) { + const elapsed = Date.now() - startTime; + assert.ok(err instanceof CancellationError, 'Error should be CancellationError'); + assert.ok(elapsed < 200, `Request should be cancelled quickly, but took ${elapsed}ms`); + } + }); + + test('should retry GET requests on transient errors', async () => { + let attemptCount = 0; + const mockRawRequest = (_opts: any, callback: Function) => { + attemptCount++; + const currentAttempt = attemptCount; + const mockReq: any = { + on: (event: string, handler: Function) => { + if (event === 'error' && currentAttempt < 3) { + const err = new Error('Connection refused') as NodeJS.ErrnoException; + err.code = 'ECONNREFUSED'; + setTimeout(() => handler(err), 0); + } + }, + end: () => { + if (currentAttempt >= 3) { + // Succeed on third attempt by calling the response callback + setTimeout(() => callback({ statusCode: 200, headers: {}, on: () => { }, pipe: () => ({ on: () => { } }) }), 0); + } + }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; + + try { + await nodeRequest({ + url: 'http://example.com', + type: 'GET', + getRawRequest: () => mockRawRequest as IRawRequestFunction + }, CancellationToken.None); + } catch (err) { + // Expected to eventually succeed or fail after retries + } + + assert.ok(attemptCount > 1, 'GET request should have been retried'); + }); + + test('should NOT retry POST requests', async () => { + let attemptCount = 0; + const mockRawRequest = () => { + attemptCount++; + const mockReq: any = { + on: (event: string, handler: Function) => { + if (event === 'error') { + const err = new Error('Connection refused') as NodeJS.ErrnoException; + err.code = 'ECONNREFUSED'; + setTimeout(() => handler(err), 0); + } + }, + end: () => { }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; + + try { + await nodeRequest({ + url: 'http://example.com', + type: 'POST', + getRawRequest: () => mockRawRequest + }, CancellationToken.None); + assert.fail('Should have thrown an error'); + } catch (err) { + assert.ok(err instanceof Error); + } + + assert.strictEqual(attemptCount, 1, 'POST request should not have been retried'); + }); + + test('should retry HEAD requests on transient errors', async () => { + let attemptCount = 0; + const mockRawRequest = (_opts: any, callback: Function) => { + attemptCount++; + const currentAttempt = attemptCount; + const mockReq: any = { + on: (event: string, handler: Function) => { + if (event === 'error' && currentAttempt < 3) { + const err = new Error('Host unreachable') as NodeJS.ErrnoException; + err.code = 'EHOSTUNREACH'; + setTimeout(() => handler(err), 0); + } + }, + end: () => { + if (currentAttempt >= 3) { + setTimeout(() => callback({ statusCode: 200, headers: {}, on: () => { }, pipe: () => ({ on: () => { } }) }), 0); + } + }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; + + try { + await nodeRequest({ + url: 'http://example.com', + type: 'HEAD', + getRawRequest: () => mockRawRequest as IRawRequestFunction + }, CancellationToken.None); + } catch (err) { + // Expected to eventually succeed or fail after retries + } + + assert.ok(attemptCount > 1, 'HEAD request should have been retried'); + }); + + test('should retry OPTIONS requests on transient errors', async () => { + let attemptCount = 0; + const mockRawRequest = (_opts: any, callback: Function) => { + attemptCount++; + const currentAttempt = attemptCount; + const mockReq: any = { + on: (event: string, handler: Function) => { + if (event === 'error' && currentAttempt < 3) { + const err = new Error('Network unreachable') as NodeJS.ErrnoException; + err.code = 'ENETUNREACH'; + setTimeout(() => handler(err), 0); + } + }, + end: () => { + if (currentAttempt >= 3) { + setTimeout(() => callback({ statusCode: 200, headers: {}, on: () => { }, pipe: () => ({ on: () => { } }) }), 0); + } + }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; + + try { + await nodeRequest({ + url: 'http://example.com', + type: 'OPTIONS', + getRawRequest: () => mockRawRequest as IRawRequestFunction + }, CancellationToken.None); + } catch (err) { + // Expected to eventually succeed or fail after retries + } + + assert.ok(attemptCount > 1, 'OPTIONS request should have been retried'); + }); + + test('should NOT retry DELETE requests', async () => { + let attemptCount = 0; + const mockRawRequest = () => { + attemptCount++; + const mockReq: any = { + on: (event: string, handler: Function) => { + if (event === 'error') { + const err = new Error('Connection refused') as NodeJS.ErrnoException; + err.code = 'ECONNREFUSED'; + setTimeout(() => handler(err), 0); + } + }, + end: () => { }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; + + try { + await nodeRequest({ + url: 'http://example.com', + type: 'DELETE', + getRawRequest: () => mockRawRequest + }, CancellationToken.None); + assert.fail('Should have thrown an error'); + } catch (err) { + assert.ok(err instanceof Error); + } + + assert.strictEqual(attemptCount, 1, 'DELETE request should not have been retried'); + }); + + test('should NOT retry PUT requests', async () => { + let attemptCount = 0; + const mockRawRequest = () => { + attemptCount++; + const mockReq: any = { + on: (event: string, handler: Function) => { + if (event === 'error') { + const err = new Error('Connection refused') as NodeJS.ErrnoException; + err.code = 'ECONNREFUSED'; + setTimeout(() => handler(err), 0); + } + }, + end: () => { }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; + + try { + await nodeRequest({ + url: 'http://example.com', + type: 'PUT', + getRawRequest: () => mockRawRequest + }, CancellationToken.None); + assert.fail('Should have thrown an error'); + } catch (err) { + assert.ok(err instanceof Error); + } + + assert.strictEqual(attemptCount, 1, 'PUT request should not have been retried'); + }); + + test('should NOT retry PATCH requests', async () => { + let attemptCount = 0; + const mockRawRequest = () => { + attemptCount++; + const mockReq: any = { + on: (event: string, handler: Function) => { + if (event === 'error') { + const err = new Error('Connection refused') as NodeJS.ErrnoException; + err.code = 'ECONNREFUSED'; + setTimeout(() => handler(err), 0); + } + }, + end: () => { }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; + + try { + await nodeRequest({ + url: 'http://example.com', + type: 'PATCH', + getRawRequest: () => mockRawRequest + }, CancellationToken.None); + assert.fail('Should have thrown an error'); + } catch (err) { + assert.ok(err instanceof Error); + } + + assert.strictEqual(attemptCount, 1, 'PATCH request should not have been retried'); + }); }); diff --git a/src/vs/platform/theme/browser/defaultStyles.ts b/src/vs/platform/theme/browser/defaultStyles.ts index aad9500236b..58702a30a78 100644 --- a/src/vs/platform/theme/browser/defaultStyles.ts +++ b/src/vs/platform/theme/browser/defaultStyles.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IButtonStyles } from '../../../base/browser/ui/button/button.js'; import { IKeybindingLabelStyles } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionForeground, menuSelectionBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder, checkboxDisabledBackground, checkboxDisabledForeground, widgetBorder } from '../common/colorRegistry.js'; +import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonSecondaryBorder, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionForeground, menuSelectionBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder, checkboxDisabledBackground, checkboxDisabledForeground, widgetBorder } from '../common/colorRegistry.js'; import { IProgressBarStyles } from '../../../base/browser/ui/progressbar/progressbar.js'; import { ICheckboxStyles, IToggleStyles } from '../../../base/browser/ui/toggle/toggle.js'; import { IDialogStyles } from '../../../base/browser/ui/dialog/dialog.js'; @@ -51,6 +51,7 @@ export const defaultButtonStyles: IButtonStyles = { buttonSecondaryForeground: asCssVariable(buttonSecondaryForeground), buttonSecondaryBackground: asCssVariable(buttonSecondaryBackground), buttonSecondaryHoverBackground: asCssVariable(buttonSecondaryHoverBackground), + buttonSecondaryBorder: asCssVariable(buttonSecondaryBorder), buttonBorder: asCssVariable(buttonBorder), }; diff --git a/src/vs/platform/theme/common/colors/inputColors.ts b/src/vs/platform/theme/common/colors/inputColors.ts index 7608c078e0f..4f642bba506 100644 --- a/src/vs/platform/theme/common/colors/inputColors.ts +++ b/src/vs/platform/theme/common/colors/inputColors.ts @@ -12,6 +12,7 @@ import { registerColor, transparent, lighten, darken, ColorTransformType } from // Import the colors we need import { foreground, contrastBorder, focusBorder, iconForeground } from './baseColors.js'; import { editorWidgetBackground } from './editorColors.js'; +import { listHoverBackground } from './listColors.js'; // ----- input @@ -130,15 +131,19 @@ export const buttonBorder = registerColor('button.border', nls.localize('buttonBorder', "Button border color.")); export const buttonSecondaryForeground = registerColor('button.secondaryForeground', - { dark: Color.white, light: Color.white, hcDark: Color.white, hcLight: foreground }, + { dark: foreground, light: foreground, hcDark: Color.white, hcLight: foreground }, nls.localize('buttonSecondaryForeground', "Secondary button foreground color.")); export const buttonSecondaryBackground = registerColor('button.secondaryBackground', - { dark: '#3A3D41', light: '#5F6A79', hcDark: null, hcLight: Color.white }, + { dark: null, light: null, hcDark: null, hcLight: Color.white }, nls.localize('buttonSecondaryBackground', "Secondary button background color.")); +export const buttonSecondaryBorder = registerColor('button.secondaryBorder', + transparent(buttonSecondaryForeground, 0.2), + nls.localize('buttonSecondaryBorder', "Secondary button border color.")); + export const buttonSecondaryHoverBackground = registerColor('button.secondaryHoverBackground', - { dark: lighten(buttonSecondaryBackground, 0.2), light: darken(buttonSecondaryBackground, 0.2), hcDark: null, hcLight: null }, + { dark: listHoverBackground, light: listHoverBackground, hcDark: null, hcLight: null }, nls.localize('buttonSecondaryHoverBackground', "Secondary button background color when hovering.")); // ------ radio diff --git a/src/vs/server/node/remoteExtensionHostAgentCli.ts b/src/vs/server/node/remoteExtensionHostAgentCli.ts index abc8a460e92..46238862457 100644 --- a/src/vs/server/node/remoteExtensionHostAgentCli.ts +++ b/src/vs/server/node/remoteExtensionHostAgentCli.ts @@ -71,6 +71,7 @@ class CliMain extends Disposable { await instantiationService.invokeFunction(async accessor => { const configurationService = accessor.get(IConfigurationService); const logService = accessor.get(ILogService); + const productService = accessor.get(IProductService); // On Windows, configure the UNC allow list based on settings if (isWindows) { @@ -82,7 +83,7 @@ class CliMain extends Disposable { } try { - await this.doRun(instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(logService.getLevel(), false))); + await this.doRun(instantiationService.createInstance(ExtensionManagementCLI, productService.extensionsForceVersionByQuality ?? [], new ConsoleLogger(logService.getLevel(), false))); } catch (error) { logService.error(error); console.error(getErrorMessage(error)); diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index c0100c3df82..34b41d8d00b 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -243,7 +243,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken socketServer.registerChannel(REMOTE_TERMINAL_CHANNEL_NAME, new RemoteTerminalChannel(environmentService, logService, ptyHostService, productService, extensionManagementService, configurationService)); - const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService); + const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, productService.extensionsForceVersionByQuality ?? [], logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService); socketServer.registerChannel(RemoteExtensionsScannerChannelName, new RemoteExtensionsScannerChannel(remoteExtensionsScanner, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); socketServer.registerChannel(NativeMcpDiscoveryHelperChannelName, instantiationService.createInstance(NativeMcpDiscoveryHelperChannel, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); diff --git a/src/vs/workbench/api/browser/mainThreadCLICommands.ts b/src/vs/workbench/api/browser/mainThreadCLICommands.ts index be73afd9fd4..ccc7a4f6131 100644 --- a/src/vs/workbench/api/browser/mainThreadCLICommands.ts +++ b/src/vs/workbench/api/browser/mainThreadCLICommands.ts @@ -18,6 +18,7 @@ import { ServiceCollection } from '../../../platform/instantiation/common/servic import { ILabelService } from '../../../platform/label/common/label.js'; import { AbstractMessageLogger, ILogger, LogLevel } from '../../../platform/log/common/log.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; import { IOpenWindowOptions, IWindowOpenable } from '../../../platform/window/common/window.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; import { IExtensionManagementServerService } from '../../services/extensionManagement/common/extensionManagement.js'; @@ -106,8 +107,9 @@ class RemoteExtensionManagementCLI extends ExtensionManagementCLI { @ILabelService labelService: ILabelService, @IWorkbenchEnvironmentService envService: IWorkbenchEnvironmentService, @IExtensionManifestPropertiesService private readonly _extensionManifestPropertiesService: IExtensionManifestPropertiesService, + @IProductService productService: IProductService, ) { - super(logger, extensionManagementService, extensionGalleryService); + super([], logger, extensionManagementService, extensionGalleryService, productService); const remoteAuthority = envService.remoteAuthority; this._location = remoteAuthority ? labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority) : undefined; diff --git a/src/vs/workbench/api/browser/mainThreadQuickOpen.ts b/src/vs/workbench/api/browser/mainThreadQuickOpen.ts index 1b1f906d1a6..e58f5d07981 100644 --- a/src/vs/workbench/api/browser/mainThreadQuickOpen.ts +++ b/src/vs/workbench/api/browser/mainThreadQuickOpen.ts @@ -159,7 +159,13 @@ export class MainThreadQuickOpen implements MainThreadQuickOpenShape { this._proxy.$onDidChangeSelection(sessionId, items.map(item => (item as TransferQuickPickItem).handle)); })); store.add(quickPick.onDidTriggerItemButton((e) => { - this._proxy.$onDidTriggerItemButton(sessionId, (e.item as TransferQuickPickItem).handle, (e.button as TransferQuickInputButton).handle); + const transferButton = e.button as TransferQuickInputButton; + this._proxy.$onDidTriggerItemButton( + sessionId, + (e.item as TransferQuickPickItem).handle, + transferButton.handle, + transferButton.toggle?.checked + ); })); } diff --git a/src/vs/workbench/api/browser/mainThreadTreeViews.ts b/src/vs/workbench/api/browser/mainThreadTreeViews.ts index 4a4a6526b90..5c562e5d6bb 100644 --- a/src/vs/workbench/api/browser/mainThreadTreeViews.ts +++ b/src/vs/workbench/api/browser/mainThreadTreeViews.ts @@ -20,6 +20,7 @@ import { DataTransferFileCache } from '../common/shared/dataTransferCache.js'; import * as typeConvert from '../common/extHostTypeConverters.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { IViewsService } from '../../services/views/common/viewsService.js'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; @extHostNamedCustomer(MainContext.MainThreadTreeViews) export class MainThreadTreeViews extends Disposable implements MainThreadTreeViewsShape { @@ -33,7 +34,8 @@ export class MainThreadTreeViews extends Disposable implements MainThreadTreeVie @IViewsService private readonly viewsService: IViewsService, @INotificationService private readonly notificationService: INotificationService, @IExtensionService private readonly extensionService: IExtensionService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostTreeViews); @@ -138,6 +140,26 @@ export class MainThreadTreeViews extends Disposable implements MainThreadTreeVie this._dataProviders.deleteAndDispose(treeViewId); } + $logResolveTreeNodeRetry(extensionId: string, retryCount: number, exhausted: boolean): void { + type TreeViewResolveRetryEvent = { + extensionId: string; + retryCount: number; + exhausted: boolean; + }; + type TreeViewResolveRetryClassification = { + extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension identifier.' }; + retryCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of retry attempts made.' }; + exhausted: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether all retry attempts were exhausted.' }; + owner: 'alexr00'; + comment: 'Tracks tree view resolve retries due to concurrent refresh races.'; + }; + this.telemetryService.publicLog2('treeView.resolveRetry', { + extensionId, + retryCount, + exhausted + }); + } + private async reveal(treeView: ITreeView, dataProvider: TreeViewDataProvider, itemIn: ITreeItem, parentChain: ITreeItem[], options: IRevealOptions): Promise { options = options ? options : { select: false, focus: false }; const select = isUndefinedOrNull(options.select) ? false : options.select; diff --git a/src/vs/workbench/api/browser/mainThreadWorkspace.ts b/src/vs/workbench/api/browser/mainThreadWorkspace.ts index 062c43408a0..30ebb309289 100644 --- a/src/vs/workbench/api/browser/mainThreadWorkspace.ts +++ b/src/vs/workbench/api/browser/mainThreadWorkspace.ts @@ -70,6 +70,7 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { this._contextService.onDidChangeWorkspaceFolders(this._onDidChangeWorkspace, this, this._toDispose); this._contextService.onDidChangeWorkbenchState(this._onDidChangeWorkspace, this, this._toDispose); this._workspaceTrustManagementService.onDidChangeTrust(this._onDidGrantWorkspaceTrust, this, this._toDispose); + this._workspaceTrustManagementService.onDidChangeTrustedFolders(this._onDidChangeWorkspaceTrustedFolders, this, this._toDispose); } dispose(): void { @@ -251,6 +252,12 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { return this._workspaceTrustRequestService.requestWorkspaceTrust(options); } + async $isResourceTrusted(resource: UriComponents): Promise { + const uri = URI.revive(resource); + const trustInfo = await this._workspaceTrustManagementService.getUriTrustInfo(uri); + return trustInfo.trusted; + } + private isWorkspaceTrusted(): boolean { return this._workspaceTrustManagementService.isWorkspaceTrusted(); } @@ -259,6 +266,10 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { this._proxy.$onDidGrantWorkspaceTrust(); } + private _onDidChangeWorkspaceTrustedFolders(): void { + this._proxy.$onDidChangeWorkspaceTrustedFolders(); + } + // --- edit sessions --- private registeredEditSessionProviders = new Map(); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 21cb7bb1839..0f9b9ec6e87 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1271,6 +1271,14 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'workspaceTrust'); return extHostWorkspace.requestWorkspaceTrust(options); }, + isResourceTrusted: (resource: vscode.Uri) => { + checkProposedApiEnabled(extension, 'workspaceTrust'); + return extHostWorkspace.isResourceTrusted(resource); + }, + onDidChangeWorkspaceTrustedFolders: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'workspaceTrust'); + return _asExtensionEvent(extHostWorkspace.onDidChangeWorkspaceTrustedFolders)(listener, thisArgs, disposables); + }, onDidGrantWorkspaceTrust: (listener, thisArgs?, disposables?) => { return _asExtensionEvent(extHostWorkspace.onDidGrantWorkspaceTrust)(listener, thisArgs, disposables); }, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 82292bd6198..3ea06f3076a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -354,6 +354,7 @@ export interface MainThreadTreeViewsShape extends IDisposable { $setBadge(treeViewId: string, badge: IViewBadge | undefined): void; $resolveDropFileData(destinationViewId: string, requestId: number, dataItemId: string): Promise; $disposeTree(treeViewId: string): Promise; + $logResolveTreeNodeRetry(extensionId: string, retryCount: number, exhausted: boolean): void; } export interface MainThreadDownloadServiceShape extends IDisposable { @@ -1613,6 +1614,7 @@ export interface MainThreadWorkspaceShape extends IDisposable { $loadCertificates(): Promise; $requestResourceTrust(options: ResourceTrustRequestOptionsDto): Promise; $requestWorkspaceTrust(options?: WorkspaceTrustRequestOptions): Promise; + $isResourceTrusted(resource: UriComponents): Promise; $registerEditSessionIdentityProvider(handle: number, scheme: string): void; $unregisterEditSessionIdentityProvider(handle: number): void; $registerCanonicalUriProvider(handle: number, scheme: string): void; @@ -2096,6 +2098,7 @@ export interface ExtHostWorkspaceShape { $acceptWorkspaceData(workspace: IWorkspaceData | null): void; $handleTextSearchResult(result: search.IRawFileMatch2, requestId: number): void; $onDidGrantWorkspaceTrust(): void; + $onDidChangeWorkspaceTrustedFolders(): void; $getEditSessionIdentifier(folder: UriComponents, token: CancellationToken): Promise; $provideEditSessionIdentityMatch(folder: UriComponents, identity1: string, identity2: string, token: CancellationToken): Promise; $onWillCreateEditSessionIdentity(folder: UriComponents, token: CancellationToken, timeout: number): Promise; @@ -2576,7 +2579,7 @@ export interface ExtHostQuickOpenShape { $onDidAccept(sessionId: number): void; $onDidChangeValue(sessionId: number, value: string): void; $onDidTriggerButton(sessionId: number, handle: number, checked?: boolean): void; - $onDidTriggerItemButton(sessionId: number, itemHandle: number, buttonHandle: number): void; + $onDidTriggerItemButton(sessionId: number, itemHandle: number, buttonHandle: number, checked?: boolean): void; $onDidHide(sessionId: number): void; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index cdcdb1580ea..bb35634e6f2 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -5,7 +5,7 @@ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; -import { DeferredPromise, timeout } from '../../../base/common/async.js'; +import { DeferredPromise, raceCancellation, timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter } from '../../../base/common/event.js'; @@ -51,7 +51,8 @@ export class ChatAgentResponseStream { private readonly _proxy: IChatAgentProgressShape, private readonly _commandsConverter: CommandsConverter, private readonly _sessionDisposables: DisposableStore, - private readonly _pendingCarouselResolvers: Map | undefined>>> + private readonly _pendingCarouselResolvers: Map | undefined>>>, + private readonly _token: CancellationToken ) { } close() { @@ -330,8 +331,8 @@ export class ChatAgentResponseStream { _report(dto); - // Wait for the user to submit answers - return deferred.p; + // Wait for the user to submit answers, but respect cancellation + return raceCancellation(deferred.p, that._token); }, beginToolInvocation(toolCallId, toolName, streamData) { throwIfDone(this.beginToolInvocation); @@ -688,7 +689,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS this._sessionDisposables.set(request.sessionResource, sessionDisposables); } - stream = new ChatAgentResponseStream(agent.extension, request, this._proxy, this._commands.converter, sessionDisposables, this._pendingCarouselResolvers); + stream = new ChatAgentResponseStream(agent.extension, request, this._proxy, this._commands.converter, sessionDisposables, this._pendingCarouselResolvers, token); const model = await this.getModelForRequest(request, agent.extension); const tools = await this.getToolsForRequest(agent.extension, request.userSelectedTools, model.id, token); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 4d5183a4251..182e9495974 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -233,7 +233,7 @@ class ExtHostChatSession { public readonly commandsConverter: CommandsConverter, public readonly sessionDisposables: DisposableStore ) { - this._stream = new ChatAgentResponseStream(extension, request, proxy, commandsConverter, sessionDisposables, this._pendingCarouselResolvers); + this._stream = new ChatAgentResponseStream(extension, request, proxy, commandsConverter, sessionDisposables, this._pendingCarouselResolvers, CancellationToken.None); } get activeResponseStream() { @@ -241,7 +241,7 @@ class ExtHostChatSession { } getActiveRequestStream(request: IChatAgentRequest) { - return new ChatAgentResponseStream(this.extension, request, this.proxy, this.commandsConverter, this.sessionDisposables, this._pendingCarouselResolvers); + return new ChatAgentResponseStream(this.extension, request, this.proxy, this.commandsConverter, this.sessionDisposables, this._pendingCarouselResolvers, CancellationToken.None); } } diff --git a/src/vs/workbench/api/common/extHostQuickOpen.ts b/src/vs/workbench/api/common/extHostQuickOpen.ts index 55e4d8bbe96..3b94c47a855 100644 --- a/src/vs/workbench/api/common/extHostQuickOpen.ts +++ b/src/vs/workbench/api/common/extHostQuickOpen.ts @@ -252,10 +252,10 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx session?._fireDidTriggerButton(handle, checked); } - $onDidTriggerItemButton(sessionId: number, itemHandle: number, buttonHandle: number): void { + $onDidTriggerItemButton(sessionId: number, itemHandle: number, buttonHandle: number, checked?: boolean): void { const session = this._sessions.get(sessionId); if (session instanceof ExtHostQuickPick) { - session._fireDidTriggerItemButton(itemHandle, buttonHandle); + session._fireDidTriggerItemButton(itemHandle, buttonHandle, checked); } } @@ -568,7 +568,11 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx return { iconPathDto: IconPath.from(button.iconPath), tooltip: button.tooltip, - handle: i + handle: i, + toggle: + typeof button.toggle === 'object' && typeof button.toggle.checked === 'boolean' + ? { checked: button.toggle.checked } + : undefined, }; }), }); @@ -670,13 +674,16 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx onDidTriggerItemButton = this._onDidTriggerItemButtonEmitter.event; - _fireDidTriggerItemButton(itemHandle: number, buttonHandle: number) { + _fireDidTriggerItemButton(itemHandle: number, buttonHandle: number, checked?: boolean) { const item = this._handlesToItems.get(itemHandle)!; if (!item || !item.buttons || !item.buttons.length) { return; } const button = item.buttons[buttonHandle]; if (button) { + if (checked !== undefined && button.toggle) { + button.toggle.checked = checked; + } this._onDidTriggerItemButtonEmitter.fire({ button, item diff --git a/src/vs/workbench/api/common/extHostTreeViews.ts b/src/vs/workbench/api/common/extHostTreeViews.ts index f848dc043a1..ca9b9304256 100644 --- a/src/vs/workbench/api/common/extHostTreeViews.ts +++ b/src/vs/workbench/api/common/extHostTreeViews.ts @@ -699,24 +699,41 @@ class ExtHostTreeView extends Disposable { return asPromise(() => this._dataProvider.getParent!(element)); } - private _resolveTreeNode(element: T, parent?: TreeNode): Promise { + private async _resolveTreeNode(element: T, parent?: TreeNode): Promise { const node = this._nodes.get(element); if (node) { - return Promise.resolve(node); + return node; } - return asPromise(() => this._dataProvider.getTreeItem(element)) - .then(extTreeItem => this._createHandle(element, extTreeItem, parent, true)) - .then(handle => this.getChildren(parent ? parent.item.handle : undefined) - .then(() => { - const cachedElement = this.getExtensionElement(handle); - if (cachedElement) { - const node = this._nodes.get(cachedElement); - if (node) { - return Promise.resolve(node); - } - } - throw new Error(`Cannot resolve tree item for element ${handle} from extension ${this._extension.identifier.value}`); - })); + const extTreeItem = await asPromise(() => this._dataProvider.getTreeItem(element)); + const handle = this._createHandle(element, extTreeItem, parent, true); + const children = await this.getChildren(parent ? parent.item.handle : undefined); + // If getChildren returned undefined, it means a concurrent refresh invalidated + // the fetch. Wait for the refresh to complete and check if the element was resolved. + if (children === undefined) { + this._logService.warn(`[${this._viewId}] Concurrent refresh detected in _resolveTreeNode for element ${handle} from extension ${this._extension.identifier.value}, waiting for refresh to complete`); + this._proxy.$logResolveTreeNodeRetry(this._extension.identifier.value, 1, false); + // Wait for any pending refresh to complete + await this._refreshPromise; + // Check if the element is now in the cache after the refresh completed + const cachedElement = this.getExtensionElement(handle); + if (cachedElement) { + const node = this._nodes.get(cachedElement); + if (node) { + return node; + } + } + // Still not found after refresh completed - log and throw + this._proxy.$logResolveTreeNodeRetry(this._extension.identifier.value, 1, true); + throw new Error(`Cannot resolve tree item for element ${handle} from extension ${this._extension.identifier.value}`); + } + const cachedElement = this.getExtensionElement(handle); + if (cachedElement) { + const node = this._nodes.get(cachedElement); + if (node) { + return node; + } + } + throw new Error(`Cannot resolve tree item for element ${handle} from extension ${this._extension.identifier.value}`); } private _getChildrenNodes(parentNodeOrHandle: TreeNode | TreeItemHandle | Root): TreeNode[] | undefined { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index d14c61952d1..c5ddf191f01 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3717,8 +3717,8 @@ export namespace LanguageModelToolSource { } export namespace LanguageModelToolResult { - export function to(result: IToolResult): vscode.LanguageModelToolResult { - return new types.LanguageModelToolResult(result.content.map(item => { + export function to(result: IToolResult): vscode.ExtendedLanguageModelToolResult { + const toolResult = new types.LanguageModelToolResult(result.content.map(item => { if (item.kind === 'text') { return new types.LanguageModelTextPart(item.value, item.audience); } else if (item.kind === 'data') { @@ -3726,7 +3726,11 @@ export namespace LanguageModelToolResult { } else { return new types.LanguageModelPromptTsxPart(item.value); } - })); + })) as vscode.ExtendedLanguageModelToolResult; + if (result.toolMetadata !== undefined) { + toolResult.toolMetadata = result.toolMetadata; + } + return toolResult; } export function from(result: vscode.ExtendedLanguageModelToolResult2, extension: IExtensionDescription): Dto | SerializableObjectWithBuffers> { diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 89b4ecf179a..55ca51e9a64 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -189,6 +189,9 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac private readonly _onDidGrantWorkspaceTrust = new Emitter(); readonly onDidGrantWorkspaceTrust: Event = this._onDidGrantWorkspaceTrust.event; + private readonly _onDidChangeWorkspaceTrustedFolders = new Emitter(); + readonly onDidChangeWorkspaceTrustedFolders: Event = this._onDidChangeWorkspaceTrustedFolders.event; + private readonly _logService: ILogService; private readonly _requestIdProvider: Counter; private readonly _barrier: Barrier; @@ -821,6 +824,14 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac } } + $onDidChangeWorkspaceTrustedFolders(): void { + this._onDidChangeWorkspaceTrustedFolders.fire(); + } + + isResourceTrusted(resource: vscode.Uri): Promise { + return this._proxy.$isResourceTrusted(resource); + } + // --- edit sessions --- private _providerHandlePool = 0; diff --git a/src/vs/workbench/api/test/browser/mainThreadTreeViews.test.ts b/src/vs/workbench/api/test/browser/mainThreadTreeViews.test.ts index ff73ee23a0a..dc20abb8c5e 100644 --- a/src/vs/workbench/api/test/browser/mainThreadTreeViews.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadTreeViews.test.ts @@ -12,6 +12,7 @@ import { TestInstantiationService } from '../../../../platform/instantiation/tes import { NullLogService } from '../../../../platform/log/common/log.js'; import { TestNotificationService } from '../../../../platform/notification/test/common/testNotificationService.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; +import { NullTelemetryService } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { MainThreadTreeViews } from '../../browser/mainThreadTreeViews.js'; import { ExtHostTreeViewsShape } from '../../common/extHost.protocol.js'; import { CustomTreeView } from '../../../browser/parts/views/treeView.js'; @@ -80,7 +81,7 @@ suite('MainThreadHostTreeView', function () { return extHostTreeViewsShape; } drain(): any { return null; } - }, new TestViewsService(), new TestNotificationService(), testExtensionService, new NullLogService())); + }, new TestViewsService(), new TestNotificationService(), testExtensionService, new NullLogService(), NullTelemetryService)); mainThreadTreeViews.$registerTreeViewDataProvider(testTreeViewId, { showCollapseAll: false, canSelectMany: false, dropMimeTypes: [], dragMimeTypes: [], hasHandleDrag: false, hasHandleDrop: false, manuallyManageCheckboxes: false }); await testExtensionService.whenInstalledExtensionsRegistered(); }); diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index af8ce70f5ad..da337f0d06e 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -117,6 +117,7 @@ interface IInitialEditorsState { const COMMAND_CENTER_SETTINGS = [ 'chat.agentsControl.enabled', + 'chat.unifiedAgentsBar.enabled', 'workbench.navigationControl.enabled', 'workbench.experimental.share.enabled', ]; @@ -354,12 +355,10 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } }; - // Maybe maximize auxiliary bar when no editors, sidebar hidden, and panel hidden + // Maybe maximize auxiliary bar when no editors are visible const maybeMaximizeAuxiliaryBar = () => { if ( this.mainPartEditorService.visibleEditors.length === 0 && - !this.isVisible(Parts.SIDEBAR_PART) && - !this.isVisible(Parts.PANEL_PART) && this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_FORCE_MAXIMIZED) === true ) { this.setAuxiliaryBarMaximized(true); @@ -383,13 +382,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi })); this._register(this.editorGroupService.mainPart.onDidActivateGroup(showEditorIfHidden)); - // Maybe maximize auxiliary bar when sidebar or panel hides - this._register(this.onDidChangePartVisibility(({ partId, visible }) => { - if (!visible && (partId === Parts.SIDEBAR_PART || partId === Parts.PANEL_PART)) { - maybeMaximizeAuxiliaryBar(); - } - })); - // Revalidate center layout when active editor changes: diff editor quits centered mode this._register(this.mainPartEditorService.onDidActiveEditorChange(() => this.centerMainEditorLayout(this.stateModel.getRuntimeValue(LayoutStateKeys.MAIN_EDITOR_CENTERED)))); }); @@ -405,10 +397,9 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi ].some(setting => e.affectsConfiguration(setting))) { // Show Command Center if command center actions enabled - const shareEnabled = e.affectsConfiguration('workbench.experimental.share.enabled') && this.configurationService.getValue('workbench.experimental.share.enabled'); - const navigationControlEnabled = e.affectsConfiguration('workbench.navigationControl.enabled') && this.configurationService.getValue('workbench.navigationControl.enabled'); + const enabledCommandCenterAction = COMMAND_CENTER_SETTINGS.some(setting => e.affectsConfiguration(setting) && this.configurationService.getValue(setting) === true); - if (shareEnabled || navigationControlEnabled) { + if (enabledCommandCenterAction) { if (this.configurationService.getValue(LayoutSettings.COMMAND_CENTER) === false) { this.configurationService.updateValue(LayoutSettings.COMMAND_CENTER, true); return; // onDidChangeConfiguration will be triggered again @@ -2990,11 +2981,9 @@ class LayoutStateModel extends Disposable { private applyOverrides(configuration: ILayoutStateLoadConfiguration): void { // Auxiliary bar: Maximized settings - const auxiliaryBarForceMaximized = this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_FORCE_MAXIMIZED); - if (this.isNew[StorageScope.WORKSPACE] || auxiliaryBarForceMaximized) { + if (this.isNew[StorageScope.WORKSPACE]) { const defaultAuxiliaryBarVisibility = this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_DEFAULT_VISIBILITY); if ( - auxiliaryBarForceMaximized || defaultAuxiliaryBarVisibility === 'maximized' || (defaultAuxiliaryBarVisibility === 'maximizedInWorkspace' && this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY) ) { diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts index 094ee8995fa..ba678499fec 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts @@ -547,7 +547,7 @@ export class BreadcrumbsControl { const pickerArrowSize = 8; let pickerArrowOffset: number; - const data = dom.getDomNodePagePosition(event.node.firstChild as HTMLElement); + const data = dom.getDomNodePagePosition(event.node); const y = data.top + data.height + pickerArrowSize; if (y + maxHeight >= window.innerHeight) { maxHeight = window.innerHeight - y - 30 /* room for shadow and status bar*/; diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 5b17b596220..743f9e6ee8b 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -821,7 +821,7 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { private get activityActionsEnabled(): boolean { const activityBarPosition = this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION); - return !this.isCompact && !this.isAuxiliary && (activityBarPosition === ActivityBarPosition.TOP || activityBarPosition === ActivityBarPosition.BOTTOM || activityBarPosition === ActivityBarPosition.HIDDEN); + return !this.isCompact && !this.isAuxiliary && (activityBarPosition === ActivityBarPosition.TOP || activityBarPosition === ActivityBarPosition.BOTTOM); } private get globalActionsEnabled(): boolean { diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index a1e90803d5a..5ea32fb1a11 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -589,7 +589,7 @@ const registry = Registry.as(ConfigurationExtensions.Con 'type': 'boolean', 'default': false, tags: ['experimental'], - 'description': localize('secondarySideBarForceMaximized', "Controls whether the secondary side bar is enforced to always show maximized unless other parts or editors are showing."), + 'description': localize('secondarySideBarForceMaximized', "Controls whether the secondary side bar is enforced to always show maximized on startup and when there are no open editors, in layouts that support a maximized secondary side bar."), }, 'workbench.secondarySideBar.showLabels': { 'type': 'boolean', diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index e532690676e..a292a3a1ba2 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -22,7 +22,8 @@ import { BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, - IBrowserViewFindInPageResult + IBrowserViewFindInPageResult, + IBrowserViewVisibilityEvent } from '../../../../platform/browserView/common/browserView.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -82,6 +83,7 @@ export interface IBrowserViewModel extends IDisposable { readonly screenshot: VSBuffer | undefined; readonly loading: boolean; readonly focused: boolean; + readonly visible: boolean; readonly canGoBack: boolean; readonly isDevToolsOpen: boolean; readonly canGoForward: boolean; @@ -98,6 +100,7 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidChangeFavicon: Event; readonly onDidRequestNewPage: Event; readonly onDidFindInPage: Event; + readonly onDidChangeVisibility: Event; readonly onDidClose: Event; readonly onWillDispose: Event; @@ -125,6 +128,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _screenshot: VSBuffer | undefined = undefined; private _loading: boolean = false; private _focused: boolean = false; + private _visible: boolean = false; private _isDevToolsOpen: boolean = false; private _canGoBack: boolean = false; private _canGoForward: boolean = false; @@ -150,6 +154,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get favicon(): string | undefined { return this._favicon; } get loading(): boolean { return this._loading; } get focused(): boolean { return this._focused; } + get visible(): boolean { return this._visible; } get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } get canGoBack(): boolean { return this._canGoBack; } get canGoForward(): boolean { return this._canGoForward; } @@ -193,6 +198,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidFindInPage(this.id); } + get onDidChangeVisibility(): Event { + return this.browserViewService.onDynamicDidChangeVisibility(this.id); + } + get onDidClose(): Event { return this.browserViewService.onDynamicDidClose(this.id); } @@ -221,6 +230,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._title = state.title; this._loading = state.loading; this._focused = state.focused; + this._visible = state.visible; this._isDevToolsOpen = state.isDevToolsOpen; this._canGoBack = state.canGoBack; this._canGoForward = state.canGoForward; @@ -262,6 +272,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._register(this.onDidChangeFocus(({ focused }) => { this._focused = focused; })); + + this._register(this.onDidChangeVisibility(({ visible }) => { + this._visible = visible; + })); } async layout(bounds: IBrowserViewBounds): Promise { @@ -269,6 +283,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } async setVisible(visible: boolean): Promise { + this._visible = visible; // Set optimistically so model is in sync immediately return this.browserViewService.setVisible(this.id, visible); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index ed80694577d..2d79adc5c8d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -5,7 +5,7 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; -import { $, addDisposableListener, Dimension, disposableWindowInterval, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, Dimension, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -192,6 +192,7 @@ export class BrowserEditor extends EditorPane { private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; private _elementSelectionCts: CancellationTokenSource | undefined; + private _screenshotTimeout: ReturnType | undefined; constructor( group: IEditorGroup, @@ -344,6 +345,9 @@ export class BrowserEditor extends EditorPane { this.focusUrlInput(); } + // Start / stop screenshots when the model visibility changes + this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); + // Listen to model events for UI updates this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => { // Handle like webview does - convert to webview KeyEvent format @@ -398,16 +402,11 @@ export class BrowserEditor extends EditorPane { this.layoutBrowserContainer(); } })); - // Capture screenshot periodically (once per second) to keep background updated - this._inputDisposables.add(disposableWindowInterval( - this.window, - () => this.capturePlaceholderSnapshot(), - 1000 - )); this.updateErrorDisplay(); this.layoutBrowserContainer(); - await this._model.setVisible(this.shouldShowView); + this.updateVisibility(); + this.doScreenshot(); } protected override setEditorVisible(visible: boolean): void { @@ -442,14 +441,26 @@ export class BrowserEditor extends EditorPane { if (this._model) { const show = this.shouldShowView; - void this._model.setVisible(show); - if ( - show && - this._browserContainer.ownerDocument.hasFocus() && - this._browserContainer.ownerDocument.activeElement === this._browserContainer - ) { - // If the editor is focused, ensure the browser view also gets focus - void this._model.focus(); + if (show === this._model.visible) { + return; + } + + if (show) { + this._model.setVisible(true); + if ( + this._browserContainer.ownerDocument.hasFocus() && + this._browserContainer.ownerDocument.activeElement === this._browserContainer + ) { + // If the editor is focused, ensure the browser view also gets focus + void this._model.focus(); + } + } else { + this.doScreenshot(); + + // Hide the browser view just before the next render. + // This attempts to give the screenshot some time to be captured and displayed. + // If we hide immediately it is more likely to flicker while the old screenshot is still visible. + this.window.requestAnimationFrame(() => this._model?.setVisible(false)); } } } @@ -780,17 +791,35 @@ export class BrowserEditor extends EditorPane { } } - /** - * Capture a screenshot of the current browser view to use as placeholder background - */ - private async capturePlaceholderSnapshot(): Promise { - if (this._model && !this._overlayVisible) { - try { - const buffer = await this._model.captureScreenshot({ quality: 80 }); - this.setBackgroundImage(buffer); - } catch (error) { - this.logService.error('BrowserEditor.capturePlaceholderSnapshot: Failed to capture screenshot', error); - } + private async doScreenshot(): Promise { + if (!this._model) { + return; + } + + // Cancel any existing timeout + this.cancelScheduledScreenshot(); + + // Only take screenshots if the model is visible + if (!this._model.visible) { + return; + } + + try { + // Capture screenshot and set as background image + const screenshot = await this._model.captureScreenshot({ quality: 80 }); + this.setBackgroundImage(screenshot); + } catch (error) { + this.logService.error('Failed to capture browser view screenshot', error); + } + + // Schedule next screenshot in 1 second + this._screenshotTimeout = setTimeout(() => this.doScreenshot(), 1000); + } + + private cancelScheduledScreenshot(): void { + if (this._screenshotTimeout) { + clearTimeout(this._screenshotTimeout); + this._screenshotTimeout = undefined; } } @@ -859,6 +888,9 @@ export class BrowserEditor extends EditorPane { this._elementSelectionCts = undefined; } + // Cancel any scheduled screenshots + this.cancelScheduledScreenshot(); + // Clear find widget model this._findWidget.rawValue?.setModel(undefined); this._findWidget.rawValue?.hide(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 89087be7beb..f4fafcbf3af 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -72,15 +72,19 @@ justify-content: center; pointer-events: none; color: var(--vscode-foreground); - background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); + background-color: color-mix(in srgb, var(--vscode-editor-background) 15%, transparent); opacity: 0; visibility: hidden; - transition: opacity 200ms ease-out; + transition: opacity 200ms ease-in; &.visible { opacity: 1; visibility: visible; } + + &.show-message { + background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); + } } .browser-overlay-paused-message { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 37b1c58b434..7b64c28cbbd 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1283,26 +1283,3 @@ registerAction2(class EditToolApproval extends Action2 { confirmationService.manageConfirmationPreferences([...toolsService.getAllToolsIncludingDisabled()], scope ? { defaultScope: scope } : undefined); } }); - -registerAction2(class ToggleChatViewTitleAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.toggleChatViewTitle', - title: localize2('chat.toggleChatViewTitle.label', "Show Chat Title"), - toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewTitleEnabled}`, true), - menu: { - id: MenuId.ChatWelcomeContext, - group: '1_modify', - order: 2, - when: ChatContextKeys.inChatEditor.negate() - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const configurationService = accessor.get(IConfigurationService); - - const chatViewTitleEnabled = configurationService.getValue(ChatConfiguration.ChatViewTitleEnabled); - await configurationService.updateValue(ChatConfiguration.ChatViewTitleEnabled, !chatViewTitleEnabled); - } -}); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index e6c881e8a0e..a5b99a144dd 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Schemas } from '../../../../../base/common/network.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; @@ -38,15 +39,20 @@ function encodePathForMarkdown(path: string): string { * The returned path is URL encoded for use in markdown link targets. */ function getRelativePath(uri: URI, workspaceFolders: readonly IWorkspaceFolder[]): string { + // On desktop, vscode-userdata scheme maps 1:1 to file scheme paths via FileUserDataProvider. + // Convert to file scheme so relativePath() can compute paths correctly. + // On web, vscode-userdata uses IndexedDB so this conversion has no effect (different schemes won't match workspace folders). + const normalizedUri = uri.scheme === Schemas.vscodeUserData ? uri.with({ scheme: Schemas.file }) : uri; + for (const folder of workspaceFolders) { - const relative = relativePath(folder.uri, uri); + const relative = relativePath(folder.uri, normalizedUri); if (relative) { return encodePathForMarkdown(relative); } } // Fall back to fsPath if not under any workspace folder // Use forward slashes for consistency in markdown links - return encodePathForMarkdown(uri.fsPath.replace(/\\/g, '/')); + return encodePathForMarkdown(normalizedUri.fsPath.replace(/\\/g, '/')); } // Tree prefixes @@ -117,8 +123,13 @@ export function registerChatCustomizationDiagnosticsAction() { }, { id: CHAT_CONFIG_MENU_ID, when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), - order: 20, + order: 14, group: '3_configure' + }, { + id: MenuId.ChatWelcomeContext, + group: '2_settings', + order: 0, + when: ChatContextKeys.inChatEditor.negate() }] }); } @@ -423,12 +434,14 @@ export function formatStatusOutput( // Count loaded and skipped files (overwritten counts as skipped) let loadedCount = info.files.filter(f => f.status === 'loaded').length; const skippedCount = info.files.filter(f => f.status === 'skipped' || f.status === 'overwritten').length; - // Include special files in the loaded count - if (info.type === PromptsType.agent && specialFiles.agentsMd.enabled) { - loadedCount += specialFiles.agentsMd.files.length; - } - if (info.type === PromptsType.instructions && specialFiles.copilotInstructions.enabled) { - loadedCount += specialFiles.copilotInstructions.files.length; + // Include special files in the loaded count for instructions + if (info.type === PromptsType.instructions) { + if (specialFiles.agentsMd.enabled) { + loadedCount += specialFiles.agentsMd.files.length; + } + if (specialFiles.copilotInstructions.enabled) { + loadedCount += specialFiles.copilotInstructions.files.length; + } } lines.push(`**${typeName}**${enabledStatus}
`); @@ -558,8 +571,9 @@ export function formatStatusOutput( hasContent = true; } - // Add special files for agents (AGENTS.md) - if (info.type === PromptsType.agent) { + // Add special files for instructions (AGENTS.md and copilot-instructions.md) + if (info.type === PromptsType.instructions) { + // AGENTS.md if (specialFiles.agentsMd.enabled && specialFiles.agentsMd.files.length > 0) { lines.push(`AGENTS.md
`); for (let i = 0; i < specialFiles.agentsMd.files.length; i++) { @@ -575,10 +589,8 @@ export function formatStatusOutput( lines.push(`AGENTS.md -
`); hasContent = true; } - } - // Add special files for instructions (copilot-instructions.md) - if (info.type === PromptsType.instructions) { + // copilot-instructions.md if (specialFiles.copilotInstructions.enabled && specialFiles.copilotInstructions.files.length > 0) { lines.push(`${COPILOT_CUSTOM_INSTRUCTIONS_FILENAME}
`); for (let i = 0; i < specialFiles.copilotInstructions.files.length; i++) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index b78c0a34125..3e4f7655a1e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -491,7 +491,7 @@ export class OpenSessionTargetPickerAction extends Action2 { tooltip: localize('setSessionTarget', "Set Session Target"), category: CHAT_CATEGORY, f1: false, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.hasCanDelegateProviders, ContextKeyExpr.or(ChatContextKeys.chatSessionIsEmpty, ChatContextKeys.inAgentSessionsWelcome)), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.or(ChatContextKeys.chatSessionIsEmpty, ChatContextKeys.inAgentSessionsWelcome), ChatContextKeys.currentlyEditingInput.negate(), ChatContextKeys.currentlyEditing.negate()), menu: [ { id: MenuId.ChatInput, @@ -500,8 +500,7 @@ export class OpenSessionTargetPickerAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.hasCanDelegateProviders, - ContextKeyExpr.or(ChatContextKeys.chatSessionIsEmpty, ChatContextKeys.hasCanDelegateProviders.negate())), + ChatContextKeys.chatSessionIsEmpty), group: 'navigation', }, ] @@ -527,7 +526,7 @@ export class OpenDelegationPickerAction extends Action2 { tooltip: localize('delegateSession', "Delegate Session"), category: CHAT_CATEGORY, f1: false, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.hasCanDelegateProviders, ChatContextKeys.chatSessionIsEmpty.negate()), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.chatSessionIsEmpty.negate(), ChatContextKeys.currentlyEditingInput.negate(), ChatContextKeys.currentlyEditing.negate()), menu: [ { id: MenuId.ChatInput, @@ -536,8 +535,7 @@ export class OpenDelegationPickerAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.hasCanDelegateProviders, - ContextKeyExpr.and(ChatContextKeys.chatSessionIsEmpty.negate(), ChatContextKeys.hasCanDelegateProviders)), + ChatContextKeys.chatSessionIsEmpty.negate()), group: 'navigation', }, ] @@ -588,7 +586,7 @@ export class ChatSessionPrimaryPickerAction extends Action2 { constructor() { super({ id: ChatSessionPrimaryPickerAction.ID, - title: localize2('interactive.openChatSessionPrimaryPicker.label', "Open Picker"), + title: localize2('interactive.openChatSessionPrimaryPicker.label', "Open Model Picker"), category: CHAT_CATEGORY, f1: false, precondition: ChatContextKeys.enabled, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index e55f256ac76..91e60f599de 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -558,44 +558,206 @@ export async function showToolsPicker( } // Capture initial state for telemetry comparison - const initialStateString = serializeToolsState(collectResults()); + const initialState = collectResults(); treePicker.show(); await Promise.race([Event.toPromise(Event.any(treePicker.onDidHide, didAcceptFinalItem.event), store)]); - // Send telemetry whether the tool selection changed - sendDidChangeEvent(source, telemetryService, initialStateString !== serializeToolsState(collectResults())); + // Send telemetry about tool selection changes + sendDidChangeEvent(source, telemetryService, initialState, collectResults(), mcpRegistry); store.dispose(); return didAccept ? collectResults() : undefined; } -function serializeToolsState(state: ReadonlyMap): string { - const entries: [string, boolean][] = []; - state.forEach((value, key) => { - entries.push([key.id, value]); - }); - entries.sort((a, b) => a[0].localeCompare(b[0])); - return JSON.stringify(entries); +/** + * Categorizes a tool or toolset source for privacy-safe telemetry. + * Returns identifying info only for built-in/extension tools where names are public. + * For user-defined and user MCP tools, only the category is returned. + * + * @param item - The tool or toolset to categorize + * @param mcpRegistry - The MCP registry to look up collection sources for MCP tools + */ +function categorizeTool(item: IToolData | IToolSet, mcpRegistry: IMcpRegistry): { category: 'builtin' | 'extension' | 'extension-mcp' | 'user-mcp' | 'user-toolset'; name?: string; extensionId?: string } { + const source = item.source; + switch (source.type) { + case 'internal': + // Built-in tools are safe to identify by name + return { category: 'builtin', name: item.id }; + case 'extension': + // Extension tools are public, safe to include name and extension ID + return { category: 'extension', name: item.id, extensionId: source.extensionId.value }; + case 'mcp': { + // MCP tools: check if the collection comes from an extension + // Never include tool names for privacy, but include extension ID if from an extension + const collection = mcpRegistry.collections.get().find(c => c.id === source.collectionId); + if (collection?.source instanceof ExtensionIdentifier) { + return { category: 'extension-mcp', extensionId: collection.source.value }; + } + // User-configured MCP server - don't include any identifying info + return { category: 'user-mcp' }; + } + case 'user': + // User-defined tool sets: don't include names for privacy + return { category: 'user-toolset' }; + case 'external': + // External tools shouldn't appear in the picker, treat as user-defined for safety + return { category: 'user-toolset' }; + default: + assertNever(source); + } } -function sendDidChangeEvent(source: string, telemetryService: ITelemetryService, changed: boolean): void { +interface IToolToggleSummary { + /** Number of built-in tools enabled */ + builtinEnabled: number; + /** Number of built-in tools disabled */ + builtinDisabled: number; + /** Number of extension tools enabled */ + extensionEnabled: number; + /** Number of extension tools disabled */ + extensionDisabled: number; + /** Number of extension MCP tools enabled */ + extensionMcpEnabled: number; + /** Number of extension MCP tools disabled */ + extensionMcpDisabled: number; + /** Number of user MCP tools enabled */ + userMcpEnabled: number; + /** Number of user MCP tools disabled */ + userMcpDisabled: number; + /** Number of user tool sets enabled */ + userToolsetEnabled: number; + /** Number of user tool sets disabled */ + userToolsetDisabled: number; + /** Detailed list of toggled items (only safe-to-log items include names) */ + details: string; +} + +function computeToolToggleSummary( + initialState: ReadonlyMap, + finalState: ReadonlyMap, + mcpRegistry: IMcpRegistry +): IToolToggleSummary { + const summary: IToolToggleSummary = { + builtinEnabled: 0, + builtinDisabled: 0, + extensionEnabled: 0, + extensionDisabled: 0, + extensionMcpEnabled: 0, + extensionMcpDisabled: 0, + userMcpEnabled: 0, + userMcpDisabled: 0, + userToolsetEnabled: 0, + userToolsetDisabled: 0, + details: '' + }; + + const detailItems: { category: string; name?: string; extensionId?: string; enabled: boolean }[] = []; + + // Compare states and record changes + for (const [item, finalEnabled] of finalState) { + const initialEnabled = initialState.get(item) ?? false; + if (initialEnabled === finalEnabled) { + continue; // No change + } + + const categorized = categorizeTool(item, mcpRegistry); + const enabled = finalEnabled; + + switch (categorized.category) { + case 'builtin': + if (enabled) { summary.builtinEnabled++; } else { summary.builtinDisabled++; } + detailItems.push({ category: 'builtin', name: categorized.name, enabled }); + break; + case 'extension': + if (enabled) { summary.extensionEnabled++; } else { summary.extensionDisabled++; } + detailItems.push({ category: 'extension', name: categorized.name, extensionId: categorized.extensionId, enabled }); + break; + case 'extension-mcp': + if (enabled) { summary.extensionMcpEnabled++; } else { summary.extensionMcpDisabled++; } + detailItems.push({ category: 'extension-mcp', extensionId: categorized.extensionId, enabled }); + break; + case 'user-mcp': + if (enabled) { summary.userMcpEnabled++; } else { summary.userMcpDisabled++; } + // Don't include name for privacy + detailItems.push({ category: 'user-mcp', enabled }); + break; + case 'user-toolset': + if (enabled) { summary.userToolsetEnabled++; } else { summary.userToolsetDisabled++; } + // Don't include name for privacy + detailItems.push({ category: 'user-toolset', enabled }); + break; + } + } + + // Serialize details as JSON + summary.details = JSON.stringify(detailItems); + return summary; +} + +function sendDidChangeEvent( + source: string, + telemetryService: ITelemetryService, + initialState: ReadonlyMap, + finalState: ReadonlyMap, + mcpRegistry: IMcpRegistry +): void { + const summary = computeToolToggleSummary(initialState, finalState, mcpRegistry); + const changed = summary.builtinEnabled > 0 || summary.builtinDisabled > 0 || + summary.extensionEnabled > 0 || summary.extensionDisabled > 0 || + summary.extensionMcpEnabled > 0 || summary.extensionMcpDisabled > 0 || + summary.userMcpEnabled > 0 || summary.userMcpDisabled > 0 || + summary.userToolsetEnabled > 0 || summary.userToolsetDisabled > 0; + type ToolPickerClosedEvent = { changed: boolean; source: string; + builtinEnabled: number; + builtinDisabled: number; + extensionEnabled: number; + extensionDisabled: number; + extensionMcpEnabled: number; + extensionMcpDisabled: number; + userMcpEnabled: number; + userMcpDisabled: number; + userToolsetEnabled: number; + userToolsetDisabled: number; + details: string; }; type ToolPickerClosedClassification = { changed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user changed the tool selection from the initial state.' }; source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the tool picker event.' }; + builtinEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of built-in tools that were enabled.' }; + builtinDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of built-in tools that were disabled.' }; + extensionEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension tools that were enabled.' }; + extensionDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension tools that were disabled.' }; + extensionMcpEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension MCP tools that were enabled.' }; + extensionMcpDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of extension MCP tools that were disabled.' }; + userMcpEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user MCP tools that were enabled.' }; + userMcpDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user MCP tools that were disabled.' }; + userToolsetEnabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user tool sets that were enabled.' }; + userToolsetDisabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of user tool sets that were disabled.' }; + details: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON array of toggled items. Built-in and extension tools include names; user-defined items only include category.' }; owner: 'benibenj'; - comment: 'Tracks whether users modify tool selection in the tool picker.'; + comment: 'Tracks which tools users toggle in the tool picker, with privacy-safe categorization.'; }; telemetryService.publicLog2('chatToolPickerClosed', { source, changed, + builtinEnabled: summary.builtinEnabled, + builtinDisabled: summary.builtinDisabled, + extensionEnabled: summary.extensionEnabled, + extensionDisabled: summary.extensionDisabled, + extensionMcpEnabled: summary.extensionMcpEnabled, + extensionMcpDisabled: summary.extensionMcpDisabled, + userMcpEnabled: summary.userMcpEnabled, + userMcpDisabled: summary.userMcpDisabled, + userToolsetEnabled: summary.userToolsetEnabled, + userToolsetDisabled: summary.userToolsetDisabled, + details: summary.details, }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index e9e4553e093..0100bbb1b49 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -19,6 +19,13 @@ export enum AgentSessionProviders { Codex = 'openai-codex', } +export function isBuiltInAgentSessionProvider(provider: string): boolean { + return provider === AgentSessionProviders.Local || + provider === AgentSessionProviders.Background || + provider === AgentSessionProviders.Cloud || + provider === AgentSessionProviders.Claude; +} + export function getAgentSessionProvider(sessionResource: URI | string): AgentSessionProviders | undefined { const type = URI.isUri(sessionResource) ? getChatSessionType(sessionResource) : sessionResource; switch (type) { @@ -57,8 +64,9 @@ export function getAgentSessionProviderIcon(provider: AgentSessionProviders): Th case AgentSessionProviders.Cloud: return Codicon.cloud; case AgentSessionProviders.Codex: + return Codicon.openai; case AgentSessionProviders.Claude: - return Codicon.code; + return Codicon.claude; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index b62deb9db0a..3af559ab6c8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -23,7 +23,7 @@ import { ChatEditorInput, showClearEditingSessionConfirmation } from '../widgetH import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; -import { ACTION_ID_NEW_CHAT, CHAT_CATEGORY } from '../actions/chatActions.js'; +import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -36,6 +36,8 @@ import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { coalesce } from '../../../../../base/common/arrays.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +const AGENT_SESSIONS_CATEGORY = localize2('chatSessions', "Chat Agent Sessions"); + //#region Chat View export class ToggleShowAgentSessionsAction extends Action2 { @@ -48,7 +50,7 @@ export class ToggleShowAgentSessionsAction extends Action2 { menu: { id: MenuId.ChatWelcomeContext, group: '0_sessions', - order: 1, + order: 2, when: ChatContextKeys.inChatEditor.negate() } }); @@ -66,7 +68,7 @@ MenuRegistry.appendMenuItem(MenuId.ChatWelcomeContext, { submenu: agentSessionsOrientationSubmenu, title: localize2('chat.sessionsOrientation', "Sessions Orientation"), group: '0_sessions', - order: 2, + order: 1, when: ChatContextKeys.inChatEditor.negate() }); @@ -143,7 +145,7 @@ export class PickAgentSessionAction extends Action2 { when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), } ], - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, icon: Codicon.history, f1: true, precondition: ChatContextKeys.enabled @@ -165,7 +167,7 @@ export class ArchiveAllAgentSessionsAction extends Action2 { id: 'workbench.action.chat.archiveAllAgentSessions', title: localize2('archiveAll.label', "Archive All Workspace Agent Sessions"), precondition: ChatContextKeys.enabled, - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, f1: true, }); } @@ -201,10 +203,16 @@ export class MarkAllAgentSessionsReadAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.markAllAgentSessionsRead', - title: localize2('markAllRead.label', "Mark All Workspace Agent Sessions as Read"), + title: localize2('markAllRead.label', "Mark All as Read"), precondition: ChatContextKeys.enabled, - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, f1: true, + menu: { + id: MenuId.AgentSessionsContext, + group: '0_read', + order: 2, + when: ChatContextKeys.isArchivedAgentSession.negate() // no read state for archived sessions + } }); } async run(accessor: ServicesAccessor) { @@ -403,7 +411,7 @@ export class MarkAgentSessionUnreadAction extends BaseAgentSessionAction { title: localize2('markUnread', "Mark as Unread"), menu: { id: MenuId.AgentSessionsContext, - group: '1_edit', + group: '0_read', order: 1, when: ContextKeyExpr.and( ChatContextKeys.isReadAgentSession, @@ -428,7 +436,7 @@ export class MarkAgentSessionReadAction extends BaseAgentSessionAction { title: localize2('markRead', "Mark as Read"), menu: { id: MenuId.AgentSessionsContext, - group: '1_edit', + group: '0_read', order: 1, when: ContextKeyExpr.and( ChatContextKeys.isReadAgentSession.negate(), @@ -631,7 +639,7 @@ export class DeleteAllLocalSessionsAction extends Action2 { id: 'workbench.action.chat.clearHistory', title: localize2('agentSessions.deleteAll', "Delete All Local Workspace Chat Sessions"), precondition: ChatContextKeys.enabled, - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, f1: true, }); } @@ -939,7 +947,7 @@ export class ShowAgentSessionsSidebar extends UpdateChatViewWidthAction { AuxiliaryBarMaximizedContext.negate() ), f1: true, - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, }); } @@ -963,7 +971,7 @@ export class HideAgentSessionsSidebar extends UpdateChatViewWidthAction { AuxiliaryBarMaximizedContext.negate() ), f1: true, - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, }); } @@ -986,7 +994,7 @@ export class ToggleAgentSessionsSidebar extends Action2 { AuxiliaryBarMaximizedContext.negate() ), f1: true, - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, }); } @@ -1017,7 +1025,7 @@ export class FocusAgentSessionsAction extends Action2 { ChatContextKeys.enabled, ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewSessionsEnabled}`, true) ), - category: CHAT_CATEGORY, + category: AGENT_SESSIONS_CATEGORY, f1: true, }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 6c4eb042e8a..5312ecd1529 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -167,7 +167,6 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo overrideStyles: this.options.overrideStyles, twistieAdditionalCssClass: () => 'force-no-twistie', collapseByDefault: (element: unknown) => collapseByDefault(element), - expandOnlyOnTwistieClick: element => !collapseByDefault(element), renderIndentGuides: RenderIndentGuides.None, } )) as WorkbenchCompressibleAsyncDataTree; @@ -325,14 +324,17 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo break; } case AgentSessionSection.More: { - const shouldCollapseMore = - !this.sessionsListFindIsOpen && // always expand when find is open - !this.options.filter.getExcludes().read; // always expand when only showing unread + if (child.collapsed) { + let autoExpandMore = false; + if (this.sessionsListFindIsOpen) { + autoExpandMore = true; // always expand when find is open + } else if (this.options.filter.getExcludes().read && child.element.sessions.some(session => !session.isRead())) { + autoExpandMore = true; // expand when showing only unread and this section includes unread + } - if (shouldCollapseMore && !child.collapsed) { - this.sessionsList.collapse(child.element); - } else if (!shouldCollapseMore && child.collapsed) { - this.sessionsList.expand(child.element); + if (autoExpandMore) { + this.sessionsList.expand(child.element); + } } break; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 7af226c8a31..9efc88521e2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -17,13 +17,15 @@ import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; import { ChatSessionStatus as AgentSessionStatus, IChatSessionFileChange, IChatSessionFileChange2, IChatSessionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js'; +import { IChatWidgetService } from '../chat.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName, isBuiltInAgentSessionProvider } from './agentSessions.js'; //#region Interfaces, Types @@ -142,8 +144,8 @@ export function isAgentSessionsModel(obj: unknown): obj is IAgentSessionsModel { } interface IAgentSessionState { - readonly archived: boolean; - readonly read: number /* last date turned read */; + readonly archived?: boolean; + readonly read?: number /* last date turned read */; } export const enum AgentSessionSection { @@ -392,6 +394,8 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode @ILifecycleService private readonly lifecycleService: ILifecycleService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, + @IProductService private readonly productService: IProductService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService ) { super(); @@ -413,8 +417,9 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode )); this.logger.logAllStatsIfTrace('Loaded cached sessions'); - this.registerListeners(); + this.readDateBaseline = this.resolveReadDateBaseline(); // we use this to account for bugfixes in the read/unread tracking + this.registerListeners(); } private registerListeners(): void { @@ -481,8 +486,6 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode } for (const session of providerSessions) { - - // Icon + Label let icon: ThemeIcon; let providerLabel: string; const agentSessionProvider = getAgentSessionProvider(chatSessionType); @@ -499,27 +502,6 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide timing information to track - // unread/read state for example. - // If somehow the provider does not provide any, fallback to last known - let { created, lastRequestStarted, lastRequestEnded } = session.timing; - if (!created || !lastRequestEnded) { - const existing = this._sessions.get(session.resource); - if (!created && existing?.timing.created) { - created = existing.timing.created; - } - - if (!lastRequestEnded && existing?.timing.lastRequestEnded) { - lastRequestEnded = existing.timing.lastRequestEnded; - } - - if (!lastRequestStarted && existing?.timing.lastRequestStarted) { - lastRequestStarted = existing.timing.lastRequestStarted; - } - } - - this.logger.logIfTrace(`Resolved session ${session.resource.toString()} with timings: created=${created}, lastRequestStarted=${lastRequestStarted}, lastRequestEnded=${lastRequestEnded}`); - sessions.set(session.resource, this.toAgentSession({ providerType: chatSessionType, providerLabel, @@ -531,15 +513,15 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status: session.status ?? AgentSessionStatus.Completed, archived: session.archived, - timing: { created, lastRequestStarted, lastRequestEnded, }, + timing: session.timing, changes: normalizedChanges, })); } } for (const [, session] of this._sessions) { - if (!resolvedProviders.has(session.providerType)) { - sessions.set(session.resource, session); // fill in existing sessions for providers that did not resolve + if (!resolvedProviders.has(session.providerType) && (isBuiltInAgentSessionProvider(session.providerType) || mapSessionContributionToType.has(session.providerType))) { + sessions.set(session.resource, session); // fill in existing sessions for providers that did not resolve if they are known or built-in } } @@ -563,10 +545,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region States - // In order to reduce the amount of sessions showing as unread, we maintain - // a certain cut off date that we consider good, given the issues we fixed - // around unread tracking. This is ~1 week before we ship 1.109 stable. - private static readonly READ_STATE_INITIAL_DATE = Date.UTC(2026, 0 /* January */, 28); + private static readonly UNREAD_MARKER = -1; private readonly sessionStates: ResourceMap; @@ -583,7 +562,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode return; // no change } - const state = this.sessionStates.get(session.resource) ?? { archived: false, read: 0 }; + const state = this.sessionStates.get(session.resource) ?? {}; this.sessionStates.set(session.resource, { ...state, archived }); const agentSession = this._sessions.get(session.resource); @@ -599,20 +578,72 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode return true; // archived sessions are always read } - const readDate = this.sessionStates.get(session.resource)?.read; - - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); - } - - private setRead(session: IInternalAgentSessionData, read: boolean): void { - if (read === this.isRead(session)) { - return; // no change + const storedReadDate = this.sessionStates.get(session.resource)?.read; + if (storedReadDate === AgentSessionsModel.UNREAD_MARKER) { + return false; } - const state = this.sessionStates.get(session.resource) ?? { archived: false, read: 0 }; - this.sessionStates.set(session.resource, { ...state, read: read ? Date.now() : 0 }); + const readDate = Math.max(storedReadDate ?? 0, this.readDateBaseline /* Use read date baseline when no read date is stored */); - this._onDidChangeSessions.fire(); + // Install a heuristic to reduce false positives: a user might observe + // the output of a session and quickly click on another session before + // it is finished. Strictly speaking the session is unread, but we + // allow a certain threshold of time to count as read to accommodate. + if (readDate >= this.sessionTimeForReadStateTracking(session) - 2000) { + return true; + } + + // Never consider a session as unread if its connected to a widget + return !!this.chatWidgetService.getWidgetBySessionResource(session.resource); + } + + private sessionTimeForReadStateTracking(session: IInternalAgentSessionData): number { + return session.timing.lastRequestEnded ?? session.timing.created; + } + + private setRead(session: IInternalAgentSessionData, read: boolean, skipEvent?: boolean): void { + const state = this.sessionStates.get(session.resource) ?? {}; + + let newRead: number; + if (read) { + newRead = Math.max(Date.now(), this.sessionTimeForReadStateTracking(session)); + + if (typeof state.read === 'number' && state.read >= newRead) { + return; // already read with a sufficient timestamp + } + } else { + newRead = AgentSessionsModel.UNREAD_MARKER; + if (state.read === AgentSessionsModel.UNREAD_MARKER) { + return; // already unread + } + } + + this.sessionStates.set(session.resource, { ...state, read: newRead }); + + if (!skipEvent) { + this._onDidChangeSessions.fire(); + } + } + + private static readonly READ_DATE_BASELINE_KEY = 'agentSessions.readDateBaseline2'; + + private readonly readDateBaseline: number; + + private resolveReadDateBaseline(): number { + let readDateBaseline = this.storageService.getNumber(AgentSessionsModel.READ_DATE_BASELINE_KEY, StorageScope.WORKSPACE, 0); + if (readDateBaseline > 0) { + return readDateBaseline; // already resolved + } + + // For stable, preserve unread state for sessions from the last 7 days + // For other qualities, mark all sessions as read + readDateBaseline = this.productService.quality === 'stable' + ? Date.now() - (7 * 24 * 60 * 60 * 1000) + : Date.now(); + + this.storageService.store(AgentSessionsModel.READ_DATE_BASELINE_KEY, readDateBaseline, StorageScope.WORKSPACE, StorageTarget.MACHINE); + + return readDateBaseline; } //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 97106a0be49..a207d53d3dd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -7,6 +7,7 @@ import './media/agentsessionsviewer.css'; import { h } from '../../../../../base/browser/dom.js'; import { localize } from '../../../../../nls.js'; import { IIdentityProvider, IListVirtualDelegate, NotSelectableGroupId, NotSelectableGroupIdType } from '../../../../../base/browser/ui/list/list.js'; +import { AriaRole } from '../../../../../base/browser/ui/aria/aria.js'; import { IListAccessibilityProvider } from '../../../../../base/browser/ui/list/listWidget.js'; import { ITreeCompressionDelegate } from '../../../../../base/browser/ui/tree/asyncDataTree.js'; import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; @@ -59,7 +60,6 @@ interface IAgentSessionItemTemplate { // Column 2 Row 2 readonly diffContainer: HTMLElement; - readonly diffFilesSpan: HTMLSpanElement; readonly diffAddedSpan: HTMLSpanElement; readonly diffRemovedSpan: HTMLSpanElement; @@ -79,10 +79,6 @@ export interface IAgentSessionRendererOptions { getHoverPosition(): HoverPosition; } -// TODO@bpasero figure out these defaults going forward -const SESSION_BADGE_ENABLED = false; -const SESSION_DIFF_FILES_INDICATOR = false; - export class AgentSessionRenderer extends Disposable implements ICompressibleTreeRenderer { static readonly TEMPLATE_ID = 'agent-session'; @@ -120,7 +116,6 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre h('div.agent-session-details-row', [ h('div.agent-session-diff-container@diffContainer', [ - h('span.agent-session-diff-files@filesSpan'), h('span.agent-session-diff-added@addedSpan'), h('span.agent-session-diff-removed@removedSpan') ]), @@ -149,7 +144,6 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre title: disposables.add(new IconLabel(elements.title, { supportHighlights: true, supportIcons: true })), titleToolbar, diffContainer: elements.diffContainer, - diffFilesSpan: elements.filesSpan, diffAddedSpan: elements.addedSpan, diffRemovedSpan: elements.removedSpan, badge: elements.badge, @@ -167,7 +161,6 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre // Clear old state template.elementDisposable.clear(); - template.diffFilesSpan.textContent = ''; template.diffAddedSpan.textContent = ''; template.diffRemovedSpan.textContent = ''; template.badge.textContent = ''; @@ -198,7 +191,6 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } } template.diffContainer.classList.toggle('has-diff', hasDiff); - template.diffFilesSpan.classList.toggle('has-diff-file-indicator', hasDiff && SESSION_DIFF_FILES_INDICATOR); let hasAgentSessionChanges = false; if ( @@ -235,10 +227,6 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } private renderBadge(session: ITreeNode, template: IAgentSessionItemTemplate): boolean { - if (!SESSION_BADGE_ENABLED) { - return false; - } - const badge = session.element.badge; if (badge) { this.renderMarkdownOrText(badge, template.badge, template.elementDisposable); @@ -269,10 +257,6 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre return false; } - if (diff.files > 0 && SESSION_DIFF_FILES_INDICATOR) { - template.diffFilesSpan.textContent = diff.files === 1 ? localize('diffFile', "1 file") : localize('diffFiles', "{0} files", diff.files); - } - if (diff.insertions >= 0 /* render even `0` for more homogeneity */) { template.diffAddedSpan.textContent = `+${diff.insertions}`; } @@ -537,6 +521,14 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate { + getWidgetRole(): AriaRole { + return 'list'; + } + + getRole(element: AgentSessionListItem): AriaRole | undefined { + return 'listitem'; + } + getWidgetAriaLabel(): string { return localize('agentSessions', "Agent Sessions"); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index 165804cf11e..a5e80a2aa79 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -101,7 +101,8 @@ export class ToggleAgentStatusAction extends ToggleTitleBarConfigAction { ContextKeyExpr.and( ChatContextKeys.enabled, IsCompactTitleBarContext.negate(), - ChatContextKeys.supported + ChatContextKeys.supported, + ContextKeyExpr.has('config.window.commandCenter') ) ); } @@ -120,7 +121,8 @@ export class ToggleUnifiedAgentsBarAction extends ToggleTitleBarConfigAction { ContextKeyExpr.and( ChatContextKeys.enabled, IsCompactTitleBarContext.negate(), - ChatContextKeys.supported + ChatContextKeys.supported, + ContextKeyExpr.has('config.window.commandCenter'), ) ); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 0de17dcf02b..65ea313a09b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -336,7 +336,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Active sessions include both InProgress and NeedsInput const activeSessions = filteredSessions.filter(s => isSessionInProgressStatus(s.status) && !s.isArchived()); - const unreadSessions = filteredSessions.filter(s => !s.isRead() && !this.chatWidgetService.getWidgetBySessionResource(s.resource)); + const unreadSessions = filteredSessions.filter(s => !s.isRead()); // Sessions that need user input/attention (subset of active) const attentionNeededSessions = filteredSessions.filter(s => s.status === AgentSessionStatus.NeedsInput && !this.chatWidgetService.getWidgetBySessionResource(s.resource)); @@ -1244,20 +1244,16 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben // Add/remove CSS classes on workbench based on settings // Force enable command center and disable chat controls when agent status or unified agents bar is enabled const updateClass = () => { - const enabled = configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; - const enhanced = configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + const commandCenterEnabled = configurationService.getValue(LayoutSettings.COMMAND_CENTER) === true; + const enabled = configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true && commandCenterEnabled; + const enhanced = configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true && commandCenterEnabled; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); mainWindow.document.body.classList.toggle('unified-agents-bar', enhanced); - - // Force enable command center when agent status or unified agents bar is enabled - if ((enabled || enhanced) && configurationService.getValue(LayoutSettings.COMMAND_CENTER) !== true) { - configurationService.updateValue(LayoutSettings.COMMAND_CENTER, true); - } }; updateClass(); this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar)) { + if (e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER)) { updateClass(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 2d5ce428880..a4a142f69e7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -26,7 +26,6 @@ background-color: unset; outline: 1px solid var(--vscode-agentSessionSelectedBadge-border); - .agent-session-diff-files, .agent-session-diff-added, .agent-session-diff-removed { color: unset; @@ -167,14 +166,6 @@ display: none; } - .agent-session-diff-files { - color: var(--vscode-descriptionForeground); - - &:not(.has-diff-file-indicator) { - display: none; - } - } - .agent-session-diff-added { color: var(--vscode-chat-linesAddedForeground); } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts index f62b55b32c2..8ef8c035627 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts @@ -397,6 +397,7 @@ class SimpleBrowserOverlayController { container.appendChild(connectingWebviewElement); } + cts.cancel(); cts = new CancellationTokenSource(); try { await this._browserElementsService.startDebugSession(cts.token, locator); @@ -405,6 +406,10 @@ class SimpleBrowserOverlayController { return; } + if (cts.token.isCancellationRequested) { + return; + } + if (!container.contains(this._domNode)) { container.appendChild(this._domNode); } @@ -413,8 +418,8 @@ class SimpleBrowserOverlayController { const hide = () => { widget.setActiveLocator(undefined, undefined); + cts.cancel(); if (container.contains(this._domNode)) { - cts.cancel(); this._domNode.remove(); } connectingWebviewElement.remove(); @@ -473,7 +478,7 @@ export class SimpleBrowserOverlay implements IWorkbenchContribution { () => editorGroupsService.groups ); - const overlayWidgets = new DisposableMap(); + const overlayWidgets = this._store.add(new DisposableMap()); this._store.add(autorun(r => { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ae0d92d901a..d211a1d0929 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -320,7 +320,7 @@ configurationRegistry.registerConfiguration({ name: 'ChatToolsAutoApprove', category: PolicyCategory.InteractiveSession, minimumVersion: '1.99', - value: (account) => account.policyData?.chat_preview_features_enabled === false ? false : undefined, + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, localization: { description: { key: 'autoApprove2.description', @@ -435,10 +435,10 @@ configurationRegistry.registerConfiguration({ default: 'sideBySide', description: nls.localize('chat.viewSessions.orientation', "Controls the orientation of the chat agent sessions view when it is shown alongside the chat."), }, - [ChatConfiguration.ChatViewTitleEnabled]: { + [ChatConfiguration.ChatViewProgressBadgeEnabled]: { type: 'boolean', - default: true, - description: nls.localize('chat.viewTitle.enabled', "Show the title of the chat above the chat in the chat view."), + default: false, + description: nls.localize('chat.viewProgressBadge.enabled', "Show a progress badge on the chat view when an agent session is in progress that is opened in that view."), }, [ChatConfiguration.NotifyWindowOnResponseReceived]: { type: 'boolean', @@ -473,11 +473,11 @@ configurationRegistry.registerConfiguration({ name: 'ChatMCP', category: PolicyCategory.InteractiveSession, minimumVersion: '1.99', - value: (account) => { - if (account.policyData?.mcp === false) { + value: (policyData) => { + if (policyData.mcp === false) { return McpAccessValue.None; } - if (account.policyData?.mcpAccess === 'registry_only') { + if (policyData.mcpAccess === 'registry_only') { return McpAccessValue.Registry; } return undefined; @@ -588,7 +588,7 @@ configurationRegistry.registerConfiguration({ name: 'ChatAgentMode', category: PolicyCategory.InteractiveSession, minimumVersion: '1.99', - value: (account) => account.policyData?.chat_agent_enabled === false ? false : undefined, + value: (policyData) => policyData.chat_agent_enabled === false ? false : undefined, localization: { description: { key: 'chat.agent.enabled.description', @@ -609,7 +609,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.AlternativeToolAction]: { type: 'boolean', description: nls.localize('chat.alternativeToolAction', "When enabled, shows the Configure Tools action in the mode picker dropdown on hover instead of in the chat input."), - default: true, + default: false, tags: ['experimental'], experiment: { mode: 'auto' @@ -665,7 +665,7 @@ configurationRegistry.registerConfiguration({ name: 'McpGalleryServiceUrl', category: PolicyCategory.InteractiveSession, minimumVersion: '1.101', - value: (account) => account.policyData?.mcpRegistryUrl, + value: (policyData) => policyData.mcpRegistryUrl, localization: { description: { key: 'mcp.gallery.serviceUrl', diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts index 9e4edd985d9..cbaa0bc4753 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorOverlay.ts @@ -402,7 +402,7 @@ export class ChatEditingEditorOverlay implements IWorkbenchContribution { () => editorGroupsService.groups ); - const overlayWidgets = new DisposableMap(); + const overlayWidgets = this._store.add(new DisposableMap()); this._store.add(autorun(r => { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 7fafe8ad4ed..7f217c9be8e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -114,19 +114,40 @@ class ModelsFilterAction extends Action { } } -function toggleFilter(currentQuery: string, query: string, alternativeQueries: string[] = []): string { - const allQueries = [query, ...alternativeQueries]; - const isChecked = allQueries.some(q => currentQuery.includes(q)); +interface IFilterQuery { + /** The primary filter query string */ + query: string; + /** Alternative query strings that are treated as synonyms of the primary query */ + synonyms?: string[]; + /** Query strings that should be removed when adding this filter (mutually exclusive filters) */ + excludes?: string[]; +} - if (!isChecked) { - const trimmedQuery = currentQuery.trim(); - return trimmedQuery ? `${trimmedQuery} ${query}` : query; - } else { +function toggleFilter(currentQuery: string, filter: IFilterQuery): string { + const { query, synonyms = [], excludes = [] } = filter; + const allSynonyms = [query, ...synonyms]; + const isChecked = allSynonyms.some(q => currentQuery.includes(q)); + const hasExcludedQuery = excludes.some(q => currentQuery.includes(q)); + + if (isChecked) { + // Query or synonym is already set, remove all of them (toggle off) let queryWithRemovedFilter = currentQuery; - for (const q of allQueries) { + for (const q of allSynonyms) { queryWithRemovedFilter = queryWithRemovedFilter.replace(q, ''); } return queryWithRemovedFilter.replace(/\s+/g, ' ').trim(); + } else if (hasExcludedQuery) { + // An excluded query is set, replace it with the new query + let newQuery = currentQuery; + for (const q of excludes) { + newQuery = newQuery.replace(q, ''); + } + newQuery = newQuery.replace(/\s+/g, ' ').trim(); + return newQuery ? `${newQuery} ${query}` : query; + } else { + // No filter is set, add the new query + const trimmedQuery = currentQuery.trim(); + return trimmedQuery ? `${trimmedQuery} ${query}` : query; } } @@ -180,7 +201,7 @@ class ModelsSearchFilterDropdownMenuActionViewItem extends DropdownMenuActionVie class: undefined, enabled: true, checked: isChecked, - run: () => this.toggleFilterAndSearch(query, [`@provider:${vendor}`]) + run: () => this.toggleFilterAndSearch({ query, synonyms: [`@provider:${vendor}`] }) }; } @@ -196,13 +217,12 @@ class ModelsSearchFilterDropdownMenuActionViewItem extends DropdownMenuActionVie class: undefined, enabled: true, checked: isChecked, - run: () => this.toggleFilterAndSearch(query) + run: () => this.toggleFilterAndSearch({ query }) }; } private createVisibleAction(visible: boolean, label: string): IAction { const query = `@visible:${visible}`; - const oppositeQuery = `@visible:${!visible}`; const currentQuery = this.search.getValue(); const isChecked = currentQuery.includes(query); @@ -213,31 +233,31 @@ class ModelsSearchFilterDropdownMenuActionViewItem extends DropdownMenuActionVie class: undefined, enabled: true, checked: isChecked, - run: () => this.toggleFilterAndSearch(query, [oppositeQuery]) + run: () => this.toggleFilterAndSearch({ query, excludes: [`@visible:${!visible}`] }) }; } - private toggleFilterAndSearch(query: string, alternativeQueries: string[] = []): void { + private toggleFilterAndSearch(filter: IFilterQuery): void { const currentQuery = this.search.getValue(); - const newQuery = toggleFilter(currentQuery, query, alternativeQueries); + const newQuery = toggleFilter(currentQuery, filter); this.search.setValue(newQuery); } private getActions(): IAction[] { const actions: IAction[] = []; - // Visibility filters - actions.push(this.createVisibleAction(true, localize('filter.visible', 'Visible'))); - actions.push(this.createVisibleAction(false, localize('filter.hidden', 'Hidden'))); - // Capability filters - actions.push(new Separator()); actions.push( - this.createCapabilityAction('tools', localize('capability.tools', 'Tools')), - this.createCapabilityAction('vision', localize('capability.vision', 'Vision')), - this.createCapabilityAction('agent', localize('capability.agent', 'Agent Mode')) + this.createCapabilityAction('tools', localize('capability.tools', "Tools")), + this.createCapabilityAction('vision', localize('capability.vision', "Vision")), + this.createCapabilityAction('agent', localize('capability.agent', "Agent Mode")) ); + // Visibility filters + actions.push(new Separator()); + actions.push(this.createVisibleAction(true, localize('filter.visible', "Visible in Chat Model Picker"))); + actions.push(this.createVisibleAction(false, localize('filter.hidden', "Hidden in Chat Model Picker"))); + // Provider filters - only show providers with configured models const configuredVendors = this.viewModel.getConfiguredVendors(); if (configuredVendors.length > 1) { @@ -248,8 +268,8 @@ class ModelsSearchFilterDropdownMenuActionViewItem extends DropdownMenuActionVie // Group By actions.push(new Separator()); const groupByActions: IAction[] = []; - groupByActions.push(this.createGroupByAction(ChatModelGroup.Vendor, localize('groupBy.provider', 'Provider'))); - groupByActions.push(this.createGroupByAction(ChatModelGroup.Visibility, localize('groupBy.visibility', 'Visibility'))); + groupByActions.push(this.createGroupByAction(ChatModelGroup.Vendor, localize('groupBy.provider', "Provider"))); + groupByActions.push(this.createGroupByAction(ChatModelGroup.Visibility, localize('groupBy.visibility', "Visibility (Chat Model Picker)"))); actions.push(new SubmenuAction('groupBy', localize('groupBy', "Group By"), groupByActions)); return actions; @@ -1013,7 +1033,7 @@ export class ChatModelsWidget extends Disposable { this.tableDisposables.add(capabilitiesColumnRenderer.onDidClickCapability(capability => { const currentQuery = this.searchWidget.getValue(); const query = `@capability:${capability}`; - const newQuery = toggleFilter(currentQuery, query); + const newQuery = toggleFilter(currentQuery, { query }); this.search(newQuery); })); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index 308b543253d..8e2fa8220f5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -48,9 +48,6 @@ import { ChatSetupController } from './chatSetupController.js'; import { ChatSetupAnonymous, ChatSetupStep, IChatSetupResult } from './chatSetup.js'; import { ChatSetup } from './chatSetupRunner.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; -import { IOutputService } from '../../../../services/output/common/output.js'; -import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; -import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; @@ -175,7 +172,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { private static readonly TRUST_NEEDED_MESSAGE = new MarkdownString(localize('trustNeeded', "You need to trust this workspace to use Chat.")); private static readonly CHAT_RETRY_COMMAND_ID = 'workbench.action.chat.retrySetup'; - private static readonly CHAT_REPORT_ISSUE_WITH_OUTPUT_COMMAND_ID = 'workbench.action.chat.reportIssueWithOutput'; private readonly _onUnresolvableError = this._register(new Emitter()); readonly onUnresolvableError = this._onUnresolvableError.event; @@ -200,50 +196,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { private registerCommands(): void { - // Report issue with output command - this._register(CommandsRegistry.registerCommand(SetupAgent.CHAT_REPORT_ISSUE_WITH_OUTPUT_COMMAND_ID, async accessor => { - const outputService = accessor.get(IOutputService); - const textModelService = accessor.get(ITextModelService); - const issueService = accessor.get(IWorkbenchIssueService); - const logService = accessor.get(ILogService); - - let outputData = ''; - let channelName = ''; - - let channel = outputService.getChannel(defaultChat.outputChannelId); - if (channel) { - channelName = defaultChat.outputChannelId; - } else { - logService.warn(`[chat setup] Output channel '${defaultChat.outputChannelId}' not found, falling back to Window output channel`); - channel = outputService.getChannel('rendererLog'); - channelName = 'Window'; - } - - if (channel) { - try { - const model = await textModelService.createModelReference(channel.uri); - try { - const rawOutput = model.object.textEditorModel.getValue(); - outputData = `
\nGitHub Copilot Chat Output (${channelName})\n\n\`\`\`\n${rawOutput}\n\`\`\`\n
`; - logService.info(`[chat setup] Retrieved ${rawOutput.length} characters from ${channelName} output channel`); - } finally { - model.dispose(); - } - } catch (error) { - logService.error(`[chat setup] Failed to retrieve output channel content: ${error}`); - } - } else { - logService.warn(`[chat setup] No output channel available`); - } - - await issueService.openReporter({ - extensionId: defaultChat.chatExtensionId, - issueTitle: 'Chat took too long to get ready', - issueBody: 'Chat took too long to get ready', - data: outputData || localize('chatOutputChannelUnavailable', "GitHub Copilot Chat output channel not available. Please ensure the GitHub Copilot Chat extension is active and try again. If the issue persists, you can manually include relevant information from the Output panel (View > Output > GitHub Copilot Chat).") - }); - })); - // Retry chat command this._register(CommandsRegistry.registerCommand(SetupAgent.CHAT_RETRY_COMMAND_ID, async (accessor, sessionResource: URI) => { const hostService = accessor.get(IHostService); @@ -430,11 +382,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { id: SetupAgent.CHAT_RETRY_COMMAND_ID, title: localize('retryChat', "Restart"), arguments: [requestModel.session.sessionResource] - }, - additionalCommands: [{ - id: SetupAgent.CHAT_REPORT_ISSUE_WITH_OUTPUT_COMMAND_ID, - title: localize('reportChatIssue', "Report Issue"), - }] + } }); // This means Chat is unhealthy and we cannot retry the diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatus.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatus.ts index ef3e5989e8c..ad964b4534d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatus.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatus.ts @@ -4,24 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import product from '../../../../../platform/product/common/product.js'; -import { isObject } from '../../../../../base/common/types.js'; export function isNewUser(chatEntitlementService: IChatEntitlementService): boolean { return !chatEntitlementService.sentiment.installed || // chat not installed chatEntitlementService.entitlement === ChatEntitlement.Available; // not yet signed up to chat } - -export function isCompletionsEnabled(configurationService: IConfigurationService, modeId: string = '*'): boolean { - const result = configurationService.getValue>(product.defaultChatAgent.completionsEnablementSetting); - if (!isObject(result)) { - return false; - } - - if (typeof result[modeId] !== 'undefined') { - return Boolean(result[modeId]); // go with setting if explicitly defined - } - - return Boolean(result['*']); // fallback to global setting otherwise -} diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index 7d94ce384e4..7ddbb06cc8c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -40,13 +40,14 @@ import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/edi import { IChatEntitlementService, ChatEntitlementService, ChatEntitlement, IQuotaSnapshot, getChatPlanName } from '../../../../services/chat/common/chatEntitlementService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; -import { isNewUser, isCompletionsEnabled } from './chatStatus.js'; +import { isNewUser } from './chatStatus.js'; import { IChatStatusItemService, ChatStatusEntry } from './chatStatusItemService.js'; import product from '../../../../../platform/product/common/product.js'; import { contrastBorder, inputValidationErrorBorder, inputValidationInfoBorder, inputValidationWarningBorder, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { Color } from '../../../../../base/common/color.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; +import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; const defaultChat = product.defaultChatAgent; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index f465335f45e..0e1160d71e4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -19,8 +19,9 @@ import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatStatusDashboard } from './chatStatusDashboard.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { disposableWindowInterval } from '../../../../../base/browser/dom.js'; -import { isNewUser, isCompletionsEnabled } from './chatStatus.js'; +import { isNewUser } from './chatStatus.js'; import product from '../../../../../platform/product/common/product.js'; +import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 71f6f880de3..3b1f279ef88 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -24,9 +24,8 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; -import { IChatModeService } from '../../common/chatModes.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; class AbstractNewPromptFileAction extends Action2 { @@ -57,7 +56,6 @@ class AbstractNewPromptFileAction extends Action2 { const editorService = accessor.get(IEditorService); const fileService = accessor.get(IFileService); const instaService = accessor.get(IInstantiationService); - const chatModeService = accessor.get(IChatModeService); const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, this.type); if (!selectedFolder) { @@ -68,7 +66,6 @@ class AbstractNewPromptFileAction extends Action2 { if (!fileName) { return; } - // create the prompt file await fileService.createFolder(selectedFolder.uri); @@ -78,11 +75,13 @@ class AbstractNewPromptFileAction extends Action2 { await openerService.open(promptUri); + const cleanName = getCleanPromptName(promptUri); + const editor = getCodeEditor(editorService.activeTextEditorControl); if (editor && editor.hasModel() && isEqual(editor.getModel().uri, promptUri)) { SnippetController2.get(editor)?.apply([{ range: editor.getModel().getFullModelRange(), - template: getDefaultContentSnippet(this.type, chatModeService), + template: getDefaultContentSnippet(this.type, cleanName), }]); } @@ -140,51 +139,47 @@ class AbstractNewPromptFileAction extends Action2 { } } -function getDefaultContentSnippet(promptType: PromptsType, chatModeService: IChatModeService): string { - const agents = chatModeService.getModes(); - const agentNames = agents.builtin.map(agent => agent.name.get()).join(',') + (agents.custom.length ? (',' + agents.custom.map(agent => agent.name.get()).join(',')) : ''); +function getDefaultContentSnippet(promptType: PromptsType, name: string | undefined): string { switch (promptType) { case PromptsType.prompt: return [ `---`, - `agent: \${1|${agentNames}|}`, + `name: ${name ?? '${1:prompt-name}'}`, + `description: \${2:Describe when to use this prompt}`, `---`, - `\${2:Define the task to achieve, including specific requirements, constraints, and success criteria.}`, + `\${3:Define the prompt content here. You can include instructions, examples, and any other relevant information to guide the AI's responses.}`, ].join('\n'); case PromptsType.instructions: return [ `---`, - `applyTo: '\${1|**,**/*.ts|}'`, + `description: \${1:Describe when these instructions should be loaded}`, + `# applyTo: '\${1|**,**/*.ts|}' # when provided, instructions will automatically be added to the request context when the pattern matches an attached file`, `---`, `\${2:Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, ].join('\n'); case PromptsType.agent: return [ `---`, - `description: '\${1:Describe what this custom agent does and when to use it.}'`, - `tools: []`, + `name: ${name ?? '${1:agent-name}'}`, + `description: \${2:Describe what this custom agent does and when to use it.}`, + `argument-hint: \${3:The inputs this agent expects, e.g., "a task to implement" or "a question to answer".}`, + `# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.`, `---`, - `\${2:Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help.}`, + `\${4:Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.}`, + ].join('\n'); + case PromptsType.skill: + return [ + `---`, + `name: ${name ?? '${1:skill-name}'}`, + `description: \${2:Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.}`, + `---`, + `\${3:Define the functionality provided by this skill, including detailed instructions and examples}`, ].join('\n'); default: throw new Error(`Unsupported prompt type: ${promptType}`); } } -/** - * Generates the content snippet for a skill file with the name pre-populated. - * Per agentskills.io/specification, the name field must match the parent directory name. - */ -function getSkillContentSnippet(skillName: string): string { - return [ - `---`, - `name: ${skillName}`, - `description: '\${1:Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.}'`, - `---`, - ``, - `\${2:Provide detailed instructions for the agent. Include step-by-step guidance, examples, and edge cases.}`, - ].join('\n'); -} export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt'; @@ -287,7 +282,7 @@ class NewSkillFileAction extends Action2 { if (editor && editor.hasModel() && isEqual(editor.getModel().uri, skillFileUri)) { SnippetController2.get(editor)?.apply([{ range: editor.getModel().getFullModelRange(), - template: getSkillContentSnippet(trimmedName), + template: getDefaultContentSnippet(PromptsType.skill, trimmedName), }]); } } @@ -309,7 +304,6 @@ class NewUntitledPromptFileAction extends Action2 { public override async run(accessor: ServicesAccessor) { const editorService = accessor.get(IEditorService); - const chatModeService = accessor.get(IChatModeService); const languageId = getLanguageIdForPromptsType(PromptsType.prompt); @@ -326,7 +320,7 @@ class NewUntitledPromptFileAction extends Action2 { if (editor && editor.hasModel()) { SnippetController2.get(editor)?.apply([{ range: editor.getModel().getFullModelRange(), - template: getDefaultContentSnippet(type, chatModeService), + template: getDefaultContentSnippet(type, undefined), }]); } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 606aac556c7..6717a86baff 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -42,7 +42,7 @@ import { ChatConfiguration } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel } from '../../common/tools/languageModelToolsService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel, IToolInvokedEvent } from '../../common/tools/languageModelToolsService.js'; import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; import { URI } from '../../../../../base/common/uri.js'; import { chatSessionResourceToId } from '../../common/model/chatUri.js'; @@ -88,6 +88,8 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo readonly onDidChangeTools = this._onDidChangeTools.event; private readonly _onDidPrepareToolCallBecomeUnresponsive = this._register(new Emitter<{ sessionResource: URI; toolData: IToolData }>()); readonly onDidPrepareToolCallBecomeUnresponsive = this._onDidPrepareToolCallBecomeUnresponsive.event; + private readonly _onDidInvokeTool = this._register(new Emitter()); + readonly onDidInvokeTool = this._onDidInvokeTool.event; /** Throttle tools updates because it sends all tools and runs on context key updates */ private readonly _onDidChangeToolsScheduler = new RunOnceScheduler(() => this._onDidChangeTools.fire(), 750); @@ -357,6 +359,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); + // Fire the event to notify listeners that a tool is being invoked + this._onDidInvokeTool.fire({ + toolId: dto.toolId, + sessionResource: dto.context?.sessionResource, + requestId: dto.chatRequestId, + subagentInvocationId: dto.subAgentInvocationId, + }); + // When invoking a tool, don't validate the "when" clause. An extension may have invoked a tool just as it was becoming disabled, and just let it go through rather than throw and break the chat. let tool = this._tools.get(dto.toolId); if (!tool) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 7ccd4dc7bcc..90ab82a4b99 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -13,9 +13,8 @@ import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; -import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js'; import { IChatQuestion, IChatQuestionCarousel } from '../../../common/chatService/chatService.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatQueryTitlePart } from './chatConfirmationWidget.js'; @@ -47,7 +46,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _isSkipped = false; - private readonly _textInputBoxes: Map = new Map(); + private readonly _textInputTextareas: Map = new Map(); private readonly _radioInputs: Map = new Map(); private readonly _checkboxInputs: Map = new Map(); private readonly _freeformTextareas: Map = new Map(); @@ -233,7 +232,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Dispose interactive UI disposables (header, nav buttons, etc.) this._interactiveUIStore.clear(); this._inputBoxes.clear(); - this._textInputBoxes.clear(); + this._textInputTextareas.clear(); this._radioInputs.clear(); this._checkboxInputs.clear(); this._freeformTextareas.clear(); @@ -321,10 +320,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent : undefined; const selectedValue = defaultOption?.value; - if (question.allowFreeformInput) { - return selectedValue !== undefined ? { selectedValue, freeformValue: undefined } : undefined; - } - return selectedValue; + // Note: Freeform input is always shown regardless of the `allowFreeformInput` API property. + // The property is kept for backwards compatibility but is no longer used. + return selectedValue !== undefined ? { selectedValue, freeformValue: undefined } : undefined; } case 'multiSelect': { @@ -336,10 +334,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent .map(opt => opt.value) .filter(v => v !== undefined) ?? []; - if (question.allowFreeformInput) { - return selectedValues.length > 0 ? { selectedValues, freeformValue: undefined } : undefined; - } - return selectedValues; + // Note: Freeform input is always shown regardless of the `allowFreeformInput` API property. + // The property is kept for backwards compatibility but is no longer used. + return selectedValues.length > 0 ? { selectedValues, freeformValue: undefined } : undefined; } default: @@ -354,7 +351,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Clear previous input boxes and stale references this._inputBoxes.clear(); - this._textInputBoxes.clear(); + this._textInputTextareas.clear(); this._radioInputs.clear(); this._checkboxInputs.clear(); this._freeformTextareas.clear(); @@ -425,24 +422,55 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + /** + * Sets up auto-resize behavior for a textarea element. + * @returns A function that triggers the resize manually (useful for initial sizing). + */ + private setupTextareaAutoResize(textarea: HTMLTextAreaElement): () => void { + const autoResize = () => { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + this._onDidChangeHeight.fire(); + }; + this._inputBoxes.add(dom.addDisposableListener(textarea, dom.EventType.INPUT, autoResize)); + return autoResize; + } + private renderTextInput(container: HTMLElement, question: IChatQuestion): void { - const inputBox = this._inputBoxes.add(new InputBox(container, undefined, { - placeholder: localize('chat.questionCarousel.enterText', 'Enter your answer'), - inputBoxStyles: defaultInputBoxStyles, - })); + const textarea = dom.$('textarea.chat-question-text-textarea'); + textarea.placeholder = localize('chat.questionCarousel.enterText', 'Enter your answer'); + textarea.rows = 1; + textarea.setAttribute('aria-label', question.title); // Restore previous answer if exists const previousAnswer = this._answers.get(question.id); if (previousAnswer !== undefined) { - inputBox.value = String(previousAnswer); + textarea.value = String(previousAnswer); } else if (question.defaultValue !== undefined) { - inputBox.value = String(question.defaultValue); + textarea.value = String(question.defaultValue); } - this._textInputBoxes.set(question.id, inputBox); + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(textarea); + + // Handle Enter to submit (Shift+Enter for newline) + this._inputBoxes.add(dom.addDisposableListener(textarea, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter && !event.shiftKey && textarea.value.trim()) { + e.preventDefault(); + e.stopPropagation(); + this.handleNext(); + } + })); + + container.appendChild(textarea); + this._textInputTextareas.set(question.id, textarea); // Focus on input when rendered using proper DOM scheduling - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(inputBox.element), () => inputBox.focus())); + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(textarea), () => { + textarea.focus(); + autoResize(); + })); } private renderSingleSelect(container: HTMLElement, question: IChatQuestion): void { @@ -494,8 +522,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._radioInputs.set(question.id, radioInputs); - // Add freeform input if allowed - if (question.allowFreeformInput) { + // Note: Freeform input is always shown regardless of the `allowFreeformInput` API property. + // The property is kept for backwards compatibility but is no longer used. + { const freeformContainer = dom.$('.chat-question-freeform'); const freeformLabelId = `freeform-label-${question.id}`; const freeformLabel = dom.$('.chat-question-freeform-label'); @@ -512,9 +541,35 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent freeformTextarea.value = previousFreeform; } + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter && !event.shiftKey && freeformTextarea.value.trim()) { + e.preventDefault(); + e.stopPropagation(); + this.handleNext(); + } + })); + + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); + + // uncheck radio when there is text + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => { + if (freeformTextarea.value.trim()) { + for (const radio of radioInputs) { + radio.checked = false; + } + } + })); + freeformContainer.appendChild(freeformTextarea); container.appendChild(freeformContainer); this._freeformTextareas.set(question.id, freeformTextarea); + + // Resize textarea if it has restored content + if (previousFreeform !== undefined) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); + } } } @@ -568,8 +623,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._checkboxInputs.set(question.id, checkboxInputs); - // Add freeform input if allowed - if (question.allowFreeformInput) { + // Note: Freeform input is always shown regardless of the `allowFreeformInput` API property. + // The property is kept for backwards compatibility but is no longer used. + { const freeformContainer = dom.$('.chat-question-freeform'); const freeformLabelId = `freeform-label-${question.id}`; const freeformLabel = dom.$('.chat-question-freeform-label'); @@ -586,9 +642,28 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent freeformTextarea.value = previousFreeform; } + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter && !event.shiftKey && freeformTextarea.value.trim()) { + e.preventDefault(); + e.stopPropagation(); + this.handleNext(); + } + })); + + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); + + // For multiSelect, both checkboxes and freeform input are combined, so don't uncheck on input + freeformContainer.appendChild(freeformTextarea); container.appendChild(freeformContainer); this._freeformTextareas.set(question.id, freeformTextarea); + + // Resize textarea if it has restored content + if (previousFreeform !== undefined) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); + } } } @@ -600,8 +675,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent switch (question.type) { case 'text': { - const inputBox = this._textInputBoxes.get(question.id); - return inputBox?.value ?? question.defaultValue; + const textarea = this._textInputTextareas.get(question.id); + return textarea?.value ?? question.defaultValue; } case 'singleSelect': { @@ -622,17 +697,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent selectedValue = defaultOption?.value; } - // Include freeform value if allowed - if (question.allowFreeformInput) { - const freeformTextarea = this._freeformTextareas.get(question.id); - const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; - if (freeformValue || selectedValue !== undefined) { - return { selectedValue, freeformValue }; - } - return undefined; + // Note: Freeform input is always shown regardless of the `allowFreeformInput` API property. + // The property is kept for backwards compatibility but is no longer used. + // For singleSelect, if freeform value is provided, use only that (ignore selected value). + const freeformTextarea = this._freeformTextareas.get(question.id); + const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; + if (freeformValue || selectedValue !== undefined) { + // if there is text in freeform, don't include selected + return { selectedValue: freeformValue ? undefined : selectedValue, freeformValue }; } - - return selectedValue; + return undefined; } case 'multiSelect': { @@ -661,17 +735,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent finalSelectedValues = defaultValues?.filter(v => v !== undefined) || []; } - // Include freeform value if allowed - if (question.allowFreeformInput) { - const freeformTextarea = this._freeformTextareas.get(question.id); - const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; - if (freeformValue || finalSelectedValues.length > 0) { - return { selectedValues: finalSelectedValues, freeformValue }; - } - return undefined; + // Note: Freeform input is always shown regardless of the `allowFreeformInput` API property. + // The property is kept for backwards compatibility but is no longer used. + // For multiSelect, include both selected values and freeform input together. + const freeformTextarea = this._freeformTextareas.get(question.id); + const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; + if (freeformValue || finalSelectedValues.length > 0) { + return { selectedValues: finalSelectedValues, freeformValue }; } - - return finalSelectedValues; + return undefined; } default: diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts index c24470548f7..c9e3d0c4a96 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts @@ -104,6 +104,14 @@ export class ChatSuggestNextWidget extends Disposable { private createPromptButton(handoff: IHandOff): HTMLElement { const disposables = new DisposableStore(); + // Capture the label to look up the current handoff at click time + // This ensures we get the latest handoff data (e.g., updated model from settings) + const handoffLabel = handoff.label; + const getCurrentHandoff = (): IHandOff | undefined => { + const currentHandoffs = this._currentMode?.handOffs?.get(); + return currentHandoffs?.find(h => h.label === handoffLabel) ?? handoff; + }; + const button = dom.$('.chat-welcome-view-suggested-prompt'); button.setAttribute('tabindex', '0'); button.setAttribute('role', 'button'); @@ -155,7 +163,10 @@ export class ChatSuggestNextWidget extends Disposable { ThemeIcon.isThemeIcon(icon) ? ThemeIcon.asClassName(icon) : undefined, true, () => { - this._onDidSelectPrompt.fire({ handoff, agentId: contrib.name }); + const currentHandoff = getCurrentHandoff(); + if (currentHandoff) { + this._onDidSelectPrompt.fire({ handoff: currentHandoff, agentId: contrib.name }); + } } ); }); @@ -180,18 +191,27 @@ export class ChatSuggestNextWidget extends Disposable { if (dom.isHTMLElement(e.target) && e.target.closest('.chat-suggest-next-dropdown')) { return; } - this._onDidSelectPrompt.fire({ handoff }); + const currentHandoff = getCurrentHandoff(); + if (currentHandoff) { + this._onDidSelectPrompt.fire({ handoff: currentHandoff }); + } })); } else { disposables.add(dom.addDisposableListener(button, 'click', () => { - this._onDidSelectPrompt.fire({ handoff }); + const currentHandoff = getCurrentHandoff(); + if (currentHandoff) { + this._onDidSelectPrompt.fire({ handoff: currentHandoff }); + } })); } disposables.add(dom.addDisposableListener(button, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - this._onDidSelectPrompt.fire({ handoff }); + const currentHandoff = getCurrentHandoff(); + if (currentHandoff) { + this._onDidSelectPrompt.fire({ handoff: currentHandoff }); + } } })); 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 89a4d81bbf0..0d29e172d50 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -27,7 +27,7 @@ import { Lazy } from '../../../../../../base/common/lazy.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { DisposableMap, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; @@ -189,6 +189,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } this.currentThinkingValue = initialText; + if (initialText.trim()) { + this.appendedItemCount++; + } + // Alert screen reader users that thinking has started alert(localize('chat.thinking.started', 'Thinking')); @@ -618,6 +622,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } private async generateTitleViaLLM(): Promise { + const cts = new CancellationTokenSource(); + const timeout = setTimeout(() => cts.cancel(), 5000); + try { let models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); if (!models.length) { @@ -628,6 +635,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } + if (cts.token.isCancellationRequested) { + this.setFallbackTitle(); + return; + } + let context: string; if (this.extractedTitles.length > 0) { context = this.extractedTitles.join(', '); @@ -720,11 +732,14 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen new ExtensionIdentifier('core'), [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], {}, - CancellationToken.None + cts.token ); let generatedTitle = ''; for await (const part of response.stream) { + if (cts.token.isCancellationRequested) { + break; + } if (Array.isArray(part)) { for (const p of part) { if (p.type === 'text') { @@ -736,6 +751,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } + if (cts.token.isCancellationRequested) { + this.setFallbackTitle(); + return; + } + await response.result; generatedTitle = generatedTitle.trim(); @@ -755,6 +775,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } catch (error) { // fall through to default title + } finally { + clearTimeout(timeout); + cts.dispose(); } this.setFallbackTitle(); @@ -784,8 +807,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } private setFallbackTitle(): void { - const finalLabel = this.toolInvocationCount > 0 - ? localize('chat.thinking.finished.withTools', 'Finished working and invoked {0} tool{1}', this.toolInvocationCount, this.toolInvocationCount === 1 ? '' : 's') + const finalLabel = this.appendedItemCount > 0 + ? localize('chat.thinking.finished.withSteps', 'Finished with {0} step{1}', this.appendedItemCount, this.appendedItemCount === 1 ? '' : 's') : localize('chat.thinking.finished', 'Finished Working'); this.currentTitle = finalLabel; @@ -1150,6 +1173,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this._store.isDisposed) { return; } + this.appendedItemCount++; this.textContainer = $('.chat-thinking-item.markdown-content'); if (content.value) { // Use lazy rendering when collapsed to preserve order with tool items diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 4094e8ca2eb..9d2697de0f7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -79,6 +79,7 @@ padding: 0; border: none; background: transparent !important; + color: var(--vscode-foreground) !important; } .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow:hover:not(.disabled) { @@ -221,25 +222,30 @@ color: var(--vscode-descriptionForeground); } -.chat-question-freeform-textarea { +.chat-question-freeform-textarea, +.chat-question-text-textarea { width: 100%; + min-height: 32px; max-height: 200px; padding: 6px 8px; border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border-radius: 4px; - resize: vertical; + resize: none; font-family: var(--vscode-chat-font-family, inherit); font-size: var(--vscode-chat-font-size-body-s); box-sizing: border-box; + overflow-y: hidden; } -.chat-question-freeform-textarea:focus { +.chat-question-freeform-textarea:focus, +.chat-question-text-textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); } -.chat-question-freeform-textarea::placeholder { +.chat-question-freeform-textarea::placeholder, +.chat-question-text-textarea::placeholder { color: var(--vscode-input-placeholderForeground); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css index 397f482c9f4..e5bb45e1b57 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css @@ -107,7 +107,12 @@ display: flex; gap: 4px; margin-left: auto; - align-self: flex-start; + align-self: center; +} + +/* Reset margin for action bar icons - they should have similar spacing to other action bars */ +.chat-terminal-content-part .chat-terminal-action-bar .monaco-action-bar .codicon { + margin: 0; } .chat-terminal-content-part .chat-terminal-content-message { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 6b310d88306..2a250a99d7a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -17,6 +17,20 @@ margin: 0px; } + > .chat-used-context-label .monaco-button.monaco-icon-button { + line-height: 1.5em; + font-size: var(--vscode-chat-font-size-body-m); + margin-top: 1px; + + .codicon { + font-size: 13px; + } + + .codicon::before { + font-size: var(--vscode-chat-font-size-body-s); + } + } + /* shimmer animation stuffs */ .chat-thinking-spinner-item .chat-thinking-spinner-label { background: linear-gradient(90deg, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts index fa8b0f00aed..7541799ba29 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts @@ -50,6 +50,8 @@ export type McpAppLoadState = * The webview is created lazily on first claim and survives across re-renders. */ export class ChatMcpAppModel extends Disposable { + private static readonly heightCache = new WeakMap(); + /** Origin store for persistent webview origins per server */ private readonly _originStore: WebviewOriginStore; @@ -69,7 +71,7 @@ export class ChatMcpAppModel extends Disposable { private _latestCsp: McpApps.McpUiResourceCsp | undefined = undefined; /** Current height of the webview */ - private _height: number = 300; + private _height: number; /** The persistent webview origin */ private readonly _webviewOrigin: string; @@ -104,6 +106,7 @@ export class ChatMcpAppModel extends Disposable { this._originStore = new WebviewOriginStore(ORIGIN_STORE_KEY, storageService); this._webviewOrigin = this._originStore.getOrigin('mcpApp', renderData.serverDefinitionId); this._mcpToolCallUI = this._register(this._instantiationService.createInstance(McpToolCallUI, renderData)); + this._height = ChatMcpAppModel.heightCache.get(this.toolInvocation) ?? 300; // Create the webview element this._webview = this._register(this._webviewService.createWebviewElement({ @@ -648,8 +651,9 @@ export class ChatMcpAppModel extends Disposable { } private _handleSizeChanged(params: McpApps.McpUiSizeChangedNotification['params']): void { - if (params.height !== undefined) { + if (params.height !== undefined && params.height !== this._height) { this._height = params.height; + ChatMcpAppModel.heightCache.set(this.toolInvocation, params.height); this._onDidChangeHeight.fire(); } } @@ -719,7 +723,7 @@ export class ChatMcpAppModel extends Disposable { jsonrpc: '2.0', id, error: { code, message }, - } satisfies MCP.JSONRPCError); + } satisfies MCP.JSONRPCErrorResponse); } private async _sendNotification(message: McpApps.HostNotification): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts index 98a2cef2f07..54776b9e5a3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts @@ -110,6 +110,10 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { this._updateContainerHeight(); })); + this._register(onDidRemount(() => { + this._model.remount(); + })); + this._register(context.onDidChangeVisibility(visible => { if (visible) { this._model.remount(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 2ed4afbf599..47580c82591 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -274,7 +274,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart ]); this._titleElement = elements.title; - const command = (terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original).trimStart(); + const command = (terminalData.commandLine.forDisplay ?? terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original).trimStart(); this._commandText = command; this._terminalOutputContextKey = ChatContextKeys.inChatTerminalToolOutput.bindTo(this._contextKeyService); @@ -371,7 +371,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this.domNode = progressPart.domNode; } - if (expandedStateByInvocation.get(toolInvocation) || (this._isInThinkingContainer && IChatToolInvocation.isComplete(toolInvocation))) { + // Only auto-expand in thinking containers if there's actual output to show + const hasStoredOutput = !!terminalData.terminalCommandOutput; + if (expandedStateByInvocation.get(toolInvocation) || (this._isInThinkingContainer && IChatToolInvocation.isComplete(toolInvocation) && hasStoredOutput)) { void this._toggleOutput(true); } this._register(this._terminalChatService.registerProgressPart(this)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 51c7137a2b6..653b9601202 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -115,11 +115,10 @@ export interface IChatListItemTemplate { */ renderedParts?: IChatContentPart[]; /** - * Whether the parts are mounted in the DOM. This is undefined after - * the element is disposed so the `renderedParts.onDidMount` can be - * called on the next render as appropriate. + * Element used to track whether the template is mounted in the DOM. */ renderedPartsMounted?: boolean; + readonly rowContainer: HTMLElement; readonly titleToolbar?: MenuWorkbenchToolBar; readonly header?: HTMLElement; @@ -535,8 +534,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + template.renderedPartsMounted = false; + }; + templateDisposables.add(this._onDidUpdateViewModel.event(() => { if (!template.currentElement || !this.viewModel?.sessionResource || !isEqual(template.currentElement.sessionResource, this.viewModel.sessionResource)) { this.clearRenderedParts(template); @@ -1332,7 +1337,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer + type === IChatToolInvocation.StateKind.Streaming || type === IChatToolInvocation.StateKind.Executing; + + if (!isWorkingState(currentState.type)) { return; } - let wasStreaming = true; + let didRemoveConfirmationWidget = false; const disposable = autorun(reader => { const state = toolInvocation.state.read(reader); - if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { - wasStreaming = false; - if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { - removeConfirmationWidget(); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { + if (didRemoveConfirmationWidget) { + return; } + didRemoveConfirmationWidget = true; + disposable.dispose(); + removeConfirmationWidget(); } }); @@ -2115,7 +2131,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, index: number, templateData: IChatListItemTemplate, details?: IListElementRenderDetails): void { this.traceLayout('disposeElement', `Disposing element, index=${index}`); templateData.elementDisposables.clear(); - templateData.renderedPartsMounted = false; if (templateData.currentElement && !this.viewModel?.editing) { this.templateDataByRequestId.delete(templateData.currentElement.id); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 04effea806c..c2d9c3e2392 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -561,11 +561,17 @@ export class ChatWidget extends Disposable implements IChatWidget { return this._attachmentCapabilities; } + /** + * Either the inline input (when editing) or the main input part + */ get input(): ChatInputPart { return this.viewModel?.editing && this.configurationService.getValue('chat.editRequests') !== 'input' ? this.inlineInputPart : this.inputPart; } - private get inputPart(): ChatInputPart { + /** + * The main input part at the buttom of the chat widget. Use `input` to get the active input (main or inline editing part). + */ + get inputPart(): ChatInputPart { return this.inputPartDisposable.value!; } @@ -1208,6 +1214,10 @@ export class ChatWidget extends Disposable implements IChatWidget { } else if (handoff.agent) { // Regular handoff to specified agent this._switchToAgentByName(handoff.agent); + // Switch to the specified model if provided + if (handoff.model) { + this.input.switchModelByQualifiedName([handoff.model]); + } // Insert the handoff prompt into the input this.input.setValue(promptToUse, false); this.input.focus(); 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 bb1c3c0f14c..cc848df7dbc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -12,6 +12,7 @@ import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../. import * as aria from '../../../../../../base/browser/ui/aria/aria.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; import { createInstantHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IAction } from '../../../../../../base/common/actions.js'; import { equals as arraysEqual } from '../../../../../../base/common/arrays.js'; @@ -70,6 +71,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../../ import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { ISharedWebContentExtractorService } from '../../../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchLayoutService, Position } from '../../../../../services/layout/browser/layoutService.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../../../common/views.js'; import { ResourceLabels } from '../../../../../browser/labels.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; @@ -100,7 +103,7 @@ import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, IWorkspacePickerDelegate } from '../../chat.js'; +import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; @@ -119,6 +122,7 @@ import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerAction import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; +import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; const $ = dom.$; @@ -164,6 +168,18 @@ export interface IWorkingSetEntry { uri: URI; } +export const enum ChatWidgetLocation { + SidebarLeft = 'sidebarLeft', + SidebarRight = 'sidebarRight', + Panel = 'panel', + Editor = 'editor', +} + +export interface IChatWidgetLocationInfo { + readonly location: ChatWidgetLocation; + readonly isMaximized: boolean; +} + const emptyInputState = observableMemento({ defaultValue: undefined, key: 'chat.untitledInputState', @@ -270,6 +286,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatInputWidgetsContainer!: HTMLElement; private readonly _widgetController = this._register(new MutableDisposable()); + private contextUsageWidget?: ChatContextUsageWidget; + private contextUsageWidgetContainer!: HTMLElement; + private readonly _contextUsageDisposables = this._register(new MutableDisposable()); + readonly height = observableValue(this, 0); private _inputEditor!: CodeEditorWidget; @@ -466,6 +486,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatContextService private readonly chatContextService: IChatContextService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, ) { super(); @@ -1655,6 +1677,31 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Updates the context usage widget based on the current model. + */ + private updateContextUsageWidget(): void { + this._contextUsageDisposables.clear(); + + const model = this._widget?.viewModel?.model; + if (!model || !this.contextUsageWidget) { + return; + } + + const store = new DisposableStore(); + this._contextUsageDisposables.value = store; + + // Subscribe to model changes to update when requests complete + store.add(model.onDidChange(e => { + if (e.kind === 'completedRequest') { + this.contextUsageWidget?.update(model.lastRequest); + } + })); + + // Initial update + this.contextUsageWidget.update(model.lastRequest); + } + render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this._widget = widget; this.computeVisibleOptionGroups(); @@ -1674,6 +1721,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.updateAgentSessionTypeContextKey(); this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); + this.updateContextUsageWidget(); })); let elements; @@ -1685,6 +1733,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ dom.h('.chat-input-container@inputContainer', [ + dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), dom.h('.chat-editor-container@editorContainer'), dom.h('.chat-input-toolbars@inputToolbars'), ]), @@ -1704,6 +1753,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ dom.h('.chat-input-container@inputContainer', [ + dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), dom.h('.chat-attachments-container@attachmentsContainer', [ dom.h('.chat-attachment-toolbar@attachmentToolbar'), dom.h('.chat-attached-context@attachedContextContainer'), @@ -1735,6 +1785,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer; this.chatInputWidgetsContainer = elements.chatInputWidgetsContainer; + this.contextUsageWidgetContainer = elements.contextUsageWidgetContainer; + + // Context usage widget + this.contextUsageWidget = this._register(this.instantiationService.createInstance(ChatContextUsageWidget)); + this.contextUsageWidgetContainer.appendChild(this.contextUsageWidget.domNode); if (this.options.enableImplicitContext && !this._implicitContext) { this._implicitContext = this._register( @@ -1869,7 +1924,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); const hoverDelegate = this._register(createInstantHoverDelegate()); - const chatSessionPosition = isIChatResourceViewContext(widget.viewContext) ? 'editor' : 'sidebar'; + + const { location, isMaximized } = this.getWidgetLocationInfo(widget); + const pickerOptions: IChatInputPickerOptions = { getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, @@ -1877,6 +1934,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditor.onDidLayoutChange, (l?: EditorLayoutInfo) => (l?.width ?? this._inputEditor.getLayoutInfo().width) < 650 /* This is a magical number based on testing*/ ).recomputeInitiallyAndOnChange(this._store), + hoverPosition: { + forcePosition: true, + hoverPosition: location === ChatWidgetLocation.SidebarRight && !isMaximized ? HoverPosition.LEFT : HoverPosition.RIGHT + }, }; this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); @@ -1942,7 +2003,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; const isWelcomeViewMode = !!this.options.sessionTypePickerDelegate?.setActiveSessionProvider; const Picker = (action.id === OpenSessionTargetPickerAction.ID || isWelcomeViewMode) ? SessionTypePickerActionItem : DelegationSessionPickerActionItem; - return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, chatSessionPosition, delegate, pickerOptions); + return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, pickerOptions); } else if (action.id === OpenWorkspacePickerAction.ID && action instanceof MenuItemAction) { if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && this.options.workspacePickerDelegate) { return this.instantiationService.createInstance(WorkspacePickerActionItem, action, this.options.workspacePickerDelegate, pickerOptions); @@ -2710,6 +2771,47 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge sideToolbarWidth: inputSideToolbarWidth > 0 ? inputSideToolbarWidth + 4 /*gap*/ : 0, }; } + + /** + * Gets the location of the chat widget and whether that location is maximized. + */ + private getWidgetLocationInfo(widget: IChatWidget): IChatWidgetLocationInfo { + // Editor context (quick chat, inline chat, etc.) + if (isIChatResourceViewContext(widget.viewContext)) { + return { location: ChatWidgetLocation.Editor, isMaximized: false }; + } + + // View context - determine actual location from view descriptor service + if (isIChatViewViewContext(widget.viewContext)) { + const viewLocation = this.viewDescriptorService.getViewLocationById(widget.viewContext.viewId); + const sideBarPosition = this.layoutService.getSideBarPosition(); + + switch (viewLocation) { + case ViewContainerLocation.Panel: + return { + location: ChatWidgetLocation.Panel, + isMaximized: this.layoutService.isPanelMaximized(), + }; + case ViewContainerLocation.AuxiliaryBar: + // AuxiliaryBar is on the opposite side of the primary sidebar + return { + location: sideBarPosition === Position.LEFT ? ChatWidgetLocation.SidebarRight : ChatWidgetLocation.SidebarLeft, + isMaximized: this.layoutService.isAuxiliaryBarMaximized(), + }; + case ViewContainerLocation.Sidebar: + default: + // Primary sidebar follows its configured position + // Note: Primary sidebar cannot be maximized, so always false + return { + location: sideBarPosition === Position.LEFT ? ChatWidgetLocation.SidebarLeft : ChatWidgetLocation.SidebarRight, + isMaximized: false, + }; + } + } + + // Fallback for unknown contexts + return { location: ChatWidgetLocation.Editor, isMaximized: false }; + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index e223350c4c7..1377aa52607 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { getActiveWindow } from '../../../../../../base/browser/dom.js'; +import { IHoverPositionOptions } from '../../../../../../base/browser/ui/hover/hover.js'; import { IAction } from '../../../../../../base/common/actions.js'; import { autorun, IObservable } from '../../../../../../base/common/observable.js'; import { ActionWidgetDropdownActionViewItem } from '../../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; @@ -24,6 +25,8 @@ export interface IChatInputPickerOptions { readonly actionContext?: IChatExecuteActionContext; readonly onlyShowIconsForDefaultActions: IObservable; + + readonly hoverPosition?: IHoverPositionOptions; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index d30bc4402cd..a51b59a4548 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -91,7 +91,7 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt class: undefined, label: localize('chat.newChatSession', "New Chat Session"), tooltip: '', - hover: { content: '' }, + hover: { content: '', position: this.pickerOptions.hoverPosition }, checked: false, icon: Codicon.plus, enabled: true, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 381f97bfe4d..4def3e504b0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -32,6 +32,7 @@ import { PromptsStorage } from '../../../common/promptSyntax/service/promptsServ import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; export interface IModePickerDelegate { readonly currentMode: IObservable; @@ -60,7 +61,8 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { @IMenuService private readonly menuService: IMenuService, @ICommandService commandService: ICommandService, @IProductService private readonly _productService: IProductService, - @ITelemetryService telemetryService: ITelemetryService + @ITelemetryService telemetryService: ITelemetryService, + @IOpenerService openerService: IOpenerService ) { // Get custom agent target (if filtering is enabled) const customAgentTarget = delegate.customAgentTarget?.(); @@ -71,7 +73,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const policyDisabledCategory = { label: localize('managedByOrganization', "Managed by your organization"), order: 999, showHeader: true }; const agentModeDisabledViaPolicy = configurationService.inspect(ChatConfiguration.AgentEnabled).policyValue === false; - const alternativeToolActionEnabled = configurationService.getValue(ChatConfiguration.AlternativeToolAction); const makeAction = (mode: IChatMode, currentMode: IChatMode): IActionWidgetDropdownAction => { const isDisabledViaPolicy = @@ -80,30 +81,55 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const tooltip = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip; + // Add toolbar actions for Agent modes const toolbarActions: IAction[] = []; - if (alternativeToolActionEnabled && mode.kind === ChatModeKind.Agent && !isDisabledViaPolicy) { - // Add toolbar actions for Agent modes when alternative tool action is enabled - const label = localize('configureToolsFor', "Configure tools for {0} {1}", mode.label.get(), isModeConsideredBuiltIn(mode, this._productService) ? 'mode' : 'agent'); - toolbarActions.push({ - id: 'configureToolsForMode', - label: label, - tooltip: label, - class: ThemeIcon.asClassName(Codicon.tools), - enabled: true, - run: async () => { - // Hide the picker before opening the tools configuration - actionWidgetService.hide(); - // First switch to the mode if not already selected - if (currentMode.id !== mode.id) { - await commandService.executeCommand( - ToggleAgentModeActionId, - { modeId: mode.id, sessionResource: this.delegate.sessionResource() } satisfies IToggleChatModeArgs - ); - } - // Then open the tools picker - await commandService.executeCommand('workbench.action.chat.configureTools', pickerOptions.actionContext, { source: 'modePicker' }); + if (mode.kind === ChatModeKind.Agent && !isDisabledViaPolicy) { + if (mode.uri) { + let label, icon, id; + if (mode.source?.storage === PromptsStorage.extension) { + icon = Codicon.eye; + id = `viewAgent:${mode.id}`; + label = localize('viewModeConfiguration', "View {0} agent", mode.label.get()); + } else { + icon = Codicon.edit; + id = `editAgent:${mode.id}`; + label = localize('editModeConfiguration', "Edit {0} agent", mode.label.get()); } - }); + + const modeResource = mode.uri; + toolbarActions.push({ + id, + label, + tooltip: label, + class: ThemeIcon.asClassName(icon), + enabled: true, + run: async () => { + openerService.open(modeResource.get()); + } + }); + } else if (!customAgentTarget) { + const label = localize('configureToolsFor', "Configure tools for {0} agent", mode.label.get()); + toolbarActions.push({ + id: `configureTools:${mode.id}`, + label, + tooltip: label, + class: ThemeIcon.asClassName(Codicon.tools), + enabled: true, + run: async () => { + // Hide the picker before opening the tools configuration + actionWidgetService.hide(); + // First switch to the mode if not already selected + if (currentMode.id !== mode.id) { + await commandService.executeCommand( + ToggleAgentModeActionId, + { modeId: mode.id, sessionResource: this.delegate.sessionResource() } satisfies IToggleChatModeArgs + ); + } + // Then open the tools picker + await commandService.executeCommand('workbench.action.chat.configureTools', pickerOptions.actionContext, { source: 'modePicker' }); + } + }); + } } return { @@ -115,7 +141,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { enabled: !isDisabledViaPolicy, checked: !isDisabledViaPolicy && currentMode.id === mode.id, tooltip: '', - hover: { content: tooltip }, + hover: { content: tooltip, position: this.pickerOptions.hoverPosition }, toolbarActions, run: async () => { if (isDisabledViaPolicy) { @@ -138,7 +164,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { return { ...makeAction(mode, currentMode), tooltip: '', - hover: { content: mode.description.get() ?? chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip }, + hover: { content: mode.description.get() ?? chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip, position: this.pickerOptions.hoverPosition }, icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon : undefined), category: agentModeDisabledViaPolicy ? policyDisabledCategory : customCategory }; @@ -151,8 +177,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { return mode.source.storage === PromptsStorage.local || mode.source.storage === PromptsStorage.user; }; - const isImplementMode = (mode: IChatMode) => isBuiltinImplementMode(mode, this._productService); - const actionProviderWithCustomAgentTarget: IActionWidgetDropdownActionProvider = { getActions: () => { const modes = chatModeService.getModes(); @@ -182,7 +206,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const otherBuiltinModes = modes.builtin.filter(mode => mode.id !== ChatMode.Agent.id && !(shouldHideEditMode && mode.id === ChatMode.Edit.id)); // Filter out 'implement' mode from the dropdown - it's available for handoffs but not user-selectable const customModes = groupBy( - modes.custom.filter(mode => !isImplementMode(mode)), + modes.custom, mode => isModeConsideredBuiltIn(mode, this._productService) ? 'builtin' : 'custom'); const customBuiltinModeActions = customModes.builtin?.map(mode => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index 5ca9a98a56f..4b419fca28c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -44,7 +44,7 @@ type ChatModelChangeEvent = { }; -function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate, telemetryService: ITelemetryService): IActionWidgetDropdownActionProvider { +function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate, telemetryService: ITelemetryService, pickerOptions: IChatInputPickerOptions): IActionWidgetDropdownActionProvider { return { getActions: () => { const models = delegate.getModels(); @@ -59,7 +59,7 @@ function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate, te description: localize('chat.modelPicker.auto.detail', "Best for your request based on capacity and performance."), tooltip: localize('chat.modelPicker.auto', "Auto"), label: localize('chat.modelPicker.auto', "Auto"), - hover: { content: localize('chat.modelPicker.auto.description', "Automatically selects the best model for your task based on context and complexity.") }, + hover: { content: localize('chat.modelPicker.auto.description', "Automatically selects the best model for your task based on context and complexity."), position: pickerOptions.hoverPosition }, run: () => { } } satisfies IActionWidgetDropdownAction]; } @@ -74,7 +74,7 @@ function modelDelegateToWidgetActionsProvider(delegate: IModelPickerDelegate, te class: undefined, description: model.metadata.multiplier ?? model.metadata.detail, tooltip: hoverContent ? '' : model.metadata.name, - hover: hoverContent ? { content: hoverContent } : undefined, + hover: hoverContent ? { content: hoverContent, position: pickerOptions.hoverPosition } : undefined, label: model.metadata.name, run: () => { const previousModel = delegate.currentModel.get(); @@ -167,7 +167,7 @@ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { }; const modelPickerActionWidgetOptions: Omit = { - actionProvider: modelDelegateToWidgetActionsProvider(delegate, telemetryService), + actionProvider: modelDelegateToWidgetActionsProvider(delegate, telemetryService, pickerOptions), actionBarActionProvider: getModelPickerActionBarActionProvider(commandService, chatEntitlementService, productService), reporter: { name: 'ChatModelPicker', includeOptions: true }, }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index ffc6e0cb22f..69721f5bd5b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -74,7 +74,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { category: this._getSessionCategory(sessionTypeItem), description: this._getSessionDescription(sessionTypeItem), tooltip: '', - hover: { content: sessionTypeItem.hoverDescription }, + hover: { content: sessionTypeItem.hoverDescription, position: this.pickerOptions.hoverPosition }, run: async () => { this._run(sessionTypeItem); }, 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 c76aa1eeacc..6663d5f2a08 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -776,6 +776,15 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 0 6px 6px 6px; /* top padding is inside the editor widget */ width: 100%; + position: relative; +} + +/* Context usage widget container - positioned at top right of chat input */ +.interactive-session .chat-input-container .chat-context-usage-container { + position: absolute; + top: 4px; + right: 6px; + z-index: 1; } .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container, diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index 77741595adb..647486aa963 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -37,6 +37,7 @@ export class ChatContextUsageDetails extends Disposable { private readonly quotaItem: HTMLElement; private readonly percentageLabel: HTMLElement; + private readonly tokenCountLabel: HTMLElement; private readonly progressFill: HTMLElement; private readonly tokenDetailsContainer: HTMLElement; private readonly warningMessage: HTMLElement; @@ -55,11 +56,15 @@ export class ChatContextUsageDetails extends Disposable { // Using same structure as ChatUsageWidget quota items this.quotaItem = this.domNode.appendChild($('.quota-item')); - // Header row with label and percentage + // Header row with label const quotaItemHeader = this.quotaItem.appendChild($('.quota-item-header')); const quotaItemLabel = quotaItemHeader.appendChild($('.quota-item-label')); quotaItemLabel.textContent = localize('contextWindow', "Context Window"); - this.percentageLabel = quotaItemHeader.appendChild($('.quota-item-value')); + + // Token count and percentage row (on same line) + const tokenRow = this.quotaItem.appendChild($('.token-row')); + this.tokenCountLabel = tokenRow.appendChild($('.token-count-label')); + this.percentageLabel = tokenRow.appendChild($('.quota-item-value')); // Progress bar - using same structure as chat usage widget const progressBar = this.quotaItem.appendChild($('.quota-bar')); @@ -98,10 +103,16 @@ export class ChatContextUsageDetails extends Disposable { } update(data: IChatContextUsageData): void { - const { percentage, promptTokenDetails } = data; + const { percentage, promptTokens, maxInputTokens, promptTokenDetails } = data; - // Update percentage label - this.percentageLabel.textContent = `${percentage.toFixed(0)}%`; + // Update token count and percentage on same line + this.tokenCountLabel.textContent = localize( + 'tokenCount', + "{0} / {1} tokens", + this.formatTokenCount(promptTokens, 1), + this.formatTokenCount(maxInputTokens, 0) + ); + this.percentageLabel.textContent = `• ${percentage.toFixed(0)}%`; // Update progress bar this.progressFill.style.width = `${Math.min(100, percentage)}%`; @@ -121,6 +132,15 @@ export class ChatContextUsageDetails extends Disposable { this.warningMessage.style.display = percentage >= 75 ? '' : 'none'; } + private formatTokenCount(count: number, decimals: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(decimals)}M`; + } else if (count >= 1000) { + return `${(count / 1000).toFixed(decimals)}K`; + } + return count.toString(); + } + private renderTokenDetails(details: readonly IChatContextUsagePromptTokenDetail[] | undefined, contextWindowPercentage: number): void { // Clear previous content dom.clearNode(this.tokenDetailsContainer); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 6d166534e17..3a336dc2111 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -6,8 +6,9 @@ import './media/chatContextUsageWidget.css'; import * as dom from '../../../../../../base/browser/dom.js'; import { EventType, addDisposableListener } from '../../../../../../base/browser/dom.js'; +import { IDelayedHoverOptions } from '../../../../../../base/browser/ui/hover/hover.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; @@ -15,6 +16,8 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatContextUsageDetails, IChatContextUsageData } from './chatContextUsageDetails.js'; +import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../../../base/common/keyCodes.js'; const $ = dom.$; @@ -99,13 +102,14 @@ export class ChatContextUsageWidget extends Disposable { readonly domNode: HTMLElement; - private readonly tokenLabel: HTMLElement; private readonly progressIndicator: CircularProgressIndicator; private readonly _isVisible = observableValue(this, false); get isVisible(): IObservable { return this._isVisible; } private readonly _lastRequestDisposable = this._register(new MutableDisposable()); + private readonly _hoverDisposable = this._register(new MutableDisposable()); + private readonly _contextUsageDetails = this._register(new MutableDisposable()); private currentData: IChatContextUsageData | undefined; @@ -116,7 +120,7 @@ export class ChatContextUsageWidget extends Disposable { ) { super(); - this.domNode = $('.chat-context-usage-widget.action-label'); + this.domNode = $('.chat-context-usage-widget'); this.domNode.style.display = 'none'; this.domNode.setAttribute('tabindex', '0'); this.domNode.setAttribute('role', 'button'); @@ -127,55 +131,59 @@ export class ChatContextUsageWidget extends Disposable { this.progressIndicator = new CircularProgressIndicator(); iconContainer.appendChild(this.progressIndicator.domNode); - // Token label (shown on hover/focus) - this.tokenLabel = this.domNode.appendChild($('.token-label')); - - // Show details popup on click - this._register(addDisposableListener(this.domNode, EventType.CLICK, () => { - this.showDetails(); - })); - - // Show details on Enter/Space for keyboard accessibility - this._register(addDisposableListener(this.domNode, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this.showDetails(); - } - })); + // Set up hover - will be configured when data is available + this.setupHover(); } - private showDetails(): void { - if (!this.currentData) { - return; - } + private setupHover(): void { + this._hoverDisposable.clear(); + const store = new DisposableStore(); + this._hoverDisposable.value = store; - // Add expanded class to keep token label visible while details are shown - this.domNode.classList.add('expanded'); + const createDetails = (): ChatContextUsageDetails | undefined => { + if (!this._isVisible.get() || !this.currentData) { + return undefined; + } + this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); + this._contextUsageDetails.value.update(this.currentData); + return this._contextUsageDetails.value; + }; - const details = this.instantiationService.createInstance(ChatContextUsageDetails); - details.update(this.currentData); + const hoverOptions: Omit = { + appearance: { showPointer: true, compact: true }, + persistence: { hideOnHover: false }, + trapFocus: true + }; - const hover = this.hoverService.showInstantHover({ - content: details.domNode, - target: { - targetElements: [this.domNode], - dispose: () => { - this.domNode.classList.remove('expanded'); - details.dispose(); - } - }, - persistence: { sticky: true, hideOnHover: false, hideOnKeyDown: false }, - appearance: { showPointer: true } - }, true); + store.add(this.hoverService.setupDelayedHover(this.domNode, () => ({ + ...hoverOptions, + content: createDetails()?.domNode ?? '' + }))); - // Focus the details widget - details.focus(); + const showStickyHover = () => { + const details = createDetails(); + if (details) { + this.hoverService.showInstantHover( + { ...hoverOptions, content: details.domNode, target: this.domNode, persistence: { hideOnHover: false, sticky: true } }, + true + ); + } + }; - // Handle case where hover couldn't be shown - if (!hover) { - this.domNode.classList.remove('expanded'); - details.dispose(); - } + // Show sticky + focused hover on click + store.add(addDisposableListener(this.domNode, EventType.CLICK, e => { + e.stopPropagation(); + showStickyHover(); + })); + + // Show sticky + focused hover on keyboard activation (Space/Enter) + store.add(addDisposableListener(this.domNode, EventType.KEY_DOWN, e => { + const evt = new StandardKeyboardEvent(e); + if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) { + e.preventDefault(); + showStickyHover(); + } + })); } /** @@ -235,23 +243,6 @@ export class ChatContextUsageWidget extends Disposable { } else if (percentage >= 75) { this.domNode.classList.add('warning'); } - - // Update token label (shown on hover/focus) - this.tokenLabel.textContent = localize( - 'tokenCount', - "{0} / {1} T", - this.formatTokenCount(promptTokens, 1), - this.formatTokenCount(maxTokens, 0) - ); - } - - private formatTokenCount(count: number, decimals: number): string { - if (count >= 1000000) { - return `${(count / 1000000).toFixed(decimals)}M`; - } else if (count >= 1000) { - return `${(count / 1000).toFixed(decimals)}K`; - } - return count.toString(); } private show(): void { 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 3c8e240f4f5..791b0da55c1 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -55,6 +55,7 @@ import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../. import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { ChatViewId } from '../../chat.js'; +import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { disposableTimeout } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; @@ -89,6 +90,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private restoringSession: Promise | undefined; private readonly modelRef = this._register(new MutableDisposable()); + private readonly activityBadge = this._register(new MutableDisposable()); + constructor( options: IViewPaneOptions, @IKeybindingService keybindingService: IKeybindingService, @@ -112,6 +115,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ICommandService private readonly commandService: ICommandService, + @IActivityService private readonly activityService: IActivityService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -602,11 +606,40 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // When showing sessions stacked, adjust the height of the sessions list to make room for chat input this._register(autorun(reader => { - chatWidget.input.height.read(reader); + chatWidget.inputPart.height.read(reader); if (this.sessionsViewerVisible && this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { this.relayout(); } })); + + // Show progress badge when the current session is in progress + const progressBadgeDisposables = this._register(new MutableDisposable()); + const updateProgressBadge = () => { + progressBadgeDisposables.value = new DisposableStore(); + + if (!this.configurationService.getValue(ChatConfiguration.ChatViewProgressBadgeEnabled)) { + this.activityBadge.clear(); + return; + } + + const model = chatWidget.viewModel?.model; + if (model) { + progressBadgeDisposables.value.add(autorun(reader => { + if (model.requestInProgress.read(reader)) { + this.activityBadge.value = this.activityService.showViewActivity(this.id, { + badge: new ProgressBadge(() => localize('sessionInProgress', "Agent Session in Progress")) + }); + } else { + this.activityBadge.clear(); + } + })); + } else { + this.activityBadge.clear(); + } + }; + this._register(chatWidget.onDidChangeViewModel(() => updateProgressBadge())); + this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.ChatViewProgressBadgeEnabled))(() => updateProgressBadge())); + updateProgressBadge(); } private setupContextMenu(parent: HTMLElement): void { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts index 8d61871b819..8cd698568b8 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts @@ -14,15 +14,12 @@ import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { localize } from '../../../../../../nls.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; import { IChatModel } from '../../../common/model/chatModel.js'; -import { ChatConfiguration } from '../../../common/constants.js'; import { ActionViewItem, IActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../../../base/common/actions.js'; import { AgentSessionsPicker } from '../../agentSessions/agentSessionsPicker.js'; -import { ChatContextUsageWidget } from './chatContextUsageWidget.js'; export interface IChatViewTitleDelegate { focusChat(): void; @@ -46,34 +43,21 @@ export class ChatViewTitleControl extends Disposable { private navigationToolbar?: MenuWorkbenchToolBar; private actionsToolbar?: MenuWorkbenchToolBar; - private contextUsageWidget?: ChatContextUsageWidget; private lastKnownHeight = 0; constructor( private readonly container: HTMLElement, private readonly delegate: IChatViewTitleDelegate, - @IConfigurationService private readonly configurationService: IConfigurationService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); this.render(this.container); - this.registerListeners(); this.registerActions(); } - private registerListeners(): void { - - // Update on configuration changes - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.ChatViewTitleEnabled)) { - this.doUpdate(); - } - })); - } - private registerActions(): void { this._register(registerAction2(class extends Action2 { constructor() { @@ -101,7 +85,6 @@ export class ChatViewTitleControl extends Disposable { private render(parent: HTMLElement): void { const elements = h('div.chat-view-title-container', [ h('div.chat-view-title-navigation-toolbar@navigationToolbar'), - h('div.chat-view-title-context-usage@contextUsage'), h('div.chat-view-title-actions-toolbar@actionsToolbar'), ]); @@ -121,13 +104,6 @@ export class ChatViewTitleControl extends Disposable { menuOptions: { shouldForwardArgs: true } })); - // Context usage widget - this.contextUsageWidget = this._register(this.instantiationService.createInstance(ChatContextUsageWidget)); - elements.contextUsage.appendChild(this.contextUsageWidget.domNode); - this._register(this.contextUsageWidget.onDidChangeVisibility(() => { - this.checkHeight(); - })); - // Actions toolbar on the right this.actionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, elements.actionsToolbar, MenuId.ChatViewSessionTitleToolbar, { menuOptions: { shouldForwardArgs: true }, @@ -153,17 +129,9 @@ export class ChatViewTitleControl extends Disposable { if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { this.doUpdate(); } - if (e.kind === 'completedRequest') { - this.updateContextUsage(); - } }); this.doUpdate(); - this.updateContextUsage(); - } - - private updateContextUsage(): void { - this.contextUsageWidget?.update(this.model?.lastRequest); } private doUpdate(): void { @@ -186,14 +154,6 @@ export class ChatViewTitleControl extends Disposable { } } - private checkHeight(): void { - const currentHeight = this.getHeight(); - if (currentHeight !== this.lastKnownHeight) { - this.lastKnownHeight = currentHeight; - this._onDidChangeHeight.fire(); - } - } - private updateTitle(title: string): void { if (!this.titleContainer) { return; @@ -211,17 +171,9 @@ export class ChatViewTitleControl extends Disposable { } private shouldRender(): boolean { - if (!this.isEnabled()) { - return false; // title hidden via setting - } - return !!this.model?.title; // we need a chat showing and not being empty } - private isEnabled(): boolean { - return this.configurationService.getValue(ChatConfiguration.ChatViewTitleEnabled) === true; - } - getHeight(): number { if (!this.titleContainer || this.titleContainer.style.display === 'none') { return 0; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css index d3d8616f643..41b7a7ad9d1 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css @@ -3,11 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* Remove the outline and change border color on focus instead */ +.workbench-hover-container.locked:has(.chat-context-usage-details) .monaco-hover.workbench-hover { + outline: none; +} +.workbench-hover-container:focus-within.locked:has(.chat-context-usage-details) .monaco-hover.workbench-hover { + outline: none; + border-color: var(--vscode-focusBorder); +} + .chat-context-usage-details { display: flex; flex-direction: column; - padding: 12px; - min-width: 220px; + padding: 4px 0; + min-width: 200px; } .chat-context-usage-details:focus { @@ -16,14 +25,14 @@ /* Using same structure as ChatUsageWidget quota items */ .chat-context-usage-details .quota-item { - margin-bottom: 8px; + margin-bottom: 4px; } .chat-context-usage-details .quota-item-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 4px; + margin-bottom: 2px; } .chat-context-usage-details .quota-item-label { @@ -34,6 +43,18 @@ color: var(--vscode-descriptionForeground); } +.chat-context-usage-details .token-row { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 2px; +} + +.chat-context-usage-details .token-count-label { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + /* Progress bar - matching chat usage implementation */ .chat-context-usage-details .quota-item .quota-bar { width: 100%; @@ -41,7 +62,7 @@ background-color: var(--vscode-gauge-background); border-radius: 4px; border: 1px solid var(--vscode-gauge-border); - margin: 4px 0; + margin: 2px 0; } .chat-context-usage-details .quota-item .quota-bar .quota-bit { @@ -70,22 +91,22 @@ .chat-context-usage-details .warning-message { font-size: 12px; color: var(--vscode-descriptionForeground); - margin-bottom: 8px; + margin-bottom: 4px; } /* Token details breakdown */ .chat-context-usage-details .token-details-container { - margin-top: 8px; + margin-top: 4px; } .chat-context-usage-details .token-category { - margin-bottom: 8px; + margin-bottom: 4px; } .chat-context-usage-details .token-category-header { font-weight: 600; color: var(--vscode-foreground); - margin-bottom: 4px; + margin-bottom: 2px; } .chat-context-usage-details .token-detail-item { @@ -105,24 +126,24 @@ .chat-context-usage-details .actions-section .separator { border-top: 1px solid var(--vscode-editorHoverWidget-border); - margin: 8px 0; + margin: 4px 0; } .chat-context-usage-details .actions-section .actions-header { font-weight: 600; color: var(--vscode-foreground); - margin-bottom: 8px; + margin-bottom: 4px; } .chat-context-usage-details .actions-section .button-bar-container { display: flex; flex-direction: column; - gap: 6px; + gap: 4px; } .chat-context-usage-details .actions-section .button-bar-container .monaco-button-bar { flex-direction: column; - gap: 6px; + gap: 4px; } .chat-context-usage-details .actions-section .button-bar-container .monaco-button { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css index ffad8682a1a..9906a0b8171 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css @@ -6,15 +6,13 @@ .chat-context-usage-widget { display: flex; align-items: center; - justify-content: flex-end; + justify-content: center; height: 22px; flex-shrink: 0; cursor: pointer; padding: 3px; border-radius: 5px; box-sizing: border-box; - overflow: hidden; - transition: background-color 0.15s ease; } .chat-context-usage-widget .icon-container { @@ -26,28 +24,6 @@ flex-shrink: 0; } -.chat-context-usage-widget .token-label { - max-width: 0; - opacity: 0; - overflow: hidden; - white-space: nowrap; - font-size: 11px; - color: var(--vscode-foreground); - transition: max-width 0.2s ease, opacity 0.2s ease, margin-right 0.2s ease; - margin-right: 0; - order: -1; -} - -/* Expand on hover, focus, or when details are shown (expanded class) */ -.chat-context-usage-widget:hover .token-label, -.chat-context-usage-widget:focus .token-label, -.chat-context-usage-widget:focus-within .token-label, -.chat-context-usage-widget.expanded .token-label { - max-width: 100px; - opacity: 1; - margin-right: 6px; -} - .chat-context-usage-widget:hover { background-color: var(--vscode-toolbar-hoverBackground); outline: 1px dashed var(--vscode-toolbar-hoverOutline); @@ -79,7 +55,6 @@ .chat-context-usage-widget .progress-pie { fill: var(--vscode-icon-foreground); opacity: 0.8; - transition: d 0.3s ease; pointer-events: none; } @@ -90,11 +65,3 @@ .chat-context-usage-widget.error .progress-pie { fill: var(--vscode-editorError-foreground); } - -.chat-context-usage-widget.warning .token-label { - color: var(--vscode-editorWarning-foreground); -} - -.chat-context-usage-widget.error .token-label { - color: var(--vscode-editorError-foreground); -} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css index f267e867323..9434f08cef3 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewTitleControl.css @@ -36,12 +36,8 @@ min-width: 0; } - .chat-view-title-context-usage { - margin-left: auto; - flex-shrink: 0; - } - .chat-view-title-actions-toolbar { + margin-left: auto; padding-left: 4px; flex-shrink: 0; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 31d3c252fa4..75b4f81832a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -413,6 +413,8 @@ export interface IChatTerminalToolInvocationData { original: string; userEdited?: string; toolEdited?: string; + // command to show in the chat UI (potentially different from what is actually run in the terminal) + forDisplay?: string; }; /** The working directory URI for the terminal */ cwd?: UriComponents; @@ -788,7 +790,7 @@ export interface IChatToolInvocationSerialized { isComplete: boolean; toolCallId: string; toolId: string; - source: ToolDataSource; + source: ToolDataSource | undefined; // undefined on pre-1.104 versions readonly subAgentInvocationId?: string; generatedTitle?: string; kind: 'toolInvocationSerialized'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 17e8088fb7f..d133ab69419 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -380,6 +380,7 @@ export class ChatService extends Disposable implements IChatService { .filter(entry => !this._sessionModels.has(LocalChatSessionUri.forSession(entry.sessionId)) && entry.initialLocation === ChatAgentLocation.Chat && !entry.isEmpty) .map((entry): IChatDetail => { const sessionResource = LocalChatSessionUri.forSession(entry.sessionId); + const lastResponseState = entry.lastResponseState ?? ResponseModelState.Complete; return ({ ...entry, sessionResource, @@ -391,7 +392,8 @@ export class ChatService extends Disposable implements IChatService { }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store - lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, + // TODO@connor4312: the check here guards old sessions from Insiders pre PR #288161 and it can be safely removed after a transition period. + lastResponseState: lastResponseState === ResponseModelState.Pending || lastResponseState === ResponseModelState.NeedsInput ? ResponseModelState.Complete : lastResponseState, }); }); } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 62e5f123429..13056d6991a 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -37,7 +37,7 @@ export enum ChatConfiguration { ChatViewSessionsEnabled = 'chat.viewSessions.enabled', ChatViewSessionsGrouping = 'chat.viewSessions.grouping', ChatViewSessionsOrientation = 'chat.viewSessions.orientation', - ChatViewTitleEnabled = 'chat.viewTitle.enabled', + ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index e6a883096fe..0df77c1014f 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -862,7 +862,7 @@ export class LanguageModelsService implements ILanguageModelsService { ? await this._languageModelsConfigurationService.updateLanguageModelsProviderGroup(existing, languageModelProviderGroup) : await this._languageModelsConfigurationService.addLanguageModelsProviderGroup(languageModelProviderGroup); - if (vendor.configuration && this.canConfigure(configuration ?? {}, vendor.configuration)) { + if (vendor.configuration && this.requireConfiguring(vendor.configuration)) { const snippet = this.getSnippetForFirstUnconfiguredProperty(configuration ?? {}, vendor.configuration); await this._languageModelsConfigurationService.configureLanguageModels({ group: saved, snippet }); } @@ -901,7 +901,7 @@ export class LanguageModelsService implements ILanguageModelsService { await this._languageModelsConfigurationService.removeLanguageModelsProviderGroup(existing); } - private canConfigure(configuration: IStringDictionary, schema: IJSONSchema): boolean { + private requireConfiguring(schema: IJSONSchema): boolean { if (schema.additionalProperties) { return true; } @@ -909,7 +909,7 @@ export class LanguageModelsService implements ILanguageModelsService { return false; } for (const property of Object.keys(schema.properties)) { - if (configuration[property] === undefined) { + if (!this.canPromptForProperty(schema.properties[property])) { return true; } } @@ -1003,7 +1003,11 @@ export class LanguageModelsService implements ILanguageModelsService { } private async promptForValue(groupName: string, property: string, propertySchema: IJSONSchema | undefined, required: boolean, existing: IStringDictionary | undefined): Promise { - if (!propertySchema || typeof propertySchema === 'boolean') { + if (!propertySchema) { + return undefined; + } + + if (!this.canPromptForProperty(propertySchema)) { return undefined; } @@ -1015,17 +1019,30 @@ export class LanguageModelsService implements ILanguageModelsService { return selectedItems; } - if (propertySchema.type !== 'string' && propertySchema.type !== 'number' && propertySchema.type !== 'integer' && propertySchema.type !== 'boolean') { - return undefined; - } - const value = await this.promptForInput(groupName, property, propertySchema, required, existing); if (value === undefined) { return undefined; } + return value; } + private canPromptForProperty(propertySchema: IJSONSchema | undefined): boolean { + if (!propertySchema || typeof propertySchema === 'boolean') { + return false; + } + + if (propertySchema.type === 'array' && propertySchema.items && !Array.isArray(propertySchema.items) && propertySchema.items.enum) { + return true; + } + + if (propertySchema.type === 'string' || propertySchema.type === 'number' || propertySchema.type === 'integer' || propertySchema.type === 'boolean') { + return true; + } + + return false; + } + private async promptForArray(groupName: string, property: string, propertySchema: IJSONSchema): Promise { if (!propertySchema.items || Array.isArray(propertySchema.items) || !propertySchema.items.enum) { return undefined; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 3c25e3b1f59..b9b87b8a0ea 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1030,6 +1030,9 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel const title = state.confirmationMessages?.title; return title ? (isMarkdownString(title) ? title.value : title) : undefined; } + if (state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { + return localize('waitingForPostApproval', "Approve tool result?"); + } } if (part.kind === 'confirmation' && !part.isUsed) { return part.title; diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index 3b3dc98eccd..9deddc5695b 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -190,7 +190,7 @@ export class ChatToolInvocation implements IChatToolInvocation { // Transition to the appropriate state if (autoConfirmed) { confirm(autoConfirmed); - } if (!this.confirmationMessages?.title) { + } else if (!this.confirmationMessages?.title) { this._state.set({ type: IChatToolInvocation.StateKind.Executing, confirmed: { type: ToolConfirmKind.ConfirmationNotNeeded, reason: this.confirmationMessages?.confirmationNotNeededReason }, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index 8e9d41c3db7..6468c60cdfa 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ -import { DisposableMap } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; import { joinPath, isEqualOrParent } from '../../../../../base/common/resources.js'; import { localize } from '../../../../../nls.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ExtensionIdentifier, IExtensionManifest } from '../../../../../platform/extensions/common/extensions.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; import { IPromptsService, PromptsStorage } from './service/promptsService.js'; @@ -15,6 +15,9 @@ import { PromptsType } from './promptTypes.js'; import { UriComponents } from '../../../../../base/common/uri.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../../services/extensionManagement/common/extensionFeatures.js'; interface IRawChatFileContribution { readonly path: string; @@ -162,3 +165,80 @@ CommandsRegistry.registerCommand('_listExtensionPromptFiles', async (accessor): return result; }); + +class ChatPromptFilesDataRenderer extends Disposable implements IExtensionFeatureTableRenderer { + readonly type = 'table'; + + constructor(private readonly contributionPoint: ChatContributionPoint) { + super(); + } + + shouldRender(manifest: IExtensionManifest): boolean { + return !!manifest.contributes?.[this.contributionPoint]; + } + + render(manifest: IExtensionManifest): IRenderedData { + const contributions = manifest.contributes?.[this.contributionPoint] ?? []; + if (!contributions.length) { + return { data: { headers: [], rows: [] }, dispose: () => { } }; + } + + const headers = [ + localize('chatFilesName', "Name"), + localize('chatFilesDescription', "Description"), + localize('chatFilesPath', "Path"), + ]; + + const rows: IRowData[][] = contributions.map(d => { + return [ + d.name ?? '-', + d.description ?? '-', + d.path, + ]; + }); + + return { + data: { + headers, + rows + }, + dispose: () => { } + }; + } +} + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: ChatContributionPoint.chatPromptFiles, + label: localize('chatPromptFiles', "Chat Prompt Files"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(ChatPromptFilesDataRenderer, [ChatContributionPoint.chatPromptFiles]), +}); + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: ChatContributionPoint.chatInstructions, + label: localize('chatInstructions', "Chat Instructions"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(ChatPromptFilesDataRenderer, [ChatContributionPoint.chatInstructions]), +}); + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: ChatContributionPoint.chatAgents, + label: localize('chatAgents', "Chat Agents"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(ChatPromptFilesDataRenderer, [ChatContributionPoint.chatAgents]), +}); + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: ChatContributionPoint.chatSkills, + label: localize('chatSkills', "Chat Skills"), + access: { + canToggle: false + }, + renderer: new SyncDescriptor(ChatPromptFilesDataRenderer, [ChatContributionPoint.chatSkills]), +}); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 10f12a1c991..d6775015ef1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -318,13 +318,17 @@ export class ComputeAutomaticInstructions { entries.push(''); if (useSkillAdherencePrompt) { // Stronger skill adherence prompt for experimental feature - entries.push('Skills are curated collections of best practices and detailed guidance for producing high-quality outputs across various domains. Each skill folder contains refined instructions developed through extensive testing to help achieve professional results. Examples include presentation skills for creating polished slides, document skills for well-formatted reports, and many others. Multiple skills can be combined when a task spans different domains.'); - entries.push(`IMPORTANT: Reading skill documentation BEFORE taking action significantly improves output quality. When you receive a task, first review the available skills listed below to identify which ones apply. Use the ${readTool.variable} tool to load the relevant SKILL.md file(s) and follow their guidance.`); - entries.push('Examples of proper skill usage:'); - entries.push(`- Task: "Create a presentation about our product roadmap" -> First load the presentation skill via ${readTool.variable}`); - entries.push(`- Task: "Clean up the formatting in this document" -> First load the document editing skill via ${readTool.variable}`); - entries.push(`- Task: "Add a flowchart to the README" -> Load both the documentation skill and any diagram-related skill`); - entries.push('Taking time to read the skill instructions first leads to substantially better results.'); + entries.push('Skills provide specialized capabilities, domain knowledge, and refined workflows for producing high-quality outputs. Each skill folder contains tested instructions for specific domains like testing strategies, API design, or performance optimization. Multiple skills can be combined when a task spans different domains.'); + entries.push(`BLOCKING REQUIREMENT: When a skill applies to the user's request, you MUST load and read the SKILL.md file IMMEDIATELY as your first action, BEFORE generating any other response or taking action on the task. Use ${readTool.variable} to load the relevant skill(s).`); + entries.push('NEVER just mention or reference a skill in your response without actually reading it first. If a skill is relevant, load it before proceeding.'); + entries.push('How to determine if a skill applies:'); + entries.push('1. Review the available skills below and match their descriptions against the user\'s request'); + entries.push('2. If any skill\'s domain overlaps with the task, load that skill immediately'); + entries.push('3. When multiple skills apply (e.g., a flowchart in documentation), load all relevant skills'); + entries.push('Examples:'); + entries.push(`- "Help me write unit tests for this module" -> Load the testing skill via ${readTool.variable} FIRST, then proceed`); + entries.push(`- "Optimize this slow function" -> Load the performance-profiling skill via ${readTool.variable} FIRST, then proceed`); + entries.push(`- "Add a discount code field to checkout" -> Load both the checkout-flow and form-validation skills FIRST`); entries.push('Available skills:'); } else { entries.push('Here is a list of skills that contain domain specific knowledge on a variety of topics.'); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 2b5997e514e..1acb5024350 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -16,7 +16,7 @@ import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { IHeaderAttribute, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getValidAttributeNames, isGithubTarget, knownGithubCopilotTools } from './promptValidator.js'; +import { getAttributeDescription, getValidAttributeNames, isGithubTarget, knownGithubCopilotTools } from './promptValidator.js'; import { localize } from '../../../../../../nls.js'; export class PromptHeaderAutocompletion implements CompletionItemProvider { @@ -127,6 +127,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { for (const attribute of attributesToPropose) { const item: CompletionItem = { label: attribute, + documentation: getAttributeDescription(attribute, promptType), kind: CompletionItemKind.Property, insertText: getInsertText(attribute), insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 8613d2513fb..f92dafd9228 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -16,7 +16,7 @@ import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; import { IHeaderAttribute, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { isGithubTarget } from './promptValidator.js'; +import { getAttributeDescription, isGithubTarget } from './promptValidator.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -68,80 +68,27 @@ export class PromptHoverProvider implements HoverProvider { } private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader): Promise { - switch (promptType) { - case PromptsType.instructions: - for (const attribute of header.attributes) { - if (attribute.range.containsPosition(position)) { - switch (attribute.key) { - case PromptHeaderAttributes.name: - return this.createHover(localize('promptHeader.instructions.name', 'The name of the instruction file as shown in the UI. If not set, the name is derived from the file name.'), attribute.range); - case PromptHeaderAttributes.description: - return this.createHover(localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'), attribute.range); - case PromptHeaderAttributes.applyTo: - return this.createHover(localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: `**/*.ts`, `**/*.js`, `client/**`'), attribute.range); - } + for (const attribute of header.attributes) { + if (attribute.range.containsPosition(position)) { + const description = getAttributeDescription(attribute.key, promptType); + if (description) { + switch (attribute.key) { + case PromptHeaderAttributes.model: + return this.getModelHover(attribute, position, description, promptType === PromptsType.agent && isGithubTarget(promptType, header.target)); + case PromptHeaderAttributes.tools: + return this.getToolHover(attribute, position, description); + case PromptHeaderAttributes.agent: + case PromptHeaderAttributes.mode: + return this.getAgentHover(attribute, position, description); + case PromptHeaderAttributes.handOffs: + return this.getHandsOffHover(attribute, position, promptType === PromptsType.agent && isGithubTarget(promptType, header.target)); + case PromptHeaderAttributes.infer: + return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', '- `all`, `true`: Available in the agent picker and can be used as a subagent.\n- `user`, `false`: Only available in the agent picker.\n- `agent`: Only available as a subagent (not shown in picker).\n- `hidden`: Not available in the picker nor as a subagent.'), attribute.range); + default: + return this.createHover(description, attribute.range); } } - break; - case PromptsType.skill: - for (const attribute of header.attributes) { - if (attribute.range.containsPosition(position)) { - switch (attribute.key) { - case PromptHeaderAttributes.name: - return this.createHover(localize('promptHeader.skill.name', 'The name of the skill.'), attribute.range); - case PromptHeaderAttributes.description: - return this.createHover(localize('promptHeader.skill.description', 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.'), attribute.range); - } - } - } - break; - case PromptsType.agent: - for (const attribute of header.attributes) { - if (attribute.range.containsPosition(position)) { - switch (attribute.key) { - case PromptHeaderAttributes.name: - return this.createHover(localize('promptHeader.agent.name', 'The name of the agent as shown in the UI.'), attribute.range); - case PromptHeaderAttributes.description: - return this.createHover(localize('promptHeader.agent.description', 'The description of the custom agent, what it does and when to use it.'), attribute.range); - case PromptHeaderAttributes.argumentHint: - return this.createHover(localize('promptHeader.agent.argumentHint', 'The argument-hint describes what inputs the custom agent expects or supports.'), attribute.range); - case PromptHeaderAttributes.model: - return this.getModelHover(attribute, position, localize('promptHeader.agent.model', 'Specify the model that runs this custom agent. Can also be a list of models. The first available model will be used.'), isGithubTarget(promptType, header.target)); - case PromptHeaderAttributes.tools: - return this.getToolHover(attribute, position, localize('promptHeader.agent.tools', 'The set of tools that the custom agent has access to.')); - case PromptHeaderAttributes.handOffs: - return this.getHandsOffHover(attribute, position, isGithubTarget(promptType, header.target)); - case PromptHeaderAttributes.target: - return this.createHover(localize('promptHeader.agent.target', 'The target to which the header attributes like tools apply to. Possible values are `github-copilot` and `vscode`.'), attribute.range); - case PromptHeaderAttributes.infer: - return this.createHover(localize('promptHeader.agent.infer', 'Controls visibility of the agent.\n\n- `all`, `true`: Available in the agent picker and can be used as a subagent.\n- `user`, `false`: Only available in the agent picker.\n- `agent`: Only available as a subagent (not shown in picker).\n- `hidden`: Not available in the picker nor as a subagent.'), attribute.range); - case PromptHeaderAttributes.agents: - return this.createHover(localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'), attribute.range); - } - } - } - break; - case PromptsType.prompt: - for (const attribute of header.attributes) { - if (attribute.range.containsPosition(position)) { - switch (attribute.key) { - case PromptHeaderAttributes.name: - return this.createHover(localize('promptHeader.prompt.name', 'The name of the prompt. This is also the name of the slash command that will run this prompt.'), attribute.range); - case PromptHeaderAttributes.description: - return this.createHover(localize('promptHeader.prompt.description', 'The description of the reusable prompt, what it does and when to use it.'), attribute.range); - case PromptHeaderAttributes.argumentHint: - return this.createHover(localize('promptHeader.prompt.argumentHint', 'The argument-hint describes what inputs the prompt expects or supports.'), attribute.range); - case PromptHeaderAttributes.model: - return this.getModelHover(attribute, position, localize('promptHeader.prompt.model', 'The model to use in this prompt. Can also be a list of models. The first available model will be used.'), false); - case PromptHeaderAttributes.tools: - return this.getToolHover(attribute, position, localize('promptHeader.prompt.tools', 'The tools to use in this prompt.')); - case PromptHeaderAttributes.agent: - case PromptHeaderAttributes.mode: - return this.getAgentHover(attribute, position); - } - } - } - break; + } } return undefined; } @@ -221,7 +168,7 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(baseMessage, node.range); } - private getAgentHover(agentAttribute: IHeaderAttribute, position: Position): Hover | undefined { + private getAgentHover(agentAttribute: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { const lines: string[] = []; const value = agentAttribute.value; if (value.type === 'string' && value.range.containsPosition(position)) { @@ -232,7 +179,7 @@ export class PromptHoverProvider implements HoverProvider { } } else { const agents = this.chatModeService.getModes(); - lines.push(localize('promptHeader.prompt.agent.description', 'The agent to use when running this prompt.')); + lines.push(baseMessage); lines.push(''); // Built-in agents @@ -255,7 +202,7 @@ export class PromptHoverProvider implements HoverProvider { } private getHandsOffHover(attribute: IHeaderAttribute, position: Position, isGitHubTarget: boolean): Hover | undefined { - const handoffsBaseMessage = localize('promptHeader.agent.handoffs', 'Possible handoff actions when the agent has completed its task.'); + const handoffsBaseMessage = getAttributeDescription(PromptHeaderAttributes.handOffs, PromptsType.agent)!; if (isGitHubTarget) { return this.createHover(handoffsBaseMessage + '\n\n' + localize('promptHeader.agent.handoffs.githubCopilot', 'Note: This attribute is not used when target is github-copilot.'), attribute.range); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 2314291840c..a0d6256ad86 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -11,7 +11,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../platform/markers/common/markers.js'; -import { IChatMode, IChatModeService } from '../../chatModes.js'; +import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; @@ -25,6 +25,7 @@ import { IPromptsService } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { AGENTS_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -40,7 +41,7 @@ export class PromptValidator { public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { promptAST.header?.errors.forEach(error => report(toMarker(error.message, error.range, MarkerSeverity.Error))); - this.validateHeader(promptAST, promptType, report); + await this.validateHeader(promptAST, promptType, report); await this.validateBody(promptAST, promptType, report); await this.validateFileName(promptAST, promptType, report); await this.validateSkillFolderName(promptAST, promptType, report); @@ -155,7 +156,7 @@ export class PromptValidator { await Promise.all(fileReferenceChecks); } - private validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): void { + private async validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { const header = promptAST.header; if (!header) { return; @@ -186,7 +187,7 @@ export class PromptValidator { if (!isGitHubTarget) { this.validateModel(attributes, ChatModeKind.Agent, report); this.validateHandoffs(attributes, report); - this.validateAgentsAttribute(attributes, header, report); + await this.validateAgentsAttribute(attributes, header, report); } break; } @@ -514,8 +515,13 @@ export class PromptValidator { report(toMarker(localize('promptValidator.handoffShowContinueOnMustBeBoolean', "The 'showContinueOn' property in a handoff must be a boolean."), prop.value.range, MarkerSeverity.Error)); } break; + case 'model': + if (prop.value.type !== 'string') { + report(toMarker(localize('promptValidator.handoffModelMustBeString', "The 'model' property in a handoff must be a string."), prop.value.range, MarkerSeverity.Error)); + } + break; default: - report(toMarker(localize('promptValidator.unknownHandoffProperty', "Unknown property '{0}' in handoff object. Supported properties are 'label', 'agent', 'prompt' and optional 'send', 'showContinueOn'.", prop.key.value), prop.value.range, MarkerSeverity.Warning)); + report(toMarker(localize('promptValidator.unknownHandoffProperty', "Unknown property '{0}' in handoff object. Supported properties are 'label', 'agent', 'prompt' and optional 'send', 'showContinueOn', 'model'.", prop.key.value), prop.value.range, MarkerSeverity.Warning)); } required.delete(prop.key.value); } @@ -565,7 +571,7 @@ export class PromptValidator { } } - private validateAgentsAttribute(attributes: IHeaderAttribute[], header: PromptHeader, report: (markers: IMarkerData) => void): undefined { + private async validateAgentsAttribute(attributes: IHeaderAttribute[], header: PromptHeader, report: (markers: IMarkerData) => void): Promise { const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.agents); if (!attribute) { return; @@ -575,13 +581,21 @@ export class PromptValidator { return; } - // Check each item is a string + // Collect available agent names + const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + const availableAgentNames = new Set(agents.map(agent => agent.name)); + availableAgentNames.add(ChatMode.Agent.name.get()); // include default agent + + // Check each item is a string and agent exists const agentNames: string[] = []; for (const item of attribute.value.items) { if (item.type !== 'string') { report(toMarker(localize('promptValidator.eachAgentMustBeString', "Each agent name in the 'agents' attribute must be a string."), item.range, MarkerSeverity.Error)); } else if (item.value) { agentNames.push(item.value); + if (item.value !== '*' && !availableAgentNames.has(item.value)) { + report(toMarker(localize('promptValidator.agentInAgentsNotFound', "Unknown agent '{0}'. Available agents: {1}.", item.value, Array.from(availableAgentNames).join(', ')), item.range, MarkerSeverity.Warning)); + } } } @@ -620,6 +634,69 @@ export function isNonRecommendedAttribute(attributeName: string): boolean { return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode; } +export function getAttributeDescription(attributeName: string, promptType: PromptsType): string | undefined { + switch (promptType) { + case PromptsType.instructions: + switch (attributeName) { + case PromptHeaderAttributes.name: + return localize('promptHeader.instructions.name', 'The name of the instruction file as shown in the UI. If not set, the name is derived from the file name.'); + case PromptHeaderAttributes.description: + return localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'); + case PromptHeaderAttributes.applyTo: + return localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: `**/*.ts`, `**/*.js`, `client/**`'); + } + break; + case PromptsType.skill: + switch (attributeName) { + case PromptHeaderAttributes.name: + return localize('promptHeader.skill.name', 'The name of the skill.'); + case PromptHeaderAttributes.description: + return localize('promptHeader.skill.description', 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.'); + } + break; + case PromptsType.agent: + switch (attributeName) { + case PromptHeaderAttributes.name: + return localize('promptHeader.agent.name', 'The name of the agent as shown in the UI.'); + case PromptHeaderAttributes.description: + return localize('promptHeader.agent.description', 'The description of the custom agent, what it does and when to use it.'); + case PromptHeaderAttributes.argumentHint: + return localize('promptHeader.agent.argumentHint', 'The argument-hint describes what inputs the custom agent expects or supports.'); + case PromptHeaderAttributes.model: + return localize('promptHeader.agent.model', 'Specify the model that runs this custom agent. Can also be a list of models. The first available model will be used.'); + case PromptHeaderAttributes.tools: + return localize('promptHeader.agent.tools', 'The set of tools that the custom agent has access to.'); + case PromptHeaderAttributes.handOffs: + return localize('promptHeader.agent.handoffs', 'Possible handoff actions when the agent has completed its task.'); + case PromptHeaderAttributes.target: + return localize('promptHeader.agent.target', 'The target to which the header attributes like tools apply to. Possible values are `github-copilot` and `vscode`.'); + case PromptHeaderAttributes.infer: + return localize('promptHeader.agent.infer', 'Controls visibility of the agent.'); + case PromptHeaderAttributes.agents: + return localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'); + } + break; + case PromptsType.prompt: + switch (attributeName) { + case PromptHeaderAttributes.name: + return localize('promptHeader.prompt.name', 'The name of the prompt. This is also the name of the slash command that will run this prompt.'); + case PromptHeaderAttributes.description: + return localize('promptHeader.prompt.description', 'The description of the reusable prompt, what it does and when to use it.'); + case PromptHeaderAttributes.argumentHint: + return localize('promptHeader.prompt.argumentHint', 'The argument-hint describes what inputs the prompt expects or supports.'); + case PromptHeaderAttributes.model: + return localize('promptHeader.prompt.model', 'The model to use in this prompt. Can also be a list of models. The first available model will be used.'); + case PromptHeaderAttributes.tools: + return localize('promptHeader.prompt.tools', 'The tools to use in this prompt.'); + case PromptHeaderAttributes.agent: + case PromptHeaderAttributes.mode: + return localize('promptHeader.prompt.agent.description', 'The agent to use when running this prompt.'); + } + break; + } + return undefined; +} + // The list of tools known to be used by GitHub Copilot custom agents export const knownGithubCopilotTools = [ SpecedToolAliases.execute, SpecedToolAliases.read, SpecedToolAliases.edit, SpecedToolAliases.search, SpecedToolAliases.agent, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 995d6d7c332..bbaacb90a73 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -238,7 +238,7 @@ export class PromptHeader { return undefined; } if (handoffsAttribute.value.type === 'array') { - // Array format: list of objects: { agent, label, prompt, send?, showContinueOn? } + // Array format: list of objects: { agent, label, prompt, send?, showContinueOn?, model? } const handoffs: IHandOff[] = []; for (const item of handoffsAttribute.value.items) { if (item.type === 'object') { @@ -247,6 +247,7 @@ export class PromptHeader { let prompt: string | undefined; let send: boolean | undefined; let showContinueOn: boolean | undefined; + let model: string | undefined; for (const prop of item.properties) { if (prop.key.value === 'agent' && prop.value.type === 'string') { agent = prop.value.value; @@ -258,6 +259,8 @@ export class PromptHeader { send = prop.value.value; } else if (prop.key.value === 'showContinueOn' && prop.value.type === 'boolean') { showContinueOn = prop.value.value; + } else if (prop.key.value === 'model' && prop.value.type === 'string') { + model = prop.value.value; } } if (agent && label && prompt !== undefined) { @@ -266,7 +269,8 @@ export class PromptHeader { label, prompt, ...(send !== undefined ? { send } : {}), - ...(showContinueOn !== undefined ? { showContinueOn } : {}) + ...(showContinueOn !== undefined ? { showContinueOn } : {}), + ...(model !== undefined ? { model } : {}) }; handoffs.push(handoff); } @@ -325,6 +329,7 @@ export interface IHandOff { readonly prompt: string; readonly send?: boolean; readonly showContinueOn?: boolean; // treated exactly like send (optional boolean) + readonly model?: string; // qualified model name to switch to (e.g., "GPT-5 (copilot)") } export interface IHeaderAttribute { diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index e9245caf859..56100a090d6 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -9,7 +9,7 @@ import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -129,6 +129,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const request = model.getRequests().at(-1)!; + const store = new DisposableStore(); + try { // Get the default agent const defaultAgent = this.chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, ChatModeKind.Agent); @@ -194,7 +196,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const progressCallback = (parts: IChatProgress[]) => { for (const part of parts) { // Write certain parts immediately to the model - if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized' || part.kind === 'textEdit' || part.kind === 'notebookEdit' || part.kind === 'codeblockUri') { + if (part.kind === 'textEdit' || part.kind === 'notebookEdit' || part.kind === 'codeblockUri') { if (part.kind === 'codeblockUri' && !inEdit) { inEdit = true; model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('```\n') }); @@ -205,11 +207,6 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } else { model.acceptResponseProgress(request, part); } - - // When we see a tool invocation starting, reset markdown collection - if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { - markdownParts.length = 0; // Clear previously collected markdown - } } else if (part.kind === 'markdownContent') { if (inEdit) { model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n') }); @@ -240,12 +237,19 @@ export class RunSubagentTool extends Disposable implements IToolImpl { variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, subAgentInvocationId: invocation.callId, - subAgentName: args.agentName, + subAgentName: args.agentName ?? 'subagent', userSelectedModelId: modeModelId, userSelectedTools: modeTools, modeInstructions, }; + // Subscribe to tool invocations to clear markdown parts when a tool is invoked + store.add(this.languageModelToolsService.onDidInvokeTool(e => { + if (e.subagentInvocationId === subAgentInvocationId) { + markdownParts.length = 0; + } + })); + // Invoke the agent const result = await this.chatAgentService.invokeAgent( defaultAgent.id, @@ -260,19 +264,34 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return createToolSimpleTextResult(`Agent error: ${result.errorDetails.message}`); } - const resultText = markdownParts.join('') || 'Agent completed with no output'; + // This is a hack due to the fact that edits are represented as empty codeblocks with URIs. That needs to be cleaned up, + // in the meantime, just strip an empty codeblock left behind. + const resultText = markdownParts.join('').replace(/^\n*```\n+```\n*/g, '').trim() || 'Agent completed with no output'; // Store result in toolSpecificData for serialization if (invocation.toolSpecificData?.kind === 'subagent') { invocation.toolSpecificData.result = resultText; } - return createToolSimpleTextResult(resultText); + // Return result with toolMetadata containing subAgentInvocationId for trajectory tracking + return { + content: [{ + kind: 'text', + value: resultText + }], + toolMetadata: { + subAgentInvocationId, + description: args.description, + agentName: agentRequest.subAgentName, + } + }; } catch (error) { const errorMessage = `Error invoking subagent: ${error instanceof Error ? error.message : 'Unknown error'}`; this.logService.error(errorMessage, error); return createToolSimpleTextResult(errorMessage); + } finally { + store.dispose(); } } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 7e573bd40b1..6fb55c9754e 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -470,6 +470,13 @@ export interface IBeginToolCallOptions { subagentInvocationId?: string; } +export interface IToolInvokedEvent { + readonly toolId: string; + readonly sessionResource: URI | undefined; + readonly requestId: string | undefined; + readonly subagentInvocationId: string | undefined; +} + export const ILanguageModelToolsService = createDecorator('ILanguageModelToolsService'); export type CountTokensCallback = (input: string, token: CancellationToken) => Promise; @@ -482,6 +489,7 @@ export interface ILanguageModelToolsService { readonly agentToolSet: ToolSet; readonly onDidChangeTools: Event; readonly onDidPrepareToolCallBecomeUnresponsive: Event<{ readonly sessionResource: URI; readonly toolData: IToolData }>; + readonly onDidInvokeTool: Event; registerToolData(toolData: IToolData): IDisposable; registerToolImplementation(id: string, tool: IToolImpl): IDisposable; registerTool(toolData: IToolData, tool: IToolImpl): IDisposable; diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 19cb4fbee53..274ca60b475 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -14,6 +14,7 @@ import { ChatEntitlementContextKeys } from '../../../../services/chat/common/cha import { IWorkbenchModeService } from '../../../../services/layout/common/workbenchModeService.js'; import { IsAgentSessionsWorkspaceContext, WorkbenchModeContext } from '../../../../common/contextkeys.js'; import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; +import { ProductQualityContext } from '../../../../../platform/contextkey/common/contextkeys.js'; export class OpenAgentSessionsWindowAction extends Action2 { constructor() { @@ -21,7 +22,7 @@ export class OpenAgentSessionsWindowAction extends Action2 { id: 'workbench.action.openAgentSessionsWindow', title: localize2('openAgentSessionsWindow', "Open Agent Sessions Window"), category: CHAT_CATEGORY, - precondition: ChatEntitlementContextKeys.Setup.hidden.negate(), + precondition: ContextKeyExpr.and(ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate()), f1: true, }); } @@ -54,6 +55,7 @@ export class SwitchToAgentSessionsModeAction extends Action2 { title: localize2('switchToAgentSessionsMode', "Switch to Agent Sessions Mode"), category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( + ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate(), IsAgentSessionsWorkspaceContext.toNegated(), WorkbenchModeContext.notEqualsTo('agent-sessions') @@ -75,6 +77,7 @@ export class SwitchToNormalModeAction extends Action2 { title: localize2('switchToNormalMode', "Switch to Default Mode"), category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( + ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate(), IsAgentSessionsWorkspaceContext.toNegated(), WorkbenchModeContext.notEqualsTo('') diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts index 1aa7974773e..105de058437 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { formatStatusOutput, IFileStatusInfo, IPathInfo, ITypeStatusInfo } from '../../../browser/actions/chatCustomizationDiagnosticsAction.js'; @@ -91,7 +92,6 @@ suite('formatStatusOutput', () => { '.github/agents
', `${TREE_BRANCH} [\`code-reviewer.agent.md\`](${filePath('.github/agents/code-reviewer.agent.md')})
`, `${TREE_END} [\`test-helper.agent.md\`](${filePath('.github/agents/test-helper.agent.md')})
`, - 'AGENTS.md -
', '' )); }); @@ -119,7 +119,6 @@ suite('formatStatusOutput', () => { '.github/agents
', `${TREE_BRANCH} [\`good-agent.agent.md\`](${filePath('.github/agents/good-agent.agent.md')})
`, `${TREE_END} ${ICON_ERROR} [\`broken-agent.agent.md\`](${filePath('.github/agents/broken-agent.agent.md')}) - *Missing name attribute*
`, - 'AGENTS.md -
', '' )); }); @@ -151,7 +150,6 @@ suite('formatStatusOutput', () => { `${TREE_END} [\`my-agent.agent.md\`](${filePath('.github/agents/my-agent.agent.md')})
`, '~/.copilot/agents
', `${TREE_END} ${ICON_WARN} [\`my-agent.agent.md\`](${filePath('home/.copilot/agents/my-agent.agent.md')}) - *Overwritten by higher priority file*
`, - 'AGENTS.md -
', '' )); }); @@ -229,16 +227,17 @@ suite('formatStatusOutput', () => { '', '.github/instructions
', `${TREE_END} [\`testing.instructions.md\`](${filePath('.github/instructions/testing.instructions.md')})
`, + 'AGENTS.md -
', 'copilot-instructions.md
', `${TREE_END} [\`copilot-instructions.md\`](${filePath('.github/copilot-instructions.md')})
`, '' )); }); - test('agents with AGENTS.md enabled', () => { + test('instructions with AGENTS.md enabled', () => { const statusInfos: ITypeStatusInfo[] = [{ - type: PromptsType.agent, - paths: [createPath('.github/agents', true)], + type: PromptsType.instructions, + paths: [createPath('.github/instructions', true)], files: [], enabled: true }]; @@ -254,13 +253,14 @@ suite('formatStatusOutput', () => { '## Chat Customization Diagnostics', '*WARNING: This file may contain sensitive information.*', '', - '**Custom Agents**
', + '**Instructions**
', '*2 files loaded*', '', - '.github/agents
', + '.github/instructions
', 'AGENTS.md
', `${TREE_BRANCH} [\`AGENTS.md\`](${filePath('AGENTS.md')})
`, `${TREE_END} [\`AGENTS.md\`](${filePath('docs/AGENTS.md')})
`, + 'copilot-instructions.md -
', '' )); }); @@ -286,7 +286,6 @@ suite('formatStatusOutput', () => { '', '.github/agents
', `${ICON_ERROR} custom/agents - *Folder does not exist*
`, - 'AGENTS.md -
', '' )); }); @@ -310,7 +309,6 @@ suite('formatStatusOutput', () => { '**Custom Agents**
', '', '.github/agents
', - 'AGENTS.md -
', '' )); }); @@ -341,7 +339,6 @@ suite('formatStatusOutput', () => { `${TREE_END} [\`local-agent.agent.md\`](${filePath('.github/agents/local-agent.agent.md')})
`, 'Extension: my-publisher.my-extension
', `${TREE_END} [\`ext-agent.agent.md\`](${filePath('extensions/my-publisher.my-extension/agents/ext-agent.agent.md')})
`, - 'AGENTS.md -
', '' )); }); @@ -406,13 +403,13 @@ suite('formatStatusOutput', () => { '', '.github/agents
', `${TREE_END} [\`helper.agent.md\`](${filePath('.github/agents/helper.agent.md')})
`, - 'AGENTS.md -
', '', '**Instructions**
', '*1 file loaded*', '', '.github/instructions
', `${TREE_END} [\`code-style.instructions.md\`](${filePath('.github/instructions/code-style.instructions.md')})
`, + 'AGENTS.md -
', 'copilot-instructions.md -
', '', '**Prompt Files**
', @@ -483,4 +480,45 @@ suite('formatStatusOutput', () => { assert.ok(output.includes('docs%20%26%20notes'), 'Ampersand should be URL-encoded'); assert.ok(output.includes('test%5B1%5D.prompt.md'), 'Brackets should be URL-encoded'); }); + + test('vscode-userdata scheme URIs are converted to file scheme for relative paths', () => { + // Create a workspace folder + const workspaceFolderUri = URI.file('/Users/test/workspace'); + const workspaceFolder = { + uri: workspaceFolderUri, + name: 'workspace', + index: 0, + toResource: (relativePath: string) => URI.joinPath(workspaceFolderUri, relativePath) + }; + + // Create a vscode-userdata URI that maps to a path under the workspace + const userDataUri = URI.file('/Users/test/workspace/.github/agents/my-agent.agent.md').with({ scheme: Schemas.vscodeUserData }); + + const statusInfos: ITypeStatusInfo[] = [{ + type: PromptsType.agent, + paths: [{ + uri: URI.file('/Users/test/workspace/.github/agents'), + exists: true, + storage: PromptsStorage.local, + scanOrder: 1, + displayPath: '.github/agents', + isDefault: true + }], + files: [{ + uri: userDataUri, + status: 'loaded', + name: 'my-agent.agent.md', + storage: PromptsStorage.local + }], + enabled: true + }]; + + const output = formatStatusOutput(statusInfos, emptySpecialFiles, [workspaceFolder]); + + // The vscode-userdata URI should be converted to file scheme internally, + // allowing relative path computation against workspace folders + assert.ok(output.includes('.github/agents/my-agent.agent.md'), 'Should use relative path from workspace folder'); + // Should not contain the full absolute path + assert.ok(!output.includes('/Users/test/workspace/.github'), 'Should not contain absolute path when relative path is available'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index e9d0189ab9c..ab75922685a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -24,7 +24,7 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js'; -suite('Agent Sessions', () => { +suite('AgentSessions', () => { suite('AgentSessionsViewModel', () => { @@ -230,17 +230,12 @@ suite('Agent Sessions', () => { chatSessionType: 'type-2', onDidChangeChatSessionItems: Event.None, provideChatSessionItems: async () => [ - { - id: 'session-2', - resource: URI.parse('test://session-2'), - label: 'Session 2', - timing: makeNewSessionTiming() - } + makeSimpleSessionItem('session-2'), ] }; - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); + disposables.add(mockChatSessionsService.registerChatSessionItemProvider(provider1)); + disposables.add(mockChatSessionsService.registerChatSessionItemProvider(provider2)); viewModel = createViewModel(); @@ -250,8 +245,8 @@ suite('Agent Sessions', () => { // Now resolve only type-1 await viewModel.resolve('type-1'); - // Should still have both sessions, but only type-1 was re-resolved - assert.strictEqual(viewModel.sessions.length, 2); + // Only type-1 sessions remain since non-resolved providers are cleared + assert.strictEqual(viewModel.sessions.length, 1); }); }); @@ -550,7 +545,7 @@ suite('Agent Sessions', () => { }); }); - test('should preserve sessions from non-resolved providers', async () => { + test('should not preserve sessions from non-resolved providers', async () => { return runWithFakedTimers({}, async () => { let provider1CallCount = 0; let provider2CallCount = 0; @@ -595,19 +590,16 @@ suite('Agent Sessions', () => { assert.strictEqual(viewModel.sessions.length, 2); assert.strictEqual(provider1CallCount, 1); assert.strictEqual(provider2CallCount, 1); - const originalSession1Label = viewModel.sessions[0].label; // Now resolve only type-2 await viewModel.resolve('type-2'); - // Should still have both sessions - assert.strictEqual(viewModel.sessions.length, 2); + // Should still have only one session + assert.strictEqual(viewModel.sessions.length, 1); // Provider 1 should not be called again assert.strictEqual(provider1CallCount, 1); // Provider 2 should be called again assert.strictEqual(provider2CallCount, 2); - // Session 1 should be preserved with original label - assert.strictEqual(viewModel.sessions.find(s => s.resource.toString() === 'test://session-1')?.label, originalSession1Label); }); }); @@ -1400,6 +1392,8 @@ suite('Agent Sessions', () => { instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); instantiationService.stub(IChatSessionsService, mockChatSessionsService); instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); + const storageService = instantiationService.get(IStorageService); + storageService.store('agentSessions.readDateBaseline', 1, StorageScope.WORKSPACE, StorageTarget.MACHINE); }); teardown(() => { @@ -1410,11 +1404,21 @@ suite('Agent Sessions', () => { test('should mark session as read and unread', async () => { return runWithFakedTimers({}, async () => { + const futureSessionTiming: IChatSessionItem['timing'] = { + created: Date.UTC(2026, 1 /* February */, 1), + lastRequestStarted: Date.UTC(2026, 1 /* February */, 1), + lastRequestEnded: Date.UTC(2026, 1 /* February */, 2), + }; + const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', onDidChangeChatSessionItems: Event.None, provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), + { + resource: URI.parse('test://session-1'), + label: 'Session 1', + timing: futureSessionTiming, + }, ] }; @@ -1520,7 +1524,7 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { - // Session with timing before the READ_STATE_INITIAL_DATE (January 28, 2026) + // Without migration, all sessions are unread by default const oldSessionTiming: IChatSessionItem['timing'] = { created: Date.UTC(2025, 10 /* November */, 1), lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), @@ -1545,14 +1549,13 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Sessions before the initial date should be considered read - assert.strictEqual(session.isRead(), true); + // Sessions are unread by default (migration already happened in setup) + assert.strictEqual(session.isRead(), false); }); }); test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { - // Session with timing after the READ_STATE_INITIAL_DATE (January 28, 2026) const newSessionTiming: IChatSessionItem['timing'] = { created: Date.UTC(2026, 1 /* February */, 1), lastRequestStarted: Date.UTC(2026, 1 /* February */, 1), @@ -1616,7 +1619,7 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { - // Session with only startTime before initial date + // Session with only startTime const sessionTiming: IChatSessionItem['timing'] = { created: Date.UTC(2025, 10 /* November */, 1), lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), @@ -1641,15 +1644,13 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use startTime (November 1) which is before the initial date - assert.strictEqual(session.isRead(), true); + // Sessions are unread by default + assert.strictEqual(session.isRead(), false); }); }); test('should treat archived sessions as read', async () => { return runWithFakedTimers({}, async () => { - // Session with timing after the READ_STATE_INITIAL_DATE (January 28, 2026) - // which would normally be unread const newSessionTiming: IChatSessionItem['timing'] = { created: Date.UTC(2026, 1 /* February */, 1), lastRequestStarted: Date.UTC(2026, 1 /* February */, 1), @@ -1689,7 +1690,6 @@ suite('Agent Sessions', () => { test('should mark session as read when archiving', async () => { return runWithFakedTimers({}, async () => { - // Session with timing after the READ_STATE_INITIAL_DATE (January 28, 2026) const newSessionTiming: IChatSessionItem['timing'] = { created: Date.UTC(2026, 1 /* February */, 1), lastRequestStarted: Date.UTC(2026, 1 /* February */, 1), @@ -1735,7 +1735,6 @@ suite('Agent Sessions', () => { test('should fire onDidChangeSessions when archiving an unread session', async () => { return runWithFakedTimers({}, async () => { - // Session with timing after the READ_STATE_INITIAL_DATE const newSessionTiming: IChatSessionItem['timing'] = { created: Date.UTC(2026, 1 /* February */, 1), lastRequestStarted: Date.UTC(2026, 1 /* February */, 1), @@ -1777,7 +1776,7 @@ suite('Agent Sessions', () => { test('should not fire onDidChangeSessions when archiving an already read session', async () => { return runWithFakedTimers({}, async () => { - // Session with timing before the READ_STATE_INITIAL_DATE (already read) + // Session with timing const oldSessionTiming: IChatSessionItem['timing'] = { created: Date.UTC(2025, 10 /* November */, 1), lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), @@ -1802,7 +1801,8 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Session before the initial date should be read + // Mark session as read first + session.setRead(true); assert.strictEqual(session.isRead(), true); let changeEventCount = 0; @@ -1813,7 +1813,7 @@ suite('Agent Sessions', () => { // Archive the session session.setArchived(true); - // Should fire once (for archived state change only, not for read since already read) + // Should fire only once for archived state change since session is already read assert.strictEqual(changeEventCount, 1); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsAccessibilityProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsAccessibilityProvider.test.ts new file mode 100644 index 00000000000..336955f5bb0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsAccessibilityProvider.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AgentSessionsAccessibilityProvider } from '../../../browser/agentSessions/agentSessionsViewer.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; + +suite('AgentSessionsAccessibilityProvider', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + let accessibilityProvider: AgentSessionsAccessibilityProvider; + + function createMockSession(overrides: Partial<{ + id: string; + label: string; + providerLabel: string; + status: ChatSessionStatus; + }> = {}): IAgentSession { + const now = Date.now(); + return { + providerType: 'test', + providerLabel: overrides.providerLabel ?? 'Test', + resource: URI.parse(`test://session/${overrides.id ?? 'default'}`), + status: overrides.status ?? ChatSessionStatus.Completed, + label: overrides.label ?? `Session ${overrides.id ?? 'default'}`, + icon: Codicon.terminal, + timing: { + created: now, + lastRequestEnded: undefined, + lastRequestStarted: undefined, + }, + changes: undefined, + isArchived: () => false, + setArchived: () => { }, + isRead: () => true, + setRead: () => { }, + }; + } + + function createMockSection(section: AgentSessionSection = AgentSessionSection.Today): IAgentSessionSection { + return { + section, + label: 'Today', + sessions: [] + }; + } + + setup(() => { + accessibilityProvider = new AgentSessionsAccessibilityProvider(); + }); + + test('getWidgetRole returns list', () => { + assert.strictEqual(accessibilityProvider.getWidgetRole(), 'list'); + }); + + test('getRole returns listitem for session', () => { + const session = createMockSession(); + assert.strictEqual(accessibilityProvider.getRole(session), 'listitem'); + }); + + test('getRole returns listitem for section', () => { + const section = createMockSection(); + assert.strictEqual(accessibilityProvider.getRole(section), 'listitem'); + }); + + test('getWidgetAriaLabel returns correct label', () => { + assert.strictEqual(accessibilityProvider.getWidgetAriaLabel(), 'Agent Sessions'); + }); + + test('getAriaLabel returns correct label for session', () => { + const session = createMockSession({ + id: 'test-session', + label: 'Test Session Title', + providerLabel: 'Agent' + }); + + const ariaLabel = accessibilityProvider.getAriaLabel(session); + + assert.ok(ariaLabel); + assert.ok(ariaLabel.includes('Test Session Title'), 'Aria label should include the session title'); + assert.ok(ariaLabel.includes('Agent'), 'Aria label should include the provider label'); + }); + + test('getAriaLabel returns correct label for section', () => { + const section = createMockSection(); + const ariaLabel = accessibilityProvider.getAriaLabel(section); + + assert.ok(ariaLabel); + assert.ok(ariaLabel.includes('sessions section'), 'Aria label should indicate it is a section'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 2dd04d73c43..21226981e1b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -25,8 +25,9 @@ import { getPromptFileExtension } from '../../../../common/promptSyntax/config/p import { PromptValidator } from '../../../../common/promptSyntax/languageProviders/promptValidator.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; -import { PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; +import { MockPromptsService } from '../../../common/promptSyntax/service/mockPromptsService.js'; suite('PromptValidator', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -144,6 +145,17 @@ suite('PromptValidator', () => { return Promise.resolve(existingFiles.has(uri)); } }); + const promptsService = new MockPromptsService(); + const customMode: ICustomAgent = { + uri: URI.parse('file:///test/custom-mode.md'), + name: 'Plan', + description: 'A test custom mode', + tools: ['tool1', 'tool2'], + agentInstructions: { content: 'Custom mode body', toolReferences: [] }, + source: { storage: PromptsStorage.local } + }; + promptsService.setCustomModes([customMode]); + instaService.stub(IPromptsService, promptsService); }); async function validate(code: string, promptType: PromptsType, uri?: URI): Promise { @@ -976,7 +988,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test"', - `agents: ['valid', 123]`, + `agents: ['agent', 123]`, `tools: ['agent']`, '---', ].join('\n'); @@ -984,11 +996,25 @@ suite('PromptValidator', () => { assert.deepStrictEqual(markers.map(m => m.message), [`Each agent name in the 'agents' attribute must be a string.`]); }); + test('unknown agent in agents attribute shows warning', async () => { + const content = [ + '---', + 'description: "Test"', + `agents: ['UnknownAgent']`, + `tools: ['agent']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `Unknown agent 'UnknownAgent'. Available agents: Plan, agent.`); + }); + test('agents attribute with non-empty value requires agent tool 1', async () => { const content = [ '---', 'description: "Test"', - `agents: ['Planning', 'Research']`, + `agents: ['agent', 'Plan']`, '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); @@ -999,7 +1025,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test"', - `agents: ['Planning', 'Research']`, + `agents: ['agent', 'Plan']`, `tools: ['shell']`, '---', ].join('\n'); @@ -1011,7 +1037,7 @@ suite('PromptValidator', () => { const content = [ '---', 'description: "Test"', - `agents: ['Planning', 'Research']`, + `agents: ['agent', 'Plan']`, `tools: ['agent']`, '---', ].join('\n'); @@ -1394,6 +1420,101 @@ suite('PromptValidator', () => { assert.deepStrictEqual(markers, [], 'Expected no validation issues when name is missing'); }); + test('skill with empty name does not validate folder match', async () => { + const content = [ + '---', + 'name: ""', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + // Should get error for empty name, but no folder mismatch warning since name is empty + assert.ok(markers.some(m => m.message.includes('must not be empty')), 'Expected error for empty name'); + assert.ok(!markers.some(m => m.message.includes('should match the folder name')), 'Should not warn about folder mismatch for empty name'); + }); + + test('skill name with whitespace trimmed matches folder name', async () => { + const content = [ + '---', + 'name: " my-skill "', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.deepStrictEqual(markers, [], 'Expected no validation issues when trimmed name matches folder'); + }); + + test('skill name validation with different folder depths', async () => { + // Test with deeper path structure + { + const content = [ + '---', + 'name: advanced-skill', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///home/user/.github/skills/advanced-skill/SKILL.md')); + assert.deepStrictEqual(markers, [], 'Expected no issues for deeper path when name matches'); + } + + // Test with mismatch in deeper path + { + const content = [ + '---', + 'name: wrong-name', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///home/user/.github/skills/correct-folder/SKILL.md')); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].message, `The skill name 'wrong-name' should match the folder name 'correct-folder'.`); + } + }); + + test('skill name validation with special characters in folder', async () => { + const content = [ + '---', + 'name: my_special-skill.v2', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my_special-skill.v2/SKILL.md')); + assert.deepStrictEqual(markers, [], 'Expected no issues when name with special chars matches folder'); + }); + + test('skill with non-string name type does not validate folder match', async () => { + const content = [ + '---', + 'name: 123', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + // Should get error for non-string name type, but no folder mismatch warning + assert.ok(markers.some(m => m.message.includes('must be a string')), 'Expected error for non-string name'); + assert.ok(!markers.some(m => m.message.includes('should match the folder name')), 'Should not warn about folder mismatch for non-string name'); + }); + + test('skill folder name validation only for skill type', async () => { + // Verify that folder name validation doesn't run for non-skill prompt types + const content = [ + '---', + 'name: different-name', + 'description: Test Agent', + '---', + 'This is an agent.' + ].join('\n'); + const markers = await validate(content, PromptsType.agent, URI.parse('file:///.github/agents/my-agent/AGENT.md')); + // Should not get folder name mismatch warning for agents + assert.ok(!markers.some(m => m.message.includes('should match the folder name')), 'Should not validate folder names for agents'); + }); + test('skill with unknown attributes shows warning', async () => { const content = [ '---', diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 59b21243c07..304216ea7f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -96,8 +96,8 @@ suite('ChatQuestionCarouselPart', () => { const inputContainer = widget.domNode.querySelector('.chat-question-input-container'); assert.ok(inputContainer); - const inputBox = inputContainer?.querySelector('.monaco-inputbox'); - assert.ok(inputBox, 'Should have an input box for text questions'); + const textarea = inputContainer?.querySelector('textarea.chat-question-text-textarea'); + assert.ok(textarea, 'Should have a textarea for text questions'); }); test('renders radio buttons for singleSelect type questions', () => { @@ -396,7 +396,9 @@ suite('ChatQuestionCarouselPart', () => { widget.skip(); assert.ok(submittedAnswers instanceof Map); - assert.strictEqual(submittedAnswers?.get('q1'), 'value_b'); + const answer = submittedAnswers?.get('q1') as { selectedValue: unknown; freeformValue: unknown }; + assert.strictEqual(answer.selectedValue, 'value_b'); + assert.strictEqual(answer.freeformValue, undefined); }); test('skip returns default values for multiSelect questions', () => { @@ -417,11 +419,12 @@ suite('ChatQuestionCarouselPart', () => { widget.skip(); assert.ok(submittedAnswers instanceof Map); - const values = submittedAnswers?.get('q1') as unknown[]; - assert.ok(Array.isArray(values)); - assert.strictEqual(values.length, 2); - assert.ok(values.includes('value_a')); - assert.ok(values.includes('value_c')); + const answer = submittedAnswers?.get('q1') as { selectedValues: unknown[]; freeformValue: unknown }; + assert.ok(Array.isArray(answer.selectedValues)); + assert.strictEqual(answer.selectedValues.length, 2); + assert.ok(answer.selectedValues.includes('value_a')); + assert.ok(answer.selectedValues.includes('value_c')); + assert.strictEqual(answer.freeformValue, undefined); }); test('skip returns defaults for multiple questions', () => { @@ -442,7 +445,9 @@ suite('ChatQuestionCarouselPart', () => { widget.skip(); assert.ok(submittedAnswers instanceof Map); assert.strictEqual(submittedAnswers?.get('q1'), 'text default'); - assert.strictEqual(submittedAnswers?.get('q2'), 'first_value'); + const answer = submittedAnswers?.get('q2') as { selectedValue: unknown; freeformValue: unknown }; + assert.strictEqual(answer.selectedValue, 'first_value'); + assert.strictEqual(answer.freeformValue, undefined); }); test('skip returns empty map when no defaults are provided', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts new file mode 100644 index 00000000000..9d7940db1de --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; +import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; +import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; +import { IChatModeService } from '../../../../common/chatModes.js'; +import { IChatService } from '../../../../common/chatService/chatService.js'; +import { ILanguageModelsService } from '../../../../common/languageModels.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; + +suite('RunSubagentTool', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('resultText trimming', () => { + test('trims leading empty codeblocks (```\\n```) from result', () => { + // This tests the regex: /^\n*```\n+```\n*/g + const testCases = [ + { input: '```\n```\nActual content', expected: 'Actual content' }, + { input: '\n```\n```\nActual content', expected: 'Actual content' }, + { input: '\n\n```\n\n```\n\nActual content', expected: 'Actual content' }, + { input: '```\n```\n```\n```\nActual content', expected: '```\n```\nActual content' }, // Only trims leading + { input: 'No codeblock here', expected: 'No codeblock here' }, + { input: '```\n```\n', expected: '' }, + { input: '', expected: '' }, + ]; + + for (const { input, expected } of testCases) { + const result = input.replace(/^\n*```\n+```\n*/g, '').trim(); + assert.strictEqual(result, expected, `Failed for input: ${JSON.stringify(input)}`); + } + }); + }); + + suite('prepareToolInvocation', () => { + test('returns correct toolSpecificData', async () => { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService(); + + const tool = testDisposables.add(new RunSubagentTool( + {} as IChatAgentService, + {} as IChatService, + {} as IChatModeService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + {} as IInstantiationService, + )); + + const result = await tool.prepareToolInvocation( + { + parameters: { + prompt: 'Test prompt', + description: 'Test task', + agentName: 'CustomAgent', + }, + chatSessionResource: URI.parse('test://session'), + }, + CancellationToken.None + ); + + assert.ok(result); + assert.strictEqual(result.invocationMessage, 'Test task'); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'Test task', + agentName: 'CustomAgent', + prompt: 'Test prompt', + }); + }); + }); + + suite('getToolData', () => { + test('returns basic tool data', () => { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService(); + + const tool = testDisposables.add(new RunSubagentTool( + {} as IChatAgentService, + {} as IChatService, + {} as IChatModeService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + {} as IInstantiationService, + )); + + const toolData = tool.getToolData(); + + assert.strictEqual(toolData.id, 'runSubagent'); + assert.ok(toolData.inputSchema); + assert.ok(toolData.inputSchema.properties?.prompt); + assert.ok(toolData.inputSchema.properties?.description); + assert.deepStrictEqual(toolData.inputSchema.required, ['prompt', 'description']); + }); + + test('includes agentName property when SubagentToolCustomAgents is enabled', () => { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService({ + 'chat.customAgentInSubagent.enabled': true, + }); + + const tool = testDisposables.add(new RunSubagentTool( + {} as IChatAgentService, + {} as IChatService, + {} as IChatModeService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + {} as IInstantiationService, + )); + + const toolData = tool.getToolData(); + + assert.ok(toolData.inputSchema?.properties?.agentName, 'agentName should be in schema when custom agents enabled'); + }); + }); + + suite('onDidInvokeTool event', () => { + test('mock service fires onDidInvokeTool events with correct data', () => { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const sessionResource = URI.parse('test://session'); + const receivedEvents: { toolId: string; sessionResource: URI | undefined; requestId: string | undefined; subagentInvocationId: string | undefined }[] = []; + + testDisposables.add(mockToolsService.onDidInvokeTool(e => { + receivedEvents.push(e); + })); + + mockToolsService.fireOnDidInvokeTool({ + toolId: 'test-tool', + sessionResource, + requestId: 'request-123', + subagentInvocationId: 'subagent-456', + }); + + assert.strictEqual(receivedEvents.length, 1); + assert.deepStrictEqual(receivedEvents[0], { + toolId: 'test-tool', + sessionResource, + requestId: 'request-123', + subagentInvocationId: 'subagent-456', + }); + }); + + test('events with different subagentInvocationId are distinguishable', () => { + // This tests the filtering logic used in RunSubagentTool.invoke() + // The tool subscribes to onDidInvokeTool and checks if e.subagentInvocationId matches its own callId + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const targetSubagentId = 'target-subagent'; + + const matchingEvents: string[] = []; + testDisposables.add(mockToolsService.onDidInvokeTool(e => { + if (e.subagentInvocationId === targetSubagentId) { + matchingEvents.push(e.toolId); + } + })); + + // Fire events with different subagentInvocationIds + mockToolsService.fireOnDidInvokeTool({ + toolId: 'unrelated-tool', + sessionResource: undefined, + requestId: undefined, + subagentInvocationId: 'different-subagent', + }); + mockToolsService.fireOnDidInvokeTool({ + toolId: 'matching-tool', + sessionResource: undefined, + requestId: undefined, + subagentInvocationId: targetSubagentId, + }); + mockToolsService.fireOnDidInvokeTool({ + toolId: 'another-unrelated-tool', + sessionResource: undefined, + requestId: undefined, + subagentInvocationId: undefined, + }); + + // Only the matching event should be captured + assert.deepStrictEqual(matchingEvents, ['matching-tool']); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index bd8095a8d53..a0bd9c40fe2 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -5,29 +5,38 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Event } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, IReader } from '../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { IProgressStep } from '../../../../../../platform/progress/common/progress.js'; import { ChatRequestToolReferenceEntry } from '../../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../../common/chatModes.js'; import { IChatToolInvocation } from '../../../common/chatService/chatService.js'; -import { CountTokensCallback, IBeginToolCallOptions, ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolSet, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; -import { URI } from '../../../../../../base/common/uri.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; +import { CountTokensCallback, IBeginToolCallOptions, ILanguageModelToolsService, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolSet, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; -export class MockLanguageModelToolsService implements ILanguageModelToolsService { +export class MockLanguageModelToolsService extends Disposable implements ILanguageModelToolsService { _serviceBrand: undefined; vscodeToolSet: ToolSet = new ToolSet('vscode', 'vscode', ThemeIcon.fromId(Codicon.code.id), ToolDataSource.Internal); executeToolSet: ToolSet = new ToolSet('execute', 'execute', ThemeIcon.fromId(Codicon.terminal.id), ToolDataSource.Internal); readToolSet: ToolSet = new ToolSet('read', 'read', ThemeIcon.fromId(Codicon.book.id), ToolDataSource.Internal); agentToolSet: ToolSet = new ToolSet('agent', 'agent', ThemeIcon.fromId(Codicon.agent.id), ToolDataSource.Internal); - constructor() { } + private readonly _onDidInvokeTool = this._register(new Emitter()); + + constructor() { + super(); + } readonly onDidChangeTools: Event = Event.None; readonly onDidPrepareToolCallBecomeUnresponsive: Event<{ sessionResource: URI; toolData: IToolData }> = Event.None; + readonly onDidInvokeTool = this._onDidInvokeTool.event; + + fireOnDidInvokeTool(event: IToolInvokedEvent): void { + this._onDidInvokeTool.fire(event); + } registerToolData(toolData: IToolData): IDisposable { return Disposable.None; diff --git a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts index 0616e6fb0f0..da642b8a11d 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/editTelemetryContribution.ts @@ -13,36 +13,39 @@ import { AnnotatedDocuments } from './helpers/annotatedDocuments.js'; import { EditTrackingFeature } from './telemetry/editSourceTrackingFeature.js'; import { VSCodeWorkspace } from './helpers/vscodeObservableWorkspace.js'; import { AiStatsFeature } from './editStats/aiStatsFeature.js'; -import { EDIT_TELEMETRY_SETTING_ID, AI_STATS_SETTING_ID } from './settingIds.js'; +import { AI_STATS_SETTING_ID, EDIT_TELEMETRY_SETTING_ID } from './settingIds.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; export class EditTelemetryContribution extends Disposable { constructor( - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + @ITelemetryService telemetryService: ITelemetryService, + @IChatEntitlementService chatEntitlementService: IChatEntitlementService ) { super(); - const workspace = derived(reader => reader.store.add(this._instantiationService.createInstance(VSCodeWorkspace))); - const annotatedDocuments = derived(reader => reader.store.add(this._instantiationService.createInstance(AnnotatedDocuments, workspace.read(reader)))); + const workspace = derived(reader => reader.store.add(instantiationService.createInstance(VSCodeWorkspace))); + const annotatedDocuments = derived(reader => reader.store.add(instantiationService.createInstance(AnnotatedDocuments, workspace.read(reader)))); - const editSourceTrackingEnabled = observableConfigValue(EDIT_TELEMETRY_SETTING_ID, true, this._configurationService); + const editSourceTrackingEnabled = observableConfigValue(EDIT_TELEMETRY_SETTING_ID, true, configurationService); this._register(autorun(r => { const enabled = editSourceTrackingEnabled.read(r); - if (!enabled || !telemetryLevelEnabled(this._telemetryService, TelemetryLevel.USAGE)) { + if (!enabled || !telemetryLevelEnabled(telemetryService, TelemetryLevel.USAGE)) { return; } - r.store.add(this._instantiationService.createInstance(EditTrackingFeature, workspace.read(r), annotatedDocuments.read(r))); + r.store.add(instantiationService.createInstance(EditTrackingFeature, workspace.read(r), annotatedDocuments.read(r))); })); - const aiStatsEnabled = observableConfigValue(AI_STATS_SETTING_ID, true, this._configurationService); + const aiStatsEnabled = observableConfigValue(AI_STATS_SETTING_ID, true, configurationService); this._register(autorun(r => { const enabled = aiStatsEnabled.read(r); - if (!enabled) { + const aiDisabled = chatEntitlementService.sentimentObs.read(r).hidden; + if (!enabled || aiDisabled) { return; } - r.store.add(this._instantiationService.createInstance(AiStatsFeature, annotatedDocuments.read(r))); + r.store.add(instantiationService.createInstance(AiStatsFeature, annotatedDocuments.read(r))); })); } } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 2b8d2e64881..f2a1f70f54f 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -29,7 +29,7 @@ import { CommandsRegistry, ICommandService } from '../../../../platform/commands import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { registerThemingParticipant, IColorTheme, ICssStyleCollector } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { buttonBackground, buttonForeground, buttonHoverBackground, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground, registerColor, editorWarningForeground, editorInfoForeground, editorErrorForeground, buttonSeparator } from '../../../../platform/theme/common/colorRegistry.js'; +import { buttonBackground, buttonForeground, buttonHoverBackground, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground, buttonSecondaryBorder, registerColor, editorWarningForeground, editorInfoForeground, editorErrorForeground, buttonSeparator } from '../../../../platform/theme/common/colorRegistry.js'; import { IJSONEditingService } from '../../../services/configuration/common/jsonEditing.js'; import { ITextEditorSelection } from '../../../../platform/editor/common/editor.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; @@ -3196,6 +3196,13 @@ registerColor('extensionButton.hoverBackground', { hcLight: null }, localize('extensionButtonHoverBackground', "Button background hover color for extension actions.")); +registerColor('extensionButton.border', { + dark: buttonSecondaryBorder, + light: buttonSecondaryBorder, + hcDark: null, + hcLight: null +}, localize('extensionButtonBorder', "Button border color for extension actions.")); + registerColor('extensionButton.separator', buttonSeparator, localize('extensionButtonSeparator', "Button separator color for extension actions")); export const extensionButtonProminentBackground = registerColor('extensionButton.prominentBackground', { diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionActions.css b/src/vs/workbench/contrib/extensions/browser/media/extensionActions.css index e999d53c5e5..6326d45f650 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionActions.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionActions.css @@ -38,7 +38,7 @@ .monaco-action-bar .action-item .action-label.extension-action.label, .monaco-action-bar .action-item.action-dropdown-item > .action-dropdown-item-separator { background-color: var(--vscode-extensionButton-background); - border: 1px solid var(--vscode-button-border, transparent); + border: 1px solid var(--vscode-extensionButton-border, transparent); } .monaco-action-bar .action-item.action-dropdown-item > .action-label.extension-action.label { @@ -73,7 +73,7 @@ } .monaco-action-bar .action-item.action-dropdown-item > .action-dropdown-item-separator > div { - background-color: var(--vscode-extensionButton-separator); + background-color: var(--vscode-extensionButton-border, var(--vscode-extensionButton-separator)); } .vscode-high-contrast .monaco-action-bar .action-item.action-dropdown-item > .action-dropdown-item-separator > div { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 6bfd83b73f4..5d53da9301b 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, debouncedObservable, derived, observableValue, runOnChange, waitForState } from '../../../../base/common/observable.js'; -import { ICodeEditor, isIOverlayWidgetPositionCoordinates } from '../../../../editor/browser/editorBrowser.js'; +import { autorun, debouncedObservable, derived, observableSignalFromEvent, observableValue, runOnChange, waitForState } from '../../../../base/common/observable.js'; +import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; +import { ScrollType } from '../../../../editor/common/editorCommon.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { InlineChatConfigKeys } from '../common/inlineChat.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -18,6 +19,7 @@ import { InlineChatGutterAffordance } from './inlineChatGutterAffordance.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; import { assertType } from '../../../../base/common/types.js'; import { CursorChangeReason } from '../../../../editor/common/cursorEvents.js'; +import { IInlineChatSessionService } from './inlineChatSessionService.js'; export class InlineChatAffordance extends Disposable { @@ -29,12 +31,12 @@ export class InlineChatAffordance extends Disposable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IChatEntitlementService chatEntiteldService: IChatEntitlementService, + @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, ) { super(); const editorObs = observableCodeEditor(this._editor); const affordance = observableConfigValue<'off' | 'gutter' | 'editor'>(InlineChatConfigKeys.Affordance, 'off', configurationService); - const suppressAffordance = observableValue(this, false); const debouncedSelection = debouncedObservable(editorObs.cursorSelection, 500); const selectionData = observableValue(this, undefined); @@ -50,7 +52,7 @@ export class InlineChatAffordance extends Disposable { this._store.add(autorun(r => { const value = debouncedSelection.read(r); - if (!value || value.isEmpty() || !explicitSelection) { + if (!value || value.isEmpty() || !explicitSelection || _editor.getModel()?.getValueInRange(value).match(/^\s+$/)) { selectionData.set(undefined, undefined); return; } @@ -61,7 +63,16 @@ export class InlineChatAffordance extends Disposable { if (chatEntiteldService.sentimentObs.read(r).hidden) { selectionData.set(undefined, undefined); } - if (suppressAffordance.read(r)) { + })); + + const hasSessionObs = derived(r => { + observableSignalFromEvent(this, inlineChatSessionService.onDidChangeSessions).read(r); + const model = editorObs.model.read(r); + return model ? inlineChatSessionService.getSessionByTextModel(model.uri) !== undefined : false; + }); + + this._store.add(autorun(r => { + if (hasSessionObs.read(r)) { selectionData.set(undefined, undefined); } })); @@ -70,29 +81,24 @@ export class InlineChatAffordance extends Disposable { InlineChatGutterAffordance, editorObs, derived(r => affordance.read(r) === 'gutter' ? selectionData.read(r) : undefined), - suppressAffordance, this._menuData )); this._store.add(this._instantiationService.createInstance( InlineChatEditorAffordance, this._editor, - derived(r => affordance.read(r) === 'editor' ? selectionData.read(r) : undefined), - suppressAffordance, - this._menuData + derived(r => affordance.read(r) === 'editor' ? selectionData.read(r) : undefined) )); - this._store.add(autorun(reader => { - editorObs.cursorSelection.read(reader); - suppressAffordance.set(false, undefined); - })); - this._store.add(autorun(r => { const data = this._menuData.read(r); if (!data) { return; } + // Reveal the line in case it's outside the viewport (e.g., when triggered from sticky scroll) + this._editor.revealLineInCenterIfOutsideViewport(data.lineNumber, ScrollType.Immediate); + const editorDomNode = this._editor.getDomNode()!; const editorRect = editorDomNode.getBoundingClientRect(); const left = data.rect.left - editorRect.left; @@ -104,12 +110,7 @@ export class InlineChatAffordance extends Disposable { this._store.add(autorun(r => { const pos = this._inputWidget.position.read(r); if (pos === null) { - suppressAffordance.set(true, undefined); this._menuData.set(undefined, undefined); - this._editor.focus(); - - } else if (isIOverlayWidgetPositionCoordinates(pos.preference) && pos.preference.left >= _editor.getLayoutInfo().contentLeft) { - suppressAffordance.set(true, undefined); } })); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 53c72418a5b..1790933cbb8 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -46,7 +46,7 @@ import { IChatService } from '../../chat/common/chatService/chatService.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData } from '../../chat/common/attachments/chatVariableEntries.js'; import { isResponseVM } from '../../chat/common/model/chatViewModel.js'; import { ChatAgentLocation } from '../../chat/common/constants.js'; -import { ILanguageModelChatSelector, ILanguageModelsService, isILanguageModelChatSelector } from '../../chat/common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatSelector, ILanguageModelsService, isILanguageModelChatSelector } from '../../chat/common/languageModels.js'; import { isNotebookContainingCellEditor as isNotebookWithCellEditor } from '../../notebook/browser/notebookEditor.js'; import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; import { CellUri, ICellEditOperation } from '../../notebook/common/notebookCommon.js'; @@ -108,10 +108,10 @@ export class InlineChatController implements IEditorContribution { } /** - * Guard flag indicating whether model defaults (including vendor/default model selection) - * should be applied for this session. + * Stores the user's explicitly chosen model (qualified name) from a previous inline chat request in the same session. + * When set, this takes priority over the inlineChat.defaultModel setting. */ - private static _applyModelDefaultsThisSession: boolean = true; + private static _userSelectedModel: string | undefined; private readonly _store = new DisposableStore(); private readonly _isActiveController = observableValue(this, false); @@ -330,7 +330,9 @@ export class InlineChatController implements IEditorContribution { const lastRequest = session.chatModel.lastRequestObs.read(r); const isInProgress = lastRequest?.response?.isInProgress.read(r); const entry = session.editingSession.readEntry(session.uri, r); - const isNotSettled = !entry || entry.state.read(r) === ModifiedFileEntryState.Modified; + // When there's no entry (no changes made) and the response is complete, the widget should be hidden. + // When there's an entry in Modified state, it needs to be settled (accepted/rejected). + const isNotSettled = entry ? entry.state.read(r) === ModifiedFileEntryState.Modified : false; if (isInProgress || isNotSettled) { sessionOverlayWidget.show(session); } else { @@ -482,12 +484,6 @@ export class InlineChatController implements IEditorContribution { // Store for tracking model changes during this session const sessionStore = new DisposableStore(); - // Check for default model setting - const defaultModelSetting = this._configurationService.getValue(InlineChatConfigKeys.DefaultModel); - if (defaultModelSetting && !this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([defaultModelSetting])) { - this._logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`); - } - try { await this._applyModelDefaults(session, sessionStore); @@ -599,19 +595,34 @@ export class InlineChatController implements IEditorContribution { * Prioritization: user session choice > inlineChat.defaultModel setting > vendor default */ private async _applyModelDefaults(session: IInlineChatSession2, sessionStore: DisposableStore): Promise { - if (InlineChatController._applyModelDefaultsThisSession) { - const defaultModelSetting = this._configurationService.getValue(InlineChatConfigKeys.DefaultModel); - if (defaultModelSetting) { - if (!this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([defaultModelSetting])) { - this._logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`); - await this._selectVendorDefaultModel(session); - } - } else { - await this._selectVendorDefaultModel(session); + const userSelectedModel = InlineChatController._userSelectedModel; + const defaultModelSetting = this._configurationService.getValue(InlineChatConfigKeys.DefaultModel); + + let modelApplied = false; + + // 1. Try user's explicitly chosen model from a previous inline chat in the same session + if (userSelectedModel) { + modelApplied = this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([userSelectedModel]); + if (!modelApplied) { + // User's previously selected model is no longer available, clear it + InlineChatController._userSelectedModel = undefined; } } - // Track model changes - disable automatic defaults once user explicitly changes the model. + // 2. Try inlineChat.defaultModel setting + if (!modelApplied && defaultModelSetting) { + modelApplied = this._zone.value.widget.chatWidget.input.switchModelByQualifiedName([defaultModelSetting]); + if (!modelApplied) { + this._logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`); + } + } + + // 3. Fall back to vendor default + if (!modelApplied) { + await this._selectVendorDefaultModel(session); + } + + // Track model changes - store user's explicit choice in the given sessions. // NOTE: This currently detects any model change, not just user-initiated ones. let initialModelId: string | undefined; sessionStore.add(autorun(r => { @@ -624,7 +635,9 @@ export class InlineChatController implements IEditorContribution { return; } if (initialModelId !== newModel.identifier) { - InlineChatController._applyModelDefaultsThisSession = false; + // User explicitly changed model, store their choice as qualified name + InlineChatController._userSelectedModel = ILanguageModelChatMetadata.asQualifiedName(newModel.metadata); + initialModelId = newModel.identifier; } })); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts index ca5f2ac9194..05514e5c594 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatDefaultModel.ts @@ -112,7 +112,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis ...{ id: 'inlineChat', title: localize('inlineChatConfigurationTitle', 'Inline Chat'), order: 30, type: 'object' }, properties: { [InlineChatDefaultModel.configName]: { - description: localize('inlineChatDefaultModelDescription', "Select the default language model to use for inline chat from the available providers. Model names may include the provider in parentheses, for example 'GPT-4o (copilot)'."), + description: localize('inlineChatDefaultModelDescription', "Select the default language model to use for inline chat from the available providers. Model names may include the provider in parentheses, for example 'Claude Haiku 4.5 (copilot)'."), type: 'string', default: '', enum: InlineChatDefaultModel.modelIds, diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index 8f2b2622e19..568fb591e54 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -10,7 +10,7 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; -import { autorun, IObservable, ISettableObservable } from '../../../../base/common/observable.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -99,8 +99,6 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi constructor( private readonly _editor: ICodeEditor, selection: IObservable, - suppressAffordance: ISettableObservable, - _hover: ISettableObservable<{ rect: DOMRect; above: boolean; lineNumber: number } | undefined>, @IInstantiationService instantiationService: IInstantiationService, ) { super(); @@ -124,8 +122,7 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi this._store.add(autorun(r => { const sel = selection.read(r); - const suppressed = suppressAffordance.read(r); - if (sel && !suppressed) { + if (sel) { this._show(sel); } else { this._hide(); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts index 4a9ab672790..3b3be83e50e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatGutterAffordance.ts @@ -24,7 +24,6 @@ export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { constructor( private readonly _myEditorObs: ObservableCodeEditor, selection: IObservable, - suppressAffordance: ISettableObservable, private readonly _hover: ISettableObservable<{ rect: DOMRect; above: boolean; lineNumber: number } | undefined>, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IHoverService hoverService: HoverService, @@ -34,7 +33,7 @@ export class InlineChatGutterAffordance extends InlineEditsGutterIndicator { ) { const data = derived(r => { const value = selection.read(r); - if (!value || suppressAffordance.read(r)) { + if (!value) { return undefined; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index e256be7a65c..0284206c2bf 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -51,15 +51,15 @@ export class InlineChatInputWidget extends Disposable { private readonly _input: IActiveCodeEditor; private readonly _position = observableValue(this, null); readonly position: IObservable = this._position; - readonly minContentWidthInPx = constObservable(0); + private readonly _showStore = this._store.add(new DisposableStore()); + private readonly _stickyScrollHeight: IObservable; private _inlineStartAction: IAction | undefined; private _anchorLineNumber: number = 0; private _anchorLeft: number = 0; private _anchorAbove: boolean = false; - readonly allowEditorOverflow = true; constructor( private readonly _editorObs: ObservableCodeEditor, @@ -106,18 +106,20 @@ export class InlineChatInputWidget extends Disposable { const model = this._store.add(modelService.createModel('', null, URI.parse(`gutter-input:${Date.now()}`), true)); this._input.setModel(model); - this._input.layout({ width: 200, height: 18 }); + + // Initialize sticky scroll height observable + const stickyScrollController = StickyScrollController.get(this._editorObs.editor); + this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); // Update placeholder based on selection state this._store.add(autorun(r => { const selection = this._editorObs.cursorSelection.read(r); const hasSelection = selection && !selection.isEmpty(); const placeholderText = hasSelection - ? localize('placeholderWithSelection', "Edit selection") + ? localize('placeholderWithSelection', "Modify selected code") : localize('placeholderNoSelection', "Generate code"); - this._input.updateOptions({ - placeholder: this._keybindingService.appendKeybinding(placeholderText, ACTION_START) - }); + + this._input.updateOptions({ placeholder: this._keybindingService.appendKeybinding(placeholderText, ACTION_START) }); })); // Listen to content size changes and resize the input editor (max 3 lines) @@ -198,8 +200,7 @@ export class InlineChatInputWidget extends Disposable { // Clear input state this._input.getModel().setValue(''); - this._inputContainer.style.height = '26px'; - this._input.layout({ width: 200, height: 18 }); + this._updateInputHeight(this._input.getContentHeight()); // Refresh actions from menu this._refreshActions(); @@ -216,8 +217,8 @@ export class InlineChatInputWidget extends Disposable { this._showStore.add(this._editorObs.createOverlayWidget({ domNode: this._domNode, position: this._position, - minContentWidthInPx: this.minContentWidthInPx, - allowEditorOverflow: this.allowEditorOverflow, + minContentWidthInPx: constObservable(0), + allowEditorOverflow: true, })); // If anchoring above, adjust position after render to account for widget height @@ -225,13 +226,14 @@ export class InlineChatInputWidget extends Disposable { this._updatePosition(); } - // Update position on scroll, hide if anchor line is out of view + // Update position on scroll, hide if anchor line is out of view (only when input is empty) this._showStore.add(this._editorObs.editor.onDidScrollChange(() => { const visibleRanges = this._editorObs.editor.getVisibleRanges(); const isLineVisible = visibleRanges.some(range => this._anchorLineNumber >= range.startLineNumber && this._anchorLineNumber <= range.endLineNumber ); - if (!isLineVisible) { + const hasContent = !!this._input.getModel().getValue(); + if (!isLineVisible && !hasContent) { this._hide(); } else { this._updatePosition(); @@ -244,6 +246,7 @@ export class InlineChatInputWidget extends Disposable { private _updatePosition(): void { const editor = this._editorObs.editor; + const lineHeight = editor.getOption(EditorOption.lineHeight); const top = editor.getTopForLineNumber(this._anchorLineNumber) - editor.getScrollTop(); let adjustedTop = top; @@ -251,12 +254,22 @@ export class InlineChatInputWidget extends Disposable { const widgetHeight = this._domNode.offsetHeight; adjustedTop = top - widgetHeight; } else { - const lineHeight = editor.getOption(EditorOption.lineHeight); adjustedTop = top + lineHeight; } + // Clamp to viewport bounds when anchor line is out of view + const stickyScrollHeight = this._stickyScrollHeight.get(); + const layoutInfo = editor.getLayoutInfo(); + const widgetHeight = this._domNode.offsetHeight; + const minTop = stickyScrollHeight; + const maxTop = layoutInfo.height - widgetHeight; + + const clampedTop = Math.max(minTop, Math.min(adjustedTop, maxTop)); + const isClamped = clampedTop !== adjustedTop; + this._domNode.classList.toggle('clamped', isClamped); + this._position.set({ - preference: { top: adjustedTop, left: this._anchorLeft }, + preference: { top: clampedTop, left: this._anchorLeft }, stackOrdinal: 10000, }, undefined); } @@ -265,6 +278,11 @@ export class InlineChatInputWidget extends Disposable { * Hide the widget (removes from editor but does not dispose). */ private _hide(): void { + // Focus editor if focus is still within the editor's DOM + const editorDomNode = this._editorObs.editor.getDomNode(); + if (editorDomNode && dom.isAncestorOfActiveElement(editorDomNode)) { + this._editorObs.editor.focus(); + } this._position.set(null, undefined); this._showStore.clear(); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css index 842572dbad9..2215f0f84dd 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css @@ -96,14 +96,20 @@ .monaco-workbench .zone-widget.inline-chat-widget.inline-chat-2 { .inline-chat .chat-widget .interactive-session .interactive-input-part { - padding: 8px 0 0 0; + padding: 8px 0 4px 0; } + .interactive-session .chat-input-container.focused, .interactive-session .chat-input-container { - border-color: transparent; + border-color: var(--vscode-inlineChat-background); + background-color: var(--vscode-inlineChat-background); padding-left: 0; } + .chat-attachments-container { + margin-right: 0; + } + .chat-attachments-container > .chat-input-toolbar { margin-left: auto; margin-right: 16px; @@ -113,6 +119,10 @@ .request-in-progress .monaco-editor [class^="ced-chat-session-detail"]::after { animation: pulse-opacity 2.5s ease-in-out infinite; } + + .chat-editor-container .interactive-input-editor .monaco-editor .monaco-editor-background { + background-color: var(--vscode-inlineChat-background); + } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css index 864a19e8ede..1af3ff339a1 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.inline-chat-gutter-menu.clamped { + transition: top 100ms; +} + .inline-chat-gutter-menu .input .monaco-editor-background { background-color: var(--vscode-menu-background); } diff --git a/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts b/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts index 22466135414..2ac90546d65 100644 --- a/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts +++ b/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts @@ -22,13 +22,13 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta * Checks if a model content change event was caused only by typing or pasting. * Returns false for AI edits, refactorings, undo/redo, etc. */ -function isTypingOrPasteEdit(event: IModelContentChangedEvent): boolean { +function isUserEdit(event: IModelContentChangedEvent): boolean { if (event.isUndoing || event.isRedoing || event.isFlush) { return false; } for (const source of event.detailedReasons) { - if (!isTypingOrPasteSource(source)) { + if (!isUserEditSource(source)) { return false; } } @@ -36,13 +36,14 @@ function isTypingOrPasteEdit(event: IModelContentChangedEvent): boolean { return event.detailedReasons.length > 0; } -function isTypingOrPasteSource(source: TextModelEditSource): boolean { +const userEditKinds = new Set(['type', 'paste', 'cut', 'executeCommands', 'executeCommand', 'compositionType', 'compositionEnd']); +function isUserEditSource(source: TextModelEditSource): boolean { const metadata = source.metadata; if (metadata.source !== 'cursor') { return false; } - const kind = (metadata as { kind?: string }).kind; - return kind === 'type' || kind === 'paste' || kind === 'compositionType' || kind === 'compositionEnd'; + const kind = metadata.kind; + return userEditKinds.has(kind); } type WordState = { @@ -74,9 +75,9 @@ class ModelSymbolRenameTracker extends Disposable { // Listen to content changes - only reset on non-typing/paste edits this._register(this._model.onDidChangeContent(e => { - if (!isTypingOrPasteEdit(e)) { - // Non-typing/paste edit occurred - reset tracking and start a new - // rename tracking at the current cursor position (if any) + if (!isUserEdit(e)) { + // Non-user edit has occurred - reset rename tracking at + // the current cursor position (if any) const position = this._lastCursorPosition; this.reset(); if (position !== undefined) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts index d7e0ed232f7..b71f9791274 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts @@ -133,6 +133,11 @@ export class McpServerRequestHandler extends Disposable { elicitation: opts.elicitationRequestHandler ? { create: {} } : undefined, }, }, + extensions: { + 'io.modelcontextprotocol/ui': { + mimeTypes: ['text/html;profile=mcp-app'] + } + } }, clientInfo: { name: productService.nameLong, @@ -321,22 +326,26 @@ export class McpServerRequestHandler extends Disposable { /** * Handle successful responses */ - private handleResult(response: MCP.JSONRPCResponse): void { - const request = this._pendingRequests.get(response.id); - if (request) { - this._pendingRequests.delete(response.id); - request.promise.complete(response.result); + private handleResult(response: MCP.JSONRPCResultResponse): void { + if (response.id !== undefined) { + const request = this._pendingRequests.get(response.id); + if (request) { + this._pendingRequests.delete(response.id); + request.promise.complete(response.result); + } } } /** * Handle error responses */ - private handleError(response: MCP.JSONRPCError): void { - const request = this._pendingRequests.get(response.id); - if (request) { - this._pendingRequests.delete(response.id); - request.promise.error(new MpcResponseError(response.error.message, response.error.code, response.error.data)); + private handleError(response: MCP.JSONRPCErrorResponse): void { + if (response.id !== undefined) { + const request = this._pendingRequests.get(response.id); + if (request) { + this._pendingRequests.delete(response.id); + request.promise.error(new MpcResponseError(response.error.message, response.error.code, response.error.data)); + } } } @@ -394,7 +403,7 @@ export class McpServerRequestHandler extends Disposable { e = McpError.unknown(e); } - const errorResponse: MCP.JSONRPCError = { + const errorResponse: MCP.JSONRPCErrorResponse = { jsonrpc: MCP.JSONRPC_VERSION, id: request.id, error: { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTaskManager.ts b/src/vs/workbench/contrib/mcp/common/mcpTaskManager.ts index 75dac9f1002..689e457469c 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTaskManager.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTaskManager.ts @@ -89,6 +89,7 @@ export class McpTaskManager extends Disposable { status: 'working', createdAt, ttl, + lastUpdatedAt: new Date().toISOString(), pollInterval: 1000, // Suggest 1 second polling interval }; @@ -171,6 +172,8 @@ export class McpTaskManager extends Disposable { } entry.task.status = status; + entry.task.lastUpdatedAt = new Date().toISOString(); + if (statusMessage !== undefined) { entry.task.statusMessage = statusMessage; } diff --git a/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts b/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts index a095b35bb9a..db33783efc6 100644 --- a/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts +++ b/src/vs/workbench/contrib/mcp/common/modelContextProtocol.ts @@ -37,14 +37,48 @@ export namespace MCP { export type JSONRPCMessage = | JSONRPCRequest | JSONRPCNotification - | JSONRPCResponse - | JSONRPCError; + | JSONRPCResponse; /** @internal */ export const LATEST_PROTOCOL_VERSION = "2025-11-25"; /** @internal */ export const JSONRPC_VERSION = "2.0"; + /** + * Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions. + * + * Certain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions. + * + * Valid keys have two segments: + * + * **Prefix:** + * - Optional - if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`). + * - Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`). + * - Any prefix consisting of zero or more labels, followed by `modelcontextprotocol` or `mcp`, followed by any label, is **reserved** for MCP use. For example: `modelcontextprotocol.io/`, `mcp.dev/`, `api.modelcontextprotocol.org/`, and `tools.mcp.com/` are all reserved. + * + * **Name:** + * - Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`). + * - Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`). + * + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ + export type MetaObject = Record; + + /** + * Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ + export interface RequestMetaObject extends MetaObject { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotification | notifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + } + /** * A progress token, used to associate progress notifications with the original request. * @@ -67,30 +101,22 @@ export namespace MCP { export interface TaskAugmentedRequestParams extends RequestParams { /** * If specified, the caller is requesting task-augmented execution for this request. - * The request will return a CreateTaskResult immediately, and the actual result can be - * retrieved later via tasks/result. + * The request will return a {@link CreateTaskResult} immediately, and the actual result can be + * retrieved later via {@link GetTaskPayloadRequest | tasks/result}. * * Task augmentation is subject to capability negotiation - receivers MUST declare support * for task augmentation of specific request types in their capabilities. */ task?: TaskMetadata; } + /** * Common params for any request. * - * @internal + * @category Common Types */ export interface RequestParams { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { - /** - * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - */ - progressToken?: ProgressToken; - [key: string]: unknown; - }; + _meta?: RequestMetaObject; } /** @internal */ @@ -101,12 +127,13 @@ export namespace MCP { params?: { [key: string]: any }; } - /** @internal */ + /** + * Common params for any notification. + * + * @category Common Types + */ export interface NotificationParams { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** @internal */ @@ -118,18 +145,17 @@ export namespace MCP { } /** + * Common result fields. + * * @category Common Types */ export interface Result { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; [key: string]: unknown; } /** - * @category Common Types + * @category Errors */ export interface Error { /** @@ -177,12 +203,30 @@ export namespace MCP { * * @category JSON-RPC */ - export interface JSONRPCResponse { + export interface JSONRPCResultResponse { jsonrpc: typeof JSONRPC_VERSION; id: RequestId; result: Result; } + /** + * A response to a request that indicates an error occurred. + * + * @category JSON-RPC + */ + export interface JSONRPCErrorResponse { + jsonrpc: typeof JSONRPC_VERSION; + id?: RequestId; + error: Error; + } + + /** + * A response to a request, containing either the result or error. + * + * @category JSON-RPC + */ + export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; + // Standard JSON-RPC error codes export const PARSE_ERROR = -32700; export const INVALID_REQUEST = -32600; @@ -190,28 +234,113 @@ export namespace MCP { export const INVALID_PARAMS = -32602; export const INTERNAL_ERROR = -32603; + /** + * A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message. + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Invalid JSON + * {@includeCode ./examples/ParseError/invalid-json.json} + * + * @category Errors + */ + export interface ParseError extends Error { + code: typeof PARSE_ERROR; + } + + /** + * A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields). + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @category Errors + */ + export interface InvalidRequestError extends Error { + code: typeof INVALID_REQUEST; + } + + /** + * A JSON-RPC error indicating that the requested method does not exist or is not available. + * + * In MCP, this error is returned when a request is made for a method that requires a capability that has not been declared. This can occur in either direction: + * + * - A server returning this error when the client requests a capability it doesn't support (e.g., requesting completions when the `completions` capability was not advertised) + * - A client returning this error when the server requests a capability it doesn't support (e.g., requesting roots when the client did not declare the `roots` capability) + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Roots not supported + * {@includeCode ./examples/MethodNotFoundError/roots-not-supported.json} + * + * @category Errors + */ + export interface MethodNotFoundError extends Error { + code: typeof METHOD_NOT_FOUND; + } + + /** + * A JSON-RPC error indicating that the method parameters are invalid or malformed. + * + * In MCP, this error is returned in various contexts when request parameters fail validation: + * + * - **Tools**: Unknown tool name or invalid tool arguments + * - **Prompts**: Unknown prompt name or missing required arguments + * - **Pagination**: Invalid or expired cursor values + * - **Logging**: Invalid log level + * - **Tasks**: Invalid or nonexistent task ID, invalid cursor, or attempting to cancel a task already in a terminal status + * - **Elicitation**: Server requests an elicitation mode not declared in client capabilities + * - **Sampling**: Missing tool result or tool results mixed with other content + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Unknown tool + * {@includeCode ./examples/InvalidParamsError/unknown-tool.json} + * + * @example Invalid tool arguments + * {@includeCode ./examples/InvalidParamsError/invalid-tool-arguments.json} + * + * @example Unknown prompt + * {@includeCode ./examples/InvalidParamsError/unknown-prompt.json} + * + * @example Invalid cursor + * {@includeCode ./examples/InvalidParamsError/invalid-cursor.json} + * + * @category Errors + */ + export interface InvalidParamsError extends Error { + code: typeof INVALID_PARAMS; + } + + /** + * A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request. + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Unexpected error + * {@includeCode ./examples/InternalError/unexpected-error.json} + * + * @category Errors + */ + export interface InternalError extends Error { + code: typeof INTERNAL_ERROR; + } + // Implementation-specific JSON-RPC error codes [-32000, -32099] /** @internal */ export const URL_ELICITATION_REQUIRED = -32042; - /** - * A response to a request that indicates an error occurred. - * - * @category JSON-RPC - */ - export interface JSONRPCError { - jsonrpc: typeof JSONRPC_VERSION; - id: RequestId; - error: Error; - } - /** * An error response that indicates that the server requires the client to provide additional information via an elicitation request. * + * @example Authorization required + * {@includeCode ./examples/URLElicitationRequiredError/authorization-required.json} + * * @internal */ - export interface URLElicitationRequiredError - extends Omit { + export interface URLElicitationRequiredError extends Omit< + JSONRPCErrorResponse, + "error" + > { error: Error & { code: typeof URL_ELICITATION_REQUIRED; data: { @@ -223,7 +352,7 @@ export namespace MCP { /* Empty result */ /** - * A response that indicates success but carries no data. + * A result that indicates success but carries no data. * * @category Common Types */ @@ -233,6 +362,9 @@ export namespace MCP { /** * Parameters for a `notifications/cancelled` notification. * + * @example User-requested cancellation + * {@includeCode ./examples/CancelledNotificationParams/user-requested-cancellation.json} + * * @category `notifications/cancelled` */ export interface CancelledNotificationParams extends NotificationParams { @@ -241,7 +373,7 @@ export namespace MCP { * * This MUST correspond to the ID of a request previously issued in the same direction. * This MUST be provided for cancelling non-task requests. - * This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead). + * This MUST NOT be used for cancelling tasks (use the {@link CancelTaskRequest | tasks/cancel} request instead). */ requestId?: RequestId; @@ -260,7 +392,10 @@ export namespace MCP { * * A client MUST NOT attempt to cancel its `initialize` request. * - * For task cancellation, use the `tasks/cancel` request instead of this notification. + * For task cancellation, use the {@link CancelTaskRequest | tasks/cancel} request instead of this notification. + * + * @example User-requested cancellation + * {@includeCode ./examples/CancelledNotification/user-requested-cancellation.json} * * @category `notifications/cancelled` */ @@ -273,6 +408,9 @@ export namespace MCP { /** * Parameters for an `initialize` request. * + * @example Full client capabilities + * {@includeCode ./examples/InitializeRequestParams/full-client-capabilities.json} + * * @category `initialize` */ export interface InitializeRequestParams extends RequestParams { @@ -287,6 +425,9 @@ export namespace MCP { /** * This request is sent from the client to the server when it first connects, asking it to begin initialization. * + * @example Initialize request + * {@includeCode ./examples/InitializeRequest/initialize-request.json} + * * @category `initialize` */ export interface InitializeRequest extends JSONRPCRequest { @@ -295,7 +436,10 @@ export namespace MCP { } /** - * After receiving an initialize request from the client, the server sends this response. + * The result returned by the server for an {@link InitializeRequest | initialize} request. + * + * @example Full server capabilities + * {@includeCode ./examples/InitializeResult/full-server-capabilities.json} * * @category `initialize` */ @@ -315,9 +459,24 @@ export namespace MCP { instructions?: string; } + /** + * A successful response from the server for a {@link InitializeRequest | initialize} request. + * + * @example Initialize result response + * {@includeCode ./examples/InitializeResultResponse/initialize-result-response.json} + * + * @category `initialize` + */ + export interface InitializeResultResponse extends JSONRPCResultResponse { + result: InitializeResult; + } + /** * This notification is sent from the client to the server after initialization has finished. * + * @example Initialized notification + * {@includeCode ./examples/InitializedNotification/initialized-notification.json} + * * @category `notifications/initialized` */ export interface InitializedNotification extends JSONRPCNotification { @@ -337,6 +496,12 @@ export namespace MCP { experimental?: { [key: string]: object }; /** * Present if the client supports listing roots. + * + * @example Roots - minimum baseline support + * {@includeCode ./examples/ClientCapabilities/roots-minimum-baseline-support.json} + * + * @example Roots - list changed notifications + * {@includeCode ./examples/ClientCapabilities/roots-list-changed-notifications.json} */ roots?: { /** @@ -346,20 +511,35 @@ export namespace MCP { }; /** * Present if the client supports sampling from an LLM. + * + * @example Sampling - minimum baseline support + * {@includeCode ./examples/ClientCapabilities/sampling-minimum-baseline-support.json} + * + * @example Sampling - tool use support + * {@includeCode ./examples/ClientCapabilities/sampling-tool-use-support.json} + * + * @example Sampling - context inclusion support (soft-deprecated) + * {@includeCode ./examples/ClientCapabilities/sampling-context-inclusion-support-soft-deprecated.json} */ sampling?: { /** - * Whether the client supports context inclusion via includeContext parameter. + * Whether the client supports context inclusion via `includeContext` parameter. * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). */ context?: object; /** - * Whether the client supports tool use via tools and toolChoice parameters. + * Whether the client supports tool use via `tools` and `toolChoice` parameters. */ tools?: object; }; /** * Present if the client supports elicitation from the server. + * + * @example Elicitation - form and URL mode support + * {@includeCode ./examples/ClientCapabilities/elicitation-form-and-url-mode-support.json} + * + * @example Elicitation - form mode only (implicit) + * {@includeCode ./examples/ClientCapabilities/elicitation-form-only-implicit.json} */ elicitation?: { form?: object; url?: object }; @@ -368,11 +548,11 @@ export namespace MCP { */ tasks?: { /** - * Whether this client supports tasks/list. + * Whether this client supports {@link ListTasksRequest | tasks/list}. */ list?: object; /** - * Whether this client supports tasks/cancel. + * Whether this client supports {@link CancelTaskRequest | tasks/cancel}. */ cancel?: object; /** @@ -384,7 +564,7 @@ export namespace MCP { */ sampling?: { /** - * Whether the client supports task-augmented sampling/createMessage requests. + * Whether the client supports task-augmented `sampling/createMessage` requests. */ createMessage?: object; }; @@ -393,12 +573,21 @@ export namespace MCP { */ elicitation?: { /** - * Whether the client supports task-augmented elicitation/create requests. + * Whether the client supports task-augmented {@link ElicitRequest | elicitation/create} requests. */ create?: object; }; }; }; + /** + * Optional MCP extensions that the client supports. Keys are extension identifiers + * (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are + * per-extension settings objects. An empty object indicates support with no settings. + * + * @example Extensions - UI extension with MIME type support + * {@includeCode ./examples/ClientCapabilities/extensions-ui-mime-types.json} + */ + extensions?: { [key: string]: object }; } /** @@ -413,14 +602,26 @@ export namespace MCP { experimental?: { [key: string]: object }; /** * Present if the server supports sending log messages to the client. + * + * @example Logging - minimum baseline support + * {@includeCode ./examples/ServerCapabilities/logging-minimum-baseline-support.json} */ logging?: object; /** * Present if the server supports argument autocompletion suggestions. + * + * @example Completions - minimum baseline support + * {@includeCode ./examples/ServerCapabilities/completions-minimum-baseline-support.json} */ completions?: object; /** * Present if the server offers any prompt templates. + * + * @example Prompts - minimum baseline support + * {@includeCode ./examples/ServerCapabilities/prompts-minimum-baseline-support.json} + * + * @example Prompts - list changed notifications + * {@includeCode ./examples/ServerCapabilities/prompts-list-changed-notifications.json} */ prompts?: { /** @@ -430,6 +631,18 @@ export namespace MCP { }; /** * Present if the server offers any resources to read. + * + * @example Resources - minimum baseline support + * {@includeCode ./examples/ServerCapabilities/resources-minimum-baseline-support.json} + * + * @example Resources - subscription to individual resource updates (only) + * {@includeCode ./examples/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json} + * + * @example Resources - list changed notifications (only) + * {@includeCode ./examples/ServerCapabilities/resources-list-changed-notifications-only.json} + * + * @example Resources - all notifications + * {@includeCode ./examples/ServerCapabilities/resources-all-notifications.json} */ resources?: { /** @@ -443,6 +656,12 @@ export namespace MCP { }; /** * Present if the server offers any tools to call. + * + * @example Tools - minimum baseline support + * {@includeCode ./examples/ServerCapabilities/tools-minimum-baseline-support.json} + * + * @example Tools - list changed notifications + * {@includeCode ./examples/ServerCapabilities/tools-list-changed-notifications.json} */ tools?: { /** @@ -455,11 +674,11 @@ export namespace MCP { */ tasks?: { /** - * Whether this server supports tasks/list. + * Whether this server supports {@link ListTasksRequest | tasks/list}. */ list?: object; /** - * Whether this server supports tasks/cancel. + * Whether this server supports {@link CancelTaskRequest | tasks/cancel}. */ cancel?: object; /** @@ -471,12 +690,21 @@ export namespace MCP { */ tools?: { /** - * Whether the server supports task-augmented tools/call requests. + * Whether the server supports task-augmented {@link CallToolRequest | tools/call} requests. */ call?: object; }; }; }; + /** + * Optional MCP extensions that the server supports. Keys are extension identifiers + * (e.g., "io.modelcontextprotocol/apps"), and values are per-extension settings + * objects. An empty object indicates support with no settings. + * + * @example Extensions - UI extension support + * {@includeCode ./examples/ServerCapabilities/extensions-ui.json} + */ + extensions?: { [key: string]: object }; } /** @@ -489,7 +717,7 @@ export namespace MCP { * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a * `data:` URI with Base64-encoded image data. * - * Consumers SHOULD takes steps to ensure URLs serving icons are from the + * Consumers SHOULD take steps to ensure URLs serving icons are from the * same domain as the client/server or a trusted domain. * * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain @@ -514,8 +742,8 @@ export namespace MCP { sizes?: string[]; /** - * Optional specifier for the theme this icon is designed for. `light` indicates - * the icon is designed to be used with a light background, and `dark` indicates + * Optional specifier for the theme this icon is designed for. `"light"` indicates + * the icon is designed to be used with a light background, and `"dark"` indicates * the icon is designed to be used with a dark background. * * If not provided, the client should assume the icon can be used with any theme. @@ -558,7 +786,7 @@ export namespace MCP { * Intended for UI and end-user contexts - optimized to be human-readable and easily understood, * even by those unfamiliar with domain-specific terminology. * - * If not provided, the name should be used for display (except for Tool, + * If not provided, the name should be used for display (except for {@link Tool}, * where `annotations.title` should be given precedence over using `name`, * if present). */ @@ -571,6 +799,9 @@ export namespace MCP { * @category `initialize` */ export interface Implementation extends BaseMetadata, Icons { + /** + * The version of this implementation. + */ version: string; /** @@ -594,6 +825,9 @@ export namespace MCP { /** * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. * + * @example Ping request + * {@includeCode ./examples/PingRequest/ping-request.json} + * * @category `ping` */ export interface PingRequest extends JSONRPCRequest { @@ -601,10 +835,25 @@ export namespace MCP { params?: RequestParams; } + /** + * A successful response for a {@link PingRequest | ping} request. + * + * @example Ping result response + * {@includeCode ./examples/PingResultResponse/ping-result-response.json} + * + * @category `ping` + */ + export interface PingResultResponse extends JSONRPCResultResponse { + result: EmptyResult; + } + /* Progress notifications */ /** - * Parameters for a `notifications/progress` notification. + * Parameters for a {@link ProgressNotification | notifications/progress} notification. + * + * @example Progress message + * {@includeCode ./examples/ProgressNotificationParams/progress-message.json} * * @category `notifications/progress` */ @@ -634,6 +883,9 @@ export namespace MCP { /** * An out-of-band notification used to inform the receiver of a progress update for a long-running request. * + * @example Progress message + * {@includeCode ./examples/ProgressNotification/progress-message.json} + * * @category `notifications/progress` */ export interface ProgressNotification extends JSONRPCNotification { @@ -643,9 +895,12 @@ export namespace MCP { /* Pagination */ /** - * Common parameters for paginated requests. + * Common params for paginated requests. * - * @internal + * @example List request with cursor + * {@includeCode ./examples/PaginatedRequestParams/list-with-cursor.json} + * + * @category Common Types */ export interface PaginatedRequestParams extends RequestParams { /** @@ -673,6 +928,9 @@ export namespace MCP { /** * Sent from the client to request a list of resources the server has. * + * @example List resources request + * {@includeCode ./examples/ListResourcesRequest/list-resources-request.json} + * * @category `resources/list` */ export interface ListResourcesRequest extends PaginatedRequest { @@ -680,7 +938,10 @@ export namespace MCP { } /** - * The server's response to a resources/list request from the client. + * The result returned by the server for a {@link ListResourcesRequest | resources/list} request. + * + * @example Resources list with cursor + * {@includeCode ./examples/ListResourcesResult/resources-list-with-cursor.json} * * @category `resources/list` */ @@ -688,9 +949,24 @@ export namespace MCP { resources: Resource[]; } + /** + * A successful response from the server for a {@link ListResourcesRequest | resources/list} request. + * + * @example List resources result response + * {@includeCode ./examples/ListResourcesResultResponse/list-resources-result-response.json} + * + * @category `resources/list` + */ + export interface ListResourcesResultResponse extends JSONRPCResultResponse { + result: ListResourcesResult; + } + /** * Sent from the client to request a list of resource templates the server has. * + * @example List resource templates request + * {@includeCode ./examples/ListResourceTemplatesRequest/list-resource-templates-request.json} + * * @category `resources/templates/list` */ export interface ListResourceTemplatesRequest extends PaginatedRequest { @@ -698,7 +974,10 @@ export namespace MCP { } /** - * The server's response to a resources/templates/list request from the client. + * The result returned by the server for a {@link ListResourceTemplatesRequest | resources/templates/list} request. + * + * @example Resource templates list + * {@includeCode ./examples/ListResourceTemplatesResult/resource-templates-list.json} * * @category `resources/templates/list` */ @@ -707,7 +986,19 @@ export namespace MCP { } /** - * Common parameters when working with resources. + * A successful response from the server for a {@link ListResourceTemplatesRequest | resources/templates/list} request. + * + * @example List resource templates result response + * {@includeCode ./examples/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json} + * + * @category `resources/templates/list` + */ + export interface ListResourceTemplatesResultResponse extends JSONRPCResultResponse { + result: ListResourceTemplatesResult; + } + + /** + * Common params for resource-related requests. * * @internal */ @@ -730,6 +1021,9 @@ export namespace MCP { /** * Sent from the client to the server, to read a specific resource URI. * + * @example Read resource request + * {@includeCode ./examples/ReadResourceRequest/read-resource-request.json} + * * @category `resources/read` */ export interface ReadResourceRequest extends JSONRPCRequest { @@ -738,7 +1032,10 @@ export namespace MCP { } /** - * The server's response to a resources/read request from the client. + * The result returned by the server for a {@link ReadResourceRequest | resources/read} request. + * + * @example File resource contents + * {@includeCode ./examples/ReadResourceResult/file-resource-contents.json} * * @category `resources/read` */ @@ -746,9 +1043,24 @@ export namespace MCP { contents: (TextResourceContents | BlobResourceContents)[]; } + /** + * A successful response from the server for a {@link ReadResourceRequest | resources/read} request. + * + * @example Read resource result response + * {@includeCode ./examples/ReadResourceResultResponse/read-resource-result-response.json} + * + * @category `resources/read` + */ + export interface ReadResourceResultResponse extends JSONRPCResultResponse { + result: ReadResourceResult; + } + /** * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. * + * @example Resources list changed + * {@includeCode ./examples/ResourceListChangedNotification/resources-list-changed.json} + * * @category `notifications/resources/list_changed` */ export interface ResourceListChangedNotification extends JSONRPCNotification { @@ -759,12 +1071,18 @@ export namespace MCP { /** * Parameters for a `resources/subscribe` request. * + * @example Subscribe to file resource + * {@includeCode ./examples/SubscribeRequestParams/subscribe-to-file-resource.json} + * * @category `resources/subscribe` */ export interface SubscribeRequestParams extends ResourceRequestParams { } /** - * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + * Sent from the client to request {@link ResourceUpdatedNotification | resources/updated} notifications from the server whenever a particular resource changes. + * + * @example Subscribe request + * {@includeCode ./examples/SubscribeRequest/subscribe-request.json} * * @category `resources/subscribe` */ @@ -773,6 +1091,18 @@ export namespace MCP { params: SubscribeRequestParams; } + /** + * A successful response from the server for a {@link SubscribeRequest | resources/subscribe} request. + * + * @example Subscribe result response + * {@includeCode ./examples/SubscribeResultResponse/subscribe-result-response.json} + * + * @category `resources/subscribe` + */ + export interface SubscribeResultResponse extends JSONRPCResultResponse { + result: EmptyResult; + } + /** * Parameters for a `resources/unsubscribe` request. * @@ -781,7 +1111,10 @@ export namespace MCP { export interface UnsubscribeRequestParams extends ResourceRequestParams { } /** - * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + * Sent from the client to request cancellation of {@link ResourceUpdatedNotification | resources/updated} notifications from the server. This should follow a previous {@link SubscribeRequest | resources/subscribe} request. + * + * @example Unsubscribe request + * {@includeCode ./examples/UnsubscribeRequest/unsubscribe-request.json} * * @category `resources/unsubscribe` */ @@ -790,9 +1123,24 @@ export namespace MCP { params: UnsubscribeRequestParams; } + /** + * A successful response from the server for a {@link UnsubscribeRequest | resources/unsubscribe} request. + * + * @example Unsubscribe result response + * {@includeCode ./examples/UnsubscribeResultResponse/unsubscribe-result-response.json} + * + * @category `resources/unsubscribe` + */ + export interface UnsubscribeResultResponse extends JSONRPCResultResponse { + result: EmptyResult; + } + /** * Parameters for a `notifications/resources/updated` notification. * + * @example File resource updated + * {@includeCode ./examples/ResourceUpdatedNotificationParams/file-resource-updated.json} + * * @category `notifications/resources/updated` */ export interface ResourceUpdatedNotificationParams extends NotificationParams { @@ -805,7 +1153,10 @@ export namespace MCP { } /** - * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a {@link SubscribeRequest | resources/subscribe} request. + * + * @example File resource updated notification + * {@includeCode ./examples/ResourceUpdatedNotification/file-resource-updated-notification.json} * * @category `notifications/resources/updated` */ @@ -817,6 +1168,9 @@ export namespace MCP { /** * A known resource that the server is capable of reading. * + * @example File resource with annotations + * {@includeCode ./examples/Resource/file-resource-with-annotations.json} + * * @category `resources/list` */ export interface Resource extends BaseMetadata, Icons { @@ -851,10 +1205,7 @@ export namespace MCP { */ size?: number; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** @@ -887,10 +1238,7 @@ export namespace MCP { */ annotations?: Annotations; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** @@ -910,13 +1258,13 @@ export namespace MCP { */ mimeType?: string; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** + * @example Text file contents + * {@includeCode ./examples/TextResourceContents/text-file-contents.json} + * * @category Content */ export interface TextResourceContents extends ResourceContents { @@ -927,6 +1275,9 @@ export namespace MCP { } /** + * @example Image file contents + * {@includeCode ./examples/BlobResourceContents/image-file-contents.json} + * * @category Content */ export interface BlobResourceContents extends ResourceContents { @@ -942,6 +1293,9 @@ export namespace MCP { /** * Sent from the client to request a list of prompts and prompt templates the server has. * + * @example List prompts request + * {@includeCode ./examples/ListPromptsRequest/list-prompts-request.json} + * * @category `prompts/list` */ export interface ListPromptsRequest extends PaginatedRequest { @@ -949,7 +1303,10 @@ export namespace MCP { } /** - * The server's response to a prompts/list request from the client. + * The result returned by the server for a {@link ListPromptsRequest | prompts/list} request. + * + * @example Prompts list with cursor + * {@includeCode ./examples/ListPromptsResult/prompts-list-with-cursor.json} * * @category `prompts/list` */ @@ -957,9 +1314,24 @@ export namespace MCP { prompts: Prompt[]; } + /** + * A successful response from the server for a {@link ListPromptsRequest | prompts/list} request. + * + * @example List prompts result response + * {@includeCode ./examples/ListPromptsResultResponse/list-prompts-result-response.json} + * + * @category `prompts/list` + */ + export interface ListPromptsResultResponse extends JSONRPCResultResponse { + result: ListPromptsResult; + } + /** * Parameters for a `prompts/get` request. * + * @example Get code review prompt + * {@includeCode ./examples/GetPromptRequestParams/get-code-review-prompt.json} + * * @category `prompts/get` */ export interface GetPromptRequestParams extends RequestParams { @@ -976,6 +1348,9 @@ export namespace MCP { /** * Used by the client to get a prompt provided by the server. * + * @example Get prompt request + * {@includeCode ./examples/GetPromptRequest/get-prompt-request.json} + * * @category `prompts/get` */ export interface GetPromptRequest extends JSONRPCRequest { @@ -984,7 +1359,10 @@ export namespace MCP { } /** - * The server's response to a prompts/get request from the client. + * The result returned by the server for a {@link GetPromptRequest | prompts/get} request. + * + * @example Code review prompt + * {@includeCode ./examples/GetPromptResult/code-review-prompt.json} * * @category `prompts/get` */ @@ -996,6 +1374,18 @@ export namespace MCP { messages: PromptMessage[]; } + /** + * A successful response from the server for a {@link GetPromptRequest | prompts/get} request. + * + * @example Get prompt result response + * {@includeCode ./examples/GetPromptResultResponse/get-prompt-result-response.json} + * + * @category `prompts/get` + */ + export interface GetPromptResultResponse extends JSONRPCResultResponse { + result: GetPromptResult; + } + /** * A prompt or prompt template that the server offers. * @@ -1012,10 +1402,7 @@ export namespace MCP { */ arguments?: PromptArgument[]; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** @@ -1044,7 +1431,7 @@ export namespace MCP { /** * Describes a message returned as part of a prompt. * - * This is similar to `SamplingMessage`, but also supports the embedding of + * This is similar to {@link SamplingMessage}, but also supports the embedding of * resources from the MCP server. * * @category `prompts/get` @@ -1057,7 +1444,10 @@ export namespace MCP { /** * A resource that the server is capable of reading, included in a prompt or tool call result. * - * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + * Note: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequest | resources/list} requests. + * + * @example File resource link + * {@includeCode ./examples/ResourceLink/file-resource-link.json} * * @category Content */ @@ -1071,6 +1461,9 @@ export namespace MCP { * It is up to the client how best to render embedded resources for the benefit * of the LLM and/or the user. * + * @example Embedded file resource with annotations + * {@includeCode ./examples/EmbeddedResource/embedded-file-resource-with-annotations.json} + * * @category Content */ export interface EmbeddedResource { @@ -1082,14 +1475,14 @@ export namespace MCP { */ annotations?: Annotations; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. * + * @example Prompts list changed + * {@includeCode ./examples/PromptListChangedNotification/prompts-list-changed.json} + * * @category `notifications/prompts/list_changed` */ export interface PromptListChangedNotification extends JSONRPCNotification { @@ -1101,6 +1494,9 @@ export namespace MCP { /** * Sent from the client to request a list of tools the server has. * + * @example List tools request + * {@includeCode ./examples/ListToolsRequest/list-tools-request.json} + * * @category `tools/list` */ export interface ListToolsRequest extends PaginatedRequest { @@ -1108,7 +1504,10 @@ export namespace MCP { } /** - * The server's response to a tools/list request from the client. + * The result returned by the server for a {@link ListToolsRequest | tools/list} request. + * + * @example Tools list with cursor + * {@includeCode ./examples/ListToolsResult/tools-list-with-cursor.json} * * @category `tools/list` */ @@ -1117,7 +1516,28 @@ export namespace MCP { } /** - * The server's response to a tool call. + * A successful response from the server for a {@link ListToolsRequest | tools/list} request. + * + * @example List tools result response + * {@includeCode ./examples/ListToolsResultResponse/list-tools-result-response.json} + * + * @category `tools/list` + */ + export interface ListToolsResultResponse extends JSONRPCResultResponse { + result: ListToolsResult; + } + + /** + * The result returned by the server for a {@link CallToolRequest | tools/call} request. + * + * @example Result with unstructured text + * {@includeCode ./examples/CallToolResult/result-with-unstructured-text.json} + * + * @example Result with structured content + * {@includeCode ./examples/CallToolResult/result-with-structured-content.json} + * + * @example Invalid tool input error + * {@includeCode ./examples/CallToolResult/invalid-tool-input-error.json} * * @category `tools/call` */ @@ -1149,9 +1569,27 @@ export namespace MCP { isError?: boolean; } + /** + * A successful response from the server for a {@link CallToolRequest | tools/call} request. + * + * @example Call tool result response + * {@includeCode ./examples/CallToolResultResponse/call-tool-result-response.json} + * + * @category `tools/call` + */ + export interface CallToolResultResponse extends JSONRPCResultResponse { + result: CallToolResult; + } + /** * Parameters for a `tools/call` request. * + * @example `get_weather` tool call params + * {@includeCode ./examples/CallToolRequestParams/get-weather-tool-call-params.json} + * + * @example Tool call params with progress token + * {@includeCode ./examples/CallToolRequestParams/tool-call-params-with-progress-token.json} + * * @category `tools/call` */ export interface CallToolRequestParams extends TaskAugmentedRequestParams { @@ -1168,6 +1606,9 @@ export namespace MCP { /** * Used by the client to invoke a tool provided by the server. * + * @example Call tool request + * {@includeCode ./examples/CallToolRequest/call-tool-request.json} + * * @category `tools/call` */ export interface CallToolRequest extends JSONRPCRequest { @@ -1178,6 +1619,9 @@ export namespace MCP { /** * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. * + * @example Tools list changed + * {@includeCode ./examples/ToolListChangedNotification/tools-list-changed.json} + * * @category `notifications/tools/list_changed` */ export interface ToolListChangedNotification extends JSONRPCNotification { @@ -1186,13 +1630,13 @@ export namespace MCP { } /** - * Additional properties describing a Tool to clients. + * Additional properties describing a {@link Tool} to clients. * - * NOTE: all properties in ToolAnnotations are **hints**. + * NOTE: all properties in `ToolAnnotations` are **hints**. * They are not guaranteed to provide a faithful description of * tool behavior (including descriptive properties like `title`). * - * Clients should never make tool use decisions based on ToolAnnotations + * Clients should never make tool use decisions based on `ToolAnnotations` * received from untrusted servers. * * @category `tools/list` @@ -1252,11 +1696,11 @@ export namespace MCP { * This allows clients to handle long-running operations through polling * the task system. * - * - "forbidden": Tool does not support task-augmented execution (default when absent) - * - "optional": Tool may support task-augmented execution - * - "required": Tool requires task-augmented execution + * - `"forbidden"`: Tool does not support task-augmented execution (default when absent) + * - `"optional"`: Tool may support task-augmented execution + * - `"required"`: Tool requires task-augmented execution * - * Default: "forbidden" + * Default: `"forbidden"` */ taskSupport?: "forbidden" | "optional" | "required"; } @@ -1264,6 +1708,18 @@ export namespace MCP { /** * Definition for a tool the client can call. * + * @example With default 2020-12 input schema + * {@includeCode ./examples/Tool/with-default-2020-12-input-schema.json} + * + * @example With explicit draft-07 input schema + * {@includeCode ./examples/Tool/with-explicit-draft-07-input-schema.json} + * + * @example With no parameters + * {@includeCode ./examples/Tool/with-no-parameters.json} + * + * @example With output schema for structured content + * {@includeCode ./examples/Tool/with-output-schema-for-structured-content.json} + * * @category `tools/list` */ export interface Tool extends BaseMetadata, Icons { @@ -1291,10 +1747,10 @@ export namespace MCP { /** * An optional JSON Schema object defining the structure of the tool's output returned in - * the structuredContent field of a CallToolResult. + * the structuredContent field of a {@link CallToolResult}. * - * Defaults to JSON Schema 2020-12 when no explicit $schema is provided. - * Currently restricted to type: "object" at the root level. + * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + * Currently restricted to `type: "object"` at the root level. */ outputSchema?: { $schema?: string; @@ -1306,14 +1762,11 @@ export namespace MCP { /** * Optional additional tool information. * - * Display name precedence order is: title, annotations.title, then name. + * Display name precedence order is: `title`, `annotations.title`, then `name`. */ annotations?: ToolAnnotations; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /* Tasks */ @@ -1386,6 +1839,11 @@ export namespace MCP { */ createdAt: string; + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: string; + /** * Actual retention duration from creation in milliseconds, null for unlimited. */ @@ -1398,7 +1856,7 @@ export namespace MCP { } /** - * A response to a task-augmented request. + * The result returned for a task-augmented request. * * @category `tasks` */ @@ -1406,6 +1864,15 @@ export namespace MCP { task: Task; } + /** + * A successful response for a task-augmented request. + * + * @category `tasks` + */ + export interface CreateTaskResultResponse extends JSONRPCResultResponse { + result: CreateTaskResult; + } + /** * A request to retrieve the state of a task. * @@ -1422,12 +1889,21 @@ export namespace MCP { } /** - * The response to a tasks/get request. + * The result returned for a {@link GetTaskRequest | tasks/get} request. * * @category `tasks/get` */ export type GetTaskResult = Result & Task; + /** + * A successful response for a {@link GetTaskRequest | tasks/get} request. + * + * @category `tasks/get` + */ + export interface GetTaskResultResponse extends JSONRPCResultResponse { + result: GetTaskResult; + } + /** * A request to retrieve the result of a completed task. * @@ -1444,9 +1920,9 @@ export namespace MCP { } /** - * The response to a tasks/result request. + * The result returned for a {@link GetTaskPayloadRequest | tasks/result} request. * The structure matches the result type of the original request. - * For example, a tools/call task would return the CallToolResult structure. + * For example, a {@link CallToolRequest | tools/call} task would return the {@link CallToolResult} structure. * * @category `tasks/result` */ @@ -1454,6 +1930,15 @@ export namespace MCP { [key: string]: unknown; } + /** + * A successful response for a {@link GetTaskPayloadRequest | tasks/result} request. + * + * @category `tasks/result` + */ + export interface GetTaskPayloadResultResponse extends JSONRPCResultResponse { + result: GetTaskPayloadResult; + } + /** * A request to cancel a task. * @@ -1470,12 +1955,21 @@ export namespace MCP { } /** - * The response to a tasks/cancel request. + * The result returned for a {@link CancelTaskRequest | tasks/cancel} request. * * @category `tasks/cancel` */ export type CancelTaskResult = Result & Task; + /** + * A successful response for a {@link CancelTaskRequest | tasks/cancel} request. + * + * @category `tasks/cancel` + */ + export interface CancelTaskResultResponse extends JSONRPCResultResponse { + result: CancelTaskResult; + } + /** * A request to retrieve a list of tasks. * @@ -1486,7 +1980,7 @@ export namespace MCP { } /** - * The response to a tasks/list request. + * The result returned for a {@link ListTasksRequest | tasks/list} request. * * @category `tasks/list` */ @@ -1494,6 +1988,15 @@ export namespace MCP { tasks: Task[]; } + /** + * A successful response for a {@link ListTasksRequest | tasks/list} request. + * + * @category `tasks/list` + */ + export interface ListTasksResultResponse extends JSONRPCResultResponse { + result: ListTasksResult; + } + /** * Parameters for a `notifications/tasks/status` notification. * @@ -1516,11 +2019,14 @@ export namespace MCP { /** * Parameters for a `logging/setLevel` request. * + * @example Set log level to "info" + * {@includeCode ./examples/SetLevelRequestParams/set-log-level-to-info.json} + * * @category `logging/setLevel` */ export interface SetLevelRequestParams extends RequestParams { /** - * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as {@link LoggingMessageNotification | notifications/message}. */ level: LoggingLevel; } @@ -1528,6 +2034,9 @@ export namespace MCP { /** * A request from the client to the server, to enable or adjust logging. * + * @example Set logging level request + * {@includeCode ./examples/SetLevelRequest/set-logging-level-request.json} + * * @category `logging/setLevel` */ export interface SetLevelRequest extends JSONRPCRequest { @@ -1535,9 +2044,24 @@ export namespace MCP { params: SetLevelRequestParams; } + /** + * A successful response from the server for a {@link SetLevelRequest | logging/setLevel} request. + * + * @example Set logging level result response + * {@includeCode ./examples/SetLevelResultResponse/set-logging-level-result-response.json} + * + * @category `logging/setLevel` + */ + export interface SetLevelResultResponse extends JSONRPCResultResponse { + result: EmptyResult; + } + /** * Parameters for a `notifications/message` notification. * + * @example Log database connection failed + * {@includeCode ./examples/LoggingMessageNotificationParams/log-database-connection-failed.json} + * * @category `notifications/message` */ export interface LoggingMessageNotificationParams extends NotificationParams { @@ -1556,7 +2080,10 @@ export namespace MCP { } /** - * JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + * JSONRPCNotification of a log message passed from server to client. If no `logging/setLevel` request has been sent from the client, the server MAY decide which messages to send automatically. + * + * @example Log database connection failed + * {@includeCode ./examples/LoggingMessageNotification/log-database-connection-failed.json} * * @category `notifications/message` */ @@ -1587,6 +2114,15 @@ export namespace MCP { /** * Parameters for a `sampling/createMessage` request. * + * @example Basic request + * {@includeCode ./examples/CreateMessageRequestParams/basic-request.json} + * + * @example Request with tools + * {@includeCode ./examples/CreateMessageRequestParams/request-with-tools.json} + * + * @example Follow-up request with tool results + * {@includeCode ./examples/CreateMessageRequestParams/follow-up-with-tool-results.json} + * * @category `sampling/createMessage` */ export interface CreateMessageRequestParams extends TaskAugmentedRequestParams { @@ -1603,8 +2139,8 @@ export namespace MCP { * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. * The client MAY ignore this request. * - * Default is "none". Values "thisServer" and "allServers" are soft-deprecated. Servers SHOULD only use these values if the client - * declares ClientCapabilities.sampling.context. These values may be removed in future spec releases. + * Default is `"none"`. Values `"thisServer"` and `"allServers"` are soft-deprecated. Servers SHOULD only use these values if the client + * declares {@link ClientCapabilities.sampling.context}. These values may be removed in future spec releases. */ includeContext?: "none" | "thisServer" | "allServers"; /** @@ -1624,12 +2160,12 @@ export namespace MCP { metadata?: object; /** * Tools that the model may use during generation. - * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. */ tools?: Tool[]; /** * Controls how the model uses tools. - * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. * Default is `{ mode: "auto" }`. */ toolChoice?: ToolChoice; @@ -1643,9 +2179,9 @@ export namespace MCP { export interface ToolChoice { /** * Controls the tool use ability of the model: - * - "auto": Model decides whether to use tools (default) - * - "required": Model MUST use at least one tool before completing - * - "none": Model MUST NOT use any tools + * - `"auto"`: Model decides whether to use tools (default) + * - `"required"`: Model MUST use at least one tool before completing + * - `"none"`: Model MUST NOT use any tools */ mode?: "auto" | "required" | "none"; } @@ -1653,6 +2189,9 @@ export namespace MCP { /** * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. * + * @example Sampling request + * {@includeCode ./examples/CreateMessageRequest/sampling-request.json} + * * @category `sampling/createMessage` */ export interface CreateMessageRequest extends JSONRPCRequest { @@ -1661,10 +2200,19 @@ export namespace MCP { } /** - * The client's response to a sampling/createMessage request from the server. + * The result returned by the client for a {@link CreateMessageRequest | sampling/createMessage} request. * The client should inform the user before returning the sampled message, to allow them * to inspect the response (human in the loop) and decide whether to allow the server to see it. * + * @example Text response + * {@includeCode ./examples/CreateMessageResult/text-response.json} + * + * @example Tool use response + * {@includeCode ./examples/CreateMessageResult/tool-use-response.json} + * + * @example Final response after tool use + * {@includeCode ./examples/CreateMessageResult/final-response.json} + * * @category `sampling/createMessage` */ export interface CreateMessageResult extends Result, SamplingMessage { @@ -1677,29 +2225,48 @@ export namespace MCP { * The reason why sampling stopped, if known. * * Standard values: - * - "endTurn": Natural end of the assistant's turn - * - "stopSequence": A stop sequence was encountered - * - "maxTokens": Maximum token limit was reached - * - "toolUse": The model wants to use one or more tools + * - `"endTurn"`: Natural end of the assistant's turn + * - `"stopSequence"`: A stop sequence was encountered + * - `"maxTokens"`: Maximum token limit was reached + * - `"toolUse"`: The model wants to use one or more tools * * This field is an open string to allow for provider-specific stop reasons. */ stopReason?: "endTurn" | "stopSequence" | "maxTokens" | "toolUse" | string; } + /** + * A successful response from the client for a {@link CreateMessageRequest | sampling/createMessage} request. + * + * @example Sampling result response + * {@includeCode ./examples/CreateMessageResultResponse/sampling-result-response.json} + * + * @category `sampling/createMessage` + */ + export interface CreateMessageResultResponse extends JSONRPCResultResponse { + result: CreateMessageResult; + } + /** * Describes a message issued to or received from an LLM API. * + * @example Single content block + * {@includeCode ./examples/SamplingMessage/single-content-block.json} + * + * @example Multiple content blocks + * {@includeCode ./examples/SamplingMessage/multiple-content-blocks.json} + * * @category `sampling/createMessage` */ export interface SamplingMessage { role: Role; content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } + + /** + * @category `sampling/createMessage` + */ export type SamplingMessageContentBlock = | TextContent | ImageContent @@ -1757,6 +2324,9 @@ export namespace MCP { /** * Text provided to or from an LLM. * + * @example Text content + * {@includeCode ./examples/TextContent/text-content.json} + * * @category Content */ export interface TextContent { @@ -1772,15 +2342,15 @@ export namespace MCP { */ annotations?: Annotations; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** * An image provided to or from an LLM. * + * @example `image/png` content with annotations + * {@includeCode ./examples/ImageContent/image-png-content-with-annotations.json} + * * @category Content */ export interface ImageContent { @@ -1803,15 +2373,15 @@ export namespace MCP { */ annotations?: Annotations; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** * Audio provided to or from an LLM. * + * @example `audio/wav` content + * {@includeCode ./examples/AudioContent/audio-wav-content.json} + * * @category Content */ export interface AudioContent { @@ -1834,15 +2404,15 @@ export namespace MCP { */ annotations?: Annotations; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** * A request from the assistant to call a tool. * + * @example `get_weather` tool use + * {@includeCode ./examples/ToolUseContent/get-weather-tool-use.json} + * * @category `sampling/createMessage` */ export interface ToolUseContent { @@ -1868,15 +2438,16 @@ export namespace MCP { /** * Optional metadata about the tool use. Clients SHOULD preserve this field when * including tool uses in subsequent sampling requests to enable caching optimizations. - * - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** * The result of a tool use, provided by the user back to the assistant. * + * @example `get_weather` tool result + * {@includeCode ./examples/ToolResultContent/get-weather-tool-result.json} + * * @category `sampling/createMessage` */ export interface ToolResultContent { @@ -1885,14 +2456,14 @@ export namespace MCP { /** * The ID of the tool use this result corresponds to. * - * This MUST match the ID from a previous ToolUseContent. + * This MUST match the ID from a previous {@link ToolUseContent}. */ toolUseId: string; /** * The unstructured result content of the tool use. * - * This has the same format as CallToolResult.content and can include text, images, + * This has the same format as {@link CallToolResult.content} and can include text, images, * audio, resource links, and embedded resources. */ content: ContentBlock[]; @@ -1900,7 +2471,7 @@ export namespace MCP { /** * An optional structured result object. * - * If the tool defined an outputSchema, this SHOULD conform to that schema. + * If the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema. */ structuredContent?: { [key: string]: unknown }; @@ -1915,10 +2486,8 @@ export namespace MCP { /** * Optional metadata about the tool result. Clients SHOULD preserve this field when * including tool results in subsequent sampling requests to enable caching optimizations. - * - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** @@ -1934,6 +2503,9 @@ export namespace MCP { * up to the client to decide how to interpret these preferences and how to * balance them against other considerations. * + * @example With hints and priorities + * {@includeCode ./examples/ModelPreferences/with-hints-and-priorities.json} + * * @category `sampling/createMessage` */ export interface ModelPreferences { @@ -2010,6 +2582,12 @@ export namespace MCP { * Parameters for a `completion/complete` request. * * @category `completion/complete` + * + * @example Prompt argument completion + * {@includeCode ./examples/CompleteRequestParams/prompt-argument-completion.json} + * + * @example Prompt argument completion with context + * {@includeCode ./examples/CompleteRequestParams/prompt-argument-completion-with-context.json} */ export interface CompleteRequestParams extends RequestParams { ref: PromptReference | ResourceTemplateReference; @@ -2041,6 +2619,9 @@ export namespace MCP { /** * A request from the client to the server, to ask for completion options. * + * @example Completion request + * {@includeCode ./examples/CompleteRequest/completion-request.json} + * * @category `completion/complete` */ export interface CompleteRequest extends JSONRPCRequest { @@ -2049,9 +2630,15 @@ export namespace MCP { } /** - * The server's response to a completion/complete request + * The result returned by the server for a {@link CompleteRequest | completion/complete} request. * * @category `completion/complete` + * + * @example Single completion value + * {@includeCode ./examples/CompleteResult/single-completion-value.json} + * + * @example Multiple completion values with more available + * {@includeCode ./examples/CompleteResult/multiple-completion-values-with-more-available.json} */ export interface CompleteResult extends Result { completion: { @@ -2070,6 +2657,18 @@ export namespace MCP { }; } + /** + * A successful response from the server for a {@link CompleteRequest | completion/complete} request. + * + * @example Completion result response + * {@includeCode ./examples/CompleteResultResponse/completion-result-response.json} + * + * @category `completion/complete` + */ + export interface CompleteResultResponse extends JSONRPCResultResponse { + result: CompleteResult; + } + /** * A reference to a resource or resource template definition. * @@ -2104,6 +2703,9 @@ export namespace MCP { * This request is typically used when the server needs to understand the file system * structure or access specific locations that the client has permission to read from. * + * @example List roots request + * {@includeCode ./examples/ListRootsRequest/list-roots-request.json} + * * @category `roots/list` */ export interface ListRootsRequest extends JSONRPCRequest { @@ -2112,24 +2714,45 @@ export namespace MCP { } /** - * The client's response to a roots/list request from the server. - * This result contains an array of Root objects, each representing a root directory + * The result returned by the client for a {@link ListRootsRequest | roots/list} request. + * This result contains an array of {@link Root} objects, each representing a root directory * or file that the server can operate on. * + * @example Single root directory + * {@includeCode ./examples/ListRootsResult/single-root-directory.json} + * + * @example Multiple root directories + * {@includeCode ./examples/ListRootsResult/multiple-root-directories.json} + * * @category `roots/list` */ export interface ListRootsResult extends Result { roots: Root[]; } + /** + * A successful response from the client for a {@link ListRootsRequest | roots/list} request. + * + * @example List roots result response + * {@includeCode ./examples/ListRootsResultResponse/list-roots-result-response.json} + * + * @category `roots/list` + */ + export interface ListRootsResultResponse extends JSONRPCResultResponse { + result: ListRootsResult; + } + /** * Represents a root directory or file that the server can operate on. * + * @example Project directory root + * {@includeCode ./examples/Root/project-directory.json} + * * @category `roots/list` */ export interface Root { /** - * The URI identifying the root. This *must* start with file:// for now. + * The URI identifying the root. This *must* start with `file://` for now. * This restriction may be relaxed in future versions of the protocol to allow * other URI schemes. * @@ -2143,16 +2766,16 @@ export namespace MCP { */ name?: string; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; + _meta?: MetaObject; } /** * A notification from the client to the server, informing it that the list of roots has changed. * This notification should be sent whenever the client adds, removes, or modifies any root. - * The server should then request an updated list of roots using the ListRootsRequest. + * The server should then request an updated list of roots using the {@link ListRootsRequest}. + * + * @example Roots list changed + * {@includeCode ./examples/RootsListChangedNotification/roots-list-changed.json} * * @category `notifications/roots/list_changed` */ @@ -2164,6 +2787,12 @@ export namespace MCP { /** * The parameters for a request to elicit non-sensitive information from the user via a form in the client. * + * @example Elicit single field + * {@includeCode ./examples/ElicitRequestFormParams/elicit-single-field.json} + * + * @example Elicit multiple fields + * {@includeCode ./examples/ElicitRequestFormParams/elicit-multiple-fields.json} + * * @category `elicitation/create` */ export interface ElicitRequestFormParams extends TaskAugmentedRequestParams { @@ -2194,6 +2823,9 @@ export namespace MCP { /** * The parameters for a request to elicit information from the user via a URL in the client. * + * @example Elicit sensitive data + * {@includeCode ./examples/ElicitRequestURLParams/elicit-sensitive-data.json} + * * @category `elicitation/create` */ export interface ElicitRequestURLParams extends TaskAugmentedRequestParams { @@ -2233,6 +2865,9 @@ export namespace MCP { /** * A request from the server to elicit additional information from the user via the client. * + * @example Elicitation request + * {@includeCode ./examples/ElicitRequest/elicitation-request.json} + * * @category `elicitation/create` */ export interface ElicitRequest extends JSONRPCRequest { @@ -2253,6 +2888,9 @@ export namespace MCP { | EnumSchema; /** + * @example Email input schema + * {@includeCode ./examples/StringSchema/email-input-schema.json} + * * @category `elicitation/create` */ export interface StringSchema { @@ -2266,6 +2904,9 @@ export namespace MCP { } /** + * @example Number input schema + * {@includeCode ./examples/NumberSchema/number-input-schema.json} + * * @category `elicitation/create` */ export interface NumberSchema { @@ -2278,6 +2919,9 @@ export namespace MCP { } /** + * @example Boolean input schema + * {@includeCode ./examples/BooleanSchema/boolean-input-schema.json} + * * @category `elicitation/create` */ export interface BooleanSchema { @@ -2290,6 +2934,9 @@ export namespace MCP { /** * Schema for single-selection enumeration without display titles for options. * + * @example Color select schema + * {@includeCode ./examples/UntitledSingleSelectEnumSchema/color-select-schema.json} + * * @category `elicitation/create` */ export interface UntitledSingleSelectEnumSchema { @@ -2315,6 +2962,9 @@ export namespace MCP { /** * Schema for single-selection enumeration with display titles for each option. * + * @example Titled color select schema + * {@includeCode ./examples/TitledSingleSelectEnumSchema/titled-color-select-schema.json} + * * @category `elicitation/create` */ export interface TitledSingleSelectEnumSchema { @@ -2357,6 +3007,9 @@ export namespace MCP { /** * Schema for multiple-selection enumeration without display titles for options. * + * @example Color multi-select schema + * {@includeCode ./examples/UntitledMultiSelectEnumSchema/color-multi-select-schema.json} + * * @category `elicitation/create` */ export interface UntitledMultiSelectEnumSchema { @@ -2396,6 +3049,9 @@ export namespace MCP { /** * Schema for multiple-selection enumeration with display titles for each option. * + * @example Titled color multi-select schema + * {@includeCode ./examples/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json} + * * @category `elicitation/create` */ export interface TitledMultiSelectEnumSchema { @@ -2449,7 +3105,7 @@ export namespace MCP { | TitledMultiSelectEnumSchema; /** - * Use TitledSingleSelectEnumSchema instead. + * Use {@link TitledSingleSelectEnumSchema} instead. * This interface will be removed in a future version. * * @category `elicitation/create` @@ -2477,30 +3133,54 @@ export namespace MCP { | LegacyTitledEnumSchema; /** - * The client's response to an elicitation request. + * The result returned by the client for an {@link ElicitRequest | elicitation/create} request. + * + * @example Input single field + * {@includeCode ./examples/ElicitResult/input-single-field.json} + * + * @example Input multiple fields + * {@includeCode ./examples/ElicitResult/input-multiple-fields.json} + * + * @example Accept URL mode (no content) + * {@includeCode ./examples/ElicitResult/accept-url-mode-no-content.json} * * @category `elicitation/create` */ export interface ElicitResult extends Result { /** * The user action in response to the elicitation. - * - "accept": User submitted the form/confirmed the action - * - "decline": User explicitly decline the action - * - "cancel": User dismissed without making an explicit choice + * - `"accept"`: User submitted the form/confirmed the action + * - `"decline"`: User explicitly declined the action + * - `"cancel"`: User dismissed without making an explicit choice */ action: "accept" | "decline" | "cancel"; /** - * The submitted form data, only present when action is "accept" and mode was "form". + * The submitted form data, only present when action is `"accept"` and mode was `"form"`. * Contains values matching the requested schema. * Omitted for out-of-band mode responses. */ content?: { [key: string]: string | number | boolean | string[] }; } + /** + * A successful response from the client for a {@link ElicitRequest | elicitation/create} request. + * + * @example Elicitation result response + * {@includeCode ./examples/ElicitResultResponse/elicitation-result-response.json} + * + * @category `elicitation/create` + */ + export interface ElicitResultResponse extends JSONRPCResultResponse { + result: ElicitResult; + } + /** * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. * + * @example Elicitation complete + * {@includeCode ./examples/ElicitationCompleteNotification/elicitation-complete.json} + * * @category `notifications/elicitation/complete` */ export interface ElicitationCompleteNotification extends JSONRPCNotification { @@ -2588,6 +3268,7 @@ export namespace MCP { | ListResourcesResult | ReadResourceResult | CallToolResult + | CreateTaskResult | ListToolsResult | GetTaskResult | GetTaskPayloadResult diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index 3f268d17355..b051bac13ef 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -220,7 +220,7 @@ suite('Workbench - MCP - ServerRequestHandler', () => { const sentMessages = transport.getSentMessages(); const pingResponse = sentMessages.find(m => 'id' in m && m.id === pingRequest.id && 'result' in m - ) as MCP.JSONRPCResponse; + ) as MCP.JSONRPCResultResponse; assert.ok(pingResponse, 'No ping response was sent'); assert.deepStrictEqual(pingResponse.result, {}); @@ -246,7 +246,7 @@ suite('Workbench - MCP - ServerRequestHandler', () => { const sentMessages = transport.getSentMessages(); const rootsResponse = sentMessages.find(m => 'id' in m && m.id === rootsRequest.id && 'result' in m - ) as MCP.JSONRPCResponse; + ) as MCP.JSONRPCResultResponse; assert.ok(rootsResponse, 'No roots/list response was sent'); assert.strictEqual((rootsResponse.result as MCP.ListRootsResult).roots.length, 2); @@ -400,6 +400,7 @@ suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://githu taskId: 'task1', status: 'working', createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), ttl: null, ...overrides }; diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 3c72f219979..a2e37c2a0c0 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -33,6 +33,7 @@ const defaultCommonlyUsedSettings: string[] = [ 'files.autoSave', 'editor.defaultFormatter', 'editor.fontFamily', + 'chat.agent.maxRequests', 'editor.wordWrap', 'files.exclude', 'workbench.colorTheme', diff --git a/src/vs/workbench/contrib/snippets/browser/snippets.contribution.ts b/src/vs/workbench/contrib/snippets/browser/snippets.contribution.ts index 46ccc93bb59..c8859acb39a 100644 --- a/src/vs/workbench/contrib/snippets/browser/snippets.contribution.ts +++ b/src/vs/workbench/contrib/snippets/browser/snippets.contribution.ts @@ -77,14 +77,14 @@ const snippetSchemaProperties: IJSONSchemaMap = { type: ['string', 'array'] }, include: { - markdownDescription: nls.localize('snippetSchema.json.include', 'A list of glob patterns to include the snippet for specific files, e.g. `["**/*.test.ts", "*.spec.ts"]` or `"**/*.spec.ts"`.'), + markdownDescription: nls.localize('snippetSchema.json.include', 'A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to include the snippet for specific files, e.g. `["**/*.test.ts", "*.spec.ts"]` or `"**/*.spec.ts"`. Patterns will match on the absolute path of a file if they contain a path separator and will match on the name of the file otherwise. You can exclude matching files via the `exclude` property.'), type: ['string', 'array'], items: { type: 'string' } }, exclude: { - markdownDescription: nls.localize('snippetSchema.json.exclude', 'A list of glob patterns to exclude the snippet from specific files, e.g. `["**/*.min.js"]` or `"*.min.js"`.'), + markdownDescription: nls.localize('snippetSchema.json.exclude', 'A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to exclude the snippet from specific files, e.g. `["**/*.min.js"]` or `"*.min.js"`. Patterns will match on the absolute path of a file if they contain a path separator and will match on the name of the file otherwise. Exclude patterns take precedence over `include` patterns.'), type: ['string', 'array'], items: { type: 'string' diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index 48986a69454..96ce5ef97fd 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -8,8 +8,8 @@ import { IStringDictionary } from '../../../../base/common/collections.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import * as glob from '../../../../base/common/glob.js'; import * as json from '../../../../base/common/json.js'; -import { Disposable, DisposableMap, DisposableStore, dispose, IDisposable, IReference, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { LRUCache, ResourceMap, Touch } from '../../../../base/common/map.js'; +import { Disposable, DisposableStore, dispose, IDisposable, IReference, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { LRUCache, Touch } from '../../../../base/common/map.js'; import * as Objects from '../../../../base/common/objects.js'; import { ValidationState, ValidationStatus } from '../../../../base/common/parsers.js'; import * as Platform from '../../../../base/common/platform.js'; @@ -106,14 +106,13 @@ export namespace ConfigureTaskAction { export type TaskQuickPickEntryType = (IQuickPickItem & { task: Task }) | (IQuickPickItem & { folder: IWorkspaceFolder }) | (IQuickPickItem & { settingType: string }); -class ProblemReporter extends Disposable implements TaskConfig.IProblemReporter { +class ProblemReporter implements TaskConfig.IProblemReporter { private _validationStatus: ValidationStatus; - private readonly _onDidError: Emitter = this._register(new Emitter()); + private readonly _onDidError: Emitter = new Emitter(); public readonly onDidError: Event = this._onDidError.event; constructor(private _outputChannel: IOutputChannel) { - super(); this._validationStatus = new ValidationStatus(); } @@ -259,7 +258,6 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer private _activatedTaskProviders: Set = new Set(); private readonly notification = this._register(new MutableDisposable()); - private readonly _workspaceTaskDisposables: DisposableMap = this._register(new DisposableMap(new ResourceMap())); constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -2645,10 +2643,8 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer } await ProblemMatcherRegistry.onReady(); const taskSystemInfo: ITaskSystemInfo | undefined = this._getTaskSystemInfo(workspaceFolder.uri.scheme); - const store = new DisposableStore(); - this._workspaceTaskDisposables.set(workspaceFolder.uri, store); - const problemReporter = store.add(new ProblemReporter(this._outputChannel)); - store.add(problemReporter.onDidError(error => this._showOutput(runSource, undefined, error))); + const problemReporter = new ProblemReporter(this._outputChannel); + this._register(problemReporter.onDidError(error => this._showOutput(runSource, undefined, error))); const parseResult = TaskConfig.parse(workspaceFolder, undefined, taskSystemInfo ? taskSystemInfo.platform : Platform.platform, workspaceFolderConfiguration.config, problemReporter, TaskConfig.TaskConfigSource.TasksJson, this._contextKeyService); let hasErrors = false; if (!parseResult.validationStatus.isOK() && (parseResult.validationStatus.state !== ValidationState.Info)) { @@ -2671,7 +2667,6 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer this._logService.warn('Custom workspace tasks are not supported.'); } return { workspaceFolder, set: { tasks: this._jsonTasksSupported ? parseResult.custom : [] }, configurations: customizedTasks, hasErrors }; - } private _testParseExternalConfig(config: TaskConfig.IExternalTaskRunnerConfiguration | undefined, location: string): { config: TaskConfig.IExternalTaskRunnerConfiguration | undefined; hasParseErrors: boolean } { diff --git a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts index 7063ae8be2b..080d0432909 100644 --- a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts +++ b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts @@ -47,9 +47,9 @@ export class RunAutomaticTasks extends Disposable implements IWorkbenchContribut if (!this._workspaceTrustManagementService.isWorkspaceTrusted()) { return; } - const hasShownPromptForAutomaticTasks = this._storageService.getBoolean(HAS_PROMPTED_FOR_AUTOMATIC_TASKS, StorageScope.WORKSPACE, false); - if (this._hasRunTasks || - (this._configurationService.getValue(ALLOW_AUTOMATIC_TASKS) === 'off' && hasShownPromptForAutomaticTasks)) { + const { value, userValue } = this._configurationService.inspect(ALLOW_AUTOMATIC_TASKS); + // If user explicitly set it to 'off', don't run or prompt + if (this._hasRunTasks || (value === 'off' && userValue !== undefined)) { return; } this._hasRunTasks = true; diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 60308557753..2f3dc25de5e 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -68,6 +68,28 @@ export function computeMaxBufferColumnWidth(buffer: { readonly length: number; g return maxWidth; } +/** + * Checks if two VT strings match around a boundary where we would slice. + * This is an efficient O(1) check that verifies a small window of characters + * before the slice point to detect if the VT sequences have diverged (common on Windows). + * + * @param newVT The new VT text to compare. + * @param oldVT The old VT text to compare against. + * @param slicePoint The point where we would slice. Must be <= both string lengths. + * @param windowSize The number of characters before slicePoint to check (default 50). + * @returns True if the boundary matches, false if VT sequences have diverged. + */ +export function vtBoundaryMatches(newVT: string, oldVT: string, slicePoint: number, windowSize: number = 50): boolean { + const start = Math.max(0, slicePoint - windowSize); + const end = slicePoint; + for (let i = start; i < end; i++) { + if (newVT.charCodeAt(i) !== oldVT.charCodeAt(i)) { + return false; + } + } + return true; +} + export interface IDetachedTerminalCommandMirrorRenderResult { lineCount?: number; maxColumnWidth?: number; @@ -280,7 +302,16 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach } await new Promise(resolve => { - if (!this._lastVT) { + // Only append if the boundary around the slice point matches; otherwise rewrite. + // This is an efficient constant-time check (checking up to 50 characters) instead of comparing the entire prefix. + // On Windows, VT sequences can differ even for equivalent content, causing corruption + // if we blindly append. + const canAppend = !!this._lastVT && vt.text.length >= this._lastVT.length && this._vtBoundaryMatches(vt.text, this._lastVT.length); + if (!canAppend) { + // Reset the terminal if we had previous content (can't append, need full rewrite) + if (this._lastVT) { + detached.xterm.clearBuffer(); + } if (vt.text) { detached.xterm.write(vt.text, resolve); } else { @@ -505,9 +536,16 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach return; } - const canAppend = !!this._lastVT && startLine >= previousCursor; + // Only append if: (1) cursor hasn't moved backwards, and (2) boundary around slice point matches. + // This is an efficient O(1) check instead of comparing the entire prefix. + // On Windows, VT sequences can differ even for equivalent content, so we must verify. + const canAppend = !!this._lastVT && startLine >= previousCursor && vt.text.length >= this._lastVT.length && this._vtBoundaryMatches(vt.text, this._lastVT.length); await new Promise(resolve => { - if (!this._lastVT || !canAppend) { + if (!canAppend) { + // Reset the terminal if we had previous content (can't append, need full rewrite) + if (this._lastVT) { + detachedRaw.clearBuffer(); + } if (vt.text) { detachedRaw.write(vt.text, resolve); } else { @@ -542,6 +580,13 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach private _getAbsoluteCursorY(raw: RawXtermTerminal): number { return raw.buffer.active.baseY + raw.buffer.active.cursorY; } + + /** + * Checks if the new VT text matches the old VT around the boundary where we would slice. + */ + private _vtBoundaryMatches(newVT: string, slicePoint: number): boolean { + return vtBoundaryMatches(newVT, this._lastVT, slicePoint); + } } /** diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts index e5fd2c6a08b..851c3d5f702 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -14,7 +14,7 @@ import { TerminalCapabilityStore } from '../../../../../platform/terminal/common import { XtermTerminal } from '../../browser/xterm/xtermTerminal.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { TestXtermAddonImporter } from './xterm/xtermTestUtils.js'; -import { computeMaxBufferColumnWidth } from '../../browser/chatTerminalCommandMirror.js'; +import { computeMaxBufferColumnWidth, vtBoundaryMatches } from '../../browser/chatTerminalCommandMirror.js'; const defaultTerminalConfig = { fontFamily: 'monospace', @@ -231,6 +231,123 @@ suite('Workbench - ChatTerminalCommandMirror', () => { // Incremental mirror should match fresh mirror strictEqual(getBufferText(mirror), getBufferText(freshMirror)); }); + + test('VT divergence detection prevents corruption (Windows scenario)', async () => { + // This test simulates the Windows issue where VT sequences can differ + // between calls even for equivalent visual content. On Windows, the + // serializer can produce different escape sequences (e.g., different + // line endings or cursor positioning) causing the prefix to diverge. + // + // Without boundary checking, blindly slicing would corrupt output: + // - vt1: "Line1\r\nLine2" (length 13) + // - vt2: "Line1\nLine2\nLine3" (different format, but starts similarly) + // - slice(13) on vt2 would give "ine3" instead of the full new content + + const mirror = await createXterm(); + + // Simulate first VT snapshot + const vt1 = 'Line1\r\nLine2'; + await write(mirror, vt1); + strictEqual(getBufferText(mirror), 'Line1\nLine2'); + + // Simulate divergent VT snapshot (different escape sequences for same content) + // This mimics what can happen on Windows where the VT serializer + // produces different output between calls + const vt2 = 'DifferentPrefix' + 'Line3'; + + // Use the actual utility function to test boundary checking + const boundaryMatches = vtBoundaryMatches(vt2, vt1, vt1.length); + + // Boundary should NOT match because the prefix diverged + strictEqual(boundaryMatches, false, 'Boundary check should detect divergence'); + + // When boundary doesn't match, the fix does a full reset + rewrite + // instead of corrupting the output by blind slicing + mirror.raw.reset(); + await write(mirror, vt2); + + // Final content should be the complete new VT, not corrupted + strictEqual(getBufferText(mirror), 'DifferentPrefixLine3'); + }); + + test('boundary check allows append when VT prefix matches', async () => { + const mirror = await createXterm(); + + // First VT snapshot + const vt1 = 'Line1\r\nLine2\r\n'; + await write(mirror, vt1); + + // Second VT snapshot that properly extends the first + const vt2 = vt1 + 'Line3\r\n'; + + // Use the actual utility function to test boundary checking + const boundaryMatches = vtBoundaryMatches(vt2, vt1, vt1.length); + + strictEqual(boundaryMatches, true, 'Boundary check should pass when prefix matches'); + + // Append should work correctly + const appended = vt2.slice(vt1.length); + await write(mirror, appended); + + strictEqual(getBufferText(mirror), 'Line1\nLine2\nLine3'); + }); + + test('incremental updates use append path (not full rewrite) in normal operation', async () => { + // This test verifies that in normal operation (VT prefix matches), + // we use the efficient append path rather than full rewrite. + + const source = await createXterm(); + const marker = source.raw.registerMarker(0)!; + + // Build up content incrementally, simulating streaming output + const writes: string[] = []; + + // Step 1: Initial content + await write(source, 'output line 1\r\n'); + const vt1 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + const mirror = await createXterm(); + await write(mirror, vt1); + writes.push(vt1); + + // Step 2: Add more content - should use append path + await write(source, 'output line 2\r\n'); + const vt2 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + // Verify VT extends properly (prefix matches) + strictEqual(vt2.startsWith(vt1), true, 'VT2 should start with VT1'); + + // Append only the new part (this is what the append path does) + const appended2 = vt2.slice(vt1.length); + strictEqual(appended2.length > 0, true, 'Should have new content to append'); + strictEqual(appended2.length < vt2.length, true, 'Append should be smaller than full rewrite'); + await write(mirror, appended2); + writes.push(appended2); + + // Step 3: Add more content - should continue using append path + await write(source, 'output line 3\r\n'); + const vt3 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + strictEqual(vt3.startsWith(vt2), true, 'VT3 should start with VT2'); + + const appended3 = vt3.slice(vt2.length); + strictEqual(appended3.length > 0, true, 'Should have new content to append'); + strictEqual(appended3.length < vt3.length, true, 'Append should be smaller than full rewrite'); + await write(mirror, appended3); + writes.push(appended3); + + marker.dispose(); + + // Verify final content is correct + strictEqual(getBufferText(mirror), 'output line 1\noutput line 2\noutput line 3'); + + // Verify we used the append path (total bytes written should be roughly + // equal to total VT, not 3x the total due to full rewrites) + const totalWritten = writes.reduce((sum, w) => sum + w.length, 0); + const fullRewriteWouldBe = vt1.length + vt2.length + vt3.length; + strictEqual(totalWritten < fullRewriteWouldBe, true, + `Append path should write less (${totalWritten}) than full rewrites would (${fullRewriteWouldBe})`); + }); }); suite('computeMaxBufferColumnWidth', () => { @@ -375,4 +492,67 @@ suite('Workbench - ChatTerminalCommandMirror', () => { strictEqual(computeMaxBufferColumnWidth(buffer, 150), 120); }); }); + + suite('vtBoundaryMatches', () => { + + test('returns true when strings match at boundary', () => { + const oldVT = 'Line1\r\nLine2\r\n'; + const newVT = oldVT + 'Line3\r\n'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), true); + }); + + test('returns false when strings diverge at boundary', () => { + const oldVT = 'Line1\r\nLine2'; + const newVT = 'DifferentPrefixLine3'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), false); + }); + + test('returns false when single character differs in window', () => { + const oldVT = 'AAAAAAAAAA'; + const newVT = 'AAAAABAAAA' + 'NewContent'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), false); + }); + + test('returns true for empty strings', () => { + strictEqual(vtBoundaryMatches('', '', 0), true); + }); + + test('returns true when slicePoint is 0', () => { + const oldVT = ''; + const newVT = 'SomeContent'; + strictEqual(vtBoundaryMatches(newVT, oldVT, 0), true); + }); + + test('handles strings shorter than window size', () => { + const oldVT = 'Short'; + const newVT = 'Short' + 'Added'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), true); + }); + + test('respects custom window size parameter', () => { + // With default window (50), this would match since the diff is at position 70 + const prefix = 'A'.repeat(80); + const oldVT = prefix; + const newVT = 'X' + 'A'.repeat(79) + 'NewContent'; // differs at position 0 + + // With window of 50, only checks chars 30-80, which would match + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length, 50), true); + + // With window of 100, would check chars 0-80, which would NOT match + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length, 100), false); + }); + + test('detects divergence in escape sequences (Windows scenario)', () => { + // Simulates Windows issue where VT escape sequences differ + const oldVT = '\x1b[0m\x1b[1mBold\x1b[0m\r\n'; + const newVT = '\x1b[0m\x1b[22mBold\x1b[0m\r\nMore'; // Different escape code for bold + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), false); + }); + + test('handles matching escape sequences', () => { + const oldVT = '\x1b[31mRed\x1b[0m\r\n'; + const newVT = '\x1b[31mRed\x1b[0m\r\nGreen'; + strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), true); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts index 1ba417cd3fe..3a3c4a3c955 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts @@ -22,4 +22,6 @@ export interface ICommandLineRewriterOptions { export interface ICommandLineRewriterResult { rewritten: string; reasoning: string; + //for scenarios where we want to show a different command in the chat UI than what is actually run in the terminal + forDisplay?: string; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index a85cb243ba9..030afe5a76a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -29,7 +29,8 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine); return { rewritten: wrappedCommand, - reasoning: 'Wrapped command for sandbox execution' + reasoning: 'Wrapped command for sandbox execution', + forDisplay: options.commandLine, // show the command that is passed as input. In this case, the output from CommandLinePreventHistoryRewriter }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index c49e493924d..7a986811d1e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -456,6 +456,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const terminalCommandId = `tool-${generateUuid()}`; let rewrittenCommand: string | undefined = args.command; + let forDisplayCommand: string | undefined = undefined; for (const rewriter of this._commandLineRewriters) { const rewriteResult = await rewriter.rewrite({ commandLine: rewrittenCommand, @@ -465,6 +466,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }); if (rewriteResult) { rewrittenCommand = rewriteResult.rewritten; + forDisplayCommand = rewriteResult.forDisplay; this._logService.info(`RunInTerminalTool: Command rewritten by ${rewriter.constructor.name}: ${rewriteResult.reasoning}`); } } @@ -476,6 +478,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { commandLine: { original: args.command, toolEdited: rewrittenCommand === args.command ? undefined : rewrittenCommand, + forDisplay: forDisplayCommand, }, cwd, language, @@ -782,6 +785,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { RunInTerminalTool._activeExecutions.set(termId, execution); // Set up OutputMonitor when start marker is created + const startMarkerPromise = Event.toPromise(execution.strategy.onDidCreateStartMarker); store.add(execution.strategy.onDidCreateStartMarker(startMarker => { if (!outputMonitor) { outputMonitor = store.add(this._instantiationService.createInstance( @@ -805,6 +809,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (args.isBackground) { // Background mode: wait for OutputMonitor to detect idle, then return this._logService.debug(`RunInTerminalTool: Starting background execution \`${command}\``); + // Wait for the start marker to be created (which creates the outputMonitor) + await startMarkerPromise; if (outputMonitor) { await Event.toPromise(outputMonitor.onDidFinishCommand); pollingResult = outputMonitor.pollingResult; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 3ef45613ee3..2fed85e9a45 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -220,7 +220,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { store.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true); + setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace'); terminalServiceDisposeEmitter = new Emitter(); chatServiceDisposeEmitter = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>(); @@ -263,6 +264,7 @@ suite('RunInTerminalTool', () => { 'sed "s/foo/bar/g"', 'sed -n "1,10p" file.txt', 'sed -n \'45,80p\' /foo/bar/Example.java', + 'sed -n \'45,80p\' extensions/markdown-language-features/src/test/copyFile.test.ts', 'sort file.txt', 'tree directory', diff --git a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts index f3a3836bf20..6b18596e49d 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts +++ b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts @@ -35,7 +35,6 @@ import { IBrowserWorkbenchEnvironmentService } from '../../../services/environme import { Extensions as DndExtensions, IDragAndDropContributionRegistry, IResourceDropHandler } from '../../../../platform/dnd/browser/dnd.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { ITextEditorService } from '../../../services/textfile/common/textEditorService.js'; -import { ChatEntitlementContextKeys } from '../../../services/chat/common/chatEntitlementService.js'; export const OpenProfileMenu = new MenuId('OpenProfile'); const ProfilesMenu = new MenuId('Profiles'); @@ -285,10 +284,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements const disposables = new DisposableStore(); const id = `workbench.action.openProfile.${profile.name.replace('/\s+/', '_')}`; - let precondition: ContextKeyExpression | undefined = HAS_PROFILES_CONTEXT; - if (profile.id === 'agent-sessions') { - precondition = ContextKeyExpr.and(precondition, ChatEntitlementContextKeys.Setup.hidden.negate()); - } + const precondition: ContextKeyExpression | undefined = HAS_PROFILES_CONTEXT; disposables.add(registerAction2(class NewWindowAction extends Action2 { diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index 8779c2596b1..3edf21e7f57 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -209,6 +209,7 @@ export class AgentSessionsWelcomePage extends EditorPane { clearNode(sessionsSection); this.buildSessionsOrPrompts(sessionsSection); } + this.layoutSessionsControl(); })); this.scrollableElement?.scanDomNode(); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css index a69ab12ebae..3021512101f 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css @@ -163,34 +163,34 @@ /* * Transform items into 2-column layout: - * - Items 0,1 form visual row 1 (top: 0) - * - Items 2,3 form visual row 2 (top: 52) - * - Items 4,5 form visual row 3 (top: 104) - * Left column (even): items stay in place or move up - * Right column (odd): items move right and up + * - Odd items (1, 3, 5, ...) stay in left column + * - Even items (2, 4, 6, ...) move to right column + * Each pair forms a visual row. + * Left column items need to move up by floor((index-1)/2) rows + * Right column items need to move right and up by (index/2) rows + * Row height is 52px. */ -/* Item 1 (index 1): move to right column of row 1 */ -.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(2) { - transform: translateX(100%) translateY(-52px); -} - -/* Item 2 (index 2): move up to row 2 left column */ +/* Left column items (odd positions): move up to form 2-column layout */ +/* Item 3: move up 1 row */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(3) { transform: translateY(-52px); } - -/* Item 3 (index 3): move to right column of row 2 */ -.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(4) { - transform: translateX(100%) translateY(-104px); -} - -/* Item 4 (index 4): move up to row 3 left column */ +/* Item 5: move up 2 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(5) { transform: translateY(-104px); } -/* Item 5 (index 5): move to right column of row 3 */ +/* Right column items (even positions): move right and up */ +/* Item 2: move right, up 1 row */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(2) { + transform: translateX(100%) translateY(-52px); +} +/* Item 4: move right, up 2 rows */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(4) { + transform: translateX(100%) translateY(-104px); +} +/* Item 6: move right, up 3 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(6) { transform: translateX(100%) translateY(-156px); } diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index e45b4e10c3c..f0a645aea45 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -111,12 +111,16 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount declare _serviceBrand: undefined; private defaultAccount: IDefaultAccount | null = null; + get policyData(): IPolicyData | null { return this.defaultAccountProvider?.policyData ?? null; } private readonly initBarrier = new Barrier(); private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; + private readonly _onDidChangePolicyData = this._register(new Emitter()); + readonly onDidChangePolicyData = this._onDidChangePolicyData.event; + private readonly defaultAccountConfig: IDefaultAccountConfig; private defaultAccountProvider: IDefaultAccountProvider | null = null; @@ -148,11 +152,15 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount } this.defaultAccountProvider = provider; + if (this.defaultAccountProvider.policyData) { + this._onDidChangePolicyData.fire(this.defaultAccountProvider.policyData); + } provider.refresh().then(account => { this.defaultAccount = account; }).finally(() => { this.initBarrier.open(); this._register(provider.onDidChangeDefaultAccount(account => this.setDefaultAccount(account))); + this._register(provider.onDidChangePolicyData(policyData => this._onDidChangePolicyData.fire(policyData))); }); } @@ -178,6 +186,16 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount } } +interface IAccountPolicyData { + readonly accountId: string; + readonly policyData: IPolicyData; +} + +interface IDefaultAccountData { + defaultAccount: IDefaultAccount; + policyData: IAccountPolicyData | null; +} + type DefaultAccountStatusTelemetry = { status: string; initial: boolean; @@ -192,12 +210,18 @@ type DefaultAccountStatusTelemetryClassification = { class DefaultAccountProvider extends Disposable implements IDefaultAccountProvider { - private _defaultAccount: IDefaultAccount | null = null; - get defaultAccount(): IDefaultAccount | null { return this._defaultAccount ?? null; } + private _defaultAccount: IDefaultAccountData | null = null; + get defaultAccount(): IDefaultAccount | null { return this._defaultAccount?.defaultAccount ?? null; } + + private _policyData: IAccountPolicyData | null = null; + get policyData(): IPolicyData | null { return this._policyData?.policyData ?? null; } private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; + private readonly _onDidChangePolicyData = this._register(new Emitter()); + readonly onDidChangePolicyData = this._onDidChangePolicyData.event; + private readonly accountStatusContext: IContextKey; private initialized = false; private readonly initPromise: Promise; @@ -220,6 +244,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid ) { super(); this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService); + this._policyData = this.getCachedPolicyData(); this.initPromise = this.init() .finally(() => { this.telemetryService.publicLog2('defaultaccount:status', { status: this.defaultAccount ? 'available' : 'unavailable', initial: true }); @@ -227,6 +252,22 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid }); } + private getCachedPolicyData(): IAccountPolicyData | null { + const cached = this.storageService.get(CACHED_POLICY_DATA_KEY, StorageScope.APPLICATION); + if (cached) { + try { + const { accountId, policyData } = JSON.parse(cached); + if (accountId && policyData) { + this.logService.debug('[DefaultAccount] Initializing with cached policy data'); + return { accountId, policyData }; + } + } catch (error) { + this.logService.error('[DefaultAccount] Failed to parse cached policy data', getErrorMessage(error)); + } + } + return null; + } + private async init(): Promise { if (isWeb && !this.environmentService.remoteAuthority) { this.logService.debug('[DefaultAccount] Running in web without remote, skipping initialization'); @@ -323,7 +364,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async fetchDefaultAccount(): Promise { + private async fetchDefaultAccount(): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); @@ -336,24 +377,47 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider); } - private setDefaultAccount(account: IDefaultAccount | null): void { + private setDefaultAccount(account: IDefaultAccountData | null): void { if (equals(this._defaultAccount, account)) { return; } this.logService.trace('[DefaultAccount] Updating default account:', account); - this._defaultAccount = account; - this._onDidChangeDefaultAccount.fire(this._defaultAccount); - if (this._defaultAccount) { + if (account) { + this._defaultAccount = account; + this.setPolicyData(account.policyData); + this._onDidChangeDefaultAccount.fire(this._defaultAccount.defaultAccount); this.accountStatusContext.set(DefaultAccountStatus.Available); this.logService.debug('[DefaultAccount] Account status set to Available'); } else { + this._defaultAccount = null; + this.setPolicyData(null); + this._onDidChangeDefaultAccount.fire(null); this.accountDataPollScheduler.cancel(); this.accountStatusContext.set(DefaultAccountStatus.Unavailable); this.logService.debug('[DefaultAccount] Account status set to Unavailable'); } } + private setPolicyData(accountPolicyData: IAccountPolicyData | null): void { + if (equals(this._policyData, accountPolicyData)) { + return; + } + this._policyData = accountPolicyData; + this.cachePolicyData(accountPolicyData); + this._onDidChangePolicyData.fire(this._policyData?.policyData ?? null); + } + + private cachePolicyData(accountPolicyData: IAccountPolicyData | null): void { + if (accountPolicyData) { + this.logService.debug('[DefaultAccount] Caching policy data for account:', accountPolicyData.accountId); + this.storageService.store(CACHED_POLICY_DATA_KEY, JSON.stringify(accountPolicyData), StorageScope.APPLICATION, StorageTarget.MACHINE); + } else { + this.logService.debug('[DefaultAccount] Removing cached policy data'); + this.storageService.remove(CACHED_POLICY_DATA_KEY, StorageScope.APPLICATION); + } + } + private scheduleAccountDataPoll(): void { if (!this._defaultAccount) { return; @@ -373,7 +437,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider): Promise { + private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider): Promise { try { this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); const sessions = await this.findMatchingProviderSession(authenticationProvider.id, this.defaultAccountConfig.authenticationProvider.scopes); @@ -390,7 +454,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[]): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[]): Promise { try { const accountId = sessions[0].account.id; const [entitlementsData, tokenEntitlementsData] = await Promise.all([ @@ -398,7 +462,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid this.getTokenEntitlements(sessions), ]); - let policyData = this.getCachedPolicyData(accountId); + let policyData: Mutable | undefined = this._policyData?.accountId === accountId ? { ...this._policyData.policyData } : undefined; if (tokenEntitlementsData) { policyData = policyData ?? {}; policyData.chat_agent_enabled = tokenEntitlementsData.chat_agent_enabled; @@ -411,18 +475,16 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid policyData.mcpAccess = mcpRegistryProvider.registry_access; } } - this.cachePolicyData(accountId, policyData); } - const account: IDefaultAccount = { + const defaultAccount: IDefaultAccount = { authenticationProvider, sessionId: sessions[0].id, enterprise: authenticationProvider.enterprise || sessions[0].account.label.includes('_'), entitlementsData, - policyData, }; this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id); - return account; + return { defaultAccount, policyData: policyData ? { accountId, policyData } : null }; } catch (error) { this.logService.error('[DefaultAccount] Failed to create default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; @@ -515,28 +577,6 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - private cachePolicyData(accountId: string, policyData: IPolicyData): void { - this.logService.debug('[DefaultAccount] Caching policy data for account:', accountId); - this.storageService.store(CACHED_POLICY_DATA_KEY, JSON.stringify({ accountId, policyData }), StorageScope.APPLICATION, StorageTarget.MACHINE); - } - - private getCachedPolicyData(accountId: string): Mutable | undefined { - const cached = this.storageService.get(CACHED_POLICY_DATA_KEY, StorageScope.APPLICATION); - if (cached) { - try { - const { accountId: cachedAccountId, policyData } = JSON.parse(cached); - if (cachedAccountId === accountId) { - this.logService.debug('[DefaultAccount] Using cached policy data for account:', accountId); - return policyData; - } - this.logService.debug('[DefaultAccount] Cached policy data is for different account, ignoring'); - } catch (error) { - this.logService.error('[DefaultAccount] Failed to parse cached policy data', getErrorMessage(error)); - } - } - return undefined; - } - private async getEntitlements(sessions: AuthenticationSession[]): Promise { const entitlementUrl = this.getEntitlementUrl(); if (!entitlementUrl) { @@ -744,7 +784,6 @@ class DefaultAccountProviderContribution extends Disposable implements IWorkbenc @IProductService productService: IProductService, @IInstantiationService instantiationService: IInstantiationService, @IDefaultAccountService defaultAccountService: IDefaultAccountService, - @ILogService logService: ILogService, ) { super(); const defaultAccountProvider = this._register(instantiationService.createInstance(DefaultAccountProvider, toDefaultAccountConfig(productService.defaultChatAgent))); @@ -752,4 +791,4 @@ class DefaultAccountProviderContribution extends Disposable implements IWorkbenc } } -registerWorkbenchContribution2(DefaultAccountProviderContribution.ID, DefaultAccountProviderContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(DefaultAccountProviderContribution.ID, DefaultAccountProviderContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index e84e364e7d8..ffd32cb2fa6 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { IStringDictionary } from '../../../../base/common/collections.js'; -import { IDefaultAccount } from '../../../../base/common/defaultAccount.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { AbstractPolicyService, IPolicyService, PolicyDefinition } from '../../../../platform/policy/common/policy.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -12,32 +11,26 @@ import { IDefaultAccountService } from '../../../../platform/defaultAccount/comm export class AccountPolicyService extends AbstractPolicyService implements IPolicyService { - private account: IDefaultAccount | null = null; - constructor( @ILogService private readonly logService: ILogService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService ) { super(); - this.defaultAccountService.getDefaultAccount() - .then(account => { - this.account = account; - this._updatePolicyDefinitions(this.policyDefinitions); - this._register(this.defaultAccountService.onDidChangeDefaultAccount(account => { - this.account = account; - this._updatePolicyDefinitions(this.policyDefinitions); - })); - }); + this._updatePolicyDefinitions(this.policyDefinitions); + this._register(this.defaultAccountService.onDidChangePolicyData(() => { + this._updatePolicyDefinitions(this.policyDefinitions); + })); } protected async _updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise { this.logService.trace(`AccountPolicyService#_updatePolicyDefinitions: Got ${Object.keys(policyDefinitions).length} policy definitions`); const updated: string[] = []; + const policyData = this.defaultAccountService.policyData; for (const key in policyDefinitions) { const policy = policyDefinitions[key]; - const policyValue = this.account && policy.value ? policy.value(this.account) : undefined; + const policyValue = policyData && policy.value ? policy.value(policyData) : undefined; if (policyValue !== undefined) { if (this.policies.get(key) !== policyValue) { this.policies.set(key, policyValue); diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index 555a62494c0..d157a664c3b 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -13,7 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { Registry } from '../../../../../platform/registry/common/platform.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; @@ -30,9 +30,11 @@ const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { class DefaultAccountProvider implements IDefaultAccountProvider { readonly onDidChangeDefaultAccount = Event.None; + readonly onDidChangePolicyData = Event.None; constructor( readonly defaultAccount: IDefaultAccount, + readonly policyData: IPolicyData = {}, ) { } getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { @@ -81,7 +83,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.policyData?.chat_preview_features_enabled === false ? 'policyValueB' : undefined, + value: policyData => policyData.chat_preview_features_enabled === false ? 'policyValueB' : undefined, } }, 'setting.C': { @@ -92,7 +94,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.policyData?.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, + value: policyData => policyData.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, } }, 'setting.D': { @@ -103,7 +105,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.policyData?.chat_preview_features_enabled === false ? false : undefined, + value: policyData => policyData.chat_preview_features_enabled === false ? false : undefined, } }, 'setting.E': { @@ -127,8 +129,8 @@ suite('AccountPolicyService', () => { }); - async function assertDefaultBehavior(defaultAccount: IDefaultAccount) { - defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + async function assertDefaultBehavior(policyData: IPolicyData | undefined) { + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); await defaultAccountService.refresh(); await policyConfiguration.initialize(); @@ -159,18 +161,16 @@ suite('AccountPolicyService', () => { test('should initialize with default account', async () => { - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT }; - await assertDefaultBehavior(defaultAccount); + await assertDefaultBehavior(undefined); }); test('should initialize with default account and preview features enabled', async () => { - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: true } }; - await assertDefaultBehavior(defaultAccount); + await assertDefaultBehavior({ chat_preview_features_enabled: true }); }); test('should initialize with default account and preview features disabled', async () => { - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: false } }; - defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + const policyData: IPolicyData = { chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); await defaultAccountService.refresh(); await policyConfiguration.initialize(); diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index 49c631ca49b..2da5b33f162 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -20,7 +20,7 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; @@ -37,9 +37,11 @@ const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { class DefaultAccountProvider implements IDefaultAccountProvider { readonly onDidChangeDefaultAccount = Event.None; + readonly onDidChangePolicyData = Event.None; constructor( readonly defaultAccount: IDefaultAccount, + readonly policyData: IPolicyData = {}, ) { } getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { @@ -90,7 +92,7 @@ suite('MultiplexPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.policyData?.chat_preview_features_enabled === false ? 'policyValueB' : undefined, + value: policyData => policyData.chat_preview_features_enabled === false ? 'policyValueB' : undefined, } }, 'setting.C': { @@ -101,7 +103,7 @@ suite('MultiplexPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.policyData?.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, + value: policyData => policyData.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, } }, 'setting.D': { @@ -112,7 +114,7 @@ suite('MultiplexPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.policyData?.chat_preview_features_enabled === false ? false : undefined, + value: policyData => policyData.chat_preview_features_enabled === false ? false : undefined, } }, 'setting.E': { @@ -186,8 +188,7 @@ suite('MultiplexPolicyService', () => { test('policy from file only', async () => { await clear(); - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT }; - defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT)); await defaultAccountService.refresh(); await fileService.writeFile(policyFile, @@ -228,8 +229,8 @@ suite('MultiplexPolicyService', () => { test('policy from default account only', async () => { await clear(); - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: false } }; - defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + const policyData: IPolicyData = { chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); await defaultAccountService.refresh(); await fileService.writeFile(policyFile, @@ -269,8 +270,8 @@ suite('MultiplexPolicyService', () => { test('policy from file and default account', async () => { await clear(); - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: false } }; - defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + const policyData: IPolicyData = { chat_preview_features_enabled: false }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, policyData)); await defaultAccountService.refresh(); await fileService.writeFile(policyFile, diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index a99db7e8532..81cd3af34f8 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -130,6 +130,9 @@ import { SideBySideEditorInput } from '../../common/editor/sideBySideEditorInput import { TextResourceEditorInput } from '../../common/editor/textResourceEditorInput.js'; import { IPaneComposite } from '../../common/panecomposite.js'; import { IView, IViewDescriptor, ViewContainer, ViewContainerLocation } from '../../common/views.js'; +import { IChatWidget, IChatWidgetService } from '../../contrib/chat/browser/chat.js'; +import { IChatEditorOptions } from '../../contrib/chat/browser/widgetHosts/editor/chatEditor.js'; +import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { FileEditorInput } from '../../contrib/files/browser/editors/fileEditorInput.js'; import { TextFileEditor } from '../../contrib/files/browser/editors/textFileEditor.js'; import { FILE_EDITOR_INPUT_ID } from '../../contrib/files/common/files.js'; @@ -374,6 +377,7 @@ export function workbenchInstantiationService( instantiationService.stub(IHoverService, NullHoverService); instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); instantiationService.stub(IMarkdownRendererService, instantiationService.createInstance(MarkdownRendererService)); + instantiationService.stub(IChatWidgetService, instantiationService.createInstance(TestChatWidgetService)); return instantiationService; } @@ -2107,3 +2111,24 @@ export class TestContextMenuService implements IContextMenuService { throw new Error('Method not implemented.'); } } + +export class TestChatWidgetService implements IChatWidgetService { + + _serviceBrand: undefined; + + lastFocusedWidget: IChatWidget | undefined; + + onDidAddWidget = Event.None; + onDidBackgroundSession = Event.None; + + async reveal(widget: IChatWidget, preserveFocus?: boolean): Promise { return false; } + async revealWidget(preserveFocus?: boolean): Promise { return undefined; } + getAllWidgets(): ReadonlyArray { return []; } + getWidgetByInputUri(uri: URI): IChatWidget | undefined { return undefined; } + openSession(sessionResource: URI): Promise; + openSession(sessionResource: URI, target?: PreferredGroup, options?: IChatEditorOptions): Promise; + async openSession(sessionResource: unknown, target?: unknown, options?: unknown): Promise { return undefined; } + getWidgetBySessionResource(sessionResource: URI): IChatWidget | undefined { return undefined; } + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray { return []; } + register(newWidget: IChatWidget): IDisposable { return Disposable.None; } +} diff --git a/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts b/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts index 2c8edbd9d18..aad6987c2b5 100644 --- a/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts +++ b/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts @@ -34,6 +34,17 @@ declare module 'vscode' { } export namespace workspace { + /** + * Event fired when the list of workspace trusted folders changes. + */ + export const onDidChangeWorkspaceTrustedFolders: Event; + + /** + * Check whether the given resource is trusted + * @param resource + */ + export function isResourceTrusted(resource: Uri): Thenable; + /** * Prompt the user to chose whether to trust the specified resource (ex: folder) * @param options Object describing the properties of the resource trust request. diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 7438ab0d27a..75ce8f4867d 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -839,6 +839,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", + "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/test/sanity/containers/alpine.dockerfile b/test/sanity/containers/alpine.dockerfile index a464b5b2fc7..61ac9439a18 100644 --- a/test/sanity/containers/alpine.dockerfile +++ b/test/sanity/containers/alpine.dockerfile @@ -1,10 +1,9 @@ -ARG BASE_IMAGE=node:22.21.1-alpine3.23 +ARG BASE_IMAGE=mcr.microsoft.com/devcontainers/base:alpine-3.22 FROM ${BASE_IMAGE} +# Node.js 22 +RUN apk add --no-cache nodejs + # Chromium RUN apk add --no-cache chromium ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/containers/centos.dockerfile b/test/sanity/containers/centos.dockerfile index 4f19112f299..6d46c33a5af 100644 --- a/test/sanity/containers/centos.dockerfile +++ b/test/sanity/containers/centos.dockerfile @@ -3,26 +3,22 @@ FROM ${BASE_IMAGE} # Node.js 22 RUN dnf module enable -y nodejs:22 && \ - dnf install -y nodejs + dnf install -y nodejs # Chromium -RUN dnf install -y epel-release && \ - dnf install -y chromium +RUN dnf install -y epel-release && \ + dnf install -y chromium ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser # Desktop Bus -RUN dnf install -y dbus-x11 && \ +RUN dnf install -y dbus-x11 && \ mkdir -p /run/dbus # X11 Server -RUN dnf install -y xorg-x11-server-Xvfb +RUN dnf install -y xorg-x11-server-Xvfb # VS Code dependencies -RUN dnf install -y \ +RUN dnf install -y \ ca-certificates \ xdg-utils - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/containers/debian-10.dockerfile b/test/sanity/containers/debian-10.dockerfile index fb91c01da36..61d8e713eb0 100644 --- a/test/sanity/containers/debian-10.dockerfile +++ b/test/sanity/containers/debian-10.dockerfile @@ -1,6 +1,7 @@ +ARG MIRROR ARG BASE_IMAGE=debian:10 ARG TARGETARCH -FROM ${BASE_IMAGE} +FROM ${MIRROR}${BASE_IMAGE} # Update to archive repos since Debian 10 is EOL RUN sed -i 's|http://deb.debian.org|http://archive.debian.org|g' /etc/apt/sources.list && \ @@ -38,7 +39,3 @@ RUN apt-get install -y xvfb # Install newer libxkbfile1 from Debian 11 since Debian 10 version is too old RUN apt-get install -y -t bullseye libxkbfile1 - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/containers/debian-12.dockerfile b/test/sanity/containers/debian-12.dockerfile index 21ba756ff6e..3163d9d8d92 100644 --- a/test/sanity/containers/debian-12.dockerfile +++ b/test/sanity/containers/debian-12.dockerfile @@ -1,8 +1,14 @@ -ARG BASE_IMAGE=node:22-bookworm -FROM ${BASE_IMAGE} +ARG MIRROR +ARG BASE_IMAGE=debian:bookworm +FROM ${MIRROR}${BASE_IMAGE} # Utilities -RUN apt-get update +RUN apt-get update && \ + apt-get install -y curl + +# Node.js 22 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs # Chromium RUN apt-get install -y chromium @@ -14,7 +20,3 @@ RUN apt-get install -y dbus-x11 && \ # X11 Server RUN apt-get install -y xvfb - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/containers/entrypoint.sh b/test/sanity/containers/entrypoint.sh index 19aea71c25b..bdf72651107 100644 --- a/test/sanity/containers/entrypoint.sh +++ b/test/sanity/containers/entrypoint.sh @@ -2,6 +2,8 @@ set -e echo "System: $(uname -s) $(uname -r) $(uname -m), page size: $(getconf PAGESIZE) bytes" +echo "Memory: $(awk '/MemTotal/ {t=$2} /MemAvailable/ {a=$2} END {printf "%.0f MB total, %.0f MB available", t/1024, a/1024}' /proc/meminfo)" +echo "Disk: $(df -h / | awk 'NR==2 {print $2 " total, " $3 " used, " $4 " available"}')" if command -v Xvfb > /dev/null 2>&1; then echo "Starting X11 Server" diff --git a/test/sanity/containers/fedora.dockerfile b/test/sanity/containers/fedora.dockerfile index 89b8feb02d3..9b97ec60578 100644 --- a/test/sanity/containers/fedora.dockerfile +++ b/test/sanity/containers/fedora.dockerfile @@ -1,5 +1,6 @@ +ARG MIRROR ARG BASE_IMAGE=fedora:36 -FROM ${BASE_IMAGE} +FROM ${MIRROR}${BASE_IMAGE} # Node.js 22 RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && \ @@ -18,7 +19,3 @@ RUN dnf install -y xorg-x11-server-Xvfb # VS Code dependencies RUN dnf install -y xdg-utils - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/containers/opensuse.dockerfile b/test/sanity/containers/opensuse.dockerfile index 3e778747c2d..4f53a6e9cfa 100644 --- a/test/sanity/containers/opensuse.dockerfile +++ b/test/sanity/containers/opensuse.dockerfile @@ -19,7 +19,3 @@ RUN zypper install -y dbus-1-x11 && \ RUN zypper install -y \ liberation-fonts \ libgtk-3-0 - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/containers/redhat.dockerfile b/test/sanity/containers/redhat.dockerfile index 6beb294d400..03fca173549 100644 --- a/test/sanity/containers/redhat.dockerfile +++ b/test/sanity/containers/redhat.dockerfile @@ -4,7 +4,3 @@ FROM ${BASE_IMAGE} # Node.js 22 RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && \ dnf install -y nodejs-22.21.1 - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/containers/ubuntu.dockerfile b/test/sanity/containers/ubuntu.dockerfile index d403546d5ad..028f916ff22 100644 --- a/test/sanity/containers/ubuntu.dockerfile +++ b/test/sanity/containers/ubuntu.dockerfile @@ -1,13 +1,30 @@ +ARG MIRROR ARG BASE_IMAGE=ubuntu:22.04 -FROM ${BASE_IMAGE} +FROM ${MIRROR}${BASE_IMAGE} + +# Use Azure package mirrors +ARG TARGETARCH +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then \ + sed -i 's|http://archive.ubuntu.com|http://azure.archive.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources; \ + else \ + sed -i 's|http://archive.ubuntu.com|http://azure.archive.ubuntu.com|g' /etc/apt/sources.list; \ + fi; \ + else \ + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then \ + sed -i 's|http://ports.ubuntu.com|http://azure.ports.ubuntu.com|g' /etc/apt/sources.list.d/ubuntu.sources; \ + else \ + sed -i 's|http://ports.ubuntu.com|http://azure.ports.ubuntu.com|g' /etc/apt/sources.list; \ + fi; \ + fi # Utilities RUN apt-get update && \ -apt-get install -y curl iproute2 + apt-get install -y curl iproute2 # Node.js 22 RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ -apt-get install -y nodejs + apt-get install -y nodejs # No UI on arm32 on Ubuntu 24.04 ARG BASE_IMAGE @@ -28,7 +45,3 @@ RUN apt-get install -y libasound2 || apt-get install -y libasound2t64 && \ libgbm1 \ libnss3 \ xdg-utils - -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/test/sanity/scripts/qemu-init.sh b/test/sanity/scripts/qemu-init.sh index 288d88d081a..c4c95755d42 100755 --- a/test/sanity/scripts/qemu-init.sh +++ b/test/sanity/scripts/qemu-init.sh @@ -1,19 +1,30 @@ #!/bin/sh +set -e -echo "Mounting essential filesystems" +# Mount kernel filesystems (proc for process info, sysfs for device info) +echo "Mounting kernel filesystems" mount -t proc proc /proc mount -t sysfs sys /sys + +# Mount pseudo-terminal and shared memory filesystems +echo "Mounting PTY and shared memory" mkdir -p /dev/pts mount -t devpts devpts /dev/pts mkdir -p /dev/shm mount -t tmpfs tmpfs /dev/shm + +# Mount temporary directories with proper permissions +echo "Mounting temporary directories" mount -t tmpfs tmpfs /tmp chmod 1777 /tmp +mount -t tmpfs tmpfs /var/tmp + +# Mount runtime directory for services (D-Bus, XDG) +echo "Mounting runtime directories" mount -t tmpfs tmpfs /run mkdir -p /run/dbus mkdir -p /run/user/0 chmod 700 /run/user/0 -mount -t tmpfs tmpfs /var/tmp echo "Setting up machine-id for D-Bus" cat /proc/sys/kernel/random/uuid | tr -d '-' > /etc/machine-id @@ -32,10 +43,8 @@ export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export XDG_RUNTIME_DIR=/run/user/0 echo "Starting entrypoint" -ARGS=$(cat /test-args) -/entrypoint.sh $ARGS -EXIT_CODE=$? -echo $EXIT_CODE > /exit-code +sh /root/containers/entrypoint.sh $(cat /test-args) +echo $? > /exit-code sync echo "Powering off" diff --git a/test/sanity/scripts/run-docker.cmd b/test/sanity/scripts/run-docker.cmd index c45919e6c5e..fd1ab024eb8 100644 --- a/test/sanity/scripts/run-docker.cmd +++ b/test/sanity/scripts/run-docker.cmd @@ -4,6 +4,7 @@ setlocal enabledelayedexpansion set ROOT=%~dp0.. set CONTAINER= set ARCH=amd64 +set MIRROR=mcr.microsoft.com/mirror/docker/library/ set BASE_IMAGE= set ARGS= @@ -34,12 +35,20 @@ if "%CONTAINER%"=="" ( exit /b 1 ) +set HOST_ARCH=amd64 +if "%PROCESSOR_ARCHITECTURE%"=="ARM64" set HOST_ARCH=arm64 +if not "%ARCH%"=="%HOST_ARCH%" ( + echo Setting up QEMU emulation for %ARCH% on %HOST_ARCH% host + docker run --privileged --rm tonistiigi/binfmt --install all >nul 2>&1 +) + set BASE_IMAGE_ARG= if not "%BASE_IMAGE%"=="" set BASE_IMAGE_ARG=--build-arg "BASE_IMAGE=%BASE_IMAGE%" echo Building container image: %CONTAINER% docker buildx build ^ --platform "linux/%ARCH%" ^ + --build-arg "MIRROR=%MIRROR%" ^ %BASE_IMAGE_ARG% ^ --tag "%CONTAINER%" ^ --file "%ROOT%\containers\%CONTAINER%.dockerfile" ^ @@ -50,5 +59,6 @@ docker run ^ --rm ^ --platform "linux/%ARCH%" ^ --volume "%ROOT%:/root" ^ + --entrypoint sh ^ "%CONTAINER%" ^ - %ARGS% + /root/containers/entrypoint.sh %ARGS% diff --git a/test/sanity/scripts/run-docker.sh b/test/sanity/scripts/run-docker.sh index 5c2a7830e11..0007f9b98f0 100755 --- a/test/sanity/scripts/run-docker.sh +++ b/test/sanity/scripts/run-docker.sh @@ -3,6 +3,7 @@ set -e CONTAINER="" ARCH="amd64" +MIRROR="mcr.microsoft.com/mirror/docker/library/" BASE_IMAGE="" PAGE_SIZE="" ARGS="" @@ -27,7 +28,7 @@ ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) # Only build if image doesn't exist (i.e., not loaded from cache) if ! docker image inspect "$CONTAINER" > /dev/null 2>&1; then - if [ "$ARCH" != "amd64" ]; then + if [ "$PAGE_SIZE" != "" ]; then echo "Setting up QEMU user-mode emulation for $ARCH" docker run --privileged --rm tonistiigi/binfmt --install "$ARCH" fi @@ -35,6 +36,7 @@ if ! docker image inspect "$CONTAINER" > /dev/null 2>&1; then echo "Building container image: $CONTAINER" docker buildx build \ --platform "linux/$ARCH" \ + --build-arg "MIRROR=$MIRROR" \ ${BASE_IMAGE:+--build-arg "BASE_IMAGE=$BASE_IMAGE"} \ --tag "$CONTAINER" \ --file "$ROOT_DIR/containers/$CONTAINER.dockerfile" \ @@ -54,6 +56,7 @@ else --rm \ --platform "linux/$ARCH" \ --volume "$ROOT_DIR:/root" \ + --entrypoint sh \ "$CONTAINER" \ - $ARGS + /root/containers/entrypoint.sh $ARGS fi diff --git a/test/sanity/scripts/run-macOS.sh b/test/sanity/scripts/run-macOS.sh index 7445fe2e2c2..7ac2197ad25 100755 --- a/test/sanity/scripts/run-macOS.sh +++ b/test/sanity/scripts/run-macOS.sh @@ -1,6 +1,10 @@ #!/bin/sh set -e +echo "System: $(uname -s) $(uname -r) $(uname -m)" +echo "Memory: $(( $(sysctl -n hw.memsize) / 1024 / 1024 / 1024 )) GB" +echo "Disk: $(df -h / | awk 'NR==2 {print $2 " total, " $3 " used, " $4 " available"}')" + echo "Installing Playwright WebKit browser" npx playwright install --with-deps webkit diff --git a/test/sanity/scripts/run-qemu-64k.sh b/test/sanity/scripts/run-qemu-64k.sh index 2b602adc2a9..55198489922 100755 --- a/test/sanity/scripts/run-qemu-64k.sh +++ b/test/sanity/scripts/run-qemu-64k.sh @@ -26,8 +26,8 @@ ROOTFS_DIR=$(mktemp -d) docker export "$CONTAINER_ID" | sudo tar -xf - -C "$ROOTFS_DIR" docker rm -f "$CONTAINER_ID" -echo "Removing container image to free disk space" -docker rmi "$CONTAINER" || true +# echo "Removing container image to free disk space" +# docker rmi "$CONTAINER" || true docker system prune -f || true echo "Copying test files into root filesystem" @@ -35,12 +35,13 @@ TEST_DIR=$(cd "$(dirname "$0")/.." && pwd) sudo cp -r "$TEST_DIR"/* "$ROOTFS_DIR/root/" echo "Downloading Ubuntu 24.04 generic-64k kernel for ARM64" -KERNEL_URL="http://ports.ubuntu.com/ubuntu-ports/pool/main/l/linux/linux-image-unsigned-6.8.0-90-generic-64k_6.8.0-90.91_arm64.deb" +KERNEL_URL="https://ports.ubuntu.com/ubuntu-ports/pool/main/l/linux/linux-image-unsigned-6.8.0-90-generic-64k_6.8.0-90.91_arm64.deb" KERNEL_DIR=$(mktemp -d) curl -fL "$KERNEL_URL" -o "$KERNEL_DIR/kernel.deb" echo "Extracting kernel" -(cd "$KERNEL_DIR" && ar x kernel.deb && tar xf data.tar*) +cd "$KERNEL_DIR" && ar x kernel.deb && rm kernel.deb +tar xf data.tar* && rm -f debian-binary control.tar* data.tar* VMLINUZ="$KERNEL_DIR/boot/vmlinuz-6.8.0-90-generic-64k" if [ ! -f "$VMLINUZ" ]; then echo "Error: Could not find kernel at $VMLINUZ" @@ -72,15 +73,14 @@ timeout 1800 qemu-system-aarch64 \ -netdev user,id=net0 \ -device virtio-net-pci,netdev=net0 \ -nographic \ - -no-reboot \ - || true + -no-reboot echo "Extracting test results from disk image" MOUNT_DIR=$(mktemp -d) sudo mount -o loop "$DISK_IMG" "$MOUNT_DIR" -if [ -f "$MOUNT_DIR/root/results.xml" ]; then - cp "$MOUNT_DIR/root/results.xml" "$TEST_DIR/results.xml" -fi -EXIT_CODE=$(cat "$MOUNT_DIR/exit-code" 2>/dev/null || echo 1) +sudo cp "$MOUNT_DIR/root/results.xml" "$TEST_DIR/results.xml" +sudo chown "$(id -u):$(id -g)" "$TEST_DIR/results.xml" + +EXIT_CODE=$(sudo cat "$MOUNT_DIR/exit-code" 2>/dev/null || echo 1) sudo umount "$MOUNT_DIR" exit $EXIT_CODE diff --git a/test/sanity/scripts/run-ubuntu.sh b/test/sanity/scripts/run-ubuntu.sh index 4fcbc48990c..884f292c38c 100755 --- a/test/sanity/scripts/run-ubuntu.sh +++ b/test/sanity/scripts/run-ubuntu.sh @@ -1,6 +1,13 @@ #!/bin/sh set -e +echo "System: $(uname -s) $(uname -r) $(uname -m)" +echo "Memory: $(free -h | awk '/^Mem:/ {print $2 " total, " $3 " used, " $7 " available"}')" +echo "Disk: $(df -h / | awk 'NR==2 {print $2 " total, " $3 " used, " $4 " available"}')" + +echo "Configuring Azure mirror" +sudo sed -i 's|http://archive.ubuntu.com|http://azure.archive.ubuntu.com|g' /etc/apt/sources.list + echo "Installing dependencies" sudo apt-get update sudo apt-get install -y dbus-x11 x11-utils xvfb diff --git a/test/sanity/scripts/run-win32.cmd b/test/sanity/scripts/run-win32.cmd index 52b0a27134a..e1dfacd1370 100644 --- a/test/sanity/scripts/run-win32.cmd +++ b/test/sanity/scripts/run-win32.cmd @@ -1,6 +1,10 @@ @echo off setlocal +echo System: %OS% %PROCESSOR_ARCHITECTURE% +powershell -NoProfile -Command "$mem = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory; Write-Host ('Memory: {0:N0} GB' -f ($mem/1GB))" +powershell -NoProfile -Command "$disk = Get-PSDrive C; Write-Host ('Disk C: {0:N0} GB free of {1:N0} GB' -f ($disk.Free/1GB), (($disk.Used+$disk.Free)/1GB))" + set "UBUNTU_ROOTFS=%TEMP%\ubuntu-rootfs.tar.gz" set "UBUNTU_INSTALL=%LOCALAPPDATA%\WSL\Ubuntu" diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index f5412c73e14..7e32e1bcac9 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -169,7 +169,7 @@ export class TestContext { */ public deleteWslDir(dir: string): void { this.log(`Deleting WSL directory: ${dir}`); - this.run('wsl', 'rm', '-rf', dir); + this.runNoErrors('wsl', 'rm', '-rf', dir); } /** @@ -470,6 +470,38 @@ export class TestContext { return dir; } + /** + * Mounts a macOS DMG file and returns the mount point. + * @param dmgPath The path to the DMG file. + * @returns The path to the mounted volume. + */ + public mountDmg(dmgPath: string): string { + this.log(`Mounting DMG ${dmgPath}`); + const result = this.runNoErrors('hdiutil', 'attach', dmgPath, '-nobrowse', '-readonly'); + + // Parse the output to find the mount point (last column of the last line) + const lines = result.stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + const mountPoint = lastLine.split('\t').pop()?.trim(); + + if (!mountPoint || !fs.existsSync(mountPoint)) { + this.error(`Failed to find mount point for DMG ${dmgPath}`); + } + + this.log(`Mounted DMG at ${mountPoint}`); + return mountPoint; + } + + /** + * Unmounts a macOS DMG volume. + * @param mountPoint The path to the mounted volume. + */ + public unmountDmg(mountPoint: string): void { + this.log(`Unmounting DMG ${mountPoint}`); + this.runNoErrors('hdiutil', 'detach', mountPoint); + this.log(`Unmounted DMG ${mountPoint}`); + } + /** * Runs a command synchronously. * @param command The command to run. @@ -914,7 +946,16 @@ export class TestContext { default: { const executablePath = process.env['PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH'] ?? '/usr/bin/chromium-browser'; this.log(`Using Chromium executable at: ${executablePath}`); - return await chromium.launch({ headless, executablePath }); + return await chromium.launch({ + headless, + executablePath, + args: [ + '--disable-gpu', + '--disable-gpu-compositing', + '--disable-software-rasterizer', + '--no-zygote', + ] + }); } } } diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts index 77305c7c173..2ef0a11925a 100644 --- a/test/sanity/src/desktop.test.ts +++ b/test/sanity/src/desktop.test.ts @@ -36,6 +36,39 @@ export function setup(context: TestContext) { } }); + context.test('desktop-darwin-x64-dmg', ['darwin', 'x64', 'desktop'], async () => { + const packagePath = await context.downloadTarget('darwin-x64-dmg'); + if (!context.options.downloadOnly) { + const mountPoint = context.mountDmg(packagePath); + context.validateAllCodesignSignatures(mountPoint); + const entryPoint = context.getDesktopEntryPoint(mountPoint); + await testDesktopApp(entryPoint); + context.unmountDmg(mountPoint); + } + }); + + context.test('desktop-darwin-arm64-dmg', ['darwin', 'arm64', 'desktop'], async () => { + const packagePath = await context.downloadTarget('darwin-arm64-dmg'); + if (!context.options.downloadOnly) { + const mountPoint = context.mountDmg(packagePath); + context.validateAllCodesignSignatures(mountPoint); + const entryPoint = context.getDesktopEntryPoint(mountPoint); + await testDesktopApp(entryPoint); + context.unmountDmg(mountPoint); + } + }); + + context.test('desktop-darwin-universal-dmg', ['darwin', 'desktop'], async () => { + const packagePath = await context.downloadTarget('darwin-universal-dmg'); + if (!context.options.downloadOnly) { + const mountPoint = context.mountDmg(packagePath); + context.validateAllCodesignSignatures(mountPoint); + const entryPoint = context.getDesktopEntryPoint(mountPoint); + await testDesktopApp(entryPoint); + context.unmountDmg(mountPoint); + } + }); + context.test('desktop-linux-arm64', ['linux', 'arm64', 'desktop'], async () => { let dir = await context.downloadAndUnpack('linux-arm64'); if (!context.options.downloadOnly) { diff --git a/test/sanity/src/wsl.test.ts b/test/sanity/src/wsl.test.ts index cc2647a04eb..d5c7212607f 100644 --- a/test/sanity/src/wsl.test.ts +++ b/test/sanity/src/wsl.test.ts @@ -204,7 +204,7 @@ export function setup(context: TestContext) { const app = await _electron.launch({ executablePath: entryPoint, args }); const window = await context.getPage(app.firstWindow()); - context.log('Installing WPS extension'); + context.log('Installing WSL extension'); await window.getByRole('button', { name: 'Install and Reload' }).click(); context.log('Waiting for WSL connection'); @@ -234,13 +234,13 @@ class WslUITest extends UITest { protected override verifyTextFileCreated() { this.context.log('Verifying file contents in WSL'); - const result = this.context.run('wsl', 'cat', `${this.wslWorkspaceDir}/helloWorld.txt`); + const result = this.context.runNoErrors('wsl', 'cat', `${this.wslWorkspaceDir}/helloWorld.txt`); assert.strictEqual(result.stdout.trim(), 'Hello, World!', 'File contents in WSL do not match expected value'); } protected override verifyExtensionInstalled() { this.context.log(`Verifying extension is installed in WSL at ${this.wslExtensionsDir}`); - const result = this.context.run('wsl', 'ls', this.wslExtensionsDir); + const result = this.context.runNoErrors('wsl', 'ls', this.wslExtensionsDir); const hasExtension = result.stdout.split('\n').some(ext => ext.startsWith('github.vscode-pull-request-github')); assert.strictEqual(hasExtension, true, 'GitHub Pull Requests extension is not installed in WSL'); }